0042 Llm Credential Resolution
ADR-0042: LLM credential resolution — 1Password op:// and Copilot device flow
- Date: 2026-06-18
- Status: accepted
- Deciders: Gerald Dagher (owner), Construct maintainers (cx-architect)
- Relates to: ADR-0003 (provider interface), ADR-0041 (owned loop)
Problem
The LLM credential path did not match how operators actually hold credentials, so configured providers read as unconfigured and the owned loop could not run.
Two concrete failures, both verified on a live machine:
- API keys in 1Password. Operators store provider keys as
op://references (the dev-machine convention isop://refs resolved at runtime viaop run). The LLM key reader (lib/orchestration/worker.mjsresolveKey) returned the literalop://...string instead of resolving it, andlib/model-router.mjsreported Anthropic/OpenAI/OpenRouter as not configured. A duplicatedop readresolver already existed for integrations (lib/integrations/intake-integrations.mjs,lib/health-check.mjs,lib/embed/daemon.mjs) but was never wired into the LLM path. - Copilot authenticated against the wrong app.
lib/bridges/copilot-proxy.mjsminted a Copilot token fromgh auth token. The GitHub CLI's OAuth app is not Copilot-entitled, socopilot_internal/v2/tokenreturned 404 and every Copilot call failed — even though the operator uses Copilot in their editor.
Decision
-
One secret resolver for the LLM path (
lib/providers/secret-resolver.mjs). Resolves a canonical var through a direct env value -> project.env->~/.config/construct/config.env->~/.env-> alternate provider stores (Construct creds, OpenCode provider config) -> theCONSTRUCT_OP_ENV_FILEcatalog -> shell rc, and resolvesop://references (bare or$(op read '...')) through theopCLI, cached per reference for the process and never logged. The file tier (project.envoverconfig.env) is reconciled withloadConstructEnvso a key resolves the same on both paths (construct-trxz.5).hasSecretchecks presence without invoking the CLI so a storedop://reference counts as configured with no biometric prompt. Resolution emits a value-free audit event (construct-trxz.6). WhenCONSTRUCT_OP_ENV_FILEpoints at anop runcatalog, keys listed there count as configured without duplicating refs into config.env.worker.mjs, OpenCode runtime integration, and the router's detection (isProviderConfigured) all resolve the same way. -
GitHub Copilot uses the community-standard OAuth device flow (
lib/providers/copilot-auth.mjs): the public Copilot app (Iv1.b507a08c87ecfe98) ->ghu_access token +ghr_refresh token -> exchange atcopilot_internal/v2/tokenfor a short-lived session token used againstapi.githubcopilot.comwith theEditor-VersionandCopilot-Integration-Idheaders. Credentials persist to Construct's auth store and the shared~/.config/github-copilot/apps.jsonother tools read; the access token refreshes from the refresh token; the session token is cached until shortly before expiry.construct creds login copilotdrives the flow. The owned loop and the worker consume this directly;copilot-proxy.mjsis repointed to the same module so OpenCode keeps working.
Rejected alternatives
gh auth tokenfor Copilot. Confirmed non-functional: the CLI app is not Copilot-entitled (404 from the exchange endpoint).- A community AI SDK Copilot provider (e.g.
@github/copilot-sdk-based). Adds a preview dependency and still requires a separatecopilot authlogin the operator does not have; the device flow reuses what editors/CLIs already store. - Requiring
op runat launch only. Works but forces a wrapper for every invocation; native resolution makes OpenCode, worker, and router behave the same whether or not a wrapper is used.
Consequences
- API keys may be stored as
op://references in~/.config/construct/config.envand resolve everywhere; no secret is written to logs or committed. - Copilot works without a Copilot-entitled
ghlogin;construct creds login copilotis the entry point and errors point operators to it. - The Copilot bridge no longer force-maps the model to
gpt-4o; the requested id is passed through and validated against the account'smodelsendpoint. - Detection (
construct models list,construct creds list, health) reports op:// providers and a stored Copilot credential as configured.
Auth-once contract (cross-reference)
All LLM and integration paths that resolve op:// references must route through
lib/providers/secret-resolver.mjs so a single reference is materialized once per
process and cached — no repeat op read spawn, no repeat biometric prompt. The
contract is enforced by tests/functional/auth-once.functional.test.mjs (hermetic,
injected opRead).
Superseded for the cross-process dimension by ADR-0049. The cache here is a
module-level Map that lives for one process, so "auth once" holds per process, not
per user session. ADR-0049 resolves at a stable parent (op run wiring in the
service tree) and keeps short-lived paths presence-first/cache-first. A real-op
cross-process test (tests/functional/secret-resolver-real-op.functional.test.mjs)
pins the per-process behavior. Consumers include worker.mjs, OpenCode runtime integration,
isProviderConfigured in lib/model-router.mjs, and intake integrations
(lib/integrations/intake-integrations.mjs via resolveOpRef). See CHANGELOG
(construct-m7k2-auth-primitives) for rollout status.