Skip to content

Do not let OAuth projections overwrite hand-named provider entries - #1007

Merged
TheGreatAxios merged 4 commits into
mainfrom
cl-6728-do-not-let-oauth-projections-overwrite-hand-named-provider
Sep 14, 2026
Merged

Do not let OAuth projections overwrite hand-named provider entries#1007
TheGreatAxios merged 4 commits into
mainfrom
cl-6728-do-not-let-oauth-projections-overwrite-hand-named-provider

Conversation

@TheGreatAxios

Copy link
Copy Markdown
Collaborator

Summary

OAuth profile projections for namespaced codex/ and xai/ entries no longer overwrite, orphan-sweep, or catalog-drop a persisted settings row that carries its own credential. Such a row is the operator's explicit config, not an OAuth placeholder. Credential-less namespaced rows keep the existing placeholder behavior (overlay, orphan-drop, catalog rebuild).

Note: the Linear Implementation-plan attachment could not be fetched from this lane, so this was implemented from the dispatch brief plus code.

Verification

  • Red: 9 new CL-6728 tests in src/config/oauth-catalog.test.ts, 6 failed before the fix (bun test src/config/oauth-catalog.test.ts: 7 pass / 6 fail)
  • Green: bun test src/config/ src/tui/provider-setup-submit.test.ts src/tui/provider-connect.test.ts — 75 pass / 0 fail
  • bun run lint — 0 warnings, 0 errors
  • bun run build — ok
  • bun run typecheck is red on a pre-existing vendor issue (vendor/intx-types missing semver/arktype modules); reproduced on pristine origin/main, unrelated to this change

Fixes CL-6728

@linear-code

linear-code Bot commented Sep 13, 2026

Copy link
Copy Markdown

CL-6728

@TheGreatAxios

Copy link
Copy Markdown
Collaborator Author

[warden trust review] CL-6728 — verdict: approve with one should-fix (no merge, no commits per brief).

What I checked: the four projection sites against exact codex/<slug> / xai/<slug> collision, orphan-drop, catalog merge, runtime overlay; every writer that could put a live access token on disk; the keyless vs apiKey guard in both directions.

Genuinely fine:

  • overlayOAuthProjections / dropOrphanedOAuthEntries / mergeOAuthCatalog / runtimeSettingsWithCatalog (all src/config/index.ts, PR hunk): exact-namespaced collision keeps the hand-named row and drops the live projection — single row, no dupe, no clobber. Credential-less placeholders keep old behavior.
  • Guard direction: isHandNamedProviderEntry (keyless === true OR non-empty apiKey) cannot over-skip a genuine login row — the OAuth submit path writes placeholders with only {baseURL, models, defaultModel} (src/tui/provider/submit.ts:94-105), never keyless/apiKey. Whitespace-key input fails closed (treated as hand-named, live token never overlays). Empty-string key stays a placeholder. Correct.
  • Token-to-disk sweep: providerCatalogToSettings still filters codexProfile/xaiProfile-marked rows (src/config/index.ts:1323-1325); clobber-recovery persist strips apiKey via providerSelectionMetadata (src/config/settings.ts:230-249); setDefaultModel uses the same metadata when the provider is absent from disk (settings.ts:262-265); mid-session connect persists via globalSettingsWriter.updateAt re-read from disk, never from config.settings (src/tui/runner/settings.ts:265-273); providerCatalogToSettings has no production callers today (tests only), so catalog persist is defense-in-depth. The new "persist round-trip" test pins the main path. No live-token-to-disk found.
  • applyPersistedOAuthDefaults only forwards defaultModel into the projection — no credential flow. Prefix matchers require the trailing slash, so bare codex/xai dedupe is untouched.

Should-fix (not blocking — narrow trigger, fail-closed direction):

  • mergeOAuthCatalog: handNamed is built from buildProviderCatalog(settings, resolved), which synthesizes a [resolved] row when settings is null or providers is empty. If resolved.providerName is itself codex/<slug> (empty/missing settings file + local/CLI selects the login provider), the synthetic row carries the live apiKey with no codexProfile marker, matches isHandNamedProviderEntry, and knocks out the real marked live entry. Result: catalog row with a stale token snapshot, no codexProfile/codexAccountId (wrong adapter, no refresh), and — for any future providerCatalogToSettings caller — an unmarked token row the persist filter would NOT strip. Trigger: mergeOAuthCatalog(null (or {providers:{}}), {providerName: "codex/mine", apiKey: <live>}, [liveMine], []). Fix suggestion (builder): derive handNamed from raw settings.providers only, never the resolved fallback.
  • Permanent tests to keep (routing to testsmith/builder, not implemented here): (1) the null/empty-settings + codex/* resolved edge above asserts the marked live entry survives; (2) xai/* mirror of the hand-named keep (new tests are codex-only); (3) keyless: true codex/* row kept and resolvable; (4) whitespace vs empty-string apiKey pinned; (5) persist round-trip from the empty-settings merge contains no live token.

Gaps (not covered, do not assume closed): PR-branch tests not re-run here (tree is on main; baseline oauth-catalog/oauth-providers/xai-providers 12 pass / 0 fail); bun run typecheck red-on-vendor claim taken from the PR body, not independently verified; auth-store file permissions out of scope of this diff.

@TheGreatAxios

Copy link
Copy Markdown
Collaborator Author

Critic review — no blocking findings

Guards hand-named codex/<slug> / xai/<slug> API-key rows at all four OAuth-projection sites so a live login can no longer overwrite, orphan-sweep, or catalog-drop explicit operator config.

Independent verification (worktree .worktrees/cl-6728-oauth-projections @ 115c6ed2)

  • bun install then bun run checkexit 0: oxfmt/oxlint clean (0 warnings, 0 errors), tsc --noEmit clean, build ok, full suite 7312 pass / 0 fail across 500 files. The reported "pre-existing vendor tsc errors" reproduce only with a stale node_modules; after bun install the gate truly passes. Key open question closed.
  • Targeted: src/config/oauth-catalog.test.ts 13 pass (9 new CL-6728 + 4 CL-5606), src/config.test.ts 97 pass / 0 fail.

Correctness (all verified by reading, not vibes)

  • src/config/index.ts:166 — single isHandNamedProviderEntry predicate (non-empty apiKey or keyless: true) shared by all four sites; no drift between them.
  • Site 1, orphan sweep (:138): hand-named row survives with zero live profiles; credential-less orphan still dropped. Both directions covered by test.
  • Site 2, runtime overlay (:176, called from loadConfig :966): skips only colliding hand-named names; placeholders still get the live token. applyPersistedOAuthDefaults only decorates the projected entry's defaultModel, and that entry is then skipped by the overlay — no overwrite path.
  • Site 3, mergeOAuthCatalog (:1202): handNamed set is built from buildProviderCatalog output, which preserves apiKey/keyless, so the predicate reads true credentials there. Colliding live profile entry is skipped, hand-named row keeps codexProfile === undefined, so providerCatalogToSettings persists it.
  • Site 4, runtimeSettingsWithCatalog (:1292, production caller src/subagent/agent-fleet.ts:995): absent/placeholder names still overlay; hand-named keeps its key. settings === undefined path returns catalog-only, unaffected.
  • Writer audit holds: updateAt (src/mcp/add-server.ts:137) loads a disk-fresh base before applying deltas; the OAuth connect persist (src/tui/provider/submit.ts:94) writes metadata only (no apiKey); resolved Config.settings is in-memory only with do-not-persist comments at both sites. Persist round-trip test asserts the hand-named key survives and live-token never appears in output.

Should-fix (non-blocking, take or leave)

  • All 9 new tests use codex/mine; no xai/<slug> mirror exercises the symmetric branch, and no test covers keyless: true counting as hand-named. One xai mirror test would lock the symmetry. Not merge-blocking — the code path is shared.

Hygiene

Clean: no dead code, no new abstraction beyond the one shared predicate, comments state why (CL-6728) not what. Two new test-only exports carry their rationale inline.

Rebase note

Branch base is 43e7eef1; origin/main has moved to 07bade77 — rebase before merge. No conflicts expected (this diff touches only src/config/index.ts + its test).

buildProviderCatalog synthesizes a resolved row when settings is null or empty; when resolved is itself codex/<slug> that row carries the live apiKey with no profile marker and ejected the real marked entry. Derive handNamed from raw settings.providers only.
@TheGreatAxios
TheGreatAxios force-pushed the cl-6728-do-not-let-oauth-projections-overwrite-hand-named-provider branch from 115c6ed to cd829f7 Compare September 13, 2026 21:18
@TheGreatAxios

Copy link
Copy Markdown
Collaborator Author

Warden follow-up addressed (should-fix): mergeOAuthCatalog now derives handNamed from raw settings.providers only, never the buildProviderCatalog resolved fallback — the synthetic [resolved] row can no longer eject the real marked entry when settings is null/empty and resolved is codex/<slug>. New edge tests in src/config/oauth-catalog.test.ts: null- and empty-settings resolved-codex/* (marked live entry survives), empty-settings persist round-trip (no live token), xai/* mirror (merge + overlay), keyless codex/* + xai/* keep, whitespace vs empty-string apiKey pinning. Branch rebased onto origin/main (9f37404e), bun run check exit 0 (7326 pass / 0 fail, 501 files). Fix SHA: cd829f74. Not merging per brief.

@TheGreatAxios

Copy link
Copy Markdown
Collaborator Author

Critic follow-up addressed: added the xai/<slug> mirror tests (merge keep + overlay keep) and the keyless cases (keyless codex/* merge keep, keyless codex/* + xai/* runtime keep) — no longer codex/mine-only. Verification still green after the rebase: bun run check exit 0 (7326 pass / 0 fail, 501 files), targeted src/config/oauth-catalog.test.ts 25 pass / 0 fail. Branch rebased onto origin/main (9f37404e), pushed with --force-with-lease. Fix SHA: cd829f74. Not merging per brief.

@TheGreatAxios

Copy link
Copy Markdown
Collaborator Author

Confirmed green on cd829f7.

  • mergeOAuthCatalog derives handNamed from raw settings.providers only (src/config/index.ts), so the synthetic resolved row no longer ejects the live marked entry when settings is null/empty and resolved is codex/<slug>.
  • xai and keyless mirrors covered: hand-named xai/<slug> kept over live profile, keyless rows kept in overlay/runtime/catalog paths.
  • bun test src/config/oauth-catalog.test.ts: 25 pass, 0 fail.
  • Rebase clean: merge-base with origin/main is 9f37404e, branch adds exactly 4 commits, worktree clean.

One file-for-later, non-blocking: a whitespace-only apiKey (" ") counts as hand-named, so it blocks the live token overlay and leaves an unauthenticatable row. Consider trim().length > 0 in isHandNamedProviderEntry.

@TheGreatAxios

Copy link
Copy Markdown
Collaborator Author

[warden trust review] Trust-confirm cd829f7: synthetic resolved row excluded from handNamed. handNamed now derives from raw settings.providers only, so null/empty settings + resolved codex/ keeps the marked live entry (codexProfile set, live routing intact) and the persist round-trip contains no live token. Predicate unchanged, so real hand-named/keyless rows still skip projection (no over-skip); prefix-based name check means the behavior delta is exactly the synthetic row. Evidence: bun test src/config/oauth-catalog.test.ts 25 pass / 0 fail; bun test src/config/ 67 pass / 0 fail in worktree at cd829f7. Verdict: trust green, no remaining trust defect in this fix.

@TheGreatAxios
TheGreatAxios merged commit ad410ef into main Sep 14, 2026
9 checks passed
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.

1 participant