Skip to content

feat: auth-aware source model resolution#348

Merged
marcusrbrown merged 6 commits into
mainfrom
feat/auth-aware-source-models
May 10, 2026
Merged

feat: auth-aware source model resolution#348
marcusrbrown merged 6 commits into
mainfrom
feat/auth-aware-source-models

Conversation

@marcusrbrown
Copy link
Copy Markdown
Owner

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_DEFAULTS becomes Record<CategoryId, readonly string[]> — an ordered preference list per agent category instead of a single string. At plugin config(cfg) time, Systematic reads OpenCode's auth.json and picks the first array entry whose provider is authenticated. Falls back to array[0] when no entry's provider is authenticated.

Category Preference order
design openai/gpt-5.5, anthropic/claude-opus-4.7
docs openai/gpt-5.4-mini, anthropic/claude-haiku-4-5
document-review anthropic/claude-opus-4.7, openai/gpt-5.5
research openai/gpt-5.5, anthropic/claude-opus-4.7
review anthropic/claude-opus-4.7, openai/gpt-5.5
workflow openai/gpt-5.4-mini, anthropic/claude-haiku-4-5

User 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 for array[0] selection plus runtime error fallback. Brainstorm research established that OpenCode's plugin lifecycle does NOT expose a hook between config and chat.params where auth-aware mutation is possible — but a synchronous read of auth.json at config(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

  • The auth-file reader calls 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.
  • The plugin never writes to auth.json and never repairs/normalizes/migrates it. A regression test compares the file's mtime + sha256 before and after.
  • Missing auth file is silently treated as "no providers authenticated." Unreadable or malformed files emit one stderr diagnostic naming the path and failure category — no file contents leaked.
  • XDG_DATA_HOME is trusted as user-controlled; the resolver does not validate path containment.

Documented limitations

  1. Autoload-true providers (e.g., AWS Bedrock loaded from environment variables) may not appear in auth.json and may be skipped by the resolver. Mitigation: pin via category or exact overlay.
  2. Race window with 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:

Commit Unit What
977f6c9 Plan doc
6f14d53 1 Source-default array shape + validator updates (+ 4 tests)
4b50475 2 getAuthenticatedProviders + auth-aware resolver (+ 15 tests)
4df3727 3 Wire resolver into config hook (+ 6 unit tests + 2 integration tests)
d41e345 4 README + configuration.mdx documentation

Verification

Check Result
bun run typecheck clean
bun run lint clean (4 warnings, 14 infos — all pre-existing)
bun run build clean
Node ESM smoke dist/index.js default export is a function
content-integrity clean (160 md + 16 ts scanned, 0 warnings)
registry:drift up to date (101 components)
Unit tests 559 pass / 0 fail (was 534, +25)
Integration tests (config hook) 10 pass / 0 fail (was 8, +2)
Plan-taxonomy grep 0 hits in source/test files

Plan / Brainstorm

  • Plan: docs/plans/2026-05-09-004-feat-auth-aware-source-model-resolution-plan.md (committed)
  • Brainstorm: docs/brainstorms/2026-05-09-auth-aware-source-model-resolution-requirements.md (gitignored, local-only per project convention)
  • Document review on the plan: 3 reviewers (coherence, feasibility, security-lens), 10 findings synthesized before commit. Confidence check passed without deepening — local patterns abundant, brainstorm research exhaustive.

Suggested release

Minor version bump (v2.11.0) — additive feature, fully backward compatible. Existing user configs continue to work unchanged.

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.
@marcusrbrown marcusrbrown enabled auto-merge (squash) May 10, 2026 01:13
Comment thread tests/unit/agent-overlays.test.ts Fixed
Copy link
Copy Markdown
Collaborator

@fro-bot fro-bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: PASS

Clean, well-scoped additive feature with excellent test coverage and thoughtful security boundaries.

Blocking issues

None

Non-blocking concerns

  1. isSystemError type assertionagent-overlays.ts:738 uses as Record<string, unknown> to access .code after 'code' in err narrowing. Functionally correct, but a narrower cast (as { code: unknown }) or an intermediate variable would be marginally cleaner.

  2. Nested-form provider testagent-overlays.test.ts:797-805 tests provider extraction logic inline rather than exercising getSourceCategoryModel with 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.

  3. XDG_DATA_HOME branch coverage — No unit test exercises the XDG_DATA_HOME → absolute path branch or the relative-path fallback in getAuthenticatedProviders. The os.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_diagnostics with zero errors.
  • bun run typecheck clean.
  • bun run lint clean (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.
@marcusrbrown
Copy link
Copy Markdown
Owner Author

All 4 findings (1 from Code Scanning + 3 from Fro Bot) addressed in 7360359:

Code Scanning #41 — Potential file system race condition (tests/unit/agent-overlays.test.ts:861)

Fixed. The test's intent was "prove the file wasn't touched after a read-only operation," but the fs.readFileSync(authPath) after getAuthenticatedProviders(dir) was a redundant TOCTOU pattern. Replaced with mtime + size assertions only — a content-modifying write would change at least one. Removed the now-unused crypto import from this test.

Fro Bot NBC #1isSystemError type assertion

Fixed. Cast narrowed from Record<string, unknown> to { code: unknown }.

Fro Bot NBC #2 — Nested-form provider test

Fixed. The previous test demonstrated the algorithm inline but didn't exercise the function's slashIndex logic through the public API (because SOURCE_CATEGORY_MODEL_DEFAULTS has no nested-form entries). Replaced with a focused public-API test naming the contract: a provider/model string in authedProviders must NOT be treated as a provider id (otherwise substring contamination would cause false matches).

Fro Bot NBC #3XDG_DATA_HOME branch coverage

Fixed. Added 3 tests covering the resolution branches: absolute XDG_DATA_HOME is used directly; empty value falls back to os.homedir(); non-absolute value falls back to os.homedir(). Tests temporarily mock os.homedir and process.env.XDG_DATA_HOME and restore both in finally.

Verification post-fix: bun run typecheck clean, bun run lint clean (pre-existing warnings only), 562 unit tests pass (was 559, +3).

Plan units 1-4 are also now ticked as completed in docs/plans/2026-05-09-004-feat-auth-aware-source-model-resolution-plan.md and the plan status is flipped to completed.

Copy link
Copy Markdown
Collaborator

@fro-bot fro-bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. isSystemError type assertion — Cast narrowed from Record<string, unknown> to { code: unknown } (src/lib/agent-overlays.ts:737).
  2. Nested-form provider test — Replaced inline logic demo with a focused public-API test proving that authedProviders entries are matched by prefix-before-slash only, preventing false matches from full provider/model strings.
  3. XDG_DATA_HOME branch 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 typecheck clean.
  • bun run build clean.
  • bun run lint clean (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

@marcusrbrown marcusrbrown merged commit 05affb9 into main May 10, 2026
11 checks passed
@marcusrbrown marcusrbrown deleted the feat/auth-aware-source-models branch May 10, 2026 01:58
marcusrbrown added a commit that referenced this pull request May 10, 2026
- 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants