Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 43 additions & 3 deletions src/claude/desktop-3p-library.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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");
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
// 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<Desktop3pMetadata>;
const parsed = JSON.parse(readDesktopLibraryText(path)) as Partial<Desktop3pMetadata>;
if (!Array.isArray(parsed.entries)) throw new Error("Claude Desktop 3P _meta.json has no entries array");
return { ...parsed, entries: parsed.entries };
}
Expand Down Expand Up @@ -82,7 +122,7 @@ export const OPENCODEX_DESKTOP_PROFILE_KEYS = new Set([

export function readDesktopProfileForeignKeys(path: string): Record<string, unknown> {
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)),
Expand Down
10 changes: 5 additions & 5 deletions src/claude/desktop-3p.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -476,7 +476,7 @@ export function inspectDesktop3pConfigLibrary(
let profile: Record<string, unknown>;
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;
Expand Down Expand Up @@ -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<string, unknown>;
const selected = JSON.parse(readDesktopLibraryText(inspected.selectedProfilePath)) as Record<string, unknown>;
mode = selected.modelDiscoveryEnabled !== true ? "static"
: Array.isArray(selected.inferenceModels) ? "hybrid" : "discovery";
} catch { return refused; }
Expand Down Expand Up @@ -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"); }
Expand Down
15 changes: 11 additions & 4 deletions src/claude/desktop-picker-profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
isOwnedDesktopGatewayEntry,
parseMetadata,
profilePath,
readDesktopLibraryText,
resolveDesktop3pConfigLibraryPath,
SAFE_DESKTOP_PROFILE_ID,
type Desktop3pConfigLibraryOptions,
Expand Down Expand Up @@ -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 ?? "");
Expand All @@ -98,7 +105,7 @@ function pickerEntry(metadata: Desktop3pMetadata): Desktop3pMetadataEntry | unde
}

function profileObject(path: string): Record<string, unknown> {
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");
}
Expand Down Expand Up @@ -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 };
Expand Down
8 changes: 5 additions & 3 deletions src/claude/desktop-remote-store-io.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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);
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
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");
Expand Down
10 changes: 10 additions & 0 deletions structure/clients/claude-desktop.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
luvs01 marked this conversation as resolved.
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
Expand Down
19 changes: 19 additions & 0 deletions tests/claude-integration/claude-desktop-picker-profile.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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" });
});
});
88 changes: 87 additions & 1 deletion tests/clients/desktop-3p-removal.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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(); }
});
Loading
Loading