feat: auth-aware source model resolution#348
Conversation
Plan for expanding SOURCE_CATEGORY_MODEL_DEFAULTS from a single provider/model string per category to an ordered array, resolved at plugin config(cfg) time by reading OpenCode's auth.json and selecting the first array entry whose provider is authenticated. 4 implementation units: 1. Expand source-default shape and update validators 2. Add auth-file reader and auth-aware resolver 3. Wire the auth-aware resolver into the config hook 4. Document the new behavior Origin: docs/brainstorms/2026-05-09-auth-aware-source-model-resolution-requirements.md (gitignored, local-only per project convention). Document review: 3 reviewers (coherence, feasibility, security-lens), 10 findings, all addressed before commit. Confidence check passed without deepening — local patterns abundant, brainstorm research exhaustive.
Change SOURCE_CATEGORY_MODEL_DEFAULTS shape from Record<CategoryId, string>
to Record<CategoryId, readonly string[]>. Each array is an ordered
preference list, most preferred first.
This commit is shape-only — getSourceCategoryModel(category) still
returns the first array entry, preserving current zero-config behavior.
The auth-aware resolution that picks based on which provider is
authenticated lands in a follow-up commit.
validateSourceCategoryModelDefaults now rejects non-arrays and empty
arrays explicitly, and iterates each entry with indexed key paths
(source category model defaults.${category}[${index}]) for clearer
error messages.
Add getAuthenticatedProviders() that reads OpenCode's auth.json synchronously using the XDG_DATA_HOME path convention. Returns the top-level keys as a ReadonlySet<string>. Treats missing files as 'no providers authenticated' silently; emits a single stderr diagnostic for unreadable or malformed files without leaking contents. Reads only Object.keys; nested values (API keys, OAuth tokens) are never inspected, logged, persisted, or transmitted. Extend getSourceCategoryModel(category, authedProviders?) with an optional second parameter. When provided, the resolver returns the first array entry whose provider ID (substring before the first /) is in the authenticated set. Falls back to array[0] when no entry matches or when no auth set is supplied (backward compat). Tests: 15 new scenarios including a real-token leak fixture that captures stderr/stdout and asserts no secret-shaped strings appear, and a file-not-modified contract verified via mtime + sha256.
Read OpenCode's authenticated providers once per config(cfg) hook invocation in createConfigHandler, thread the ReadonlySet through collectAgents and applyAgentOverlays, and pass it to getSourceCategoryModel(category, authedProviders) so source defaults pick the first array entry whose provider is authenticated. Overlay precedence is unchanged: source default -> category overlay -> exact overlay. Auth-aware resolution only affects the source default; user/category/exact overlays still win as today. Add ConfigHandlerDeps.getAuthenticatedProviders for test injection. Tests: - 6 new unit tests in tests/unit/config-handler.test.ts covering no-auth zero-config, single-provider match, multi-provider first-match-wins, category-overlay-wins, exact-overlay-wins, and the read-once contract via injected counting spy. - 2 new integration scenarios in tests/integration/opencode.test.ts using the existing homeDir fixture: single-provider auth and multi-provider auth, asserting emitted models match per-category expectations.
Document that SOURCE_CATEGORY_MODEL_DEFAULTS is now an ordered preference array per category and that the resolver reads OpenCode's auth.json at config(cfg) time to pick the first array entry whose provider is authenticated. Falls back to the first array entry when no match is found. Make the resolution precedence explicit: user agents.<key>.model > user categories.<id>.model > source-default-resolver > bundled markdown > OpenCode inheritance. User overlays remain scalar provider/model strings; arrays are not accepted in user config in this iteration. Document two known limitations: (1) autoload-true providers like AWS Bedrock that load from environment variables may not appear in auth.json and may be skipped by the resolver — pin via category or exact overlay; (2) a tiny race window exists with 'opencode auth login' that resolves on next OpenCode restart. Preserve the existing 'Systematic does not support fallback_models' sentence — the arrays are a preference list, not a runtime fallback chain.
fro-bot
left a comment
There was a problem hiding this comment.
Verdict: PASS
Clean, well-scoped additive feature with excellent test coverage and thoughtful security boundaries.
Blocking issues
None
Non-blocking concerns
-
isSystemErrortype assertion —agent-overlays.ts:738usesas Record<string, unknown>to access.codeafter'code' in errnarrowing. Functionally correct, but a narrower cast (as { code: unknown }) or an intermediate variable would be marginally cleaner. -
Nested-form provider test —
agent-overlays.test.ts:797-805tests provider extraction logic inline rather than exercisinggetSourceCategoryModelwith an actual nested-form entry in a source-defaults array. The function logic is correct, but the test is more of a logic demonstration than an integration assertion. -
XDG_DATA_HOMEbranch coverage — No unit test exercises theXDG_DATA_HOME→ absolute path branch or the relative-path fallback ingetAuthenticatedProviders. Theos.homedir()fallback is covered by integration tests, but the XDG branch is not.
Missing tests
None blocking. The gaps noted above are minor and the existing coverage (25 new tests across unit + integration) is already strong.
Risk assessment (LOW) + rationale
- Regression likelihood: Low. The change is purely additive — existing single-string behavior is preserved as
array[0]fallback. User overlays continue to win unconditionally. - Security exposure: Very low. The auth-file reader is read-only, accesses
Object.keys()exclusively, and has regression tests asserting no credential leakage via stderr/stdout or file mutation. The threat model is well-documented. - Blast radius: Confined to source model default selection for bundled agents. No changes to skill loading, tool registration, or the bootstrap hook.
Additional notes
- All changed files pass
lsp_diagnosticswith zero errors. bun run typecheckclean.bun run lintclean (only pre-existing warnings).- 132 tests pass across the affected test files.
- The plan document is exemplary — requirements trace, scope boundaries, security analysis, and risk matrix all present.
Run Summary
| Field | Value |
|---|---|
| Event | pull_request |
| Repository | marcusrbrown/systematic |
| Run ID | 25616312322 |
| Cache | hit |
| Session | ses_1f08d7f50ffeXxQgdIl4z32bej |
- Fix CodeQL js/file-system-race in tests/unit/agent-overlays.test.ts
by removing the redundant second fs.readFileSync; mtime + size
comparison is sufficient to prove the file wasn't touched.
- Narrow isSystemError type assertion from Record<string, unknown>
to { code: unknown } per Fro Bot NBC.
- Replace the nested-form provider-extraction test with a focused
public-API assertion of the slashIndex logic, naming the false-match
guarantee in the test description.
- Add 3 XDG_DATA_HOME branch tests covering absolute path, empty
fallback, and non-absolute fallback.
- Mark plan units 1-4 as completed and flip plan status to completed.
Net: +3 tests, 562 unit tests pass.
|
All 4 findings (1 from Code Scanning + 3 from Fro Bot) addressed in
Fixed. The test's intent was "prove the file wasn't touched after a read-only operation," but the
Fixed. Cast narrowed from
Fixed. The previous test demonstrated the algorithm inline but didn't exercise the function's
Fixed. Added 3 tests covering the resolution branches: absolute Verification post-fix: Plan units 1-4 are also now ticked as completed in |
fro-bot
left a comment
There was a problem hiding this comment.
Verdict: PASS
All prior review feedback has been addressed cleanly in 7360359.
Blocking issues
None
Non-blocking concerns
None. All three prior NBCs are resolved:
isSystemErrortype assertion — Cast narrowed fromRecord<string, unknown>to{ code: unknown }(src/lib/agent-overlays.ts:737).- Nested-form provider test — Replaced inline logic demo with a focused public-API test proving that
authedProvidersentries are matched by prefix-before-slash only, preventing false matches from fullprovider/modelstrings. XDG_DATA_HOMEbranch coverage — Added 3 tests covering absolute path usage, empty-string fallback, and non-absolute fallback.
Additionally, the Code Scanning TOCTOU finding in the file-mutation test was fixed by removing the post-call readFileSync and asserting on mtime + size only. The unused crypto import was also removed.
Missing tests
None
Risk assessment (LOW) + rationale
- Regression likelihood: Low. Purely additive; existing behavior preserved as
array[0]fallback. - Security exposure: Very low. Read-only
Object.keys()access with no credential leakage. - Blast radius: Confined to source model default selection for bundled agents.
Additional notes
bun run typecheckclean.bun run buildclean.bun run lintclean (pre-existing warnings only).- 135 tests pass across affected files (62 unit agent-overlays + 73 config-handler/integration).
- Plan document units 1-4 ticked as completed, status flipped to
completed.
Run Summary
| Field | Value |
|---|---|
| Event | pull_request |
| Repository | marcusrbrown/systematic |
| Run ID | 25616976756 |
| Cache | hit |
| Session | ses_1f08d7f50ffeXxQgdIl4z32bej |
- 2026-05-05-001-feat-ce-work-beta-graduation-plan.md: active -> completed (work shipped via PR #341, v2.8.0) - 2026-05-09-003-feat-source-configured-agent-models-plan.md: active -> superseded (work shipped via PR #345 v2.10.0; auth-aware array-shape evolution shipped via PR #348 v2.11.0) Frontmatter only, no behavior change.
Source category model defaults adapt to the user's authenticated providers. A user authenticated only to OpenAI gets OpenAI-backed agents; a user authenticated to Copilot and Anthropic gets a different selection from the same source defaults. No config required.
What changes
SOURCE_CATEGORY_MODEL_DEFAULTSbecomesRecord<CategoryId, readonly string[]>— an ordered preference list per agent category instead of a single string. At pluginconfig(cfg)time, Systematic reads OpenCode'sauth.jsonand picks the first array entry whose provider is authenticated. Falls back toarray[0]when no entry's provider is authenticated.designopenai/gpt-5.5,anthropic/claude-opus-4.7docsopenai/gpt-5.4-mini,anthropic/claude-haiku-4-5document-reviewanthropic/claude-opus-4.7,openai/gpt-5.5researchopenai/gpt-5.5,anthropic/claude-opus-4.7reviewanthropic/claude-opus-4.7,openai/gpt-5.5workflowopenai/gpt-5.4-mini,anthropic/claude-haiku-4-5User overlays (
agents.<key>.model,categories.<id>.model) remain scalar strings — arrays are not accepted in user-supplied config in this iteration. User overlays still win over source defaults regardless of auth state.Why this and not error-driven fallback
The published reference plugins (
@cortexkit/opencode-magic-context,oh-my-opencode-slim,@kodrunhq/opencode-autopilot) all considered multi-model defaults and all settled forarray[0]selection plus runtime error fallback. Brainstorm research established that OpenCode's plugin lifecycle does NOT expose a hook betweenconfigandchat.paramswhere auth-aware mutation is possible — but a synchronous read ofauth.jsonatconfig(cfg)time produces correct results for the explicit-credential case (API/OAuth/WellKnown providers), which is the common shape. This is the smallest mechanism that picks the right model the first time.The arrays are a preference list, not a runtime fallback chain. Systematic still does not support
fallback_models; runtime model failures are still surfaced by OpenCode.Security boundaries
Object.keys()only — nested values (API keys, OAuth tokens) are never inspected, logged, persisted, or transmitted. This is enforced by a regression test that writes a known secret-shaped token into a fixture and asserts the literal substring does NOT appear in captured stderr/stdout AND that no[A-Za-z0-9_-]{30,}pattern appears.auth.jsonand never repairs/normalizes/migrates it. A regression test compares the file's mtime + sha256 before and after.XDG_DATA_HOMEis trusted as user-controlled; the resolver does not validate path containment.Documented limitations
auth.jsonand may be skipped by the resolver. Mitigation: pin via category or exact overlay.opencode auth login— a partial write during plugin load collapses to malformed/empty-set behavior (safe). Restart OpenCode to refresh.Both limitations are documented in the configuration docs.
Trace
5 commits, mapping cleanly to the plan units:
977f6c96f14d534b50475getAuthenticatedProviders+ auth-aware resolver (+ 15 tests)4df3727d41e345Verification
bun run typecheckbun run lintbun run builddist/index.jsdefault export is a functioncontent-integrityregistry:driftconfig hook)Plan / Brainstorm
docs/plans/2026-05-09-004-feat-auth-aware-source-model-resolution-plan.md(committed)docs/brainstorms/2026-05-09-auth-aware-source-model-resolution-requirements.md(gitignored, local-only per project convention)Suggested release
Minor version bump (
v2.11.0) — additive feature, fully backward compatible. Existing user configs continue to work unchanged.