Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
98ee7a2
fix(request): remove double-exponential in getRateLimitBackoffWithReason
ndycode Apr 17, 2026
d38ef10
refactor(codex-manager): delete dead command modules and ghost tests
ndycode Apr 17, 2026
fcb5521
fix(auth): redact opaque tokens from free-form log messages (LIB-HIGH…
ndycode Apr 17, 2026
f15933a
fix(request): linear SSE buffering with pre-append size check
ndycode Apr 17, 2026
22db44c
fix(accounts): invalidate runtime tracker key on removeAccount (HI-01)
ndycode Apr 17, 2026
3226352
fix(accounts): sync cursorByFamily in markSwitched (HI-02)
ndycode Apr 17, 2026
20c5f65
fix(recovery): prependThinkingPart unique id prevents retry overwrite…
ndycode Apr 17, 2026
8ecb724
test(rotation): add concurrency coverage for selectHybridAccount (HI-05)
ndycode Apr 17, 2026
258d5b4
fix(auth): require state in manual-paste callback flow
ndycode Apr 18, 2026
8895b76
fix(storage): cap import file size to prevent oversized reads (STORAG…
ndycode Apr 18, 2026
6f06bc3
fix(settings): remove Atomics.wait from sync retry loops (LIB-HIGH-002)
ndycode Apr 18, 2026
aabb652
fix(ui): honor Q hotkey consistently in select/confirm (RPTU-003)
ndycode Apr 18, 2026
eb5408d
fix(auth): enforce expectedState in waitForCode (AUTH-HIGH-2)
ndycode Apr 18, 2026
06f2390
fix(settings): retry section writes against latest disk state (R-LOGI…
ndycode Apr 18, 2026
8aad2a7
fix(shutdown): make runCleanup idempotent under concurrent calls (LIB…
ndycode Apr 18, 2026
ff4dff6
fix(accounts): clear shifted numeric tracker state after removeAccount
ndycode Apr 18, 2026
33ec2c6
test(accounts): cover markSwitchedLocked cursor sync (PR #421)
ndycode Apr 18, 2026
f474aa2
fix(auth): anchor urlencoded redaction to full param boundaries
ndycode Apr 18, 2026
afd1fff
fix(request): count utf-8 bytes in SSE size guard
ndycode Apr 18, 2026
d1f4caf
test(recovery): make RPTU-001 regression deterministic (PR #423)
ndycode Apr 18, 2026
366e7e8
test(recovery): relax deterministic target-path assertion in RPTU-001…
ndycode Apr 18, 2026
ccd1274
test(recovery): keep RPTU-001 regression deterministic without brittl…
ndycode Apr 18, 2026
118b852
test(recovery): remove brittle path/id specificity from RPTU-001 regr…
ndycode Apr 18, 2026
9b4306b
test(settings): ensure sync backup retry test actually exercises copy…
ndycode Apr 18, 2026
81824d7
test(settings): import dirname in sync backup retry regression (PR #426)
ndycode Apr 18, 2026
6f2c1ec
fix(storage): use file handle stat/read + add import boundary tests (…
ndycode Apr 18, 2026
a7bea8b
fix(auth): scrub codeVerifier and token-like substrings in sanitized…
ndycode Apr 18, 2026
6931526
test(rotation): assert sawPredecessorWrite in hybrid concurrency prop…
ndycode Apr 18, 2026
3279218
fix(settings): capture optimistic mtime from the same read handle (PR…
ndycode Apr 18, 2026
d582d0d
Revert "fix(settings): capture optimistic mtime from the same read ha…
ndycode Apr 18, 2026
e6de13d
fix(settings): snapshot mtime before async unified-settings read (PR …
ndycode Apr 18, 2026
0ad6a25
rollup: merge fix/rate-limit-backoff-double-exponential
ndycode Apr 18, 2026
2d8bfc2
rollup: merge fix/token-log-redaction
ndycode Apr 18, 2026
e0a8a34
rollup: merge fix/sse-buffer-linear
ndycode Apr 18, 2026
0e21c80
rollup: merge fix/manual-paste-state-binding
ndycode Apr 18, 2026
4c35972
rollup: merge fix/oauth-server-waitforcode-state
ndycode Apr 18, 2026
d7acb80
rollup: merge fix/thinking-part-unique-id
ndycode Apr 18, 2026
7c90a6e
rollup: merge fix/import-size-limit
ndycode Apr 18, 2026
045a914
rollup: merge fix/unified-settings-stale-overwrite
ndycode Apr 18, 2026
9f2442a
rollup: merge fix/unified-settings-sync-sleep
ndycode Apr 18, 2026
de1e179
rollup: merge fix/stale-runtime-tracker-key
ndycode Apr 18, 2026
098a17f
rollup: merge fix/marksswitched-cursor-sync
ndycode Apr 18, 2026
11e6f1d
rollup: merge fix/hybrid-selector-test-coverage
ndycode Apr 18, 2026
25fc5b6
rollup: merge fix/ui-q-hotkey
ndycode Apr 18, 2026
fd4d36d
rollup: merge fix/shutdown-cleanup-ordering
ndycode Apr 18, 2026
47253a8
rollup: merge refactor/delete-dead-command-modules
ndycode Apr 18, 2026
7f50455
fix(rollup): add missing numeric tracker cleanup helpers from PR #419
ndycode Apr 18, 2026
056ad18
test(rollup): restore auth-list empty-state expectation to current be…
ndycode Apr 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 41 additions & 2 deletions lib/accounts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -866,6 +866,16 @@ export class AccountManager {
): void {
account.lastSwitchReason = reason;
this.currentAccountIndexByFamily[family] = account.index;
// HI-02: keep cursorByFamily in lockstep so subsequent round-robin
// passes resume AFTER the just-switched account, matching the
// convention used in getCurrentOrNextForFamilyHybrid / the
// getCurrentOrNextForFamily inner loop. Without this, the cursor
// still points at the pre-switch position and the next selection
// can re-pick or skip the freshly marked account.
const count = this.accounts.length;
if (count > 0) {
this.cursorByFamily[family] = (account.index + 1) % count;
}
}

/**
Expand Down Expand Up @@ -900,6 +910,13 @@ export class AccountManager {
return withRoutingMutex(this.routingMutexMode, () => {
account.lastSwitchReason = reason;
this.currentAccountIndexByFamily[family] = account.index;
// HI-02: keep cursorByFamily in lockstep with the active-index
// mutation so the mutex-serialized variant preserves the same
// round-robin invariant as the legacy `markSwitched` path.
const count = this.accounts.length;
if (count > 0) {
this.cursorByFamily[family] = (account.index + 1) % count;
}
const trackerKey = getRuntimeTrackerKey(account);
const healthTracker = getHealthTracker();
const tokenTracker = getTokenTracker();
Expand Down Expand Up @@ -1316,16 +1333,38 @@ export class AccountManager {

// Snapshot family pointers before splice so we can distinguish "was
// pointing at the removed account" from "was pointing past it".
const priorCursor: Record<ModelFamily, number> = {} as Record<ModelFamily, number>;
const priorActive: Record<ModelFamily, number> = {} as Record<ModelFamily, number>;
const priorCursor: Record<ModelFamily, number> = {} as Record<
ModelFamily,
number
>;
const priorActive: Record<ModelFamily, number> = {} as Record<
ModelFamily,
number
>;
for (const family of MODEL_FAMILIES) {
priorCursor[family] = this.cursorByFamily[family];
priorActive[family] = this.currentAccountIndexByFamily[family];
}

this.accounts.splice(idx, 1);
// Clear numeric-keyed tracker state in the shifted range. After reindex,
// any refresh-only account that moved from N to N-1 must not inherit the
// stale health/token entries that used to belong to the old numeric slot.
getHealthTracker().clearNumericKeysAtOrAbove(idx);
getTokenTracker().clearNumericKeysAtOrAbove(idx);
this.accounts.forEach((acc, index) => {
acc.index = index;
// Invalidate the cached runtime tracker key when it was keyed by
// numeric index (fallback path in getRuntimeAccountIdentityKey).
// After the splice+reindex above, a remaining account that was at
// index N (e.g. 3) may now live at index N-1 (e.g. 2); if we keep
// the previously cached numeric key, rotation/health/token state
// queries would consult the stale position and mismatch the
// current one. Identity-based string keys remain stable because
// accountId/email are not affected by array position changes.
if (typeof acc._runtimeTrackerKey === "number") {
acc._runtimeTrackerKey = undefined;
}
});

if (this.accounts.length === 0) {
Expand Down
102 changes: 98 additions & 4 deletions lib/auth/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,98 @@ function getOAuthResponseLogMetadata(
return { responseType: typeof rawResponse };
}

const OAUTH_SENSITIVE_BODY_KEYS = [
"refresh_token",
"refreshToken",
"access_token",
"accessToken",
"id_token",
"idToken",
"codeVerifier",
"token",
"code",
"code_verifier",
] as const;

function scrubTokenLikeSubstrings(value: string): string {
let scrubbed = value.replace(
/(\b(?:refresh|access|id)[_-]?token\s*[:=]\s*)([^\s,;"'}]{8,})/gi,
(_match, prefix) => `${prefix}***REDACTED***`,
);
scrubbed = scrubbed.replace(
/\b(?:RT|AT)_ch_[A-Za-z0-9_-]{20,}\b/g,
"***REDACTED***",
);
return scrubbed;
}

function redactSensitiveFields(value: unknown): unknown {
if (Array.isArray(value)) {
return value.map((item) => redactSensitiveFields(item));
}
if (value !== null && typeof value === "object") {
const out: Record<string, unknown> = {};
const sensitiveSet = new Set<string>(OAUTH_SENSITIVE_BODY_KEYS);
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
if (sensitiveSet.has(k)) {
out[k] = "***REDACTED***";
} else {
out[k] = redactSensitiveFields(v);
}
}
return out;
}
if (typeof value === "string") {
return scrubTokenLikeSubstrings(value);
}
return value;
}

/**
* Scrub opaque tokens from a raw OAuth token-endpoint response body before
* interpolating it into a log message.
*
* Error and success responses from `/oauth/token` may contain `refresh_token`,
* `access_token`, or `id_token` values. ChatGPT refresh tokens are opaque
* high-entropy strings that do NOT match the logger's `TOKEN_PATTERNS`
* (JWT, long hex, `sk-*`, `Bearer <x>`), so they would be written verbatim
* to disk log files when a status-body string is concatenated into a
* `logError` message.
*
* Strategy:
* 1. If the body parses as JSON, walk it and mask sensitive keys.
* 2. Otherwise fall back to a targeted regex scrub of `"key":"value"` and
* `key=value` patterns for the known sensitive keys.
*
* The returned string is safe to interpolate into log messages.
*/
export function sanitizeOAuthResponseBodyForLog(rawBody: string): string {
if (!rawBody) return rawBody;
const trimmed = rawBody.trim();
if (trimmed.length === 0) return rawBody;

if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
try {
const parsed = JSON.parse(trimmed) as unknown;
const redacted = redactSensitiveFields(parsed);
return JSON.stringify(redacted);
} catch {
// Fall through to regex scrub for malformed JSON.
}
}

let scrubbed = rawBody;
for (const key of OAUTH_SENSITIVE_BODY_KEYS) {
// "key":"value" style (JSON-like text)
const jsonPattern = new RegExp(`("${key}"\\s*:\\s*)"[^"]*"`, "g");
scrubbed = scrubbed.replace(jsonPattern, `$1"***REDACTED***"`);
// key=value style (urlencoded / query-string)
const urlPattern = new RegExp(`(^|[?&\\s])(${key}=)[^&\\s]+`, "g");
scrubbed = scrubbed.replace(urlPattern, `$1$2***REDACTED***`);
}
return scrubbed;
}
Comment on lines +69 to +159

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

sanitizer looks solid; one subtle gap worth covering.

lib/auth/auth.ts:134-159 does the right thing: structured json → recursive key redaction, otherwise a targeted regex scrub. two notes:

  1. ast-grep's redos hit on lib/auth/auth.ts:152 and lib/auth/auth.ts:155 is a false positive — OAUTH_SENSITIVE_BODY_KEYS is a frozen const array of ascii identifiers, not user input, so no regex injection surface here. no action.
  2. real gap: when the body is neither json nor urlencoded but is a loose text blob (e.g. a reverse proxy returns "upstream error: refresh_token=RT_ch_...; trace=..." with leading plain text), the json path is skipped (doesn't start with {/[) and the urlencoded regex at lib/auth/auth.ts:155 requires a ^|[?&\s] boundary before key=. a body like upstream error refresh_token=RT_ch_xxxx... with a space before refresh_token is covered by \s, but upstream:refresh_token=... (colon boundary) is not. the scrubTokenLikeSubstrings helper at lib/auth/auth.ts:82-92 would save you here for the RT_ch_/AT_ch_ shape, but it is only invoked from redactSensitiveFields on parsed-json strings, not on the raw-text fallback path.

please either (a) pipe the fallback output through scrubTokenLikeSubstrings as a final pass, or (b) loosen the boundary to also accept [:]. recommend (a) since it also catches opaque tokens that appear without a key= prefix.

proposed fix
 	let scrubbed = rawBody;
 	for (const key of OAUTH_SENSITIVE_BODY_KEYS) {
 		const jsonPattern = new RegExp(`("${key}"\\s*:\\s*)"[^"]*"`, "g");
 		scrubbed = scrubbed.replace(jsonPattern, `$1"***REDACTED***"`);
 		const urlPattern = new RegExp(`(^|[?&\\s])(${key}=)[^&\\s]+`, "g");
 		scrubbed = scrubbed.replace(urlPattern, `$1$2***REDACTED***`);
 	}
-	return scrubbed;
+	return scrubTokenLikeSubstrings(scrubbed);
 }

and add a test in test/auth.test.ts alongside the LIB-HIGH-001 regression suite covering a non-json/non-urlencoded body carrying an opaque RT_ch_... substring.

As per coding guidelines: "check for logging that leaks tokens or emails."

🧰 Tools
🪛 ast-grep (0.42.1)

[warning] 151-151: Regular expression constructed from variable input detected. This can lead to Regular Expression Denial of Service (ReDoS) attacks if the variable contains malicious patterns. Use libraries like 'recheck' to validate regex safety or use static patterns.
Context: new RegExp(("${key}"\\s*:\\s*)"[^"]*", "g")
Note: [CWE-1333] Inefficient Regular Expression Complexity [REFERENCES]
- https://owasp.org/www-community/attacks/Regular_expression_Denial_of_Service_-_ReDoS
- https://cwe.mitre.org/data/definitions/1333.html

(regexp-from-variable)


[warning] 154-154: Regular expression constructed from variable input detected. This can lead to Regular Expression Denial of Service (ReDoS) attacks if the variable contains malicious patterns. Use libraries like 'recheck' to validate regex safety or use static patterns.
Context: new RegExp((^|[?&\\s])(${key}=)[^&\\s]+, "g")
Note: [CWE-1333] Inefficient Regular Expression Complexity [REFERENCES]
- https://owasp.org/www-community/attacks/Regular_expression_Denial_of_Service_-_ReDoS
- https://cwe.mitre.org/data/definitions/1333.html

(regexp-from-variable)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/auth/auth.ts` around lines 69 - 159, The fallback text-scrub in
sanitizeOAuthResponseBodyForLog misses token-like substrings when rawBody isn't
JSON/URL-encoded; call scrubTokenLikeSubstrings on the final scrubbed string
before returning (so sanitizeOAuthResponseBodyForLog runs
scrubTokenLikeSubstrings(scrubbed) as a last pass), referencing
scrubTokenLikeSubstrings, sanitizeOAuthResponseBodyForLog, and
OAUTH_SENSITIVE_BODY_KEYS/redactSensitiveFields to locate the JSON-path handling
and final regex loop; also add a unit test in test/auth.test.ts that verifies an
upstream plain-text body with an RT_ch_... substring is redacted.


/**
* Redacts sensitive OAuth query parameters for safe logging.
* Returns the original string when parsing fails.
Expand Down Expand Up @@ -160,12 +252,13 @@ export async function exchangeAuthorizationCode(
});
if (!res.ok) {
const text = await res.text().catch(() => "");
logError(`code->token failed: ${res.status} ${text}`);
const safeText = sanitizeOAuthResponseBodyForLog(text);
logError(`code->token failed: ${res.status} ${safeText}`);
return {
type: "failed",
reason: "http_error",
statusCode: res.status,
message: text || undefined,
message: safeText || undefined,
};
}
const rawJson = (await res.json()) as unknown;
Expand Down Expand Up @@ -252,12 +345,13 @@ export async function refreshAccessToken(

if (!response.ok) {
const text = await response.text().catch(() => "");
logError(`Token refresh failed: ${response.status} ${text}`);
const safeText = sanitizeOAuthResponseBodyForLog(text);
logError(`Token refresh failed: ${response.status} ${safeText}`);
return {
type: "failed",
reason: "http_error",
statusCode: response.status,
message: text || undefined,
message: safeText || undefined,
};
}

Expand Down
27 changes: 21 additions & 6 deletions lib/auth/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,14 +66,18 @@ export function startLocalOAuthServer({
"default-src 'none'; style-src 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; script-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
);
res.end(successHtml);
const trackedServer = server as http.Server & { _lastCode?: string };
const trackedServer = server as http.Server & {
_lastCode?: string;
_lastState?: string;
};
if (trackedServer._lastCode) {
logWarn(
"Duplicate OAuth callback received; preserving first authorization code",
);
return;
}
trackedServer._lastCode = code;
trackedServer._lastState = state;
Comment on lines +69 to +80

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

state pinning is correct but partially redundant; worth a comment.

lib/auth/server.ts:49 already rejects callbacks whose state doesn't match the closure-captured state, so by the time _lastState is written at lib/auth/server.ts:80, it is provably equal to state. that means the expectedState check inside waitForCode at lib/auth/server.ts:116-121 only fires when the caller in lib/codex-manager.ts:1894 or the runtime flows passes a value different from what it handed to startLocalOAuthServer({ state }) — i.e. a caller-side bug, not an attacker-controlled path.

that's still a reasonable defense-in-depth check for the rollup of #418, but please add a short comment at lib/auth/server.ts:116 spelling out the invariant (_lastState is always equal to the server-closure state; the compare guards against caller misuse) so a future refactor doesn't conclude the check is dead code and delete it.

also, one concurrency nit worth calling out for windows/EBUSY-prone environments per the repo guidelines: waitForCode polls _lastCode every 100ms on a single-threaded event loop, so there's no TOCTOU here, but if anyone later adds an async step between the lastCode read at lib/auth/server.ts:114 and the state compare at :116, this becomes racy. leave a comment or snapshot both fields in one read:

suggested tightening
-							const lastCode = trackedServer._lastCode;
-							if (lastCode) {
-								if (trackedServer._lastState !== expectedState) {
+							const { _lastCode: lastCode, _lastState: lastState } =
+								trackedServer;
+							if (lastCode) {
+								if (lastState !== expectedState) {
 									logWarn(
 										"Discarding OAuth callback due to state mismatch in waitForCode",
 									);
 									return null;
 								}
 								return { code: lastCode };
 							}

As per coding guidelines: "focus on auth rotation, windows filesystem IO, and concurrency."

Also applies to: 102-128, 148-148

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/auth/server.ts` around lines 69 - 80, Add a short comment in waitForCode
(around the compare at the expectedState check) explaining the invariant that
trackedServer._lastState is always equal to the closure-captured state written
earlier in startLocalOAuthServer, and that the expectedState compare is a
defense‑in‑depth guard against caller misuse (not an attacker-controlled path);
additionally, to eliminate a potential TOCTOU race if someone later inserts
async work, read/snapshot both trackedServer._lastCode and
trackedServer._lastState into local variables in a single synchronous read
before doing the state compare and code handling inside waitForCode (referencing
trackedServer._lastCode, trackedServer._lastState, startLocalOAuthServer, and
waitForCode).

} catch (err) {
logError(
`Request handler error: ${(err as Error)?.message ?? String(err)}`,
Expand All @@ -95,17 +99,28 @@ export function startLocalOAuthServer({
pollAborted = true;
server.close();
},
waitForCode: async () => {
waitForCode: async (expectedState: string) => {
const POLL_INTERVAL_MS = 100;
const TIMEOUT_MS = 5 * 60 * 1000;
const maxIterations = Math.floor(TIMEOUT_MS / POLL_INTERVAL_MS);
const poll = () =>
new Promise<void>((r) => setTimeout(r, POLL_INTERVAL_MS));
for (let i = 0; i < maxIterations; i++) {
if (pollAborted) return null;
const lastCode = (server as http.Server & { _lastCode?: string })
._lastCode;
if (lastCode) return { code: lastCode };
const trackedServer = server as http.Server & {
_lastCode?: string;
_lastState?: string;
};
const lastCode = trackedServer._lastCode;
if (lastCode) {
if (trackedServer._lastState !== expectedState) {
logWarn(
"Discarding OAuth callback due to state mismatch in waitForCode",
);
return null;
}
return { code: lastCode };
}
await poll();
}
logWarn("OAuth poll timeout after 5 minutes");
Expand All @@ -130,7 +145,7 @@ export function startLocalOAuthServer({
);
}
},
waitForCode: () => Promise.resolve(null),
waitForCode: (_expectedState: string) => Promise.resolve(null),
});
});
});
Expand Down
4 changes: 2 additions & 2 deletions lib/codex-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1360,8 +1360,8 @@ async function promptManualCallback(
return null;
}
const parsed = parseAuthorizationInput(answer);
if (!parsed.code) return null;
if (parsed.state && parsed.state !== state) return null;
if (!parsed.code || !parsed.state) return null;
if (parsed.state !== state) return null;
return parsed.code;
} catch (error) {
if (isAbortError(error) || isReadlineClosedError(error)) {
Expand Down
Loading