From 502f72cc19c4dc5270f9bd5c6d7cd021e0efb4a5 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Sun, 11 Oct 2026 21:31:29 -0700 Subject: [PATCH 1/8] fix(claude): read Desktop profiles through verified regular-file descriptors Preserve normal profile parsing and fingerprint bytes. Reject non-regular paths and descriptor replacements; use nonblocking no-follow opens on POSIX before checking the descriptor. Reuse the reader for metadata, observations, foreign-key preservation and refresh checks. Add six focused regression cases, including real FIFO inputs in bounded child processes. Keep the candidate in draft pending complete current-tree and native-platform validation. --- src/claude/desktop-3p-library.ts | 29 +++++++++- src/claude/desktop-3p.ts | 10 ++-- tests/clients/desktop-3p-removal.test.ts | 72 +++++++++++++++++++++++- 3 files changed, 101 insertions(+), 10 deletions(-) diff --git a/src/claude/desktop-3p-library.ts b/src/claude/desktop-3p-library.ts index 392bf025441..03967e8cade 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 } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; import { claudeDesktopConfigLibraryDir, resolveConfigLibraryDir } from "./desktop-3p-paths"; @@ -44,9 +44,32 @@ export interface Desktop3pMetadata { export const DESKTOP_PICKER_ENTRY_NAME = "opencodex-picker"; +/** 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); + const fd = openSync(path, flags); + 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 +105,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/tests/clients/desktop-3p-removal.test.ts b/tests/clients/desktop-3p-removal.test.ts index 0d2dec2d420..5f509b933a4 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"; @@ -147,7 +151,7 @@ test("an owned gateway with no saved fingerprint is treated as drifted and can b expect(inspectDesktop3pConfigLibrary({ env: envFor(library), appliedFingerprint: null }).kind).toBe("gateway_drifted"); expect(removeDesktop3pStandardPivot({ env: envFor(library), appliedFingerprint: null })).toMatchObject({ ok: true, changed: true, kind: "removed", - }); + })); expect(existsSync(join(library, `${id}.json`))).toBe(false); }); @@ -195,3 +199,67 @@ 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(); } +}); From 2513ba05ef5f7286b747cb3bcc104a18c234d32e Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 12 Oct 2026 04:54:49 +0000 Subject: [PATCH 2/8] fix(claude): repair removal-suite paren and document shared regular-file reads Co-Authored-By: Epinephrine --- structure/clients/claude-desktop.md | 12 ++++++++++++ tests/clients/desktop-3p-removal.test.ts | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index 42bdab0c1a2..a30d0c18a6f 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -94,6 +94,18 @@ 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 and the refresh fingerprint hash — +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`, +which inspection reports through its existing `metadata_unreadable`/`broken` outcomes and the writer +through its existing refusals. + 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/clients/desktop-3p-removal.test.ts b/tests/clients/desktop-3p-removal.test.ts index 5f509b933a4..c7fb6b7e4d4 100644 --- a/tests/clients/desktop-3p-removal.test.ts +++ b/tests/clients/desktop-3p-removal.test.ts @@ -151,7 +151,7 @@ test("an owned gateway with no saved fingerprint is treated as drifted and can b expect(inspectDesktop3pConfigLibrary({ env: envFor(library), appliedFingerprint: null }).kind).toBe("gateway_drifted"); expect(removeDesktop3pStandardPivot({ env: envFor(library), appliedFingerprint: null })).toMatchObject({ ok: true, changed: true, kind: "removed", - })); + }); expect(existsSync(join(library, `${id}.json`))).toBe(false); }); From 0aba4281f573786b8909b10f895f3c3b69f11383 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 12 Oct 2026 05:00:59 +0000 Subject: [PATCH 3/8] fix(claude): normalize swapped-symlink open refusal and scope shared-reader contract Co-Authored-By: Epinephrine --- src/claude/desktop-3p-library.ts | 9 ++++++++- structure/clients/claude-desktop.md | 16 ++++++++++------ tests/clients/desktop-3p-removal.test.ts | 18 ++++++++++++++++++ 3 files changed, 36 insertions(+), 7 deletions(-) diff --git a/src/claude/desktop-3p-library.ts b/src/claude/desktop-3p-library.ts index 03967e8cade..418a864a59d 100644 --- a/src/claude/desktop-3p-library.ts +++ b/src/claude/desktop-3p-library.ts @@ -51,7 +51,14 @@ export function readDesktopLibraryBytes(path: string): Buffer { // 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); - const fd = openSync(path, flags); + let fd: number; + try { + fd = openSync(path, flags); + } catch (error) { + // A symlink swapped in after lstat fails the no-follow open with ELOOP; surface the refusal name. + if ((error as NodeJS.ErrnoException).code === "ELOOP") throw new Error("desktop_config_not_regular"); + throw error; + } try { const opened = fstatSync(fd, { bigint: true }); if (!opened.isFile() || opened.dev !== expected.dev || opened.ino !== expected.ino diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index a30d0c18a6f..da8d361738b 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -94,17 +94,21 @@ 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 and the refresh fingerprint hash — -goes through the shared verified regular-file reader in `src/claude/desktop-3p-library.ts` +Every local inspection and writer read of Desktop library configuration — selected-profile +inspection, `_meta.json` parsing, foreign-key preservation during writes, refresh mode re-resolution +and the refresh fingerprint hash — 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`, -which inspection reports through its existing `metadata_unreadable`/`broken` outcomes and the writer -through its existing refusals. +without the POSIX-only open flags. A non-regular or swapped path throws `desktop_config_not_regular` +— including the no-follow open's refusal of a swapped symlink — which inspection reports through its +existing `metadata_unreadable`/`broken` outcomes and the writer through its existing refusals. The +connection-owned store is a separate reader: `StoreIO.read` in `src/claude/desktop-remote-store-io.ts` +still reads metadata and profile paths directly under its own lstat/size/post-read-identity contract, +not through this reader. 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 diff --git a/tests/clients/desktop-3p-removal.test.ts b/tests/clients/desktop-3p-removal.test.ts index c7fb6b7e4d4..0168e607375 100644 --- a/tests/clients/desktop-3p-removal.test.ts +++ b/tests/clients/desktop-3p-removal.test.ts @@ -263,3 +263,21 @@ test.skipIf(process.platform === "win32")("a regular-to-FIFO replacement between 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(); } +}); From cfe7646109fb9dd962e69d0369e76b45dd329a5b Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 12 Oct 2026 05:10:41 +0000 Subject: [PATCH 4/8] fix(claude): route connection-owned store reads through shared reader Co-Authored-By: Epinephrine --- src/claude/desktop-3p-library.ts | 18 ++++++++++++++---- src/claude/desktop-remote-store-io.ts | 8 +++++--- structure/clients/claude-desktop.md | 21 +++++++++++---------- 3 files changed, 30 insertions(+), 17 deletions(-) diff --git a/src/claude/desktop-3p-library.ts b/src/claude/desktop-3p-library.ts index 418a864a59d..73c5e067673 100644 --- a/src/claude/desktop-3p-library.ts +++ b/src/claude/desktop-3p-library.ts @@ -1,4 +1,4 @@ -import { closeSync, constants, existsSync, fstatSync, lstatSync, openSync, 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,6 +44,15 @@ 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 }); @@ -55,9 +64,10 @@ export function readDesktopLibraryBytes(path: string): Buffer { try { fd = openSync(path, flags); } catch (error) { - // A symlink swapped in after lstat fails the no-follow open with ELOOP; surface the refusal name. - if ((error as NodeJS.ErrnoException).code === "ELOOP") throw new Error("desktop_config_not_regular"); - throw 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 }); 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 da8d361738b..369e32dd882 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -94,21 +94,22 @@ 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 local inspection and writer read of Desktop library configuration — selected-profile -inspection, `_meta.json` parsing, foreign-key preservation during writes, refresh mode re-resolution -and the refresh fingerprint hash — goes through the shared verified regular-file reader in -`src/claude/desktop-3p-library.ts` +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 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 the no-follow open's refusal of a swapped symlink — which inspection reports through its -existing `metadata_unreadable`/`broken` outcomes and the writer through its existing refusals. The -connection-owned store is a separate reader: `StoreIO.read` in `src/claude/desktop-remote-store-io.ts` -still reads metadata and profile paths directly under its own lstat/size/post-read-identity contract, -not through this reader. +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 From 60197f50db05ee9faa44fa49d8109c2322fe4c7e Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 12 Oct 2026 05:20:48 +0000 Subject: [PATCH 5/8] test(claude): cover connection-owned store reads against a swap-to-FIFO refusal Co-Authored-By: Epinephrine --- tests/clients/desktop-remote-store.test.ts | 37 +++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) 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); }); From fb55d32f3a9feee15e3ca0f26f96a154b5bb4832 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Tue, 13 Oct 2026 11:31:57 +0900 Subject: [PATCH 6/8] docs(structure): keep claude-desktop contract within line budget --- structure/clients/claude-desktop.md | 25 +++++++++---------------- 1 file changed, 9 insertions(+), 16 deletions(-) diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index 91796a2bd45..821c0f0ce16 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -94,22 +94,15 @@ 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 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`. +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 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 From 8aedd750b1ab9208bac9cc8436e53abb64359479 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Tue, 13 Oct 2026 13:32:19 +0900 Subject: [PATCH 7/8] fix(desktop): route picker profile reads through the verified reader readFileSnapshot and profileObject read _meta.json, the picker profile and its backup with unbounded readFileSync, so a swapped FIFO could block inspect/apply indefinitely despite the documented regular-file guarantee. Desktop library snapshots now go through readDesktopLibraryText; the opencodex-owned picker state file keeps the plain read. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- src/claude/desktop-picker-profile.ts | 15 +++++++++++---- structure/clients/claude-desktop.md | 2 +- .../claude-desktop-picker-profile.test.ts | 19 +++++++++++++++++++ 3 files changed, 31 insertions(+), 5 deletions(-) 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/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index 821c0f0ce16..b8227d84baa 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -95,7 +95,7 @@ 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 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` +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. 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" }); + }); }); From a3137becda4102bfcc2c1cda6cdfe75aedb08a87 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Tue, 13 Oct 2026 14:22:34 +0900 Subject: [PATCH 8/8] test(oauth): report completed writer failure diagnostics Retain the fixture's credential-free phase and stack metadata when a registration writer finishes with a nonzero exit, not only when it exits before the ready barrier. Keep the existing exit-code and empty-output assertions unchanged. Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../oauth-anthropic-instance-multiprocess.test.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) 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);