From 6c05e15827cf0e428324b1544b6cf0a5dcce657b Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Mon, 12 Oct 2026 20:36:01 +0900 Subject: [PATCH 1/4] fix(desktop): bound library reads and preflight write sizes Complete the Desktop byte-bound portion of PR #719 on top of PR #718, preserving identity checks and existing bytes on size refusals. Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../src/content/docs/guides/claude-code.md | 6 ++ src/claude/desktop-3p-library.ts | 36 +++++++++- src/claude/desktop-3p.ts | 29 +++++--- src/claude/desktop-picker-profile.ts | 27 ++++--- src/claude/desktop-remote-store-io.ts | 4 +- structure/clients/claude-desktop.md | 6 ++ .../claude-desktop-picker-profile.test.ts | 25 ++++++- tests/clients/desktop-3p-removal.test.ts | 50 ++++++++++++- tests/clients/desktop-3p.test.ts | 71 ++++++++++++++++++- 9 files changed, 227 insertions(+), 27 deletions(-) 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..275e976f5de 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,17 @@ 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"); + const bytes = Buffer.alloc(maxBytes + 1); + let length = 0; + while (length < bytes.length) { + 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 (!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 a439d0a7fe5..7dce11249ef 100644 --- a/src/claude/desktop-picker-profile.ts +++ b/src/claude/desktop-picker-profile.ts @@ -18,6 +18,7 @@ import { profilePath, resolveDesktop3pConfigLibraryPath, SAFE_DESKTOP_PROFILE_ID, + serializeDesktopLibraryJson, type Desktop3pConfigLibraryOptions, type Desktop3pMetadata, type Desktop3pMetadataEntry, @@ -47,7 +48,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 { @@ -183,6 +184,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 @@ -192,10 +197,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); @@ -239,6 +241,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); @@ -246,16 +250,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 */ } @@ -265,9 +275,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 369e32dd882..534b47e0fcf 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -110,6 +110,12 @@ longer names the expected file, while a same-path failure keeps its own errno. I 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`. +The opened size is checked before allocation, reads stop after the bound plus one sentinel byte, and +the path must still name the opened file after the read completes or the same +`desktop_config_not_regular` refusal is raised. Profiles and `_meta.json` metadata are capped at +4 MiB (`MAX_DESKTOP_PROFILE_FILE_BYTES`); the connection store keeps its existing tighter limits. Profile, metadata and removal writes preflight +the bounded serialization of both outputs before any replacement, pivot or unlink mutation, so a +`desktop_config_too_large` refusal cannot strand the reader on a file the writer just created. 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 c8043ba1b66..047555cdd58 100644 --- a/tests/claude-integration/claude-desktop-picker-profile.test.ts +++ b/tests/claude-integration/claude-desktop-picker-profile.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; -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"; @@ -15,7 +15,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; @@ -172,4 +172,25 @@ describe("Claude Desktop picker profile", () => { expect(isOwnedDesktopGatewayEntry({ id: picker, name: "opencodex-picker" })).toBe(false); 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); }); 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); From caf9cc9f04fda4e0fad36d3e5d0acdbc1525d4a6 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Mon, 12 Oct 2026 23:49:16 +0900 Subject: [PATCH 2/4] docs(desktop): keep combined read and write contract within budget --- structure/clients/claude-desktop.md | 39 +++++++++++++---------------- 1 file changed, 17 insertions(+), 22 deletions(-) diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index 3ad1019a331..a5acf9f6c54 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -94,28 +94,23 @@ 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 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`. -The opened size is checked before allocation, reads stop after the bound plus one sentinel byte, and -the path must still name the opened file after the read completes or the same -`desktop_config_not_regular` refusal is raised. Profiles and `_meta.json` metadata are capped at -4 MiB (`MAX_DESKTOP_PROFILE_FILE_BYTES`); the connection store keeps its existing tighter limits. Profile, metadata and removal writes preflight -the bounded serialization of both outputs before any replacement, pivot or unlink mutation, so a -`desktop_config_too_large` refusal cannot strand the reader on a file the writer just created. +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, 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 From 2cbf5ee65a8c38df2ca1e515f167831cddcf7410 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Tue, 13 Oct 2026 11:54:44 +0900 Subject: [PATCH 3/4] docs(structure): fit claude-desktop contract within the line budget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The merged regular-file contract paragraph carried 602 lines on the pushed head; reflow keeps every guarantee — verified reader, POSIX no-follow and identity checks, errno mapping, size cap and preflight — at exactly 600. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- structure/clients/claude-desktop.md | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index d17654eb5ac..68e2bd4f4cc 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -94,17 +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 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, 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. +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, 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 From 996942041f3c644a67c741f26b6630c306708269 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Tue, 13 Oct 2026 13:38:00 +0900 Subject: [PATCH 4/4] fix(desktop): bound picker reads and grow library read buffers incrementally Merge the picker-profile verified-reader migration, then stop preallocating maxBytes+1 per read: allocate the observed size plus one sentinel byte and grow within the bound when a file expands after descriptor validation, preserving desktop_config_too_large for the grown case. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- src/claude/desktop-3p-library.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/claude/desktop-3p-library.ts b/src/claude/desktop-3p-library.ts index ea8fbe4b5f8..da07c7f304e 100644 --- a/src/claude/desktop-3p-library.ts +++ b/src/claude/desktop-3p-library.ts @@ -95,14 +95,21 @@ export function readDesktopLibraryBytes( || opened.ctimeNs !== expected.ctimeNs) throw new Error("desktop_config_not_regular"); 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 grown file is caught by - // the post-read identity check. - const bytes = Buffer.alloc(Number(opened.size) + 1); + // 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; - while (length < bytes.length) { + 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");