diff --git a/src/claude/desktop-3p-library.ts b/src/claude/desktop-3p-library.ts index 392bf025441..73c5e067673 100644 --- a/src/claude/desktop-3p-library.ts +++ b/src/claude/desktop-3p-library.ts @@ -1,4 +1,4 @@ -import { existsSync, readFileSync } from "node:fs"; +import { closeSync, constants, existsSync, fstatSync, lstatSync, openSync, readFileSync, type BigIntStats } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; import { claudeDesktopConfigLibraryDir, resolveConfigLibraryDir } from "./desktop-3p-paths"; @@ -44,9 +44,49 @@ export interface Desktop3pMetadata { export const DESKTOP_PICKER_ENTRY_NAME = "opencodex-picker"; +/** A path that no longer names the observed file was swapped; a same-path failure keeps its own errno. */ +function pathStillNamesExpectedFile(path: string, expected: BigIntStats): boolean { + try { + const now = lstatSync(path, { bigint: true }); + return now.isFile() && now.dev === expected.dev && now.ino === expected.ino + && now.ctimeNs === expected.ctimeNs; + } catch { return false; } +} + +/** Read regular config files only; a path swap must not turn an observation into a FIFO wait. */ +export function readDesktopLibraryBytes(path: string): Buffer { + const expected = lstatSync(path, { bigint: true }); + if (!expected.isFile()) throw new Error("desktop_config_not_regular"); + // POSIX open of a FIFO can itself block, before fstat is possible. Windows has no POSIX + // FIFO entry; retain descriptor identity validation there without unsupported open flags. + const flags = constants.O_RDONLY | (process.platform === "win32" ? 0 : constants.O_NONBLOCK | constants.O_NOFOLLOW); + let fd: number; + try { + fd = openSync(path, flags); + } catch (error) { + // A swap can fail at open — ELOOP on a symlink, ENXIO on a socket — before fstat can + // classify it. Re-observing the path reports every swap as the documented refusal. + if (pathStillNamesExpectedFile(path, expected)) throw error; + throw new Error("desktop_config_not_regular"); + } + try { + const opened = fstatSync(fd, { bigint: true }); + if (!opened.isFile() || opened.dev !== expected.dev || opened.ino !== expected.ino + || opened.ctimeNs !== expected.ctimeNs) throw new Error("desktop_config_not_regular"); + return readFileSync(fd); + } finally { + closeSync(fd); + } +} + +/** Text decoding stays identical to node:fs readFileSync(path, "utf8"). */ +export function readDesktopLibraryText(path: string): string { + return readDesktopLibraryBytes(path).toString("utf8"); +} + export function parseMetadata(path: string): Desktop3pMetadata { if (!existsSync(path)) return { entries: [] }; - const parsed = JSON.parse(readFileSync(path, "utf8")) as Partial; + const parsed = JSON.parse(readDesktopLibraryText(path)) as Partial; if (!Array.isArray(parsed.entries)) throw new Error("Claude Desktop 3P _meta.json has no entries array"); return { ...parsed, entries: parsed.entries }; } @@ -82,7 +122,7 @@ export const OPENCODEX_DESKTOP_PROFILE_KEYS = new Set([ export function readDesktopProfileForeignKeys(path: string): Record { if (!existsSync(path)) return {}; - const parsed = JSON.parse(readFileSync(path, "utf8")) as unknown; + const parsed = JSON.parse(readDesktopLibraryText(path)) as unknown; if (!isRecord(parsed)) throw new Error("Claude Desktop 3P profile is not a JSON object"); return Object.fromEntries( Object.entries(parsed).filter(([key]) => !OPENCODEX_DESKTOP_PROFILE_KEYS.has(key)), diff --git a/src/claude/desktop-3p.ts b/src/claude/desktop-3p.ts index 79c9fb312e6..46e943a9840 100644 --- a/src/claude/desktop-3p.ts +++ b/src/claude/desktop-3p.ts @@ -5,12 +5,12 @@ import { applyRemoteDesktopStore, inspectRemoteDesktopCleanup, restoreRemoteDesk import { canonicalDirectory } from "./desktop-remote-store-io"; import { resolveDesktop3pConfigLibraryPath, parseMetadata, SAFE_DESKTOP_PROFILE_ID, isRecord, - isOwnedDesktopEntry, isOwnedDesktopGatewayEntry, profilePath, readDesktopProfileForeignKeys, + isOwnedDesktopEntry, isOwnedDesktopGatewayEntry, profilePath, readDesktopProfileForeignKeys, readDesktopLibraryText, readDesktopLibraryBytes, type Desktop3pConfigLibraryOptions, type Desktop3pMetadata, type Desktop3pMetadataEntry, } from "./desktop-3p-library"; export { resolveDesktop3pConfigLibraryPath, type Desktop3pConfigLibraryOptions } from "./desktop-3p-library"; import { createHash, randomUUID } from "node:crypto"; -import { copyFileSync, existsSync, mkdirSync, readFileSync, lstatSync, unlinkSync } from "node:fs"; +import { copyFileSync, existsSync, mkdirSync, lstatSync, unlinkSync } from "node:fs"; import { join } from "node:path"; import { atomicWriteFile, readConfigDiagnostics, withConfigMutationLockSync } from "../config"; import { atomicWriteFileNoFollow } from "../config/atomic-write"; @@ -476,7 +476,7 @@ export function inspectDesktop3pConfigLibrary( let profile: Record; let fingerprint: string; try { - const source = readFileSync(selectedProfilePath, "utf8"); + const source = readDesktopLibraryText(selectedProfilePath); const parsed = JSON.parse(source) as unknown; if (!isRecord(parsed)) return { kind: "broken", libraryPath, selectedProfilePath, appliedId, residualPaths, ownedProfileActive }; profile = parsed; @@ -709,7 +709,7 @@ export function writeDesktop3pConfig( const metadata = parseMetadata(join(inspected.libraryPath, "_meta.json")); if (!isOwnedDesktopGatewayEntry(metadata.entries.find(entry => entry.id === inspected.appliedId))) return refused; try { - const selected = JSON.parse(readFileSync(inspected.selectedProfilePath, "utf8")) as Record; + const selected = JSON.parse(readDesktopLibraryText(inspected.selectedProfilePath)) as Record; mode = selected.modelDiscoveryEnabled !== true ? "static" : Array.isArray(selected.inferenceModels) ? "hybrid" : "discovery"; } catch { return refused; } @@ -825,7 +825,7 @@ function writeDesktop3pConfigWithGenerator( const freshMetadata = parseMetadata(metadataPath); if (freshMetadata.appliedId !== refreshOnly.requiredId || !isOwnedDesktopGatewayEntry(freshMetadata.entries.find(entry => entry.id === refreshOnly.requiredId)) - || createHash("sha256").update(readFileSync(configPath)).digest("hex").slice(0, 16) !== refreshOnly.expectedFingerprint) { + || createHash("sha256").update(readDesktopLibraryBytes(configPath)).digest("hex").slice(0, 16) !== refreshOnly.expectedFingerprint) { throw new Error(); } } catch { throw new Error("desktop_refresh_only_skipped"); } diff --git a/src/claude/desktop-picker-profile.ts b/src/claude/desktop-picker-profile.ts index a439d0a7fe5..b7c1430bbe3 100644 --- a/src/claude/desktop-picker-profile.ts +++ b/src/claude/desktop-picker-profile.ts @@ -16,6 +16,7 @@ import { isOwnedDesktopGatewayEntry, parseMetadata, profilePath, + readDesktopLibraryText, resolveDesktop3pConfigLibraryPath, SAFE_DESKTOP_PROFILE_ID, type Desktop3pConfigLibraryOptions, @@ -73,6 +74,12 @@ function readFileSnapshot(path: string): { exists: boolean; content?: string } { return existsSync(path) ? { exists: true, content: readFileSync(path, "utf8") } : { exists: false }; } +// Desktop library paths go through the verified regular-file reader: a swapped FIFO or symlink +// must surface as a refusal, never as a blocking open. Our own picker state file keeps the plain read. +function readLibrarySnapshot(path: string): { exists: boolean; content?: string } { + return existsSync(path) ? { exists: true, content: readDesktopLibraryText(path) } : { exists: false }; +} + function restoreFile(path: string, snapshot: { exists: boolean; content?: string }): void { if (snapshot.exists) { rollbackAtomicWriteFile(path, snapshot.content ?? ""); @@ -98,7 +105,7 @@ function pickerEntry(metadata: Desktop3pMetadata): Desktop3pMetadataEntry | unde } function profileObject(path: string): Record { - const parsed = JSON.parse(readFileSync(path, "utf8")) as unknown; + const parsed = JSON.parse(readDesktopLibraryText(path)) as unknown; if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { throw new Error("picker_profile_unreadable"); } @@ -175,10 +182,10 @@ function applyDesktopPickerProfileLocked(options: { proxyPort: number } & Deskto const alreadySelected = selectedId === entryId; const priorState = readPickerState(previousStatePath); const previousAppliedId = alreadySelected ? priorState?.previousAppliedId ?? null : selectedId; - const oldMeta = readFileSnapshot(metaPath); - const oldProfile = readFileSnapshot(profile); + const oldMeta = readLibrarySnapshot(metaPath); + const oldProfile = readLibrarySnapshot(profile); const backupPath = `${profile}.bak`; - const oldBackup = readFileSnapshot(backupPath); + const oldBackup = readLibrarySnapshot(backupPath); const oldState = readFileSnapshot(previousStatePath); const changed = !alreadySelected || !oldProfile.exists || oldProfile.content !== target; if (!changed) return { ok: true, changed: false, path: profile }; diff --git a/src/claude/desktop-remote-store-io.ts b/src/claude/desktop-remote-store-io.ts index 3c73aaa4a15..2039deceb86 100644 --- a/src/claude/desktop-remote-store-io.ts +++ b/src/claude/desktop-remote-store-io.ts @@ -1,8 +1,8 @@ -import { chmodSync, lstatSync, mkdirSync, readFileSync, realpathSync, unlinkSync } from "node:fs"; +import { chmodSync, lstatSync, mkdirSync, realpathSync, unlinkSync } from "node:fs"; import { basename, dirname, join, resolve } from "node:path"; import { atomicWriteFile, getConfigDir } from "../config"; import { hardenSecretDir } from "../lib/windows-secret-acl"; -import { isRecord, resolveDesktop3pConfigLibraryPath, SAFE_DESKTOP_PROFILE_ID, type Desktop3pMetadata } from "./desktop-3p-library"; +import { isRecord, readDesktopLibraryBytes, resolveDesktop3pConfigLibraryPath, SAFE_DESKTOP_PROFILE_ID, type Desktop3pMetadata } from "./desktop-3p-library"; import { DesktopStoreError, digest } from "./desktop-remote-store-state"; export const MAX_DESKTOP_METADATA_ENTRIES = 256; @@ -43,7 +43,9 @@ export class StoreIO { this.seenSizes.set(path, Math.max(counted, stat.size)); if (this.bytes > 8 * 1024 * 1024) throw new DesktopStoreError("unsafe"); try { - const bytes = readFileSync(path); + // The shared verified regular-file read keeps a swapped FIFO from parking this + // open before the post-read identity recheck below can run. + const bytes = readDesktopLibraryBytes(path); if (bytes.byteLength > maxBytes || identity(lstatSync(path)) !== identity(stat)) throw new DesktopStoreError("conflict"); const value: unknown = JSON.parse(bytes.toString("utf8")); if (!isRecord(value)) throw new DesktopStoreError("unsafe"); diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index 78893a3bb85..b8227d84baa 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -94,6 +94,16 @@ owned settings are still observed; otherwise the field is `null`. first-party; both re-resolve after model discovery before writing. Background refresh preserves the selected owned gateway profile's mode, refuses symlinked library, metadata, profile or backup paths and non-regular profiles, and rechecks admission, the selected owned metadata entry and its applied fingerprint in the no-follow writer's pre-rename hook; it leaves metadata and backup bytes unchanged. +Every read of Desktop library configuration — selected-profile inspection, `_meta.json` parsing, foreign-key preservation during writes, refresh mode re-resolution, the refresh +fingerprint hash, the picker profile's inspection and apply snapshots, and the connection-owned store's metadata and profile reads — goes through the shared verified regular-file reader in `src/claude/desktop-3p-library.ts` +(`readDesktopLibraryBytes`/`readDesktopLibraryText`). The path `lstat` must already name a regular file; on POSIX the open is nonblocking and no-follow, so a path swapped to a FIFO +cannot turn an observation into a blocking read and a swapped symlink is not followed; the opened descriptor must still be a regular file whose bigint device, inode and change +time match the path observation before its bytes are read, and it is always closed. Windows keeps the same path-and-descriptor identity check without the POSIX-only open flags. +A non-regular or swapped path throws `desktop_config_not_regular`, including a swap that surfaces only at open (a symlink's `ELOOP`, a socket's `ENXIO`, a deletion's `ENOENT`): +the failed open re-observes the path and reports the documented refusal whenever it no longer names the expected file, while a same-path failure keeps its own errno. Inspection +reports the refusal through its existing `metadata_unreadable`/`broken` outcomes and the writer through its existing refusals; `StoreIO.read` in `src/claude/desktop-remote-store-io.ts` +layers its size cap, private-file mode and post-read identity contract on top of the same read and reports it as `unsafe`. + Mode switches establish the replacement before removing the previous connection. A failed first-party apply (disabled intercept, CA failure, unreadable settings or foreign env) preserves the gateway; a failed gateway apply preserves the first-party env. After a successful first-party diff --git a/tests/claude-integration/claude-desktop-picker-profile.test.ts b/tests/claude-integration/claude-desktop-picker-profile.test.ts index c8043ba1b66..99b317e9679 100644 --- a/tests/claude-integration/claude-desktop-picker-profile.test.ts +++ b/tests/claude-integration/claude-desktop-picker-profile.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { spawnSync } from "node:child_process"; import { existsSync, mkdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs"; import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -172,4 +173,22 @@ describe("Claude Desktop picker profile", () => { expect(isOwnedDesktopGatewayEntry({ id: picker, name: "opencodex-picker" })).toBe(false); expect(inspectDesktop3pConfigLibrary({ env: env() })).toMatchObject({ kind: "standard", appliedId: picker }); }); + + test("a non-regular picker profile path inspects as unsafe instead of blocking", () => { + const picker = "picker"; + mkdirSync(join(library, `${picker}.json`)); + writeMetadata({ appliedId: picker, entries: [{ id: picker, name: "opencodex-picker" }] }); + expect(inspectDesktopPickerProfile({ env: env(), configDir })).toEqual({ kind: "unsafe", reason: "desktop_config_not_regular" }); + }); + + test("a FIFO swapped onto the picker profile path refuses instead of waiting", () => { + // Windows has no POSIX FIFO entry; the directory case above covers the portable contract. + if (process.platform === "win32") return; + const picker = "picker"; + const result = spawnSync("mkfifo", [join(library, `${picker}.json`)]); + expect(result.status).toBe(0); + writeMetadata({ appliedId: picker, entries: [{ id: picker, name: "opencodex-picker" }] }); + expect(inspectDesktopPickerProfile({ env: env(), configDir })).toEqual({ kind: "unsafe", reason: "desktop_config_not_regular" }); + expect(applyDesktopPickerProfile({ proxyPort: 41234, env: env(), configDir })).toEqual({ ok: false, reason: "write_failed" }); + }); }); diff --git a/tests/clients/desktop-3p-removal.test.ts b/tests/clients/desktop-3p-removal.test.ts index 0d2dec2d420..0168e607375 100644 --- a/tests/clients/desktop-3p-removal.test.ts +++ b/tests/clients/desktop-3p-removal.test.ts @@ -1,4 +1,8 @@ -import { afterEach, beforeEach, expect, test } from "bun:test"; +import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; +import { spawnSync } from "node:child_process"; +import * as filesystem from "node:fs"; +import { repoPath } from "../helpers/repo-root"; +import { readDesktopLibraryText, parseMetadata, readDesktopProfileForeignKeys } from "../../src/claude/desktop-3p-library"; import { saveConfig, readConfigDiagnostics } from "../../src/config"; import type { OcxConfig } from "../../src/types"; import { removeTreeWithRetry } from "../helpers/remove-tree"; @@ -195,3 +199,85 @@ test("interrupted cleanup prefers the selected opencodex row and reports another expect(existsSync(join(library, `${selected}.json`))).toBe(false); expect(result.residualPaths).toContain(join(library, `${residual}.json`)); }); + + +test("regular Desktop library reads preserve the exact text and foreign keys", () => { + const path = join(fixtureHome, "regular.json"); + const source = JSON.stringify({ entries: [], foreign: "설정 값" }) + "\n"; + writeFileSync(path, source); + expect(readDesktopLibraryText(path)).toBe(source); + expect(parseMetadata(path).entries).toEqual([]); + expect(readDesktopProfileForeignKeys(path).foreign).toBe("설정 값"); + expect(readFileSync(path, "utf8")).toBe(source); +}); + +test("Desktop library reads refuse directories without reading their contents", () => { + expect(() => readDesktopLibraryText(fixtureHome)).toThrow("desktop_config_not_regular"); +}); + +test.skipIf(process.platform === "win32")("Desktop library reads refuse symlinks without modifying the target", () => { + const target = join(fixtureHome, "target.json"); + const link = join(fixtureHome, "link.json"); + writeFileSync(target, "{}\n"); + filesystem.symlinkSync(target, link); + expect(() => readDesktopLibraryText(link)).toThrow("desktop_config_not_regular"); + expect(readFileSync(target, "utf8")).toBe("{}\n"); + expect(filesystem.lstatSync(link).isSymbolicLink()).toBe(true); +}); + +for (const entry of ["_meta.json", "selected.json"]) { + test.skipIf(process.platform === "win32")(`Desktop inspection refuses a FIFO at ${entry} without a writer`, () => { + const library = join(fixtureHome, "fifo-library"); + mkdirSync(library); + if (entry !== "_meta.json") writeFileSync(join(library, "_meta.json"), JSON.stringify({ + appliedId: "selected", entries: [{ id: "selected", name: "foreign-fixture" }], + })); + const fifo = join(library, entry); + expect(spawnSync("mkfifo", [fifo], { timeout: 5000 }).status).toBe(0); + // Bound the parent even if a regression opens the FIFO synchronously. The child imports + // the real inspector and does not use a simulated read or start a production listener. + const code = `import { inspectDesktop3pConfigLibrary } from ${JSON.stringify(repoPath("src/claude/desktop-3p.ts"))}; + console.log(JSON.stringify(inspectDesktop3pConfigLibrary({env:{OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR:${JSON.stringify(library)}}})));`; + const child = spawnSync(process.execPath, ["--no-env-file", "-e", code], { + env: { ...process.env }, encoding: "utf8", timeout: 10000, maxBuffer: 1024 * 1024, + }); + expect(child.error).toBeUndefined(); + expect(child.status).toBe(0); + expect(JSON.parse(child.stdout.trim()).kind).toBe(entry === "_meta.json" ? "unsafe" : "broken"); + expect(filesystem.lstatSync(fifo).isFIFO()).toBe(true); + }, 20000); +} + +test.skipIf(process.platform === "win32")("a regular-to-FIFO replacement between stat and open is refused", () => { + const path = join(fixtureHome, "race.json"); + const fifo = join(fixtureHome, "replacement"); + writeFileSync(path, "{}"); + expect(spawnSync("mkfifo", [fifo], { timeout: 5000 }).status).toBe(0); + const open = filesystem.openSync; + const opening = spyOn(filesystem, "openSync").mockImplementation((target, flags, mode) => { + if (target === path) filesystem.renameSync(fifo, path); + return open(target, flags, mode); + }); + try { + expect(() => readDesktopLibraryText(path)).toThrow("desktop_config_not_regular"); + expect(filesystem.lstatSync(path).isFIFO()).toBe(true); + } finally { opening.mockRestore(); } +}); + +test.skipIf(process.platform === "win32")("a regular-to-symlink replacement between stat and open is refused", () => { + const path = join(fixtureHome, "race-link.json"); + const target = join(fixtureHome, "link-target.json"); + const link = join(fixtureHome, "link-replacement"); + writeFileSync(path, "{}"); + writeFileSync(target, "{}"); + filesystem.symlinkSync(target, link); + const open = filesystem.openSync; + const opening = spyOn(filesystem, "openSync").mockImplementation((target_, flags, mode) => { + if (target_ === path) filesystem.renameSync(link, path); + return open(target_, flags, mode); + }); + try { + expect(() => readDesktopLibraryText(path)).toThrow("desktop_config_not_regular"); + expect(filesystem.lstatSync(path).isSymbolicLink()).toBe(true); + } finally { opening.mockRestore(); } +}); diff --git a/tests/clients/desktop-remote-store.test.ts b/tests/clients/desktop-remote-store.test.ts index 3ec619f65f7..94068793c23 100644 --- a/tests/clients/desktop-remote-store.test.ts +++ b/tests/clients/desktop-remote-store.test.ts @@ -1,9 +1,11 @@ import { removeDesktop3pStandardPivot, writeDesktop3pConfig } from "../../src/claude/desktop-3p"; import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; -import { existsSync, mkdirSync, mkdtempSync, realpathSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { existsSync, lstatSync, mkdirSync, mkdtempSync, realpathSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import * as configIO from "../../src/config"; +import { repoPath } from "../helpers/repo-root"; import { saveConfig, loadConfig, readConfigDiagnostics, atomicWriteFile } from "../../src/config"; import { withClientLifecycleSync, type ClientLifecycleHeld } from "../../src/client/lifecycle-lock"; import { serviceApiTokenFingerprint, writeServiceApiTokenFile, replaceServiceApiTokenFile, writeTokenBackup, serviceApiTokenBackupPath, removeServiceApiTokenFileIfOwned } from "../../src/lib/service-secrets"; @@ -359,4 +361,37 @@ describe("connection-owned Desktop projection store", () => { expect(readDesktopDisconnectReceipt()).toMatchObject({ kind: "valid", value: { phase: "complete" } }); expect(inspectRemoteDesktopCleanup().kind).toBe("absent"); }); + + test.skipIf(process.platform === "win32")("a regular-to-FIFO swap during a store read is refused and classified unsafe", () => { + const target = join(library, "original.json"); + const fifo = join(dir, "store-read-replacement"); + writeFileSync(target, "{}\n"); + expect(spawnSync("mkfifo", [fifo], { timeout: 5000 }).status).toBe(0); + // Bound the parent even if a regression reintroduces a blocking open: the child swaps the + // regular profile for the FIFO at the shared reader's open, then performs the store read. + // "unsafe" is the store's classification of the reader refusal; "conflict" would mean only + // the post-read identity recheck fired, and a hang is killed by the child timeout. + const code = `import { spyOn } from "bun:test"; + import * as fs from "node:fs"; + import { StoreIO } from ${JSON.stringify(repoPath("src/claude/desktop-remote-store-io.ts"))}; + import { DesktopStoreError } from ${JSON.stringify(repoPath("src/claude/desktop-remote-store-state.ts"))}; + const open = fs.openSync; + spyOn(fs, "openSync").mockImplementation((path, flags, mode) => { + if (path === ${JSON.stringify(target)}) fs.renameSync(${JSON.stringify(fifo)}, path); + return open(path, flags, mode); + }); + try { + new StoreIO().read(${JSON.stringify(target)}); + console.log("read_not_refused"); + } catch (error) { + console.log(error instanceof DesktopStoreError ? error.reason : "unexpected_error"); + }`; + const child = spawnSync(process.execPath, ["--no-env-file", "-e", code], { + env: { ...process.env }, encoding: "utf8", timeout: 10000, maxBuffer: 1024 * 1024, + }); + expect(child.error).toBeUndefined(); + expect(child.status).toBe(0); + expect(child.stdout.trim()).toBe("unsafe"); + expect(lstatSync(target).isFIFO()).toBe(true); + }, 20000); }); diff --git a/tests/oauth/oauth-anthropic-instance-multiprocess.test.ts b/tests/oauth/oauth-anthropic-instance-multiprocess.test.ts index c92ff3dc821..89f99dbc952 100644 --- a/tests/oauth/oauth-anthropic-instance-multiprocess.test.ts +++ b/tests/oauth/oauth-anthropic-instance-multiprocess.test.ts @@ -125,8 +125,16 @@ async function runWriters(values: readonly [OAuthCredentials, OAuthCredentials], writeFileSync(join(root, "start"), "start\n"); const finished = await bounded(Promise.all(output), INTERNAL_DEADLINE_MS, "completion"); - finished.forEach(([exitCode, stdout, stderr]) => { - expect(exitCode).toBe(0); + finished.forEach(([exitCode, stdout, stderr], index) => { + let failure: unknown = null; + if (exitCode !== 0) { + try { + failure = JSON.parse(readFileSync(join(root, `${instances[index]}-failure.json`), "utf8")); + } catch { + failure = { phase: "diagnostic-unavailable" }; + } + } + expect(exitCode, `Anthropic registration writer completion: ${JSON.stringify({ instance: instances[index], failure })}`).toBe(0); // Assert lengths so even a failing logging check does not print credential text. expect(stdout.length).toBe(0); expect(stderr.length).toBe(0);