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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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");
// 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>;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Reject dangling metadata links before treating them as absent

When _meta.json is a dangling symlink (for example, after a linked configuration target is moved), existsSync on the preceding line follows the missing target and returns false, so this checked read is skipped and parseMetadata returns an empty library. A normal apply then writes a newly allocated profile and fails only when atomicWriteFile reaches the dangling metadata link; because there was no backup, that new profile is left orphaned (and each retry can add another) instead of refusing before mutation. Use lstat/ENOENT handling for optional paths so an existing non-regular directory entry reaches desktop_config_not_regular.

Useful? React with 👍 / 👎.

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 @@ -490,7 +490,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 @@ -723,7 +723,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 @@ -839,7 +839,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);
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
3 changes: 2 additions & 1 deletion structure/INDEX.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ The dashboard, the management API, and third-party client config ownership.
| [`clients/integrations.md`](clients/integrations.md) | Third-party client config ownership, snapshots, refresh, disable, and restore. |
| [`clients/chatgpt-desktop.md`](clients/chatgpt-desktop.md) | Experimental macOS app-server stdout shim, opt-in launch, restore and failure boundaries. |
| [`clients/claude-desktop.md`](clients/claude-desktop.md) | Claude Desktop profile ownership and config-library resolution. |
| [`clients/claude-desktop-config-reads.md`](clients/claude-desktop-config-reads.md) | Verified regular-file reads of Claude Desktop config-library metadata, profiles and snapshots. |
| [`companion.md`](companion.md) | Shared timeline filtering, usage/quotas, native and web tray title, and WidgetKit display contracts. |
| [`codex-account-controls.md`](codex-account-controls.md) | Account selection order, custom usage thresholds, and stable account-card editing. |

Expand Down Expand Up @@ -138,7 +139,7 @@ A source area can be described by more than one doc, because these docs are orga
| `src/bridge/` | [`transports/responses.md`](transports/responses.md)<br>[`transports/responses-wire-shapes.md`](transports/responses-wire-shapes.md) |
| `src/chat/` | [`runtime.md`](runtime.md)<br>[`transports/byte-accounting.md`](transports/byte-accounting.md)<br>[`transports/inventory.md`](transports/inventory.md)<br>[`data-planes/inbound-compat.md`](data-planes/inbound-compat.md)<br>[`providers-and-adapters.md`](providers-and-adapters.md)<br>[`providers/chat-compat.md`](providers/chat-compat.md) |
| `src/chatgpt/` | [`clients/chatgpt-desktop.md`](clients/chatgpt-desktop.md) |
| `src/claude/` | [`runtime.md`](runtime.md)<br>[`clients/claude-desktop.md`](clients/claude-desktop.md) |
| `src/claude/` | [`runtime.md`](runtime.md)<br>[`clients/claude-desktop.md`](clients/claude-desktop.md)<br>[`clients/claude-desktop-config-reads.md`](clients/claude-desktop-config-reads.md) |
| `src/cli.ts` | [`runtime.md`](runtime.md)<br>[`ops/docs-and-release.md`](ops/docs-and-release.md) |
| `src/cli/` | [`runtime.md`](runtime.md)<br>[`config.md`](config.md)<br>[`local-messaging.md`](local-messaging.md)<br>[`cli-management.md`](cli-management.md)<br>[`clients/integrations.md`](clients/integrations.md)<br>[`clients/chatgpt-desktop.md`](clients/chatgpt-desktop.md)<br>[`clients/claude-desktop.md`](clients/claude-desktop.md)<br>[`ops/docs-and-release.md`](ops/docs-and-release.md) |
| `src/client/` | [`runtime.md`](runtime.md)<br>[`clients/claude-desktop.md`](clients/claude-desktop.md) |
Expand Down
22 changes: 22 additions & 0 deletions structure/clients/claude-desktop-config-reads.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Claude Desktop Configuration Reads

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
Comment on lines +3 to +7

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Include backup copies in the verified-read path

During a normal (non-refresh) rewrite of an existing profile, this “every read” claim is false: after readDesktopProfileForeignKeys(configPath) returns, atomicReplaceDesktopConfig reopens the same path via copyFileSync(path, backupPath), and the rollback similarly path-copies the backup. If another process renames a FIFO onto the profile in that gap, the unchecked synchronous source open blocks indefinitely rather than producing desktop_config_not_regular; retain the bytes from the verified descriptor for backup/rollback (or use the same checked open for those reads) before documenting the invariant.

AGENTS.md reference: structure/AGENTS.md:L9-L10

Useful? React with 👍 / 👎.

`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`.

Tests: `tests/clients/desktop-3p-removal.test.ts`, `tests/clients/desktop-remote-store.test.ts`, and
`tests/claude-integration/claude-desktop-picker-profile.test.ts`.
2 changes: 1 addition & 1 deletion structure/clients/claude-desktop.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ traffic through a local interception proxy, which Anthropic may treat as a terms
owned settings are still observed; otherwise the field is `null`.
`/api/sync` and roster-update auto-apply never write a gateway profile while the resolved mode is
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.
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. Desktop library configuration reads follow [`clients/claude-desktop-config-reads.md`](claude-desktop-config-reads.md).

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
Expand Down
9 changes: 9 additions & 0 deletions structure/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -536,6 +536,15 @@
"src/server/"
]
},
{
"path": "clients/claude-desktop-config-reads.md",
"tier": 5,
"title": "Claude Desktop Configuration Reads",
"scope": "Verified regular-file reads of Claude Desktop config-library metadata, profiles and snapshots.",
"documents": [
"src/claude/"
]
},
{
"path": "desktop-shell.md",
"tier": 6,
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" });
});
});
Loading
Loading