From 30859d7d30f71e50d7748c3e7845dac1c6696c1d Mon Sep 17 00:00:00 2001 From: lidge-jun Date: Sun, 20 Sep 2026 06:16:19 +0900 Subject: [PATCH 1/3] refactor(oauth): move in-flight login state into its own module src/oauth/index.ts reached 2009 lines after #5090 and is not in the file-size baseline, so it tripped NEW_OVERSIZED at the 2000-line threshold and failed the gate for every branch whose merge ref includes current dev. Raising the cap is not available: updateBaseline only lowers, and tests/ci-workflows/file-size-ratchet.test.ts asserts it. Trimming the comments that #5090 added to explain why a raw paste carrying an explicit state suffix is state-bearing would have fit the budget and is the wrong trade. src/oauth/login-flow-state.ts takes the bookkeeping for a login that has started and not yet settled: the per-provider state, abort and manual paste maps, the slot helpers, the flow-state reconciliation, and submitManualLoginCode. The cut follows a seam rather than a line count. Nothing moved reads or writes a stored credential, and nothing left behind touches an in-flight slot. index.ts re-exports reconcileOAuthFlowState and submitManualLoginCode, so the management routes and the Codex login flow that import them are unchanged. The raw code#state validation from #5090 moves with submitManualLoginCode unchanged, comments included. index.ts ends at 1894 lines. No local suite, focused test, typecheck or build was run. Exact-head hosted CI is the execution evidence. --- src/oauth/index.ts | 121 +------------------------------- src/oauth/login-flow-state.ts | 127 ++++++++++++++++++++++++++++++++++ 2 files changed, 130 insertions(+), 118 deletions(-) create mode 100644 src/oauth/login-flow-state.ts diff --git a/src/oauth/index.ts b/src/oauth/index.ts index d4fab2354fb..f33a6855c0b 100644 --- a/src/oauth/index.ts +++ b/src/oauth/index.ts @@ -1,6 +1,5 @@ import type { KiroOAuthMetadata, OAuthController, OAuthCredentials } from "./types"; import { initializeProviderModelSelection } from "../providers/initial-model-selection"; -import { parseCallbackInput } from "./callback-server"; import type { OcxConfig, OcxProviderConfig, RefreshPolicy } from "../types"; import { ConfigMutationLockError, loadConfig, mutatePersistedConfig, saveConfig } from "../config"; import { resolveProviderApiKey } from "../providers/key-store"; @@ -51,8 +50,9 @@ import { providerModelsUrl, resolveProviderModelDiscoveryUrl } from "../provider import { resolveProviderTransport } from "../providers/xai-transport"; import { detectClaudeCodeToken, detectGrokCliToken, hasComparableGrokIdentity, isSameGrokIdentity, shouldAdoptGrokGeneration } from "./local-token-detect"; import { logOAuthEvent } from "./log"; -import { captureConfigGeneration, sweepExpiredOnWrite, type GenerationContext } from "../lib/state-store-sweeper"; -import { retainedUtf8Bytes } from "../lib/admission"; +import { captureConfigGeneration, sweepExpiredOnWrite } from "../lib/state-store-sweeper"; +import { clearManualCodeSlot, ensureManualCodeSlot, kiroLoginSettling, loginAbort, loginState, waitForManualLoginCode } from "./login-flow-state"; +export { reconcileOAuthFlowState, submitManualLoginCode } from "./login-flow-state"; import { randomUUID } from "node:crypto"; export { CODEX_HEALTH_AUTH_FAILED_NOTE, @@ -1710,121 +1710,6 @@ export async function runLogin( * localhost), the GUI can POST the final redirect URL or authorization code via * submitManualLoginCode(), which feeds OAuthController.onManualCodeInput. */ -const loginState = new Map(); -const loginAbort = new Map(); -const kiroLoginSettling = new Set(); - -/** Pending paste for a login in progress: either a waiter or a stashed early submission. */ -interface ManualCodeSlot { - pendingInput?: string; - resolve?: (value: string) => void; - /** Registered by the callback flow so submits can validate state synchronously. */ - expectedState?: string; -} -const loginManual = new Map(); -const OAUTH_PENDING_CODE_MAX_BYTES = 4 * 1024; -let lastOAuthFlowReconciledGeneration = 0; - -export function reconcileOAuthFlowState(context: GenerationContext): number { - if (context.generation <= lastOAuthFlowReconciledGeneration) return 0; - let removed = 0; - for (const [provider, state] of loginState) { - if (context.providerNames.has(provider) || !state.done || loginAbort.has(provider)) continue; - if (loginState.delete(provider)) removed += 1; - if (loginManual.delete(provider)) removed += 1; - if (loginAbort.delete(provider)) removed += 1; - } - lastOAuthFlowReconciledGeneration = context.generation; - return removed; -} - -function clearManualCodeSlot(provider: string): void { - loginManual.delete(provider); -} - -function ensureManualCodeSlot(provider: string): ManualCodeSlot { - let slot = loginManual.get(provider); - if (!slot) { - slot = {}; - loginManual.set(provider, slot); - } - return slot; -} - -/** Wait for a GUI/CLI paste of the OAuth redirect URL or code (or return a stashed early submit). */ -function waitForManualLoginCode(provider: string, signal: AbortSignal, expectedState?: string): Promise { - if (signal.aborted) { - return Promise.reject(new Error(`OAuth callback cancelled: ${signal.reason}`)); - } - const slot = ensureManualCodeSlot(provider); - if (expectedState !== undefined) slot.expectedState = expectedState; - if (slot.pendingInput !== undefined) { - const value = slot.pendingInput; - slot.pendingInput = undefined; - return Promise.resolve(value); - } - return new Promise((resolve, reject) => { - const onAbort = () => { - if (slot.resolve === resolve) slot.resolve = undefined; - reject(new Error(`OAuth callback cancelled: ${signal.reason}`)); - }; - signal.addEventListener("abort", onAbort, { once: true }); - slot.resolve = (value: string) => { - signal.removeEventListener("abort", onAbort); - if (slot.resolve === resolve) slot.resolve = undefined; - resolve(value); - }; - }); -} - -/** - * Feed a pasted redirect URL or authorization code into an in-progress GUI login. - * Returns ok:false when no login is waiting (or input is empty). Invalid pastes are accepted - * here and re-prompted by the OAuth callback loop if they cannot be parsed / fail state checks. - */ -export function submitManualLoginCode(provider: string, input: string): { ok: true } | { ok: false; error: string } { - const trimmed = input.trim(); - if (!trimmed) return { ok: false, error: "empty code" }; - if (retainedUtf8Bytes(trimmed) > OAUTH_PENDING_CODE_MAX_BYTES) return { ok: false, error: "code too large" }; - const st = loginState.get(provider); - if (!st || st.done) return { ok: false, error: "no login in progress" }; - const slot = ensureManualCodeSlot(provider); - // Synchronous validation (validated request/ack): reject un-parseable input and - // authorization responses (url/query kind) whose state is missing or mismatched - // once the flow has registered its expected state. Raw codes stay in-session-PKCE - // protected — but a raw paste carrying an explicit #state suffix is state-bearing - // and checked too. Early posts (flow not yet waiting, no expectedState) are - // stashed and re-validated by the callback loop. - const parsed = parseCallbackInput(trimmed); - // Command Code's manual fallback accepts a pasted JSON callback payload - // (`{ apiKey, state, ... }`) which has no `code` param. Let it through the - // shared gate so the provider-specific parser can validate it. - const isCommandCodeJson = provider === "command-code" && trimmed.startsWith("{") && !parsed.code; - if (!parsed.code && !isCommandCodeJson) return { ok: false, error: "no authorization code found in input" }; - // A raw paste carrying an explicit code#state suffix is state-bearing too: it - // must match the expected state rather than bypass validation. - const stateBearing = parsed.kind !== "raw" || parsed.state !== undefined; - if (stateBearing && slot.expectedState !== undefined) { - if (parsed.state === undefined) return { ok: false, error: "redirect URL is missing the state parameter" }; - if (parsed.state !== slot.expectedState) { - return { - ok: false, - error: parsed.kind === "raw" - ? "state mismatch — paste the bare code, or the correct code#state from THIS login attempt" - : "state mismatch — paste the redirect URL from THIS login attempt", - }; - } - } - if (slot.resolve) { - const resolve = slot.resolve; - slot.resolve = undefined; - resolve(trimmed); - } else { - // Race: GUI may POST before the flow reaches onManualCodeInput — stash for the waiter. - slot.pendingInput = trimmed; - } - return { ok: true }; -} export interface OAuthAccountSummary { id: string; diff --git a/src/oauth/login-flow-state.ts b/src/oauth/login-flow-state.ts new file mode 100644 index 00000000000..a3a4951c3f4 --- /dev/null +++ b/src/oauth/login-flow-state.ts @@ -0,0 +1,127 @@ +import { parseCallbackInput } from "./callback-server"; +import { retainedUtf8Bytes } from "../lib/admission"; +import type { GenerationContext } from "../lib/state-store-sweeper"; + +/** + * In-flight login flow state and the manual paste slot that feeds it. + * + * Split out of `index.ts` when that file crossed the repository file-size ratchet at 2009 + * lines. The cut follows a seam rather than a line count: everything here is bookkeeping for + * a login that has started and not yet settled, and none of it reads or writes a stored + * credential. `index.ts` re-exports the two public names, so existing importers are unaffected. + */ +export const loginState = new Map(); +export const loginAbort = new Map(); +export const kiroLoginSettling = new Set(); + +/** Pending paste for a login in progress: either a waiter or a stashed early submission. */ +export interface ManualCodeSlot { + pendingInput?: string; + resolve?: (value: string) => void; + /** Registered by the callback flow so submits can validate state synchronously. */ + expectedState?: string; +} +const loginManual = new Map(); +const OAUTH_PENDING_CODE_MAX_BYTES = 4 * 1024; +let lastOAuthFlowReconciledGeneration = 0; + +export function reconcileOAuthFlowState(context: GenerationContext): number { + if (context.generation <= lastOAuthFlowReconciledGeneration) return 0; + let removed = 0; + for (const [provider, state] of loginState) { + if (context.providerNames.has(provider) || !state.done || loginAbort.has(provider)) continue; + if (loginState.delete(provider)) removed += 1; + if (loginManual.delete(provider)) removed += 1; + if (loginAbort.delete(provider)) removed += 1; + } + lastOAuthFlowReconciledGeneration = context.generation; + return removed; +} + +export function clearManualCodeSlot(provider: string): void { + loginManual.delete(provider); +} + +export function ensureManualCodeSlot(provider: string): ManualCodeSlot { + let slot = loginManual.get(provider); + if (!slot) { + slot = {}; + loginManual.set(provider, slot); + } + return slot; +} + +/** Wait for a GUI/CLI paste of the OAuth redirect URL or code (or return a stashed early submit). */ +export function waitForManualLoginCode(provider: string, signal: AbortSignal, expectedState?: string): Promise { + if (signal.aborted) { + return Promise.reject(new Error(`OAuth callback cancelled: ${signal.reason}`)); + } + const slot = ensureManualCodeSlot(provider); + if (expectedState !== undefined) slot.expectedState = expectedState; + if (slot.pendingInput !== undefined) { + const value = slot.pendingInput; + slot.pendingInput = undefined; + return Promise.resolve(value); + } + return new Promise((resolve, reject) => { + const onAbort = () => { + if (slot.resolve === resolve) slot.resolve = undefined; + reject(new Error(`OAuth callback cancelled: ${signal.reason}`)); + }; + signal.addEventListener("abort", onAbort, { once: true }); + slot.resolve = (value: string) => { + signal.removeEventListener("abort", onAbort); + if (slot.resolve === resolve) slot.resolve = undefined; + resolve(value); + }; + }); +} + +/** + * Feed a pasted redirect URL or authorization code into an in-progress GUI login. + * Returns ok:false when no login is waiting (or input is empty). Invalid pastes are accepted + * here and re-prompted by the OAuth callback loop if they cannot be parsed / fail state checks. + */ +export function submitManualLoginCode(provider: string, input: string): { ok: true } | { ok: false; error: string } { + const trimmed = input.trim(); + if (!trimmed) return { ok: false, error: "empty code" }; + if (retainedUtf8Bytes(trimmed) > OAUTH_PENDING_CODE_MAX_BYTES) return { ok: false, error: "code too large" }; + const st = loginState.get(provider); + if (!st || st.done) return { ok: false, error: "no login in progress" }; + const slot = ensureManualCodeSlot(provider); + // Synchronous validation (validated request/ack): reject un-parseable input and + // authorization responses (url/query kind) whose state is missing or mismatched + // once the flow has registered its expected state. Raw codes stay in-session-PKCE + // protected — but a raw paste carrying an explicit #state suffix is state-bearing + // and checked too. Early posts (flow not yet waiting, no expectedState) are + // stashed and re-validated by the callback loop. + const parsed = parseCallbackInput(trimmed); + // Command Code's manual fallback accepts a pasted JSON callback payload + // (`{ apiKey, state, ... }`) which has no `code` param. Let it through the + // shared gate so the provider-specific parser can validate it. + const isCommandCodeJson = provider === "command-code" && trimmed.startsWith("{") && !parsed.code; + if (!parsed.code && !isCommandCodeJson) return { ok: false, error: "no authorization code found in input" }; + // A raw paste carrying an explicit code#state suffix is state-bearing too: it + // must match the expected state rather than bypass validation. + const stateBearing = parsed.kind !== "raw" || parsed.state !== undefined; + if (stateBearing && slot.expectedState !== undefined) { + if (parsed.state === undefined) return { ok: false, error: "redirect URL is missing the state parameter" }; + if (parsed.state !== slot.expectedState) { + return { + ok: false, + error: parsed.kind === "raw" + ? "state mismatch — paste the bare code, or the correct code#state from THIS login attempt" + : "state mismatch — paste the redirect URL from THIS login attempt", + }; + } + } + if (slot.resolve) { + const resolve = slot.resolve; + slot.resolve = undefined; + resolve(trimmed); + } else { + // Race: GUI may POST before the flow reaches onManualCodeInput — stash for the waiter. + slot.pendingInput = trimmed; + } + return { ok: true }; +} From 7b953e968287e9d9f2eb64ba7ce9702caca4ca22 Mon Sep 17 00:00:00 2001 From: lidge-jun Date: Sun, 20 Sep 2026 06:23:55 +0900 Subject: [PATCH 2/3] fix(adapters): keep the bounded image decode inside the file's size cap src/adapters/openai-chat.ts reached 823 lines against a committed cap of 822 after #5208, so the ratchet reports GREW and the gate fails for every branch whose merge ref includes current dev. The overage is one line out of a six-line addition, which does not justify extracting a module. Naming the options object instead of spreading it across three lines expresses the same call with the same arguments and leaves the file at 821, one line under the cap. No comment was removed and no behaviour changed: normalizeOpenAIChatImages receives the same tierBias and abortSignal, and the rejection handler still rethrows on a real abort and otherwise finishes. No local suite, focused test, typecheck or build was run. Exact-head hosted CI is the execution evidence. --- src/adapters/openai-chat.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/adapters/openai-chat.ts b/src/adapters/openai-chat.ts index ab6d5dcf3f0..633c693c7ff 100644 --- a/src/adapters/openai-chat.ts +++ b/src/adapters/openai-chat.ts @@ -297,10 +297,8 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd }; }; if (hasShrinkableOpenAIChatImages(messages)) { - return normalizeOpenAIChatImages(messages, { - tierBias: incoming?.imageTierBias, - abortSignal: incoming?.abortSignal, - }).then(finish, error => { + const imageOptions = { tierBias: incoming?.imageTierBias, abortSignal: incoming?.abortSignal }; + return normalizeOpenAIChatImages(messages, imageOptions).then(finish, error => { if (incoming?.abortSignal?.aborted) throw error; return finish(); }); From bbc825290c734c15c826cbd3f1f41feaa07fd160 Mon Sep 17 00:00:00 2001 From: lidge-jun Date: Sun, 20 Sep 2026 06:30:30 +0900 Subject: [PATCH 3/3] test(layout): register the client runtime test in both inventories tests/clients/client-runtime.test.ts arrived with #5144 and was recorded in neither scripts/test-layout/layout.json explicit nor tests/fixtures/test-layout-expected.json, so the layout guards fail for every branch whose merge ref includes current dev. The file already sits in the domain directory its name implies, so the registration is the whole fix: clients in both inventories, matching the other tests/clients entries. No guard was relaxed and no seed regex was widened to make the file resolve implicitly. Reported by the contract-campaign task, which verified the omission by static blob lookup on dev before raising it. No local suite, focused test, typecheck or build was run. Exact-head hosted CI is the execution evidence. --- scripts/test-layout/layout.json | 3 ++- tests/fixtures/test-layout-expected.json | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 42711f4eb46..9e826464d46 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1558,7 +1558,8 @@ "gui-codex-usage-score-parity.test.ts": "gui", "web-search-sidecar-429.test.ts": "web-search", "management-google-tool-schema-policy.test.ts": "server", - "codex-shim-destroyed-probe.test.ts": "codex-integration" + "codex-shim-destroyed-probe.test.ts": "codex-integration", + "client-runtime.test.ts": "clients" }, "migrated": [ "adapters", diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 0eb3bc1e06f..48450536ede 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1390,5 +1390,6 @@ "responses-code-mode-patch-compile.test.ts": "responses", "gui-codex-usage-score-parity.test.ts": "gui", "web-search-sidecar-429.test.ts": "web-search", - "codex-shim-destroyed-probe.test.ts": "codex-integration" + "codex-shim-destroyed-probe.test.ts": "codex-integration", + "client-runtime.test.ts": "clients" }