Skip to content
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 @@ -218,6 +218,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, existsSync, fstatSync, lstatSync, openSync, readFileSync, type BigIntStats } from "node:fs";
import { closeSync, constants, existsSync, 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
29 changes: 20 additions & 9 deletions src/claude/desktop-3p.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { canonicalDirectory } from "./desktop-remote-store-io";
import {
resolveDesktop3pConfigLibraryPath, parseMetadata, SAFE_DESKTOP_PROFILE_ID, isRecord,
isOwnedDesktopEntry, isOwnedDesktopGatewayEntry, profilePath, readDesktopProfileForeignKeys, readDesktopLibraryText, readDesktopLibraryBytes,
assertDesktopLibraryTextSize, serializeDesktopLibraryJson,
type Desktop3pConfigLibraryOptions, type Desktop3pMetadata, type Desktop3pMetadataEntry,
} from "./desktop-3p-library";
export { resolveDesktop3pConfigLibraryPath, type Desktop3pConfigLibraryOptions } from "./desktop-3p-library";
Expand Down Expand Up @@ -627,13 +628,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 @@ -659,10 +671,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 @@ -815,7 +824,7 @@ function writeDesktop3pConfigWithGenerator(

const generated = generate();
const preserved = readDesktopProfileForeignKeys(configPath);
const configJson = JSON.stringify({ ...preserved, ...generated }, null, 2) + "\n";
const configJson = serializeDesktopLibraryJson({ ...preserved, ...generated });
const fingerprint = createHash("sha256").update(configJson).digest("hex").slice(0, 16);
if (refreshOnly) {
atomicWriteFileNoFollow(configPath, configJson, undefined, {
Expand All @@ -833,9 +842,10 @@ function writeDesktop3pConfigWithGenerator(
});
return { written: true, path: configPath, fingerprint };
}
const metadataJson = serializeDesktopLibraryJson({ ...metadata, appliedId: id, entries });
const { backupPath } = atomicReplaceDesktopConfig(configPath, configJson);
try {
atomicWriteFile(metadataPath, JSON.stringify({ ...metadata, appliedId: id, entries }, null, 2) + "\n");
atomicWriteFile(metadataPath, metadataJson);
} catch (metaError) {
// Rollback: restore the backed-up config if metadata write fails.
if (backupPath && existsSync(backupPath)) copyFileSync(backupPath, configPath);
Expand All @@ -855,6 +865,7 @@ export function atomicReplaceDesktopConfig(
writer: (path: string, content: string) => void = atomicWriteFile,
): { backupPath?: string } {
const backupPath = `${path}.bak`;
assertDesktopLibraryTextSize(content);
if (existsSync(path)) copyFileSync(path, backupPath);
writer(path, content);
return existsSync(backupPath) ? { backupPath } : {};
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 });
Comment thread
luvs01 marked this conversation as resolved.
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
18 changes: 9 additions & 9 deletions structure/clients/claude-desktop.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,15 +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, the picker profile's inspection and apply snapshots, and the connection-owned store's metadata and profile reads — goes through the shared verified regular-file reader in `src/claude/desktop-3p-library.ts`
(`readDesktopLibraryBytes`/`readDesktopLibraryText`). The path `lstat` must already name a regular file; on POSIX the open is nonblocking and no-follow, so a path swapped to a FIFO
cannot turn an observation into a blocking read and a swapped symlink is not followed; the opened descriptor must still be a regular file whose bigint device, inode and change
time match the path observation before its bytes are read, and it is always closed. Windows keeps the same path-and-descriptor identity check without the POSIX-only open flags.
A non-regular or swapped path throws `desktop_config_not_regular`, including a swap that surfaces only at open (a symlink's `ELOOP`, a socket's `ENXIO`, a deletion's `ENOENT`):
the failed open re-observes the path and reports the documented refusal whenever it no longer names the expected file, while a same-path failure keeps its own errno. Inspection
reports the refusal through its existing `metadata_unreadable`/`broken` outcomes and the writer through its existing refusals; `StoreIO.read` in `src/claude/desktop-remote-store-io.ts`
layers its size cap, private-file mode and post-read identity contract on top of the same read and reports it as `unsafe`.
Every Desktop library configuration read uses the verified regular-file reader in `src/claude/desktop-3p-library.ts` (`readDesktopLibraryBytes`/`readDesktopLibraryText`): selected-profile
inspection, metadata parsing, foreign-key preservation, refresh mode and fingerprint reads, picker inspection/apply snapshots, and connection-owned metadata/profile reads. `lstat` must name a regular file. POSIX opens
are nonblocking and no-follow; Windows retains path/descriptor identity checks without these flags. The opened descriptor must be regular and match the observed bigint device,
inode and change time before reading, and is always closed. The path is checked again after reading. Nonregular or replaced paths raise `desktop_config_not_regular`. An open
failure (`ELOOP`, `ENXIO`, or `ENOENT`) rechecks the path and uses that refusal if its identity changed; a same-path failure retains its errno. Inspection maps refusals to
`metadata_unreadable`/`broken`, and writers retain their existing refusal results. `StoreIO.read` in `src/claude/desktop-remote-store-io.ts` adds its tighter byte cap,
private-file mode and post-read identity contract, reporting unsafe reads as `unsafe`. The opened size is checked before allocation; reads stop at the bound plus one sentinel
byte. Profiles and `_meta.json` have a 4 MiB cap (`MAX_DESKTOP_PROFILE_FILE_BYTES`). Profile, metadata and removal writes preflight both serialized outputs before replacement,
pivot or unlink, so `desktop_config_too_large` cannot leave a newly written file that the reader refuses.

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
Loading
Loading