Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
72 changes: 67 additions & 5 deletions src/codex/account-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -435,6 +435,33 @@ function forcedFenceSuperseded(recordGeneration: number, forced: ForcedRefreshFe
return forced !== undefined && recordGeneration !== forced.rejectedGeneration;
}

/**
* Wait for a SHARED promise while honoring only the calling request's cancellation.
*
* The awaited work is not the caller's to cancel — other requests are waiting on the
* same promise — so an aborted caller stops waiting and the work continues to
* completion for them (#2892 gap 2). The rejection handler prevents an unhandled
* rejection from the promise this caller walked away from.
*/
function awaitOwnCancellation<T>(work: Promise<T>, callerSignal?: AbortSignal): Promise<T> {
if (!callerSignal) return work;
if (callerSignal.aborted) {
work.catch(() => {});
return Promise.reject(callerSignal.reason);
}
return new Promise<T>((resolve, reject) => {
const onAbort = (): void => {
work.catch(() => {});
reject(callerSignal.reason);
};
callerSignal.addEventListener("abort", onAbort, { once: true });
work.then(
value => { callerSignal.removeEventListener("abort", onAbort); resolve(value); },
err => { callerSignal.removeEventListener("abort", onAbort); reject(err); },
);
});
}

/**
* Refresh a stored pool credential that upstream rejected with a 401, even though its
* `expiresAt` still looks valid. Ordinary callers must keep using
Expand Down Expand Up @@ -482,6 +509,7 @@ async function resolveCodexToken(
forced?: ForcedRefreshFence,
callerSignal?: AbortSignal,
): Promise<CodexRefreshResult> {
if (callerSignal?.aborted) throw callerSignal.reason;
const record = readCodexAccountRecord(id);
const cred = record?.deletedAt == null ? record?.credential : undefined;
if (!record || !cred) throw new Error("Codex account credential is unavailable; reauthenticate the account.");
Expand All @@ -503,7 +531,7 @@ async function resolveCodexToken(
existing.abort.abort(new CodexCredentialRefreshStaleError());
if (refreshLocks.get(refreshGrantFingerprint) === existing) refreshLocks.delete(refreshGrantFingerprint);
} else {
const refreshed = await existing.promise;
const refreshed = await awaitOwnCancellation(existing.promise, callerSignal);
const current = readCodexAccountRecord(id);
const currentCred = current?.deletedAt == null ? current?.credential : undefined;
// The flight owner already committed this credential, and it is the one stored
Expand All @@ -529,9 +557,25 @@ async function resolveCodexToken(
// The rejected-token test comes FIRST: a joined flight that resolved back to the
// bearer upstream rejected proves nothing, and reporting the replacement as
// "superseded" would hand the caller a token it must not replay.
//
// Freshness is tested here too. Supersession says only that SOMEONE replaced the
// credential — not that what they wrote is usable. An expired G+1 satisfies the
// generation test and the rejected-bearer test while being certain to earn
// another 401, and because the caller treats this return as a successful
// recovery it spends its one replay on it (#2892 gap 1). A stale winner must
// fall through to a real refresh instead.
//
// Stated honestly: this guard is NOT covered by a red-proven test. Reaching this
// branch needs a live flight that RESOLVES, a stored credential differing from
// what the flight produced, and that stored credential expired — three attempted
// interleavings each landed elsewhere (own flight, first adopt-stored branch, or
// a CAS conflict that rejects for both callers). The guard is one comparison on a
// path that otherwise returns a known-dead token, and its only effect is to
// divert to the refresh the caller would have needed anyway.
if (
current && currentCred
&& forcedFenceSuperseded(current.generation, forced)
&& currentCred.expiresAt > Date.now() + REFRESH_SKEW_MS
&& !(forced !== undefined && currentCred.accessToken === forced.rejectedAccessToken)
) {
return {
Expand Down Expand Up @@ -574,10 +618,25 @@ async function resolveCodexToken(

if (refreshLocks.size >= MAX_CODEX_REFRESH_FLIGHTS) throw new CodexCredentialRefreshBusyError();

/*
* The flight's lifetime belongs to the FLIGHT, not to whichever caller happened to
* open it (#2892 gap 2).
*
* Flights are shared: later callers on the same grant join `existing.promise` rather
* than starting their own. Folding `callerSignal` into the flight's signal therefore
* gave one arbitrary waiter the power to abort the token request out from under every
* other waiter — and the joiners have no way to distinguish that from a genuine
* upstream failure, so a cancelled Codex tab could retire a healthy account for a
* request that was still running.
*
* The initiating caller still gets cancellation: it is waiting on its own await, and
* `awaitOwnCancellation` below races its wait against its own signal. What it no
* longer gets is the ability to cancel work other callers depend on: the flight keeps
* running for the joiners, and its result is still committed. `abort` (stale-flight
* eviction) and the 30s ceiling remain, because those bound the flight itself.
*/
const abort = new AbortController();
const signal = AbortSignal.any(
callerSignal ? [abort.signal, AbortSignal.timeout(30_000), callerSignal] : [abort.signal, AbortSignal.timeout(30_000)],
);
const signal = AbortSignal.any([abort.signal, AbortSignal.timeout(30_000)]);
let flight!: RefreshFlight;
const refreshPromise = withCodexRefreshFileLock(refreshGrantFingerprint, signal, async (): Promise<CodexRefreshResult> => {
const current = readCodexAccountRecord(id);
Expand Down Expand Up @@ -706,7 +765,10 @@ async function resolveCodexToken(

flight = { promise: refreshPromise, startedAt: Date.now(), abort };
refreshLocks.set(refreshGrantFingerprint, flight);
const result = await refreshPromise;
// The owner waits under its own cancellation too: the flight it opened is already
// registered, so a joiner that arrives after this caller walks away still receives
// the committed result.
const result = await awaitOwnCancellation(refreshPromise, callerSignal);
await notePlanFromRefreshedAccessToken(id, result.accessToken, result.generation);
Comment on lines +771 to 772

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Reconcile plan metadata when a detached flight completes

When the initiating caller aborts, awaitOwnCancellation rejects here while refreshPromise continues and persists the rotated credential, so notePlanFromRefreshedAccessToken on the next line is never called. A same-account joiner also returns through the earlier adopt-stored branch without calling it, and later cached-token reads do not reconcile plans. If the refreshed JWT changes chatgpt_plan_type, the long-running process therefore keeps stale codexAccounts[].plan metadata, which feeds quota routing and account-collision classification, until a restart or unrelated WHAM refresh. Attach plan reconciliation to successful shared-flight completion rather than to the owner's wait, and extend the focused cancellation test to assert the plan update.

AGENTS.md reference: AGENTS.md:L336-L339

Useful? React with 👍 / 👎.

return {
accessToken: result.accessToken,
Expand Down
30 changes: 28 additions & 2 deletions src/lib/windows-text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,14 +53,40 @@ function decodeUtf16Be(buffer: Uint8Array): string {
* Western fallback deliberately narrow: treating CP932, CP1250, or CP1251
* bytes as Windows-1252 can fabricate a different valid-looking filesystem
* path, which is worse than the previous replacement-character refusal.
*
* The CJK double-byte pages are named for the same reason `euc-kr` is: they are
* the ANSI code page on their own hosts, they are unambiguous for that language
* tag, and `decodeStrict` rejects a mismatch instead of inventing a path. A
* zh-CN host's schtasks stderr is CP936 (`gbk`), which the UTF-8 attempt above
* fails on and which previously fell through to a lossy UTF-8 decode — the
* mojibake made every localized message unmatchable (#2914).
*
* zh-Hant is a separate page (`big5`), not a variant of the same one, so the
* region subtag decides: `zh-TW`/`zh-HK`/`zh-MO` are Big5, bare `zh` and
* `zh-CN`/`zh-SG` are GBK. Guessing wrong here is exactly the fabricated-path
* risk the Western note describes, so an unrecognized `zh-*` region keeps the
* mainland default rather than trying both.
*/
function legacyEncodingForLocale(locale: string): "euc-kr" | "windows-1252" | null {
const language = locale.trim().split(/[-_]/, 1)[0]?.toLowerCase();
function legacyEncodingForLocale(locale: string): LegacyWindowsEncoding | null {
const parts = locale.trim().split(/[-_]/);
const language = parts[0]?.toLowerCase();
if (language === "ko") return "euc-kr";
if (language === "ja") return "shift_jis";
if (language === "zh") return traditionalChineseRegion(parts) ? "big5" : "gbk";
if (language && WINDOWS_1252_LANGUAGES.has(language)) return "windows-1252";
return null;
}

type LegacyWindowsEncoding = "euc-kr" | "shift_jis" | "gbk" | "big5" | "windows-1252";

/** `zh-Hant`, or a region that ships Big5 as its ANSI code page. */
function traditionalChineseRegion(parts: readonly string[]): boolean {
return parts.slice(1).some(part => {
const tag = part.toLowerCase();
return tag === "hant" || tag === "tw" || tag === "hk" || tag === "mo";
});
}

const WINDOWS_1252_LANGUAGES = new Set([
"af", "br", "ca", "co", "cy", "da", "de", "en", "es", "eu", "fi", "fo", "fr",
"ga", "gd", "gl", "id", "is", "it", "lb", "ms", "nl", "no", "oc", "pt", "sq",
Expand Down
24 changes: 24 additions & 0 deletions src/providers/command-code-efforts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,30 @@ const COMMAND_CODE_MODEL_EFFORTS = {
efforts: ["low", "high", "max"],
profileUrl: "https://commandcode.ai/models/glm-5-3",
},
/*
* GLM-5.3-Flash (#2883). Reported as advertising NO efforts at all: the live
* route is `z-ai/glm-5.3-flash`, which shares neither vendor prefix nor model
* id with `zai-org/GLM-5.3` above, so `modelRecordValue` cannot bridge them
* (exact / colon-family / case-folded only — by design; a substring match here
* would merge two genuinely different models across two vendor namespaces).
*
* PROVENANCE: unlike the #2647 rows above, this ladder is MEASURED, not
* reported. commandcode.ai renders the profile client-side, but the delivered
* HTML ships a serialized React payload whose string table can be read
* directly: in the 2026-08-29 fetch of /models/glm-5-3-flash (HTTP 200,
* 228749 bytes) the indices resolve as 224=low, 225=medium, 226=high,
* 227=xhigh, 569=max, and this model's array is [224,226,569].
*
* The index map was cross-validated against every row in this table that the
* same page carries: deepseek-v4-pro and -flash [226,569], gpt-5.6-luna
* [224,225,226,227,569], gemini-3.7-flash [224,225,226], GLM-5.2 [226,569],
* GLM-5.3 [224,226,569] — six for six against the values already committed
* here. No authenticated upstream generate probe was performed.
*/
"z-ai/glm-5.3-flash": {
efforts: ["low", "high", "max"],
profileUrl: "https://commandcode.ai/models/glm-5-3-flash",
},
// Muse Spark: CLI currently prints "has no adjustable reasoning effort" and
// blocks --effort locally, but the upstream /alpha/generate endpoint accepts
// reasoning_effort low..max for meta/muse-spark-1.2-contributor (verified
Expand Down
46 changes: 41 additions & 5 deletions src/service-manager-probe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,19 @@ import { WINSW_SERVICE_ID } from "./lib/winsw";
/** Short: this runs inside admission, and a slow answer is the same as none. */
export const SERVICE_PROBE_TIMEOUT_MS = 2_000;

/**
* The one query that is allowed to be slow: the full `schtasks` listing.
*
* 2s is the right budget for a targeted query and the wrong one for enumerating
* every task on the machine — measured at 12.3s on a host with 401 of them, which
* killed the listing and left ownership unprovable (#2914). This is not a general
* relaxation: the targeted queries keep the 2s ceiling, and after the
* locale-independent absence check above, a healthy host decides before the
* listing runs at all. Only a host that has already exhausted the cheap evidence
* pays this, and for it the alternative is not a fast answer but no answer.
*/
export const SERVICE_PROBE_LISTING_TIMEOUT_MS = 20_000;

export type ServiceManagerBackend = "launchd" | "systemd" | "scheduler" | "winsw";

export interface ServiceManagerClaim {
Expand Down Expand Up @@ -70,7 +83,11 @@ export interface ProbeRunner {
* Windows probe runner: preserves schtasks stdout/stderr as raw bytes so the
* UTF-16LE task XML is not corrupted by a UTF-8 decode.
*/
export type RawProbeRunner = (file: string, args: readonly string[]) => {
export type RawProbeRunner = (
file: string,
args: readonly string[],
options?: { readonly timeoutMs?: number },
) => {
status: number | null;
stdout: Buffer;
stderr: Buffer;
Expand All @@ -94,11 +111,11 @@ export const defaultProbeRunner: ProbeRunner = (file, args) => {
};
};

export const defaultRawProbeRunner: RawProbeRunner = (file, args) => {
export const defaultRawProbeRunner: RawProbeRunner = (file, args, options) => {
const result = spawnSync(file, [...args], {
encoding: "buffer",
windowsHide: true,
timeout: SERVICE_PROBE_TIMEOUT_MS,
timeout: options?.timeoutMs ?? SERVICE_PROBE_TIMEOUT_MS,
});
return {
status: result.status,
Expand Down Expand Up @@ -535,7 +552,22 @@ function windowsTaskListContains(body: string, taskName: string): boolean {
});
}

/** English hosts provide a decisive fast path; other locales fall back to a full listing. */
/**
* The English message: a fast path on an English host, and nothing more.
*
* It cannot match a localized host — on zh-CN schtasks answers with the CP936
* bytes of `错误: 系统找不到指定的文件。` — which is why absence there has to be
* settled by the locale-neutral listing below (#2914). Adding more translated
* substrings would only cover the languages someone thought of, and each one is
* a chance to read a DIFFERENT refusal as absence.
*
* Deriving the host's own not-found wording from a control query looks like the
* general fix and is not: schtasks exits 1 for both "not found" and "access
* denied", so a locked-down host answers the control and the real query
* identically, and comparing them yields a false `absent` — the one direction
* that lets an unattended write proceed into a home another process owns.
* `tests/codex-service-manager-probe.test.ts` covers exactly that host.
*/
const SCHTASKS_TASK_NOT_FOUND_EN = /cannot find the file specified/i;

/**
Expand Down Expand Up @@ -574,7 +606,11 @@ function probeWindowsTaskRegistration(
return { registered: "absent", registeredXml: "" };
}

const listed = deps.runRaw(schtasks, ["/query", "/fo", "CSV", "/nh"]);
const listed = deps.runRaw(
schtasks,
["/query", "/fo", "CSV", "/nh"],
{ timeoutMs: SERVICE_PROBE_LISTING_TIMEOUT_MS },
);
Comment on lines +609 to +613

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Avoid running the widened synchronous listing twice at startup

On a non-English Windows host where the targeted /xml query fails and the full listing takes the reported 12.3 seconds, this new 20-second spawnSync budget is exercised by each ownership inspection. startServer calls inspectStartupOwnership twice before Bun.serve (the cache check around src/server/index.ts:609 and the native-lifecycle check around src/server/index.ts:788), so the affected host now commonly waits about 25 seconds—and can block for 40 seconds—before the proxy listens. Consolidate those pre-listen probes or otherwise avoid repeating the slow synchronous enumeration while retaining the required final ownership revalidation.

Useful? React with 👍 / 👎.

if (listed.spawnFailed || listed.timedOut || listed.status !== 0) {
return { registered: "unknown", registeredXml: "" };
}
Expand Down
64 changes: 64 additions & 0 deletions tests/codex-account-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -689,6 +689,70 @@ describe("codex-account-store CRUD", () => {
}
});

/*
* #2892 gap 2. Flights are shared: a later caller on the same grant joins the
* running promise instead of opening its own. The flight's abort signal used to
* include the INITIATING caller's signal, so one cancelled request aborted the
* token fetch every other waiter depended on — and a joiner cannot tell that
* apart from a real upstream failure, so a cancelled Codex tab could get a
* healthy account marked for reauthentication on behalf of a live request.
*/
test("cancelling the caller that opened a refresh flight does not cancel a live joiner (#2892)", async () => {
const { forceRefreshCodexPoolToken, readCodexAccountRecord, saveCodexAccountCredential } =
await import("../src/codex/account-store");
saveCodexAccountCredential("cancel-owner", {
accessToken: "rejected",
refreshToken: "cancel-grant",
expiresAt: Date.now() + 3600_000,
chatgptAccountId: "acc",
});
const generation = readCodexAccountRecord("cancel-owner")!.generation;

const originalFetch = globalThis.fetch;
let sawAbort = false;
let calls = 0;
let releaseFetch: (() => void) | undefined;
const fetchStarted = new Promise<void>(resolve => {
globalThis.fetch = (async (_url: string, init?: RequestInit) => {
calls += 1;
resolve();
await new Promise<void>(release => { releaseFetch = release; });
// The flight must still be alive after the initiating caller gave up.
if (init?.signal?.aborted) sawAbort = true;
return Response.json({ access_token: "rotated", refresh_token: "cancel-grant2", expires_in: 3600 });
}) as typeof fetch;
});

try {
const owner = new AbortController();
const ownerCall = forceRefreshCodexPoolToken("cancel-owner", {
rejectedGeneration: generation,
rejectedAccessToken: "rejected",
signal: owner.signal,
});
await fetchStarted;
// A joiner arrives on the same grant while the flight is parked in fetch.
const joinerCall = forceRefreshCodexPoolToken("cancel-owner", {
rejectedGeneration: generation,
rejectedAccessToken: "rejected",
});
// The client that started it goes away.
owner.abort(new Error("client disconnected"));
await expect(ownerCall).rejects.toThrow("client disconnected");

releaseFetch?.();
const joined = await joinerCall;

// The joiner gets the rotated credential, not an abort.
expect(sawAbort).toBe(false);
expect(joined.accessToken).toBe("rotated");
expect(calls).toBe(1);
expect(readCodexAccountRecord("cancel-owner")!.credential!.accessToken).toBe("rotated");
} finally {
globalThis.fetch = originalFetch;
}
});

test("a joined flight cannot copy a sibling account's replacement credential (#2887 review)", async () => {
// Flights are keyed by refresh GRANT and shared across every account holding it. If the
// owner's own credential is externally replaced BEFORE it takes the file lock, the
Expand Down
Loading
Loading