diff --git a/src/codex/account-store.ts b/src/codex/account-store.ts index 9f5853ddea..73658a6078 100644 --- a/src/codex/account-store.ts +++ b/src/codex/account-store.ts @@ -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(work: Promise, callerSignal?: AbortSignal): Promise { + if (!callerSignal) return work; + if (callerSignal.aborted) { + work.catch(() => {}); + return Promise.reject(callerSignal.reason); + } + return new Promise((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 @@ -482,6 +509,7 @@ async function resolveCodexToken( forced?: ForcedRefreshFence, callerSignal?: AbortSignal, ): Promise { + 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."); @@ -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 @@ -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 { @@ -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 => { const current = readCodexAccountRecord(id); @@ -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); return { accessToken: result.accessToken, diff --git a/src/lib/windows-text.ts b/src/lib/windows-text.ts index 8e26657986..e8c30011c4 100644 --- a/src/lib/windows-text.ts +++ b/src/lib/windows-text.ts @@ -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", diff --git a/src/providers/command-code-efforts.ts b/src/providers/command-code-efforts.ts index 1cd4831662..b5bfa93b37 100644 --- a/src/providers/command-code-efforts.ts +++ b/src/providers/command-code-efforts.ts @@ -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 diff --git a/src/service-manager-probe.ts b/src/service-manager-probe.ts index d9c752ad32..96bf9d900a 100644 --- a/src/service-manager-probe.ts +++ b/src/service-manager-probe.ts @@ -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 { @@ -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; @@ -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, @@ -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; /** @@ -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 }, + ); if (listed.spawnFailed || listed.timedOut || listed.status !== 0) { return { registered: "unknown", registeredXml: "" }; } diff --git a/tests/codex-account-store.test.ts b/tests/codex-account-store.test.ts index 7dac3e4c7f..760cd610ab 100644 --- a/tests/codex-account-store.test.ts +++ b/tests/codex-account-store.test.ts @@ -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(resolve => { + globalThis.fetch = (async (_url: string, init?: RequestInit) => { + calls += 1; + resolve(); + await new Promise(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 diff --git a/tests/codex-service-manager-probe-hardening.test.ts b/tests/codex-service-manager-probe-hardening.test.ts index be2a3e7fb4..477357e3d3 100644 --- a/tests/codex-service-manager-probe-hardening.test.ts +++ b/tests/codex-service-manager-probe-hardening.test.ts @@ -138,6 +138,76 @@ function taskAbsentRunner(calls: Array<{ file: string; args: readonly string[] } } describe("Windows ownership probe hardening regressions", () => { + /* + * #2914, the reported host: zh-CN Windows, no task, no service, 401 scheduled + * tasks. The targeted query answers in CP936, which the English regex cannot + * match, so absence is settled by the locale-neutral listing — and that + * listing needed 12.3s while the probe killed it at 2s, leaving `unknown` and + * an `ocx sync` that refused to write for want of ownership proof. + */ + const GBK_TASK_NOT_FOUND = Buffer.from( + "b4edcef33a20cfb5cdb3d5d2b2bbb5bdd6b8b6a8b5c4cec4bcfea1a3", + "hex", + ); + + test("the reported zh-CN host reaches absence through the listing (#2914)", () => { + const calls: Array<{ file: string; args: readonly string[]; timeoutMs?: number }> = []; + const runRaw: RawProbeRunner = (file, args, runnerOptions) => { + calls.push({ file, args, timeoutMs: runnerOptions?.timeoutMs }); + if (file.toLowerCase().endsWith("sc.exe")) return raw(1, "", "1060"); + if (args.includes("/xml")) { + return { status: 1, stdout: Buffer.alloc(0), stderr: GBK_TASK_NOT_FOUND, timedOut: false, spawnFailed: false }; + } + // 401 tasks, none of them ours. + if (args.includes("/fo")) return raw(0, '"\\SomeOtherTask","N/A","Ready"\r\n'); + return raw(1, "", ""); + }; + + const result = inspectServiceManagerInstallation({ + platform: "win32", + home, + configDir, + windowsLocale: "zh-CN", + runRaw, + winswStatus: () => "nonexistent", + }); + + // "absent" is the answer that admits the write; "unknown" is what refused it. + expect(result.kind).toBe("absent"); + // The listing is the only locale-neutral evidence, so it MUST get the budget + // that a 401-task host can actually finish inside. + const listing = calls.find(call => call.args.includes("/fo")); + expect(listing?.timeoutMs).toBe(20_000); + // Targeted queries keep the short admission ceiling: this is not a general + // relaxation of the probe's budget. + for (const call of calls.filter(c => c.args.includes("/xml"))) { + expect(call.timeoutMs).toBeUndefined(); + } + }); + + test("a listing that outruns even the wider budget stays unknown (#2914)", () => { + const runRaw: RawProbeRunner = (file, args) => { + if (file.toLowerCase().endsWith("sc.exe")) return raw(1, "", "1060"); + if (args.includes("/xml")) { + return { status: 1, stdout: Buffer.alloc(0), stderr: GBK_TASK_NOT_FOUND, timedOut: false, spawnFailed: false }; + } + if (args.includes("/fo")) return raw(null, "", "", { timedOut: true }); + return raw(1, "", ""); + }; + + const result = inspectServiceManagerInstallation({ + platform: "win32", + home, + configDir, + windowsLocale: "zh-CN", + runRaw, + winswStatus: () => "nonexistent", + }); + + // A wider budget must not become an excuse to guess when it still expires. + expect(result.kind).toBe("unknown"); + }); + test("a scheduler registered for another OpenCodex home does not claim the current home (#2800)", () => { const foreignConfigDir = join(home, "foreign-opencodex"); const foreignLauncher = join(foreignConfigDir, "opencodex-service-launcher.vbs"); diff --git a/tests/command-code-provider.test.ts b/tests/command-code-provider.test.ts index 730d01638c..f434687a1c 100644 --- a/tests/command-code-provider.test.ts +++ b/tests/command-code-provider.test.ts @@ -87,6 +87,31 @@ describe("Command Code provider", () => { }); }); + /* + * #2883. `z-ai/glm-5.3-flash` is a live-discovered route whose id shares + * neither the vendor prefix nor the model of `zai-org/GLM-5.3`, so no + * `modelRecordValue` relaxation reaches it — only an explicit row does. Both + * presets must carry it, because the picker is empty on whichever one the + * user configured, and the two entries are separately constructed. + */ + test("the live GLM-5.3-Flash route carries its own effort ladder on both presets", () => { + const oauth = PROVIDER_REGISTRY.find(row => row.id === "command-code"); + const apiKey = PROVIDER_REGISTRY.find(row => row.id === "commandcode"); + for (const [label, entry] of [["oauth", oauth], ["api-key", apiKey]] as const) { + expect(entry?.modelReasoningEfforts?.["z-ai/glm-5.3-flash"], `${label} preset ladder`) + .toEqual(["low", "high", "max"]); + } + // Distinct rows for distinct upstream models: GLM-5.3 and GLM-5.3-Flash happen to + // share a ladder today, but neither may be derived from the other. + expect(commandCodeReasoningEfforts("z-ai/glm-5.3-flash")).toEqual(["low", "high", "max"]); + expect(commandCodeReasoningEfforts("zai-org/GLM-5.3")).toEqual(["low", "high", "max"]); + // The reported id arrives lowercase from live discovery; a caller may still fold case. + expect(commandCodeReasoningEfforts("Z-AI/GLM-5.3-Flash")).toEqual(["low", "high", "max"]); + // Nothing widened into a substring match: a sibling that upstream does not list + // must stay unknown rather than inheriting the Flash ladder. + expect(commandCodeReasoningEfforts("z-ai/glm-5.3-flash-vision")).toBeUndefined(); + }); + test("OAuth and API-key presets share only verified image capabilities", () => { const oauth = PROVIDER_REGISTRY.find(row => row.id === "command-code"); const apiKey = PROVIDER_REGISTRY.find(row => row.id === "commandcode"); diff --git a/tests/provider-registry-parity.test.ts b/tests/provider-registry-parity.test.ts index 53c7fc2076..f9c3c51e1b 100644 --- a/tests/provider-registry-parity.test.ts +++ b/tests/provider-registry-parity.test.ts @@ -1003,6 +1003,34 @@ describe("provider registry parity", () => { // so the request still reaches Command Code as `deepseek/deepseek-v4-flash`. expect(entries.find(e => e.slug === "commandcode/deepseek-deepseek-v4-flash")).toBeTruthy(); }); + + /* + * #2883, at the surface the reporter actually saw. A live-discovered model with no + * row in the effort table falls through `configuredReasoningEfforts` to the + * provider-level `reasoningEfforts: []`, which `applyProviderConfigHints` then + * writes onto the model — so the Codex App picker renders empty and the request + * carries `requestedEffort: "none"`. Asserting the catalog entry rather than just + * the table is what makes this a regression test for the symptom. + */ + test("the Command Code GLM-5.3-Flash route reaches the catalog with a selectable ladder", () => { + const commandcode = PROVIDER_REGISTRY.find(entry => entry.id === "commandcode"); + const seed = providerConfigSeed(commandcode!); + const model = applyProviderConfigHints("commandcode", seed, { + id: "z-ai/glm-5.3-flash", + provider: "commandcode", + }); + expect(model.id).toBe("z-ai/glm-5.3-flash"); + expect(model.reasoningEfforts).toEqual(["low", "high", "max"]); + + const entries = buildCatalogEntries(nativeTemplate() as never, [], [model]); + const entry = entries.find(e => e.slug === "commandcode/z-ai-glm-5.3-flash"); + expect(entry).toBeTruthy(); + // The empty ladder is exactly the reported symptom: an empty picker in the app. + expect(entry?.supported_reasoning_levels).not.toEqual([]); + // Routed catalogs append the synthetic top rung, as every other routed row above does. + expect((entry?.supported_reasoning_levels as { effort: string }[]).map(l => l.effort)) + .toEqual(["low", "high", "max", "ultra"]); + }); /* * #1043. Zen publishes no modality metadata, so the classification below is an * empirical list measured against the live endpoint on 2026-08-05, not something diff --git a/tests/windows-text-decoding.test.ts b/tests/windows-text-decoding.test.ts index 58da9e58c7..4262b0a499 100644 --- a/tests/windows-text-decoding.test.ts +++ b/tests/windows-text-decoding.test.ts @@ -18,9 +18,52 @@ describe("Windows system text decoding (#1573)", () => { expect(decodeWindowsTextBytes(windows1252, { locale: "de-DE" })).toBe("C:\\Users\\Jörg"); }); + /* + * #2914 narrowed this case rather than deleting it. The invariant is "never + * guess a code page for a locale we have not named" — it was demonstrated with + * CP932 only because ja was unnamed at the time. ja now has its real page, so + * the demonstration moves to a locale that is still unnamed; asserting the old + * expectation would now be asserting that Japanese output must stay broken. + */ test("does not guess Windows-1252 for an unsupported legacy-codepage locale", () => { + // CP1251 Cyrillic. Reading these as Windows-1252 would yield a plausible + // but wrong string, which is the failure this refusal exists to prevent. + const cp1251 = Buffer.from([0xd0, 0xf0, 0xe8, 0xe2, 0xe5, 0xf2]); + expect(decodeWindowsTextBytes(cp1251, { locale: "ru-RU" })).toContain("\uFFFD"); + }); + + test("decodes CP932 Japanese output under a Japanese Windows locale", () => { const cp932 = Buffer.from([0x82, 0xa0]); - expect(decodeWindowsTextBytes(cp932, { locale: "ja-JP" })).toContain("\uFFFD"); + expect(decodeWindowsTextBytes(cp932, { locale: "ja-JP" })).toBe("あ"); + }); + + /* + * The exact bytes `schtasks /query /tn opencodex-proxy /xml` writes to stderr on + * a zh-CN host (#2914). Before this, they fell through to a lossy UTF-8 decode + * and every localized not-found message became unmatchable mojibake. + */ + test("decodes CP936 schtasks output under a Simplified Chinese locale", () => { + const gbk = Buffer.from("b4edcef33a20cfb5cdb3d5d2b2bbb5bdd6b8b6a8b5c4cec4bcfea1a3", "hex"); + const decoded = decodeWindowsTextBytes(gbk, { locale: "zh-CN" }); + expect(decoded).not.toContain("\uFFFD"); + expect(decoded).toBe("错误: 系统找不到指定的文件。"); + }); + + test("decodes the full CP932 schtasks refusal, not just one kana", () => { + const cp932 = Buffer.from( + "83478389815b3a208e7792e882b382ea82bd837483408343838b82aa8ca982c282a982e882dc82b982f18142", + "hex", + ); + expect(decodeWindowsTextBytes(cp932, { locale: "ja-JP" })) + .toBe("エラー: 指定されたファイルが見つかりません。"); + }); + + test("uses Big5 for Traditional Chinese regions instead of the mainland page", () => { + // Same abstract message, Big5 bytes: 找不到 + const big5 = Buffer.from("a7e4a4a3a8ec", "hex"); + expect(decodeWindowsTextBytes(big5, { locale: "zh-TW" })).toBe("找不到"); + // The mainland default must NOT be applied to a Big5 region. + expect(decodeWindowsTextBytes(big5, { locale: "zh-CN" })).not.toBe("找不到"); }); test("preserves UTF-16LE task XML", () => {