diff --git a/docs-site/src/content/docs/guides/claude-code.md b/docs-site/src/content/docs/guides/claude-code.md index d780ece5c1a..7aef49043d0 100644 --- a/docs-site/src/content/docs/guides/claude-code.md +++ b/docs-site/src/content/docs/guides/claude-code.md @@ -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 diff --git a/src/claude/desktop-3p-library.ts b/src/claude/desktop-3p-library.ts index 73c5e067673..da07c7f304e 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, 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"; @@ -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 { @@ -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 @@ -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); } diff --git a/src/claude/desktop-3p.ts b/src/claude/desktop-3p.ts index 46e943a9840..98a6a1b4819 100644 --- a/src/claude/desktop-3p.ts +++ b/src/claude/desktop-3p.ts @@ -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"; @@ -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[] = []; @@ -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) }; @@ -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, { @@ -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); @@ -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 } : {}; diff --git a/src/claude/desktop-picker-profile.ts b/src/claude/desktop-picker-profile.ts index b7c1430bbe3..0165ec436ea 100644 --- a/src/claude/desktop-picker-profile.ts +++ b/src/claude/desktop-picker-profile.ts @@ -19,6 +19,7 @@ import { readDesktopLibraryText, resolveDesktop3pConfigLibraryPath, SAFE_DESKTOP_PROFILE_ID, + serializeDesktopLibraryJson, type Desktop3pConfigLibraryOptions, type Desktop3pMetadata, type Desktop3pMetadataEntry, @@ -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 { @@ -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 @@ -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); @@ -246,6 +248,8 @@ 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); @@ -253,16 +257,22 @@ function removeDesktopPickerProfileLocked(options: DesktopPickerProfileOptions): 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 */ } @@ -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 { diff --git a/src/claude/desktop-remote-store-io.ts b/src/claude/desktop-remote-store-io.ts index 2039deceb86..97579222def 100644 --- a/src/claude/desktop-remote-store-io.ts +++ b/src/claude/desktop-remote-store-io.ts @@ -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; @@ -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"); diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index b8227d84baa..97b94e170b4 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -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 diff --git a/tests/claude-integration/claude-desktop-picker-profile.test.ts b/tests/claude-integration/claude-desktop-picker-profile.test.ts index 99b317e9679..daf175dffef 100644 --- a/tests/claude-integration/claude-desktop-picker-profile.test.ts +++ b/tests/claude-integration/claude-desktop-picker-profile.test.ts @@ -1,6 +1,6 @@ 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 { existsSync, mkdirSync, readFileSync, readdirSync, statSync, unlinkSync, writeFileSync } from "node:fs"; import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -16,7 +16,7 @@ import { removeDesktop3pStandardPivot, writeDesktop3pConfig, } from "../../src/claude/desktop-3p"; -import { isOwnedDesktopEntry, isOwnedDesktopGatewayEntry } from "../../src/claude/desktop-3p-library"; +import { isOwnedDesktopEntry, isOwnedDesktopGatewayEntry, MAX_DESKTOP_PROFILE_FILE_BYTES } from "../../src/claude/desktop-3p-library"; import { removeTreeWithRetry } from "../helpers/remove-tree"; let root: string; @@ -174,6 +174,27 @@ describe("Claude Desktop picker profile", () => { expect(inspectDesktop3pConfigLibrary({ env: env() })).toMatchObject({ kind: "standard", appliedId: picker }); }); + // Multi-MiB real-file fixtures plus ACL-hardened writes need extra Windows wall-clock budget. + test("oversized picker metadata refuses before profile and state writes", () => { + const id = "foreign-selected"; + writeFileSync(join(library, `${id}.json`), "{}\n"); + const metadata = { + appliedId: id, entries: [{ id, name: "Personal" }], + foreignRows: Array(500_000).fill("x"), + }; + const before = JSON.stringify(metadata); + expect(Buffer.byteLength(before)).toBeLessThan(MAX_DESKTOP_PROFILE_FILE_BYTES); + expect(Buffer.byteLength(JSON.stringify(metadata, null, 2))).toBeGreaterThan(MAX_DESKTOP_PROFILE_FILE_BYTES); + const path = join(library, "_meta.json"); + writeFileSync(path, before); + const names = readdirSync(library).sort(); + const result = applyDesktopPickerProfile({ proxyPort: 41234, env: env(), configDir }); + expect(result.ok).toBe(false); + expect(readFileSync(path, "utf8")).toBe(before); + expect(readdirSync(library).sort()).toEqual(names); + expect(existsSync(join(configDir, "claude-picker", "profile-state.json"))).toBe(false); + }, 20_000); + test("a non-regular picker profile path inspects as unsafe instead of blocking", () => { const picker = "picker"; mkdirSync(join(library, `${picker}.json`)); diff --git a/tests/clients/desktop-3p-removal.test.ts b/tests/clients/desktop-3p-removal.test.ts index 0168e607375..47b9cbce40d 100644 --- a/tests/clients/desktop-3p-removal.test.ts +++ b/tests/clients/desktop-3p-removal.test.ts @@ -2,7 +2,7 @@ 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 { readDesktopLibraryText, readDesktopLibraryBytes, MAX_DESKTOP_PROFILE_FILE_BYTES, 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"; @@ -281,3 +281,51 @@ test.skipIf(process.platform === "win32")("a regular-to-symlink replacement betw expect(filesystem.lstatSync(path).isSymbolicLink()).toBe(true); } finally { opening.mockRestore(); } }); + +test("Desktop library reads accept the byte boundary and refuse a larger file", () => { + const path = join(fixtureHome, "bounded.json"); + const source = "{}" + " ".repeat(MAX_DESKTOP_PROFILE_FILE_BYTES - 2); + writeFileSync(path, source); + expect(readDesktopLibraryText(path)).toBe(source); + writeFileSync(path, source + " "); + expect(() => readDesktopLibraryText(path)).toThrow("desktop_config_too_large"); + expect(readFileSync(path, "utf8")).toBe(source + " "); +}); + +test("Desktop library reads bound growth after descriptor validation", () => { + const path = join(fixtureHome, "growing.json"); + writeFileSync(path, "0"); + const read = filesystem.readSync; + let grew = false; + let consumed = 0; + const reading = spyOn(filesystem, "readSync").mockImplementation((fd, buffer, offset, length, position) => { + if (!grew) { + grew = true; + filesystem.appendFileSync(path, "0".repeat(65)); + } + const count = read(fd, buffer, offset, length, position); + consumed += count; + return count; + }); + try { + expect(() => readDesktopLibraryBytes(path, 64)).toThrow("desktop_config_too_large"); + expect(grew).toBe(true); + expect(consumed).toBe(65); + } finally { reading.mockRestore(); } +}); + +test("Desktop library reads refuse a path removed after descriptor validation", () => { + const path = join(fixtureHome, "removed-during-read.json"); + writeFileSync(path, "{}"); + const read = filesystem.readSync; + let removed = false; + const reading = spyOn(filesystem, "readSync").mockImplementation((fd, buffer, offset, length, position) => { + const count = read(fd, buffer, offset, length, position); + if (!removed) { removed = true; unlinkSync(path); } + return count; + }); + try { + expect(() => readDesktopLibraryBytes(path)).toThrow("desktop_config_not_regular"); + expect(removed).toBe(true); + } finally { reading.mockRestore(); } +}); diff --git a/tests/clients/desktop-3p.test.ts b/tests/clients/desktop-3p.test.ts index 0156ee1ab12..3142c95fdae 100644 --- a/tests/clients/desktop-3p.test.ts +++ b/tests/clients/desktop-3p.test.ts @@ -4,7 +4,7 @@ import { withClientLifecycleSync } from "../../src/client/lifecycle-lock"; import { applyRemoteDesktopStore } from "../../src/claude/desktop-remote-store"; import type { OcxConfig } from "../../src/types"; import { describe, expect, spyOn, test } from "bun:test"; -import { mkdirSync, mkdtempSync, realpathSync, readFileSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, realpathSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, posix, win32 } from "node:path"; import { @@ -24,6 +24,7 @@ import { writeRemoteDesktop3pConfig, type Desktop3pModelEntry, } from "../../src/claude/desktop-3p"; +import { assertDesktopLibraryTextSize, MAX_DESKTOP_PROFILE_FILE_BYTES } from "../../src/claude/desktop-3p-library"; import { moveDesktopRoute, reconcileDesktopProfile, setDesktopFamilyDefault } from "../../src/claude/desktop-profile"; import { resolveInboundModel } from "../../src/claude/inbound"; import { removeTreeWithRetry } from "../helpers/remove-tree"; @@ -643,3 +644,71 @@ test("Desktop FAST base validation preserves exact catalog IDs and clears stale expect(isUnresolvedDesktop3pAlias(unknown)).toBe(true); } finally { buildDesktop3pRegistry([], []); } }); + +// Multi-MiB real-file fixtures plus ACL-hardened writes need extra Windows wall-clock budget. +test("oversized Desktop replacement is refused before writing a backup", () => { + const dir = mkdtempSync(join(tmpdir(), "ocx-desktop-bound-")); + const path = join(dir, "profile.json"); + try { + writeFileSync(path, '{"original":true}'); + expect(() => atomicReplaceDesktopConfig(path, "x".repeat(MAX_DESKTOP_PROFILE_FILE_BYTES + 1))) + .toThrow("desktop_config_too_large"); + expect(readFileSync(path, "utf8")).toBe('{"original":true}'); + expect(existsSync(path + ".bak")).toBe(false); + expect(() => assertDesktopLibraryTextSize("é".repeat(MAX_DESKTOP_PROFILE_FILE_BYTES / 2))).not.toThrow(); + expect(() => assertDesktopLibraryTextSize("é".repeat(MAX_DESKTOP_PROFILE_FILE_BYTES / 2 + 1))) + .toThrow("desktop_config_too_large"); + } finally { removeTreeWithRetry(dir); } +}, 20_000); + +test.each(["profile", "metadata"] as const)("oversized Desktop %s output refuses before file mutation", kind => { + const dir = mkdtempSync(join(tmpdir(), "ocx-desktop-write-bound-")); + const previous = process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR; + const previousHome = process.env.OPENCODEX_HOME; + process.env.OPENCODEX_HOME = join(dir, "ocx"); + process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR = dir; + try { + saveConfig({ + providers: { test: { adapter: "openai-chat", baseUrl: "http://127.0.0.1:1/v1", + allowPrivateNetwork: true, liveModels: false, models: ["fixture-model"] } }, + defaultProvider: "test", port: 4096, + } as OcxConfig); + const id = "owned-profile"; + const metadata = { + appliedId: id, entries: [{ id, name: "opencodex" }], + ...(kind === "metadata" ? { foreignRows: Array(500_000).fill("x") } : {}), + }; + const profile = { + inferenceProvider: "gateway", inferenceCredentialKind: "static", + inferenceGatewayBaseUrl: "http://127.0.0.1:1", inferenceGatewayApiKey: "old-key", + modelDiscoveryEnabled: false, inferenceModels: [], + ...(kind === "profile" ? { foreignPad: "x".repeat(MAX_DESKTOP_PROFILE_FILE_BYTES - 512) } : {}), + }; + const metadataPath = join(dir, "_meta.json"); + const profilePath = join(dir, `${id}.json`); + const beforeMeta = JSON.stringify(metadata); + const beforeProfile = JSON.stringify(profile); + expect(Buffer.byteLength(beforeMeta)).toBeLessThan(MAX_DESKTOP_PROFILE_FILE_BYTES); + expect(Buffer.byteLength(beforeProfile)).toBeLessThan(MAX_DESKTOP_PROFILE_FILE_BYTES); + if (kind === "metadata") { + expect(Buffer.byteLength(JSON.stringify(metadata, null, 2) + "\n")).toBeGreaterThan(MAX_DESKTOP_PROFILE_FILE_BYTES); + } + writeFileSync(metadataPath, beforeMeta); + writeFileSync(profilePath, beforeProfile); + const models = Array.from({ length: 100 }, (_, index) => ({ provider: "test", id: `fixture-${index}` })); + const result = writeDesktop3pConfig(4096, [], models, "new-key", "static", undefined, undefined, { + lockPath: join(dir, "locks", "desktop.sqlite"), + }); + expect(result.written).toBe(false); + expect(result.reason).toBe("desktop_config_too_large"); + expect(readFileSync(profilePath, "utf8")).toBe(beforeProfile); + expect(readFileSync(metadataPath, "utf8")).toBe(beforeMeta); + expect(existsSync(profilePath + ".bak")).toBe(false); + } finally { + if (previous === undefined) delete process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR; + else process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR = previous; + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + removeTreeWithRetry(dir); + } +}, 20_000);