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
6 changes: 6 additions & 0 deletions docs-site/src/content/docs/guides/claude-code.md
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,12 @@ Claude Desktop can use OpenCodex in one of two mutually exclusive modes. Pick it
**Connect → Claude Desktop → Connection mode** in the dashboard or with `ocx claude desktop apply
--first-party|--gateway`.

> Desktop library profiles and `_meta.json` must be regular files of at most
> 4 MiB. OpenCodex also refuses generated profile or metadata output above that
> limit before replacing files. Back up and reduce oversized configuration
> manually; a size refusal does not truncate the existing data. Connection-owned
> stores retain their existing tighter limits.

### Gateway (default)

New installs use **gateway** by default. Its third-party profile, described below, switches the
Expand Down
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 { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync, type BigIntStats } from "node:fs";
import { closeSync, constants, fstatSync, lstatSync, openSync, readSync, 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,6 +44,20 @@ export interface Desktop3pMetadata {

export const DESKTOP_PICKER_ENTRY_NAME = "opencodex-picker";

export const MAX_DESKTOP_PROFILE_FILE_BYTES = 4 * 1024 * 1024;

export function assertDesktopLibraryTextSize(text: string): void {
if (Buffer.byteLength(text, "utf8") > MAX_DESKTOP_PROFILE_FILE_BYTES) {
throw new Error("desktop_config_too_large");
}
}

export function serializeDesktopLibraryJson(value: unknown): string {
const text = JSON.stringify(value, null, 2) + "\n";
assertDesktopLibraryTextSize(text);
return text;
}

/** 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 {
Expand All @@ -54,7 +68,13 @@ function pathStillNamesExpectedFile(path: string, expected: BigIntStats): boolea
}

/** Read regular config files only; a path swap must not turn an observation into a FIFO wait. */
export function readDesktopLibraryBytes(path: string): Buffer {
export function readDesktopLibraryBytes(
path: string,
maxBytes: number = MAX_DESKTOP_PROFILE_FILE_BYTES,
): Buffer {
if (!Number.isSafeInteger(maxBytes) || maxBytes < 1 || maxBytes > MAX_DESKTOP_PROFILE_FILE_BYTES) {
throw new RangeError("invalid Desktop library read limit");
}
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
Expand All @@ -73,7 +93,27 @@ export function readDesktopLibraryBytes(path: string): Buffer {
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);
if (opened.size > BigInt(maxBytes)) throw new Error("desktop_config_too_large");
// Allocate for the observed size plus one sentinel byte, not the full cap: callers scan many
// small profiles, and the cap would zero-fill megabytes per read. A file that fills the
// buffer grew after validation — keep reading within the bound so it classifies as
// desktop_config_too_large rather than silently truncating.
let bytes = Buffer.alloc(Number(opened.size) + 1);
let length = 0;
for (;;) {
const count = readSync(fd, bytes, length, Math.min(64 * 1024, bytes.length - length), null);
if (count === 0) break;
length += count;
if (length > maxBytes) throw new Error("desktop_config_too_large");
if (length === bytes.length && bytes.length <= maxBytes) {
const grown = Buffer.alloc(Math.min(maxBytes + 1, bytes.length * 2));
bytes.copy(grown, 0, 0, length);
bytes = grown;
}
}
if (length > maxBytes) throw new Error("desktop_config_too_large");
if (!pathStillNamesExpectedFile(path, opened)) throw new Error("desktop_config_not_regular");
return bytes.subarray(0, length);
} finally {
closeSync(fd);
}
Expand Down
30 changes: 20 additions & 10 deletions src/claude/desktop-3p.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { canonicalDirectory } from "./desktop-remote-store-io";
import {
resolveDesktop3pConfigLibraryPath, parseMetadata, SAFE_DESKTOP_PROFILE_ID, isRecord,
isOwnedDesktopEntry, isOwnedDesktopGatewayEntry, profilePath, readDesktopProfileSnapshot, readDesktopLibraryText, readDesktopLibraryBytes,
readOptionalDesktopLibraryBytes,
readOptionalDesktopLibraryBytes, assertDesktopLibraryTextSize, serializeDesktopLibraryJson,
type Desktop3pConfigLibraryOptions, type Desktop3pMetadata, type Desktop3pMetadataEntry,
} from "./desktop-3p-library";
export { resolveDesktop3pConfigLibraryPath, type Desktop3pConfigLibraryOptions } from "./desktop-3p-library";
Expand Down Expand Up @@ -642,13 +642,24 @@ function removeDesktop3pStandardPivotLocal(
if (targetIds.length === 0) return { ok: true, changed: false, kind: "noop", libraryPath: inspected.libraryPath };

let metadataAfterPivot = metadata;
let standardPath: string | undefined;
let pivotMetadataJson: string | undefined;
if (selectedOwnedGatewayActive) {
const standardId = randomUUID();
const standardPath = profilePath(inspected.libraryPath, standardId);
atomicWriteFile(standardPath, "{}\n");
standardPath = profilePath(inspected.libraryPath, standardId);
const standardEntry: Desktop3pMetadataEntry = { id: standardId, name: "opencodex-standard" };
metadataAfterPivot = { ...metadata, appliedId: standardId, entries: [...metadata.entries, standardEntry] };
atomicWriteFile(metadataPath, JSON.stringify(metadataAfterPivot, null, 2) + "\n");
pivotMetadataJson = serializeDesktopLibraryJson(metadataAfterPivot);
}
// Bound the final metadata before any unlink so a size refusal cannot strand
// a row pointing at an already-deleted profile.
const removalMetadataJson = serializeDesktopLibraryJson({
...metadataAfterPivot,
entries: metadataAfterPivot.entries.filter(entry => !targetIds.includes(entry.id)),
});
if (standardPath !== undefined && pivotMetadataJson !== undefined) {
atomicWriteFile(standardPath, "{}\n");
atomicWriteFile(metadataPath, pivotMetadataJson);
}

const residualPaths: string[] = [];
Expand All @@ -674,10 +685,7 @@ function removeDesktop3pStandardPivotLocal(
}
// Do not leave a metadata row pointing at a deleted profile. For a foreign
// selection this only removes proven opencodex residues; appliedId is kept.
atomicWriteFile(
metadataPath,
JSON.stringify({ ...metadataAfterPivot, entries: metadataAfterPivot.entries.filter(entry => !targetIds.includes(entry.id)) }, null, 2) + "\n",
);
atomicWriteFile(metadataPath, removalMetadataJson);
return { ok: true, changed: true, kind: "removed", libraryPath: inspected.libraryPath };
} catch (error) {
return { ok: false, changed: false, kind: "write_failed", libraryPath: inspected.libraryPath, reason: desktopMutationFailureReason(error) };
Expand Down Expand Up @@ -831,7 +839,7 @@ function writeDesktop3pConfigWithGenerator(

const generated = generate();
const previous = readDesktopProfileSnapshot(configPath);
const configJson = JSON.stringify({ ...previous.foreign, ...generated }, null, 2) + "\n";
const configJson = serializeDesktopLibraryJson({ ...previous.foreign, ...generated });
const fingerprint = createHash("sha256").update(configJson).digest("hex").slice(0, 16);
if (refreshOnly) {
atomicWriteFileNoFollow(configPath, configJson, undefined, {
Expand All @@ -849,9 +857,10 @@ function writeDesktop3pConfigWithGenerator(
});
return { written: true, path: configPath, fingerprint };
}
const metadataJson = serializeDesktopLibraryJson({ ...metadata, appliedId: id, entries });
atomicReplaceDesktopConfig(configPath, configJson, previous.bytes);
try {
atomicWriteFile(metadataPath, JSON.stringify({ ...metadata, appliedId: id, entries }, null, 2) + "\n");
atomicWriteFile(metadataPath, metadataJson);
} catch (metaError) {
// Roll back from the verified bytes in memory: reopening the backup could meet a swapped
// entry and leave the new profile beside the old metadata. A profile this call created is
Expand Down Expand Up @@ -895,6 +904,7 @@ export function atomicReplaceDesktopConfig(
previous: Buffer | undefined,
writer: typeof atomicWriteFileNoFollow = atomicWriteFileNoFollow,
): { backupPath?: string } {
assertDesktopLibraryTextSize(content);
const validateBeforeRename = () => assertDesktopProfileUnchanged(path, previous);
validateBeforeRename();
const backupPath = `${path}.bak`;
Expand Down
27 changes: 18 additions & 9 deletions src/claude/desktop-picker-profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
readDesktopLibraryText,
resolveDesktop3pConfigLibraryPath,
SAFE_DESKTOP_PROFILE_ID,
serializeDesktopLibraryJson,
type Desktop3pConfigLibraryOptions,
type Desktop3pMetadata,
type Desktop3pMetadataEntry,
Expand Down Expand Up @@ -48,7 +49,7 @@ function metadataPath(libraryPath: string): string {
}

function metadataJson(metadata: Desktop3pMetadata): string {
return JSON.stringify(metadata, null, 2) + "\n";
return serializeDesktopLibraryJson(metadata);
}

function isValidMetadata(metadata: Desktop3pMetadata): boolean {
Expand Down Expand Up @@ -190,6 +191,10 @@ function applyDesktopPickerProfileLocked(options: { proxyPort: number } & Deskto
const changed = !alreadySelected || !oldProfile.exists || oldProfile.content !== target;
if (!changed) return { ok: true, changed: false, path: profile };

const entries = existing
? metadata.entries.map(entry => entry.id === entryId ? { ...entry, name: DESKTOP_PICKER_ENTRY_NAME } : entry)
: [...metadata.entries, { id: entryId, name: DESKTOP_PICKER_ENTRY_NAME }];
const nextMetadataJson = metadataJson({ ...metadata, appliedId: entryId, entries });
mkdirSync(join(configDir, PICKER_DIRECTORY), { recursive: true, mode: 0o700 });
try {
// Keep the same backup convention as the existing Desktop writer. It is removed with the
Expand All @@ -199,10 +204,7 @@ function applyDesktopPickerProfileLocked(options: { proxyPort: number } & Deskto
if (!alreadySelected) {
atomicWriteFile(previousStatePath, JSON.stringify({ entryId, previousAppliedId }) + "\n");
}
const entries = existing
? metadata.entries.map(entry => entry.id === entryId ? { ...entry, name: DESKTOP_PICKER_ENTRY_NAME } : entry)
: [...metadata.entries, { id: entryId, name: DESKTOP_PICKER_ENTRY_NAME }];
atomicWriteFile(metaPath, metadataJson({ ...metadata, appliedId: entryId, entries }));
atomicWriteFile(metaPath, nextMetadataJson);
} catch {
try {
restoreFile(metaPath, oldMeta);
Expand Down Expand Up @@ -246,23 +248,31 @@ function removeDesktopPickerProfileLocked(options: DesktopPickerProfileOptions):
try { state = readPickerState(statePath); } catch { return { ok: false, reason: "profile_state_unreadable" }; }

let metadataAfterPivot = metadata;
let standardPath: string | undefined;
let pivotMetadataJson: string | undefined;
if (selected) {
const previous = state?.entryId === picker.id ? state.previousAppliedId : null;
const previousExists = previous !== null && previous !== picker.id && metadata.entries.some(entry => entry.id === previous);
if (previousExists) {
metadataAfterPivot = { ...metadata, appliedId: previous };
} else {
const standardId = randomUUID();
atomicWriteFile(profilePath(libraryPath, standardId), "{}\n");
standardPath = profilePath(libraryPath, standardId);
metadataAfterPivot = {
...metadata,
appliedId: standardId,
entries: [...metadata.entries, { id: standardId, name: "opencodex-standard" }],
};
}
atomicWriteFile(metaPath, metadataJson(metadataAfterPivot));
pivotMetadataJson = metadataJson(metadataAfterPivot);
}

const entries = metadataAfterPivot.entries.filter(entry => entry.id !== picker.id);
const finalMetadataJson = metadataJson({ ...metadataAfterPivot, entries });

if (standardPath !== undefined) atomicWriteFile(standardPath, "{}\n");
if (pivotMetadataJson !== undefined) atomicWriteFile(metaPath, pivotMetadataJson);

const residualPaths: string[] = [];
for (const path of [profilePath(libraryPath, picker.id), `${profilePath(libraryPath, picker.id)}.bak`]) {
try { unlinkIfPresent(path); } catch { /* report the path without exposing file contents */ }
Expand All @@ -272,9 +282,8 @@ function removeDesktopPickerProfileLocked(options: DesktopPickerProfileOptions):
return { ok: false, reason: "cleanup_incomplete", residualPaths };
}

const entries = metadataAfterPivot.entries.filter(entry => entry.id !== picker.id);
try {
atomicWriteFile(metaPath, metadataJson({ ...metadataAfterPivot, entries }));
atomicWriteFile(metaPath, finalMetadataJson);
try { unlinkIfPresent(statePath); } catch { /* residual is reported below */ }
if (existsSync(statePath)) return { ok: false, reason: "cleanup_incomplete", residualPaths: [statePath] };
} catch {
Expand Down
4 changes: 2 additions & 2 deletions src/claude/desktop-remote-store-io.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { chmodSync, lstatSync, mkdirSync, realpathSync, unlinkSync } from "node:
import { basename, dirname, join, resolve } from "node:path";
import { atomicWriteFile, getConfigDir } from "../config";
import { hardenSecretDir } from "../lib/windows-secret-acl";
import { isRecord, readDesktopLibraryBytes, resolveDesktop3pConfigLibraryPath, SAFE_DESKTOP_PROFILE_ID, type Desktop3pMetadata } from "./desktop-3p-library";
import { isRecord, MAX_DESKTOP_PROFILE_FILE_BYTES, 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 @@ -45,7 +45,7 @@ export class StoreIO {
try {
// 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);
const bytes = readDesktopLibraryBytes(path, Math.min(maxBytes, MAX_DESKTOP_PROFILE_FILE_BYTES));
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
22 changes: 19 additions & 3 deletions structure/clients/claude-desktop-config-reads.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@ profile reads — goes through the shared verified regular-file reader in
`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
time match the path observation before its bytes are read, and it is always closed. The path is
checked again after reading. 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
Expand All @@ -36,5 +37,20 @@ reported with "profile rollback skipped". A failed rollback keeps the metadata e
primary cause and reports that profile rollback also failed. An absent profile does not adopt a
leftover backup from an earlier operation.

Tests: `tests/clients/desktop-3p-write-files.test.ts`, `tests/clients/desktop-3p-removal.test.ts`, `tests/clients/desktop-remote-store.test.ts`, and
The reader defaults to a 4 MiB profile and metadata limit (`MAX_DESKTOP_PROFILE_FILE_BYTES`). It
checks the opened size before allocation and reads no more than the bound plus one sentinel byte;
oversized input raises `desktop_config_too_large`. `StoreIO.read` in
`src/claude/desktop-remote-store-io.ts` passes its tighter limit to the same reader and retains its
private-file mode and post-read identity checks, reporting reader refusals as `unsafe`.

`serializeDesktopLibraryJson` checks the UTF-8 byte length of the formatted JSON, including its
trailing newline, against the same 4 MiB limit. Gateway apply in `src/claude/desktop-3p.ts`
preflights the generated profile and metadata before replacing either; refresh preflights its
generated profile while leaving metadata unchanged. Picker apply in
`src/claude/desktop-picker-profile.ts` preflights metadata before its backup and profile writes.
Gateway and picker removal preflight the pivot and final metadata before creating a standard
profile, changing the selected row or unlinking profiles and backups. A serialization size refusal
therefore leaves the existing library files in place.

Tests: `tests/clients/desktop-3p.test.ts`, `tests/clients/desktop-3p-write-files.test.ts`, `tests/clients/desktop-3p-removal.test.ts`, `tests/clients/desktop-remote-store.test.ts`, and
`tests/claude-integration/claude-desktop-picker-profile.test.ts`.
Loading
Loading