From 3f05d9694285765c2b585fcddb440539e46206f1 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Sun, 18 Oct 2026 01:10:54 -0700 Subject: [PATCH 1/3] fix(claude): bound Desktop library reads and preflight writes --- .../src/content/docs/guides/claude-code.md | 6 + src/claude/desktop-3p-library.ts | 46 ++++++- src/claude/desktop-3p.ts | 30 +++-- src/claude/desktop-picker-profile.ts | 27 ++-- src/claude/desktop-remote-store-io.ts | 4 +- .../clients/claude-desktop-config-reads.md | 22 +++- .../claude-desktop-picker-profile.test.ts | 79 +++++++++++- tests/clients/desktop-3p-removal.test.ts | 116 +++++++++++++++++- tests/clients/desktop-3p-write-files.test.ts | 14 ++- tests/clients/desktop-3p.test.ts | 71 ++++++++++- 10 files changed, 383 insertions(+), 32 deletions(-) diff --git a/docs-site/src/content/docs/guides/claude-code.md b/docs-site/src/content/docs/guides/claude-code.md index eb1b9b9d14a..4a815b3c6d1 100644 --- a/docs-site/src/content/docs/guides/claude-code.md +++ b/docs-site/src/content/docs/guides/claude-code.md @@ -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 diff --git a/src/claude/desktop-3p-library.ts b/src/claude/desktop-3p-library.ts index 0e1ddbf4a41..9410cf707b4 100644 --- a/src/claude/desktop-3p-library.ts +++ b/src/claude/desktop-3p-library.ts @@ -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"; @@ -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 b816ae5b62d..46ff2e77843 100644 --- a/src/claude/desktop-3p.ts +++ b/src/claude/desktop-3p.ts @@ -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"; @@ -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[] = []; @@ -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) }; @@ -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, { @@ -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 @@ -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`; 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-config-reads.md b/structure/clients/claude-desktop-config-reads.md index 8c929ec8640..db1cd7ab2aa 100644 --- a/structure/clients/claude-desktop-config-reads.md +++ b/structure/clients/claude-desktop-config-reads.md @@ -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 @@ -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`. diff --git a/tests/claude-integration/claude-desktop-picker-profile.test.ts b/tests/claude-integration/claude-desktop-picker-profile.test.ts index 99b317e9679..526a9f8daf3 100644 --- a/tests/claude-integration/claude-desktop-picker-profile.test.ts +++ b/tests/claude-integration/claude-desktop-picker-profile.test.ts @@ -1,10 +1,12 @@ 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"; +import * as clientLifecycle from "../../src/client/lifecycle-lock"; import * as configIO from "../../src/config"; +import * as desktopLibrary from "../../src/claude/desktop-3p-library"; import { saveConfig } from "../../src/config"; import { inspectDesktopPickerProfile, @@ -16,7 +18,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; @@ -24,6 +26,8 @@ let library: string; let configDir: string; let previousHome: string | undefined; let previousLibrary: string | undefined; +let restoreLifecycleLock: (() => void) | undefined; +const realClientLifecycleSync = clientLifecycle.withClientLifecycleSync; function env(): NodeJS.ProcessEnv { return { ...process.env, OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR: library }; @@ -47,6 +51,13 @@ beforeEach(() => { mkdirSync(library, { recursive: true }); process.env.OPENCODEX_HOME = configDir; process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR = library; + // Keep real SQLite exclusion and lease checks, but isolate the user-scoped lock too. + const lifecycleLock = spyOn(clientLifecycle, "withClientLifecycleSync").mockImplementation( + (work, deps) => realClientLifecycleSync(work, { + ...deps, lockPath: deps?.lockPath ?? join(root, "locks", "lifecycle.sqlite"), + }), + ); + restoreLifecycleLock = () => lifecycleLock.mockRestore(); saveConfig({ port: 10100, defaultProvider: "test", @@ -56,6 +67,8 @@ beforeEach(() => { }); afterEach(() => { + restoreLifecycleLock?.(); + restoreLifecycleLock = undefined; if (previousHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previousHome; if (previousLibrary === undefined) delete process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR; @@ -174,6 +187,68 @@ 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 serialize = spyOn(desktopLibrary, "serializeDesktopLibraryJson"); + try { + expect(applyDesktopPickerProfile({ proxyPort: 41234, env: env(), configDir })) + .toEqual({ ok: false, reason: "write_failed" }); + expect(serialize).toHaveBeenCalledTimes(1); + } finally { + serialize.mockRestore(); + } + 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.each([ + ["selected with a previous row", true, true], + ["selected without a previous row", true, false], + ["unselected", false, true], + ] as const)("oversized removal metadata preserves all artifacts: %s", (_name, selected, previousExists) => { + const picker = "picker", previous = "foreign"; + const metadata = { + appliedId: selected ? picker : previous, + entries: [ + { id: picker, name: "opencodex-picker" }, + ...(previousExists ? [{ id: previous, name: "Personal" }] : []), + ], + foreignRows: Array(500_000).fill("x"), + }; + const compact = JSON.stringify(metadata); + expect(Buffer.byteLength(compact)).toBeLessThan(MAX_DESKTOP_PROFILE_FILE_BYTES); + expect(Buffer.byteLength(JSON.stringify(metadata, null, 2))).toBeGreaterThan(MAX_DESKTOP_PROFILE_FILE_BYTES); + const metaPath = join(library, "_meta.json"), profilePath = join(library, `${picker}.json`); + const backupPath = `${profilePath}.bak`, statePath = join(configDir, "claude-picker", "profile-state.json"); + mkdirSync(join(configDir, "claude-picker"), { recursive: true }); + writeFileSync(metaPath, compact); + writeFileSync(profilePath, '{"egressProxyUrl":"http://127.0.0.1:41234"}\n'); + writeFileSync(backupPath, '{"egressProxyUrl":"http://127.0.0.1:41233"}\n'); + writeFileSync(statePath, JSON.stringify({ entryId: picker, previousAppliedId: previous }) + "\n"); + if (previousExists) writeFileSync(join(library, `${previous}.json`), '{"foreign":true}\n'); + const paths = [metaPath, profilePath, backupPath, statePath]; + const snapshots = paths.map(path => readFileSync(path, "utf8")); + const names = readdirSync(library).sort(); + + expect(removeDesktopPickerProfile({ env: env(), configDir })) + .toEqual({ ok: false, reason: "desktop_config_too_large" }); + expect(paths.map(path => readFileSync(path, "utf8"))).toEqual(snapshots); + expect(readdirSync(library).sort()).toEqual(names); + }, 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..db0b8dd6972 100644 --- a/tests/clients/desktop-3p-removal.test.ts +++ b/tests/clients/desktop-3p-removal.test.ts @@ -2,7 +2,9 @@ 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 { StoreIO } from "../../src/claude/desktop-remote-store-io"; +import { DesktopStoreError } from "../../src/claude/desktop-remote-store-state"; import { saveConfig, readConfigDiagnostics } from "../../src/config"; import type { OcxConfig } from "../../src/types"; import { removeTreeWithRetry } from "../helpers/remove-tree"; @@ -281,3 +283,115 @@ 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(); } +}); + +// Both the selected gateway pivot and foreign-selected residue cleanup must preflight +// serialized metadata before creating a standard row or deleting any profile/backup. +test.each([true, false])("oversized removal metadata preserves all files with selected gateway=%s", selectedGateway => { + const library = join(fixtureHome, "oversized-removal"); + mkdirSync(library); + const owned = "owned-profile"; + const foreign = "foreign-profile"; + const metadata = { + appliedId: selectedGateway ? owned : foreign, + entries: [{ id: owned, name: "opencodex" }, { id: foreign, name: "Personal" }], + foreignRows: Array(500_000).fill("x"), + }; + const metadataBefore = JSON.stringify(metadata); + expect(Buffer.byteLength(metadataBefore)).toBeLessThan(MAX_DESKTOP_PROFILE_FILE_BYTES); + expect(Buffer.byteLength(JSON.stringify(metadata, null, 2) + "\n")).toBeGreaterThan(MAX_DESKTOP_PROFILE_FILE_BYTES); + const originals: Array<[string, string]> = [ + ["_meta.json", metadataBefore], + [`${owned}.json`, JSON.stringify({ + inferenceProvider: "gateway", inferenceCredentialKind: "static", + inferenceGatewayBaseUrl: "http://127.0.0.1:10100", inferenceGatewayApiKey: "not-a-secret", + })], + [`${owned}.json.bak`, '{"originalBackup":true}\n'], + [`${foreign}.json`, '{"foreign":true}\n'], + ]; + for (const [name, content] of originals) writeFileSync(join(library, name), content); + const options = { env: envFor(library), appliedFingerprint: appliedFingerprint(join(library, `${owned}.json`)) }; + expect(inspectDesktop3pConfigLibrary(options).kind).toBe(selectedGateway ? "gateway_ours" : "foreign"); + const namesBefore = filesystem.readdirSync(library).sort(); + const unlinked: string[] = []; + const result = removeDesktop3pStandardPivot({ + ...options, unlink: path => { unlinked.push(path); unlinkSync(path); }, + }); + expect(result).toMatchObject({ ok: false, changed: false, kind: "write_failed", reason: "desktop_mutation_failed" }); + expect(unlinked).toEqual([]); + expect(filesystem.readdirSync(library).sort()).toEqual(namesBefore); + for (const [name, content] of originals) expect(readFileSync(join(library, name), "utf8")).toBe(content); +}, 20_000); + +test("StoreIO limits a growing read to its tighter cap plus one sentinel byte", () => { + const path = join(fixtureHome, "store-growing.json"); + const maxBytes = 64; + const original = "{}\n"; + const appended = " ".repeat(maxBytes + 1); + writeFileSync(path, original); + 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, appended); } + const count = read(fd, buffer, offset, length, position); + consumed += count; + return count; + }); + try { + let failure: unknown; + try { new StoreIO().read(path, maxBytes); } catch (error) { failure = error; } + expect(failure).toBeInstanceOf(DesktopStoreError); + expect(failure).toMatchObject({ reason: "unsafe" }); + expect(grew).toBe(true); + expect(consumed).toBe(maxBytes + 1); + } finally { reading.mockRestore(); } + expect(readFileSync(path, "utf8")).toBe(original + appended); +}); diff --git a/tests/clients/desktop-3p-write-files.test.ts b/tests/clients/desktop-3p-write-files.test.ts index 4406a4b4162..85b21db30e1 100644 --- a/tests/clients/desktop-3p-write-files.test.ts +++ b/tests/clients/desktop-3p-write-files.test.ts @@ -5,7 +5,7 @@ import { tmpdir } from "node:os"; import { basename, join } from "node:path"; import { saveConfig } from "../../src/config"; import { atomicReplaceDesktopConfig, writeDesktop3pConfig } from "../../src/claude/desktop-3p"; -import { parseMetadata, readDesktopProfileForeignKeys, readDesktopProfileSnapshot } from "../../src/claude/desktop-3p-library"; +import { MAX_DESKTOP_PROFILE_FILE_BYTES, parseMetadata, readDesktopProfileForeignKeys, readDesktopProfileSnapshot } from "../../src/claude/desktop-3p-library"; import * as desktopLibrary from "../../src/claude/desktop-3p-library"; import { isTestHomeGuardArmed } from "../../src/lib/test-home-guard"; import type { OcxConfig } from "../../src/types"; @@ -322,3 +322,15 @@ for (const scenario of ["backup", "rollback"] as const) { } }, 20000); } + +test("oversized backup source refuses before backup or replacement writes", () => { + const path = join(library, "oversized.json"); + const original = Buffer.alloc(MAX_DESKTOP_PROFILE_FILE_BYTES + 1, 32); + fs.writeFileSync(path, original); + let writes = 0; + expect(() => atomicReplaceDesktopConfig(path, "{}\n", original, () => { writes++; })) + .toThrow("desktop_config_too_large"); + expect(writes).toBe(0); + expect(fs.readFileSync(path).equals(original)).toBe(true); + expect(fs.existsSync(path + ".bak")).toBe(false); +}); diff --git a/tests/clients/desktop-3p.test.ts b/tests/clients/desktop-3p.test.ts index f69ca3b095f..4d8c3687b24 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"; @@ -677,3 +678,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), Buffer.from('{"original":true}'))) + .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 fc800e121437275d43daf8b8d0fc5e8fe5246f8d Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Sun, 18 Oct 2026 01:39:56 -0700 Subject: [PATCH 2/3] test(claude): inject FIFO swaps through bounded descriptor reads --- tests/clients/desktop-3p-write-files.test.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/clients/desktop-3p-write-files.test.ts b/tests/clients/desktop-3p-write-files.test.ts index 85b21db30e1..3f845ac4ab7 100644 --- a/tests/clients/desktop-3p-write-files.test.ts +++ b/tests/clients/desktop-3p-write-files.test.ts @@ -274,19 +274,19 @@ for (const scenario of ["backup", "rollback"] as const) { if (!isTestHomeGuardArmed()) throw new Error("guard not armed"); const profile = ${JSON.stringify(profile)}, meta = ${JSON.stringify(meta)}, fifo = ${JSON.stringify(fifo)}; const scenario = ${JSON.stringify(scenario)}; - const open = fs.openSync, read = fs.readFileSync, rename = fs.renameSync; + const open = fs.openSync, read = fs.readSync, rename = fs.renameSync; let profileFd, swapped = false; spyOn(fs, "openSync").mockImplementation((path, flags, mode) => { const fd = open(path, flags, mode); if (path === profile) profileFd = fd; return fd; }); - spyOn(fs, "readFileSync").mockImplementation((path, options) => { - const bytes = read(path, options); - if (scenario === "backup" && path === profileFd && !swapped) { + spyOn(fs, "readSync").mockImplementation((fd, buffer, offset, length, position) => { + const count = read(fd, buffer, offset, length, position); + if (scenario === "backup" && fd === profileFd && !swapped) { rename(fifo, profile); swapped = true; } - return bytes; + return count; }); spyOn(fs, "renameSync").mockImplementation((source, destination) => { if (scenario === "rollback" && destination === meta) throw new Error("fixture metadata failure"); From d908f21615d3645ff12b423e54ce0d6f12476b34 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Sun, 18 Oct 2026 08:32:06 -0700 Subject: [PATCH 3/3] test(claude): cover Desktop library boundary and recovery receipts --- .../claude-desktop-picker-profile.test.ts | 91 ++++++++++++ tests/clients/desktop-3p-removal.test.ts | 136 ++++++++++++++++++ tests/clients/desktop-3p-write-files.test.ts | 24 ++++ 3 files changed, 251 insertions(+) diff --git a/tests/claude-integration/claude-desktop-picker-profile.test.ts b/tests/claude-integration/claude-desktop-picker-profile.test.ts index 526a9f8daf3..ff176d8a8fe 100644 --- a/tests/claude-integration/claude-desktop-picker-profile.test.ts +++ b/tests/claude-integration/claude-desktop-picker-profile.test.ts @@ -1,3 +1,4 @@ +import * as pickerFilesystem from "node:fs"; import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; import { spawnSync } from "node:child_process"; import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, unlinkSync, writeFileSync } from "node:fs"; @@ -267,3 +268,93 @@ describe("Claude Desktop picker profile", () => { expect(applyDesktopPickerProfile({ proxyPort: 41234, env: env(), configDir })).toEqual({ ok: false, reason: "write_failed" }); }); }); + +describe("picker boundary and recovery receipts", () => { + for (const operation of ["apply", "remove selected", "remove fallback", "remove unselected"] as const) for (const extra of [0, 1]) { + test(`${operation} preflights exact metadata output limit+${extra} and preserves extensions`, () => { + const picker = { id: "picker", name: "opencodex-picker", extension: { keep: [1, "picker"] } }; + const foreign = { id: "foreign", name: "Personal", extension: { keep: [2, "foreign"] } }; + const selected = operation === "remove selected" || operation === "remove fallback"; + const fallback = operation === "remove fallback"; + const metadata = { appliedId: selected ? picker.id : foreign.id, entries: fallback ? [picker] : [picker, foreign], foreignMeta: { keep: [true, "nested"] }, pad: "" }; + const standard = { id: "0".repeat(36), name: "opencodex-standard" }; + const projected = operation === "apply" ? { ...metadata, appliedId: picker.id } + : fallback ? { ...metadata, appliedId: standard.id, entries: [...metadata.entries, standard] } + : selected ? { ...metadata, appliedId: foreign.id } + : { ...metadata, entries: [foreign] }; + metadata.pad = "x".repeat(MAX_DESKTOP_PROFILE_FILE_BYTES + extra - Buffer.byteLength(JSON.stringify(projected, null, 2) + "\n")); + projected.pad = metadata.pad; + expect(Buffer.byteLength(JSON.stringify(projected, null, 2) + "\n")).toBe(MAX_DESKTOP_PROFILE_FILE_BYTES + extra); + const beforeMeta = JSON.stringify(metadata); expect(Buffer.byteLength(beforeMeta)).toBeLessThan(MAX_DESKTOP_PROFILE_FILE_BYTES); + writeFileSync(join(library, "_meta.json"), beforeMeta); + writeFileSync(join(library, "picker.json"), '{"egressProxyUrl":"http://127.0.0.1:41233"}\n'); + writeFileSync(join(library, "picker.json.bak"), "old backup"); + writeFileSync(join(library, "foreign.json"), '{"unowned":{"preserve":true}}\n'); + mkdirSync(join(configDir, "claude-picker"), { recursive: true }); + const statePath = join(configDir, "claude-picker", "profile-state.json"); + writeFileSync(statePath, JSON.stringify({ entryId: picker.id, previousAppliedId: foreign.id }) + "\n"); + const names = readdirSync(library).sort(), originals = names.map(n => readFileSync(join(library, n), "utf8")); + const beforeState = readFileSync(statePath, "utf8"); + const result = operation === "apply" + ? applyDesktopPickerProfile({ proxyPort: 41234, env: env(), configDir }) + : removeDesktopPickerProfile({ env: env(), configDir }); + if (extra) { + expect(result).toEqual({ ok: false, reason: operation === "apply" ? "write_failed" : "desktop_config_too_large" }); + expect(readdirSync(library).sort()).toEqual(names); + expect(names.map(n => readFileSync(join(library, n), "utf8"))).toEqual(originals); + expect(readFileSync(statePath, "utf8")).toBe(beforeState); + } else { + expect(result).toMatchObject({ ok: true, changed: true }); + const after = readJson(join(library, "_meta.json")); + expect(after.foreignMeta).toEqual(metadata.foreignMeta); + if (!fallback) expect(after.entries.find((e: any) => e.id === foreign.id)).toEqual(foreign); + expect(readFileSync(join(library, "foreign.json"), "utf8")).toBe('{"unowned":{"preserve":true}}\n'); + if (operation === "apply") { + expect(after.entries.find((e: any) => e.id === picker.id)).toEqual(picker); + expect(after.appliedId).toBe(picker.id); + } else { + expect(after.entries.some((e: any) => e.id === picker.id)).toBe(false); + expect(existsSync(join(library, "picker.json"))).toBe(false); expect(existsSync(statePath)).toBe(false); + if (fallback) expect(readJson(join(library, `${after.appliedId}.json`))).toEqual({}); + else expect(after.appliedId).toBe(foreign.id); + } + } + }, 20_000); + } + + for (const existing of [true, false]) { + test(`picker reports failure when metadata publication and ${existing ? "rollback restore" : "rollback cleanup"} fail`, () => { + const foreign = "foreign"; writeFileSync(join(library, `${foreign}.json`), '{"foreign":true}\n'); + writeMetadata({ appliedId: foreign, foreignMeta: { keep: true }, entries: [{ id: foreign, name: "Personal" }] }); + let profile: string | undefined; + if (existing) { + const first = applyDesktopPickerProfile({ proxyPort: 41233, env: env(), configDir }); + expect(first.ok).toBe(true); if (!first.ok) throw new Error("fixture setup failed"); profile = first.path; + } + const meta = join(library, "_meta.json"), beforeMeta = readFileSync(meta, "utf8"); + const realWrite = configIO.atomicWriteFile, rename = pickerFilesystem.renameSync, unlink = pickerFilesystem.unlinkSync; + let metadataFailed = false, recoveryFailed = false; + const writing = spyOn(configIO, "atomicWriteFile").mockImplementation((path, content, io, hooks) => { + if (path === meta) { metadataFailed = true; throw new Error("fixture metadata failure"); } + if (path.startsWith(library) && path.endsWith(".json") && path !== meta) profile = path; + return realWrite(path, content, io, hooks); + }); + const renaming = spyOn(pickerFilesystem, "renameSync").mockImplementation((source, destination) => { + if (existing && metadataFailed && destination === profile) { recoveryFailed = true; throw new Error("fixture restore failure"); } + return rename(source, destination); + }); + const removing = spyOn(pickerFilesystem, "unlinkSync").mockImplementation(path => { + if (!existing && metadataFailed && path === profile) { recoveryFailed = true; throw new Error("fixture cleanup failure"); } + return unlink(path); + }); + try { + expect(applyDesktopPickerProfile({ proxyPort: 41234, env: env(), configDir })).toEqual({ ok: false, reason: "write_failed" }); + expect(metadataFailed).toBe(true); expect(recoveryFailed).toBe(true); + expect(readFileSync(meta, "utf8")).toBe(beforeMeta); + // A failed rollback leaves a real residue; do not mistake the opaque failure for restoration. + expect(readJson(profile!).egressProxyUrl).toBe("http://127.0.0.1:41234"); + expect(readFileSync(join(library, `${foreign}.json`), "utf8")).toBe('{"foreign":true}\n'); + } finally { removing.mockRestore(); renaming.mockRestore(); writing.mockRestore(); } + }); + } +}); diff --git a/tests/clients/desktop-3p-removal.test.ts b/tests/clients/desktop-3p-removal.test.ts index db0b8dd6972..a846e65a033 100644 --- a/tests/clients/desktop-3p-removal.test.ts +++ b/tests/clients/desktop-3p-removal.test.ts @@ -395,3 +395,139 @@ test("StoreIO limits a growing read to its tighter cap plus one sentinel byte", } finally { reading.mockRestore(); } expect(readFileSync(path, "utf8")).toBe(original + appended); }); + +test("bounded reader accepts an empty regular file unchanged", () => { + const path = join(fixtureHome, "empty.json"); + writeFileSync(path, ""); + expect(readDesktopLibraryBytes(path, 64)).toEqual(Buffer.alloc(0)); + expect(readDesktopLibraryText(path)).toBe(""); + expect(filesystem.statSync(path).size).toBe(0); +}); + +test("bounded reader caps growth from an empty file at limit plus one", () => { + const path = join(fixtureHome, "empty-growing.json"); + writeFileSync(path, ""); + const read = filesystem.readSync; + let grew = false, consumed = 0; + const reading = spyOn(filesystem, "readSync").mockImplementation((fd, buffer, offset, length, position) => { + if (!grew) { grew = true; filesystem.appendFileSync(path, "x".repeat(80)); } + 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); + expect(readFileSync(path, "utf8")).toBe("x".repeat(80)); + } finally { reading.mockRestore(); } +}); + +for (const timing of ["before open", "after read"] as const) { + // Windows refuses rename-over-open with EPERM before the intended identity recheck. + test.skipIf(process.platform === "win32" && timing === "after read")(`bounded reader refuses a same-byte regular inode replacement ${timing}`, () => { + const path = join(fixtureHome, "inode.json"), replacement = join(fixtureHome, "other-inode.json"); + writeFileSync(path, "{}"); writeFileSync(replacement, "{}"); + const original = filesystem.statSync(path, { bigint: true }); + const next = filesystem.statSync(replacement, { bigint: true }); + expect(next.ino).not.toBe(original.ino); + const open = filesystem.openSync, read = filesystem.readSync; + let changed = false; + const swap = () => { if (!changed) { filesystem.renameSync(replacement, path); changed = true; } }; + const opening = spyOn(filesystem, "openSync").mockImplementation((target, flags, mode) => { + if (target === path && timing === "before open") swap(); + return open(target, flags, mode); + }); + const reading = spyOn(filesystem, "readSync").mockImplementation((fd, buffer, offset, length, position) => { + const count = read(fd, buffer, offset, length, position); + if (timing === "after read") swap(); + return count; + }); + try { + expect(() => readDesktopLibraryBytes(path)).toThrow("desktop_config_not_regular"); + expect(changed).toBe(true); expect(filesystem.statSync(path, { bigint: true }).ino).toBe(next.ino); + } finally { reading.mockRestore(); opening.mockRestore(); } + expect(readFileSync(path, "utf8")).toBe("{}"); + }); +} + +// Deterministic comparison coverage: inject only ctimeNs, avoiding filesystem clock granularity. +// This does not attest a real OS ctime transition or POSIX metadata behavior on Windows. +for (const timing of ["descriptor", "path recheck"] as const) { + test(`bounded reader refuses a ctime-only observation change at ${timing}`, () => { + const path = join(fixtureHome, "ctime.json"); writeFileSync(path, "{}"); + const original = filesystem.lstatSync(path, { bigint: true }); + const changed = { ...original, ctimeNs: original.ctimeNs + 1n, isFile: () => true }; + expect(changed.dev).toBe(original.dev); expect(changed.ino).toBe(original.ino); + expect(changed.size).toBe(original.size); + const fstat = filesystem.fstatSync, lstat = filesystem.lstatSync; + let pathCalls = 0, injected = false; + const descriptor = spyOn(filesystem, "fstatSync").mockImplementation(((fd: number, options: any) => { + if (timing === "descriptor") { injected = true; return changed; } + return fstat(fd, options); + }) as typeof filesystem.fstatSync); + const observing = spyOn(filesystem, "lstatSync").mockImplementation(((target: filesystem.PathLike, options: any) => { + if (target === path && ++pathCalls > 1 && timing === "path recheck") { injected = true; return changed; } + return lstat(target, options); + }) as typeof filesystem.lstatSync); + try { + expect(() => readDesktopLibraryBytes(path)).toThrow("desktop_config_not_regular"); + expect(injected).toBe(true); + } finally { observing.mockRestore(); descriptor.mockRestore(); } + expect(readFileSync(path, "utf8")).toBe("{}"); + expect(filesystem.statSync(path, { bigint: true }).ino).toBe(original.ino); + }); +} + +// Windows timestamp update/share semantics are not a POSIX ctime receipt. Keep the +// portable injected comparisons above; execute this real timestamp case on POSIX. +test.skipIf(process.platform === "win32")("bounded reader refuses a real ctime-only change after read on POSIX", () => { + const path = join(fixtureHome, "real-ctime.json"); writeFileSync(path, "{}"); + filesystem.utimesSync(path, 1, 1); + const before = filesystem.statSync(path, { bigint: true }), read = filesystem.readSync; + let changed = false; + const reading = spyOn(filesystem, "readSync").mockImplementation((fd, buffer, offset, length, position) => { + const count = read(fd, buffer, offset, length, position); + if (!changed) { filesystem.utimesSync(path, 1, 1); changed = true; } + return count; + }); + try { expect(() => readDesktopLibraryBytes(path)).toThrow("desktop_config_not_regular"); } + finally { reading.mockRestore(); } + const after = filesystem.statSync(path, { bigint: true }); + expect(changed).toBe(true); expect(after.ctimeNs).not.toBe(before.ctimeNs); + for (const key of ["dev", "ino", "size", "mode", "mtimeNs"] as const) expect(after[key]).toBe(before[key]); + expect(readFileSync(path, "utf8")).toBe("{}"); +}); + +for (const selectedGateway of [true, false]) for (const extra of [0, 1]) { + test(`standard removal preflights exact output limit+${extra}, selected gateway=${selectedGateway}`, () => { + const library = join(fixtureHome, "boundary-removal"); mkdirSync(library); + const foreign = { id: "foreign", name: "Personal", extension: { tags: ["preserve"] } }; + const owned = { id: "owned", name: "opencodex" }; + const metadata = { appliedId: selectedGateway ? owned.id : foreign.id, entries: [owned, foreign], foreignMeta: { nested: [1, "keep"] }, pad: "" }; + const standard = { id: "0".repeat(36), name: "opencodex-standard" }; + const expected = selectedGateway + ? { ...metadata, appliedId: standard.id, entries: [...metadata.entries, standard] } + : { ...metadata, entries: [foreign] }; + metadata.pad = "x".repeat(MAX_DESKTOP_PROFILE_FILE_BYTES + extra - Buffer.byteLength(JSON.stringify(expected, null, 2) + "\n")); + expected.pad = metadata.pad; + expect(Buffer.byteLength(JSON.stringify(expected, null, 2) + "\n")).toBe(MAX_DESKTOP_PROFILE_FILE_BYTES + extra); + const before = JSON.stringify(metadata); expect(Buffer.byteLength(before)).toBeLessThan(MAX_DESKTOP_PROFILE_FILE_BYTES); + writeFileSync(join(library, "_meta.json"), before); + writeFileSync(join(library, "owned.json"), '{"inferenceProvider":"gateway","inferenceCredentialKind":"static","inferenceGatewayBaseUrl":"http://127.0.0.1:10100","inferenceGatewayApiKey":"not-a-secret"}'); + writeFileSync(join(library, "owned.json.bak"), "backup"); writeFileSync(join(library, "foreign.json"), '{"foreign":true}\n'); + const names = filesystem.readdirSync(library).sort(), snapshots = names.map(n => readFileSync(join(library, n), "utf8")); + const unlinked: string[] = []; + const result = removeDesktop3pStandardPivot({ env: envFor(library), unlink: path => { unlinked.push(path); unlinkSync(path); } }); + if (extra) { + expect(result).toMatchObject({ ok: false, changed: false, kind: "write_failed" }); + expect(unlinked).toEqual([]); expect(filesystem.readdirSync(library).sort()).toEqual(names); + expect(names.map(n => readFileSync(join(library, n), "utf8"))).toEqual(snapshots); + } else { + expect(result).toMatchObject({ ok: true, changed: true, kind: "removed" }); + const after = JSON.parse(readFileSync(join(library, "_meta.json"), "utf8")); + expect(after.foreignMeta).toEqual(metadata.foreignMeta); expect(after.entries.find((e: any) => e.id === foreign.id)).toEqual(foreign); + expect(readFileSync(join(library, "foreign.json"), "utf8")).toBe('{"foreign":true}\n'); + expect(existsSync(join(library, "owned.json"))).toBe(false); + if (selectedGateway) expect(JSON.parse(readFileSync(join(library, `${after.appliedId}.json`), "utf8"))).toEqual({}); + else expect(after.appliedId).toBe(foreign.id); + } + }, 20_000); +} diff --git a/tests/clients/desktop-3p-write-files.test.ts b/tests/clients/desktop-3p-write-files.test.ts index 3f845ac4ab7..1ef73396db8 100644 --- a/tests/clients/desktop-3p-write-files.test.ts +++ b/tests/clients/desktop-3p-write-files.test.ts @@ -334,3 +334,27 @@ test("oversized backup source refuses before backup or replacement writes", () = expect(fs.readFileSync(path).equals(original)).toBe(true); expect(fs.existsSync(path + ".bak")).toBe(false); }); + +for (const kind of ["profile", "metadata"] as const) for (const extra of [0, 1]) { + test(`normal writer preflights exact ${kind} output limit+${extra}`, () => { + const first = writeProfile(); expect(first.written).toBe(true); + const meta = join(library, "_meta.json"), target = kind === "profile" ? first.path : meta; + const value = { ...JSON.parse(fs.readFileSync(target, "utf8")), foreignPad: "" }; + value.foreignPad = "x".repeat(MAX_DESKTOP_PROFILE_FILE_BYTES + extra - Buffer.byteLength(JSON.stringify(value, null, 2) + "\n")); + expect(Buffer.byteLength(JSON.stringify(value, null, 2) + "\n")).toBe(MAX_DESKTOP_PROFILE_FILE_BYTES + extra); + const input = JSON.stringify(value); expect(Buffer.byteLength(input)).toBeLessThan(MAX_DESKTOP_PROFILE_FILE_BYTES); + fs.writeFileSync(target, input); + const beforeProfile = fs.readFileSync(first.path), beforeMeta = fs.readFileSync(meta); + expect(fs.existsSync(first.path + ".bak")).toBe(false); + const result = writeProfile(); + if (extra) { + expect(result).toMatchObject({ written: false, reason: "desktop_config_too_large" }); + expect(fs.readFileSync(first.path)).toEqual(beforeProfile); expect(fs.readFileSync(meta)).toEqual(beforeMeta); + expect(fs.existsSync(first.path + ".bak")).toBe(false); + } else { + expect(result.written).toBe(true); expect(fs.statSync(target).size).toBe(MAX_DESKTOP_PROFILE_FILE_BYTES); + expect(JSON.parse(fs.readFileSync(target, "utf8")).foreignPad).toBe(value.foreignPad); + expect(fs.readFileSync(first.path + ".bak")).toEqual(beforeProfile); + } + }, 20_000); +}