From 5c5a721b8ba31360738b37d89c38dc83d1063abf Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Fri, 30 Oct 2026 01:44:11 +0000 Subject: [PATCH 1/4] fix(responses): strip rejected reasoning toggle --- src/server/responses/core-normalize.ts | 8 +++++++- structure/transports/responses.md | 1 + .../reasoning-effort-summary-default.test.ts | 20 +++++++++++++++++++ 3 files changed, 28 insertions(+), 1 deletion(-) diff --git a/src/server/responses/core-normalize.ts b/src/server/responses/core-normalize.ts index 880f3a4d099..744ed28bdec 100644 --- a/src/server/responses/core-normalize.ts +++ b/src/server/responses/core-normalize.ts @@ -346,11 +346,17 @@ export async function applyFinalRouteRequestNormalization(args: { // Native passthrough keeps its snapshot-owned effort contract, including saved exclusions. if (!shouldApplyNativeEffortClamp(route.providerName, route.provider, finalSelectedModelId)) { const previousEffort = parsed.options.reasoning; + const previousThinkingEnabled = parsed.options.thinkingEnabled; normalizeThinkingOptions(route.provider, route.modelId, parsed.options); + const raw = parsed._rawBody as { reasoning?: unknown } | undefined; if (previousEffort !== undefined && parsed.options.reasoning === undefined) { - const raw = parsed._rawBody as { reasoning?: unknown } | undefined; if (raw) raw.reasoning = stripEmptyLadderEffort(raw.reasoning, []); } + if (previousThinkingEnabled !== undefined && parsed.options.thinkingEnabled === undefined + && raw?.reasoning && typeof raw.reasoning === "object" && !Array.isArray(raw.reasoning)) { + delete (raw.reasoning as { enabled?: boolean }).enabled; + if (Object.keys(raw.reasoning).length === 0) delete raw.reasoning; + } } const { applyEffortCap, effortCapAppliesTo, supportedLadderFor } = await import("../effort-policy"); const surface = collabSurface(parsed); diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 810c8d782e4..62718ba8c1c 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -17,6 +17,7 @@ provider, lets the selected adapter speak the upstream protocol, then bridges ad Responses-compatible streaming output. Every Responses serializer projects unreferenced hosted-search history when the final body declares no hosted search tool; with search declared, the canonical Codex ordinary serializer retains structural hosted history by default, and only selected-provider `projectResponsesLiteHostedSearchHistory === true` plus exact canonical `/responses` and explicit Lite intent enables model-independent projection under the [hosted-search history boundary](../providers-and-adapters.md#hosted-search-continuation-binding), shared by HTTP/SSE and WS. The routed provider name follows every adapter build, including retries and continuations; only `github-copilot` injects a configured `modelContextTiers` value as upstream `contextTier`, while unconfigured passthrough retains the caller field. For an opted-in key-auth provider, a hosted-search continuation stays bound to the API-key selection that served the first leg; the contract is the [hosted-search continuation binding](../providers-and-adapters.md#hosted-search-continuation-binding). The native passthrough undeclared-tool guard fails closed on the Responses inbound wire in SSE and buffered JSON before continuation state is stored. Chat and Anthropic inbounds defer to the caller's own registry except an explicit empty catalog; the [declared-tool contract](responses-wire-shapes.md#declared-tool-membership-by-inbound-wire) also covers managed native, translated, directly encoded, and final media output while preserving internal hosted operations. GitHub Copilot Auto admission rechecks the negotiated provider/model against the key scope captured at request admission (a key edited or removed mid-request cannot widen it) before route mutation or dispatch, on initial and renewal negotiation; denial returns the standard `model_not_allowed_for_key` 403 naming the caller's inbound selector without disclosing the destination. The web-search and image sidecars answer a denial during their credential rotation with that 403 instead of the original 429, and a combo child's denial keeps the same envelope. Local model-scope refusals are non-replayable. A denial during Copilot dispatch renewal propagates as an admission exception to the request boundary, never as a provider fetch response; account recovery and combo/policy fallback cannot continue after that denial. A committed continuation, including OAuth account rotation after a 429, emits the same refusal type in-stream without dispatching another inference; ordinary negotiation failures still preserve the original provider refusal. The `openai-responses` adapter preserves caller `User-Agent` unless provider headers own it, in either auth mode. +Final route capability normalization removes rejected reasoning effort and toggle fields from the native passthrough body before custom gateway-object serialization. `forwardClientHeaders` additionally opts into `originator`, `x-client-request-id`, `x-codex-app-version`, or `user-agent`. Provider headers win case-insensitively; arbitrary names are rejected on load/write and ignored at runtime. Canonical forward auth retains its separate fixed credential/metadata allowlist; absent caller identity is not invented. Upstream WebSocket reasoning-inclusion headers follow the [native prelude and connection-signal contract](responses-wire-shapes.md#passthrough-sse-stream-shapes-314). diff --git a/tests/responses/reasoning-effort-summary-default.test.ts b/tests/responses/reasoning-effort-summary-default.test.ts index 564af8427ef..155e41e5d85 100644 --- a/tests/responses/reasoning-effort-summary-default.test.ts +++ b/tests/responses/reasoning-effort-summary-default.test.ts @@ -111,6 +111,26 @@ describe("reasoning effort preserves visible thinking when summary is omitted", expect(child.reasoning).toEqual({ effort: "high", summary: "auto" }); }); + test("final route removes a rejected thinking toggle from native passthrough", async () => { + const config: OcxConfig = { + port: 0, + defaultProvider: "native", + providers: { native: { + adapter: "openai-responses", baseUrl: "https://example.test/v1", reasoningWireFormat: "gateway-object", + models: ["test"], + modelCapabilities: { test: { supportsReasoning: false, supportsThinkingToggle: false, supportsReasoningEffort: false } }, + } }, + }; + const parsed = parseRequest({ model: "native/test", input: [], reasoning: { enabled: true, effort: "high", summary: "auto" } }); + const route = routeModel(config, parsed.modelId); + await applyFinalRouteRequestNormalization({ + parsed, route, config, req: new Request("http://localhost/v1/responses"), + logCtx: { model: parsed.modelId, provider: route.providerName }, inboundWire: "responses", + }); + expect(parsed.options.thinkingEnabled).toBeUndefined(); + expect((parsed._rawBody as { reasoning?: unknown }).reasoning).toEqual({ summary: "auto" }); + }); + test("Chat final-route normalization strips both shapes without losing summary or later-route effort", async () => { const config: OcxConfig = { port: 0, From 5849c48950c72ddb11c1216fe197d1761c4d140b Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Fri, 30 Oct 2026 05:36:25 +0000 Subject: [PATCH 2/4] docs(responses): qualify native reasoning cleanup --- structure/transports/responses.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 62718ba8c1c..3717c09f8ee 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -17,7 +17,7 @@ provider, lets the selected adapter speak the upstream protocol, then bridges ad Responses-compatible streaming output. Every Responses serializer projects unreferenced hosted-search history when the final body declares no hosted search tool; with search declared, the canonical Codex ordinary serializer retains structural hosted history by default, and only selected-provider `projectResponsesLiteHostedSearchHistory === true` plus exact canonical `/responses` and explicit Lite intent enables model-independent projection under the [hosted-search history boundary](../providers-and-adapters.md#hosted-search-continuation-binding), shared by HTTP/SSE and WS. The routed provider name follows every adapter build, including retries and continuations; only `github-copilot` injects a configured `modelContextTiers` value as upstream `contextTier`, while unconfigured passthrough retains the caller field. For an opted-in key-auth provider, a hosted-search continuation stays bound to the API-key selection that served the first leg; the contract is the [hosted-search continuation binding](../providers-and-adapters.md#hosted-search-continuation-binding). The native passthrough undeclared-tool guard fails closed on the Responses inbound wire in SSE and buffered JSON before continuation state is stored. Chat and Anthropic inbounds defer to the caller's own registry except an explicit empty catalog; the [declared-tool contract](responses-wire-shapes.md#declared-tool-membership-by-inbound-wire) also covers managed native, translated, directly encoded, and final media output while preserving internal hosted operations. GitHub Copilot Auto admission rechecks the negotiated provider/model against the key scope captured at request admission (a key edited or removed mid-request cannot widen it) before route mutation or dispatch, on initial and renewal negotiation; denial returns the standard `model_not_allowed_for_key` 403 naming the caller's inbound selector without disclosing the destination. The web-search and image sidecars answer a denial during their credential rotation with that 403 instead of the original 429, and a combo child's denial keeps the same envelope. Local model-scope refusals are non-replayable. A denial during Copilot dispatch renewal propagates as an admission exception to the request boundary, never as a provider fetch response; account recovery and combo/policy fallback cannot continue after that denial. A committed continuation, including OAuth account rotation after a 429, emits the same refusal type in-stream without dispatching another inference; ordinary negotiation failures still preserve the original provider refusal. The `openai-responses` adapter preserves caller `User-Agent` unless provider headers own it, in either auth mode. -Final route capability normalization removes rejected reasoning effort and toggle fields from the native passthrough body before custom gateway-object serialization. +Final route capability normalization removes rejected reasoning effort and toggle fields from the native passthrough body when those fields were parsed into options, before custom gateway-object serialization. `forwardClientHeaders` additionally opts into `originator`, `x-client-request-id`, `x-codex-app-version`, or `user-agent`. Provider headers win case-insensitively; arbitrary names are rejected on load/write and ignored at runtime. Canonical forward auth retains its separate fixed credential/metadata allowlist; absent caller identity is not invented. Upstream WebSocket reasoning-inclusion headers follow the [native prelude and connection-signal contract](responses-wire-shapes.md#passthrough-sse-stream-shapes-314). From c34d26ff2bde9516517fe7fd58c2bcda70ca5e03 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 31 Oct 2026 04:42:12 +0900 Subject: [PATCH 3/4] test(claude): report the hardened local-CA ACL in the first-party apply suite (#8016) * test(claude): report the hardened local-CA ACL in the first-party apply suite The local-CA ACL inspection spawns PowerShell under the shared 30 s principal budget, which a loaded hosted Windows runner exhausted (windows 1/11 of lane=all run 41073826161: local_ca_path_unsafe after 30095 ms). This suite covers first-party apply; inject the private ACL for the real current SID through the existing seam. Native inspection stays covered by claude-intercept-local-ca-windows.test.ts. * test(claude): read the cached SID inside the injected ACL runner and reset it in finally Review: no separate principal lookup before the environment snapshot; reset the seam even if the hardening drain throws. --------- Co-authored-by: JUN <243035832+lidge-jun@users.noreply.github.com> --- .../claude-desktop-first-party.test.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/tests/claude-integration/claude-desktop-first-party.test.ts b/tests/claude-integration/claude-desktop-first-party.test.ts index 3ab33edae87..1595da1f9b4 100644 --- a/tests/claude-integration/claude-desktop-first-party.test.ts +++ b/tests/claude-integration/claude-desktop-first-party.test.ts @@ -25,6 +25,8 @@ import { firstPartyDesired } from "../../src/claude/first-party-settings"; import type { OcxConfig } from "../../src/types"; import { removeTreeWithRetry } from "../helpers/remove-tree"; import { flushConfigDirHardeningAndReaps } from "../../src/config/paths"; +import { setLocalCaWindowsAclRunnerForTests } from "../../src/claude/intercept/local-ca-windows"; +import { resolveCurrentWindowsPrincipal } from "../../src/lib/windows-user-principal"; let root = ""; let lifecycleLock: ReturnType; @@ -72,10 +74,23 @@ beforeEach(() => { process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR = library; process.env.CLAUDE_CONFIG_DIR = claudeDir; writeFileSync(join(root, "config.json"), JSON.stringify(config())); + // The local-CA ACL inspection spawns PowerShell under the shared 30 s principal-lookup budget, + // which a loaded hosted Windows runner can exhaust (local_ca_path_unsafe). This file tests + // first-party apply; claude-intercept-local-ca.test.ts runs the native inspection. Report the + // private ACL for the current SID, which production resolves (and caches) before this runner. + if (process.platform === "win32") { + setLocalCaWindowsAclRunnerForTests((_path, timeoutMs) => { + const sid = resolveCurrentWindowsPrincipal(timeoutMs).replace(/^\*/, ""); + return { success: true, exitCode: 0, timedOut: false, stdout: JSON.stringify({ + owner: sid, protected: true, rules: [{ sid, type: 0, rights: 2032127 }], + }) }; + }); + } }); afterEach(async () => { - await flushConfigDirHardeningAndReaps(root); + try { await flushConfigDirHardeningAndReaps(root); } + finally { setLocalCaWindowsAclRunnerForTests(null); } lifecycleLock.restore(); for (const key of ENV_KEYS) { if (previous[key] === undefined) delete process.env[key]; From 86b72a689c21b97b0a425303e64b54ccceeb530a Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 31 Oct 2026 06:17:51 +0900 Subject: [PATCH 4/4] fix: compare file identity with bigint stat (#8019) * fix: compare file identity with bigint stat NTFS file IDs can exceed 2^53, where adjacent IDs are equal as JS numbers. Take dev/ino from stat calls made with { bigint: true } wherever a module compares file identity, and add a shared FileIdentity helper. * test: register file-identity tests in a layout shard and scan stat calls with balanced parentheses * test: resolve guard scopes by declaration and ignore strings and comments * test: mask template text and regex literals, require same-line method bodies and an exact bigint options argument * test: recognize regex literals after keywords in the identity tripwire * fix(oauth): name the best-effort catches on the rewritten lock line * fix(oauth): name the best-effort metadata parse catch beside the lock line --------- Co-authored-by: JUN <243035832+lidge-jun@users.noreply.github.com> --- scripts/test-layout/layout-file-identity.json | 4 + scripts/test-layout/layout.json | 2 +- src/adapters/command-code-project-context.ts | 6 +- src/claude/desktop-remote-store-io.ts | 16 +- src/claude/intercept/proxy-auth.ts | 32 +-- src/codex/catalog/write-audit.ts | 36 ++-- src/config/initialize.ts | 12 +- src/integrations/aside-profile-context.ts | 2 +- src/integrations/droid-settings.ts | 8 +- src/integrations/hermes-profiles.ts | 6 +- src/lib/bounded-file-read.ts | 12 +- src/lib/file-identity.ts | 10 + src/lib/spend-pool-alias-validation.ts | 12 +- src/oauth/store.ts | 12 +- src/providers/cc-switch-import-source.ts | 16 +- src/server/persistent-gui-session.ts | 8 +- src/service/state-lock.ts | 22 +- .../codex-catalog-write-audit.test.ts | 29 ++- tests/config/config-mutation-lock.test.ts | 25 ++- tests/fixtures/test-layout-expected.json | 2 + tests/lib/file-identity-callers.test.ts | 191 ++++++++++++++++++ tests/lib/file-identity.test.ts | 12 ++ tests/providers/cc-switch-import.test.ts | 23 +++ .../command-code-project-context.test.ts | 34 +++- tests/service/service-ownership-state.test.ts | 22 +- 25 files changed, 442 insertions(+), 112 deletions(-) create mode 100644 scripts/test-layout/layout-file-identity.json create mode 100644 src/lib/file-identity.ts create mode 100644 tests/lib/file-identity-callers.test.ts create mode 100644 tests/lib/file-identity.test.ts diff --git a/scripts/test-layout/layout-file-identity.json b/scripts/test-layout/layout-file-identity.json new file mode 100644 index 00000000000..e9595bddef2 --- /dev/null +++ b/scripts/test-layout/layout-file-identity.json @@ -0,0 +1,4 @@ +{ + "file-identity.test.ts": "lib", + "file-identity-callers.test.ts": "lib" +} diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 2442a618c9c..3232f870b0f 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1,7 +1,7 @@ { "version": 1, "root": "tests", - "explicitShards": ["layout-clients.json", "layout-codex-collision.json", "layout-agent-assertion.json", "layout-chat.json", "layout-replay.json", "layout-adapters.json", "layout-jev.json", "layout-workbuddy.json", "layout-combos.json", "layout-credit-renewal.json", "layout-server-inbound.json", "layout-providers.json", "layout-responses.json", "layout-exec-boundary.json", "layout-persistent-dashboard.json", "layout-start-ownership.json", "layout-config.json", "layout-kiro-usage.json", "layout-usage.json"], + "explicitShards": ["layout-file-identity.json", "layout-clients.json", "layout-codex-collision.json", "layout-agent-assertion.json", "layout-chat.json", "layout-replay.json", "layout-adapters.json", "layout-jev.json", "layout-workbuddy.json", "layout-combos.json", "layout-credit-renewal.json", "layout-server-inbound.json", "layout-providers.json", "layout-responses.json", "layout-exec-boundary.json", "layout-persistent-dashboard.json", "layout-start-ownership.json", "layout-config.json", "layout-kiro-usage.json", "layout-usage.json"], "explicit": { "service-desktop-recovery.test.ts": "service", "catalog-omit-disabled-native.test.ts": "codex-integration", "provider-input-modalities.test.ts": "providers", "combo-description.test.ts": "routing", "combo-description-catalog.test.ts": "codex-integration", "codex-provider-routing.test.ts": "codex-integration", "config-native-reasoning-retention.test.ts": "config", "server-native-reasoning-retention-merge.test.ts": "server", "server-native-reasoning-retention.test.ts": "server", "config-workflow-budget.test.ts": "config", "server-workflow-budget-settings.test.ts": "server", "responses-retained-reasoning-stream-recovery.test.ts": "responses", "openai-native-hosted-search-history.test.ts": "adapters/openai", "responses-native-compact-search-history.test.ts": "responses", "config-hosted-search-projection.test.ts": "config", "gcp-credential-boundary.test.ts": "adapters/google", "gcp-sa-marker.test.ts": "adapters/google", "gcp-sa-adapter.test.ts": "adapters/google", "management-gcp-credential-input.test.ts": "server", "codex-preserve-signed-in.test.ts": "codex-integration", "cli-account-preservation.test.ts": "cli", "cli-provider-list-active.test.ts": "cli", "service-state-isolation.test.ts": "service", "compaction-history-framing.test.ts": "responses", "catalog-native-max-context.test.ts": "codex-integration", "protocol-argon-thought-boundary.test.ts": "responses", "service-log.test.ts": "service", "messages-native-empty-inline.test.ts": "claude-integration", "catalog-auto-review-override.test.ts": "codex-integration", "management-auto-review-settings.test.ts": "server", "management-auto-review-auth.test.ts": "server", "prefer-model-alias-ids.test.ts": "server", "responses-mimo-envelope.test.ts": "responses", "responses-call-id-linkage.test.ts": "responses", "bridge-terminal-refusal.test.ts": "adapters", "loop-terminal-refusal.test.ts": "images", "web-search-terminal-refusal.test.ts": "web-search", "combo-terminal-refusal.test.ts": "routing", "server-terminal-refusal-wiring.test.ts": "server", "incomplete-reason-normalization.test.ts": "responses", "advisor-retention.test.ts": "advisor", "responses-native-key-cooldown.test.ts": "responses", "responses-run-turn-key-readmission.test.ts": "responses", "qoder-spawn-readmission.test.ts": "adapters", "api-key-selection-cooldown.test.ts": "providers", "api-key-auto-pause-distribution.test.ts": "providers", "explicit-key-pause-oauth-boundary.test.ts": "providers", "server-chat-native-key-selection.test.ts": "server", "compact-key-selection.test.ts": "responses", "messages-native-key-selection.test.ts": "claude-integration", "coding-agent-windows-dispatch.test.ts": "adapters", "coding-agent-native-empty-argv.test.ts": "windows", "stop-restore-history-ownership.test.ts": "service", "cli-stop-refusal-transport.test.ts": "cli", diff --git a/src/adapters/command-code-project-context.ts b/src/adapters/command-code-project-context.ts index c79eff473c1..617cbfeda62 100644 --- a/src/adapters/command-code-project-context.ts +++ b/src/adapters/command-code-project-context.ts @@ -189,15 +189,15 @@ function truncateUtf8(text: string, capBytes: number): string { async function openedFileIsConfined( handle: Awaited>, path: string, cwdCanonical: string, deadline: number, scope: ScanScope, ): Promise { - const opened = await withinDeadline(() => handle.stat(), deadline, scope); + const opened = await withinDeadline(() => handle.stat({ bigint: true }), deadline, scope); if (!opened.isFile()) return false; - const current = await withinDeadline(() => lstat(path), deadline, scope); + const current = await withinDeadline(() => lstat(path, { bigint: true }), deadline, scope); if (!current.isFile() || opened.dev !== current.dev || opened.ino !== current.ino) return false; const resolved = await withinDeadline(() => realpath(path), deadline, scope); // The input path was canonical before open. A changed intermediate symlink changes this // result even though O_NOFOLLOW protects only the final component on macOS and Linux. if (!isContainedCanonicalPath(cwdCanonical, resolved) || resolved !== path) return false; - const resolvedInfo = await withinDeadline(() => lstat(resolved), deadline, scope); + const resolvedInfo = await withinDeadline(() => lstat(resolved, { bigint: true }), deadline, scope); return resolvedInfo.isFile() && opened.dev === resolvedInfo.dev && opened.ino === resolvedInfo.ino; } diff --git a/src/claude/desktop-remote-store-io.ts b/src/claude/desktop-remote-store-io.ts index 97579222def..4d33891862b 100644 --- a/src/claude/desktop-remote-store-io.ts +++ b/src/claude/desktop-remote-store-io.ts @@ -1,4 +1,4 @@ -import { chmodSync, lstatSync, mkdirSync, realpathSync, unlinkSync } from "node:fs"; +import { chmodSync, lstatSync, mkdirSync, realpathSync, unlinkSync, type BigIntStats } from "node:fs"; import { basename, dirname, join, resolve } from "node:path"; import { atomicWriteFile, getConfigDir } from "../config"; import { hardenSecretDir } from "../lib/windows-secret-acl"; @@ -8,7 +8,7 @@ import { DesktopStoreError, digest } from "./desktop-remote-store-state"; export const MAX_DESKTOP_METADATA_ENTRIES = 256; export interface JsonFile { value: Record; hash: string; identity: string } -const identity = (s: NonNullable>): string => `${s.dev}:${s.ino}:${s.size}:${s.mtimeMs}`; +const identity = (s: BigIntStats): string => `${s.dev}:${s.ino}:${s.size}:${s.mtimeNs}`; function missing(error: unknown): boolean { return (error as NodeJS.ErrnoException)?.code === "ENOENT"; } export function canonicalDirectory(path: string): string { const absolute = resolve(path); @@ -33,20 +33,20 @@ export class StoreIO { private bytes = 0; private readonly seenSizes = new Map(); read(path: string, maxBytes = 1024 * 1024, privateFile = false): JsonFile | null { - let stat: ReturnType; - try { stat = lstatSync(path); } + let stat: BigIntStats; + try { stat = lstatSync(path, { bigint: true }); } catch (error) { if (missing(error)) return null; throw new DesktopStoreError("unsafe"); } if (!stat.isFile() || stat.isSymbolicLink() || stat.size > maxBytes - || (privateFile && process.platform !== "win32" && (stat.mode & 0o077) !== 0)) throw new DesktopStoreError("unsafe"); + || (privateFile && process.platform !== "win32" && (stat.mode & 0o077n) !== 0n)) throw new DesktopStoreError("unsafe"); const counted = this.seenSizes.get(path) ?? 0; - this.bytes += Math.max(0, stat.size - counted); - this.seenSizes.set(path, Math.max(counted, stat.size)); + this.bytes += Math.max(0, Number(stat.size) - counted); + this.seenSizes.set(path, Math.max(counted, Number(stat.size))); if (this.bytes > 8 * 1024 * 1024) throw new DesktopStoreError("unsafe"); 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, Math.min(maxBytes, MAX_DESKTOP_PROFILE_FILE_BYTES)); - if (bytes.byteLength > maxBytes || identity(lstatSync(path)) !== identity(stat)) throw new DesktopStoreError("conflict"); + if (bytes.byteLength > maxBytes || identity(lstatSync(path, { bigint: true })) !== identity(stat)) throw new DesktopStoreError("conflict"); const value: unknown = JSON.parse(bytes.toString("utf8")); if (!isRecord(value)) throw new DesktopStoreError("unsafe"); return { value, hash: digest(bytes.toString("utf8")), identity: identity(stat) }; diff --git a/src/claude/intercept/proxy-auth.ts b/src/claude/intercept/proxy-auth.ts index a1131b5f913..80ea683119b 100644 --- a/src/claude/intercept/proxy-auth.ts +++ b/src/claude/intercept/proxy-auth.ts @@ -1,7 +1,7 @@ import { randomBytes } from "node:crypto"; import { closeSync, constants, fchmodSync, fstatSync, linkSync, lstatSync, mkdirSync, - openSync, readSync, unlinkSync, writeFileSync, type Stats, + openSync, readSync, unlinkSync, writeFileSync, type BigIntStats, } from "node:fs"; import { join } from "node:path"; import { forgetEphemeralSecretPath, hardenSecretDir, hardenSecretPath } from "../../lib/windows-secret-acl"; @@ -20,15 +20,15 @@ function invalidCredential(): Error { return new Error("Claude intercept credential is not a safe, valid owner-controlled file"); } -function assertOwnedNode(stat: Stats, directory = false): void { +function assertOwnedNode(stat: BigIntStats, directory = false): void { if (stat.isSymbolicLink() || !(directory ? stat.isDirectory() : stat.isFile())) throw invalidCredential(); - if (process.platform !== "win32" && typeof process.geteuid === "function" && stat.uid !== process.geteuid()) { + if (process.platform !== "win32" && typeof process.geteuid === "function" && stat.uid !== BigInt(process.geteuid())) { throw invalidCredential(); } } -function assertSameNode(path: string, expected: Stats, directory = false): void { - const current = lstatSync(path); +function assertSameNode(path: string, expected: BigIntStats, directory = false): void { + const current = lstatSync(path, { bigint: true }); assertOwnedNode(current, directory); if (current.dev !== expected.dev || current.ino !== expected.ino) throw invalidCredential(); } @@ -36,17 +36,17 @@ function assertSameNode(path: string, expected: Stats, directory = false): void /** Read only through the validated descriptor, with a fixed maximum allocation and no links. */ function readPinnedToken(configDir: string, harden: boolean): string { const dir = join(configDir, "claude-intercept"); - const directory = lstatSync(dir); + const directory = lstatSync(dir, { bigint: true }); assertOwnedNode(directory, true); - if (process.platform !== "win32" && (directory.mode & 0o077) !== 0) throw invalidCredential(); + if (process.platform !== "win32" && (directory.mode & 0o077n) !== 0n) throw invalidCredential(); const path = claudeInterceptProxyTokenPath(configDir); - const entry = lstatSync(path); + const entry = lstatSync(path, { bigint: true }); assertOwnedNode(entry); if (entry.size < TOKEN_LENGTH || entry.size > TOKEN_MAX_BYTES) throw invalidCredential(); // NONBLOCK prevents a raced-in FIFO from blocking before fstat can reject it. const fd = openSync(path, constants.O_RDONLY | NOFOLLOW | NONBLOCK); try { - const opened = fstatSync(fd); + const opened = fstatSync(fd, { bigint: true }); assertOwnedNode(opened); if (opened.dev !== entry.dev || opened.ino !== entry.ino || opened.size < TOKEN_LENGTH || opened.size > TOKEN_MAX_BYTES) throw invalidCredential(); @@ -62,8 +62,8 @@ function readPinnedToken(configDir: string, harden: boolean): string { const ending = contents.slice(TOKEN_LENGTH); if (token.length !== TOKEN_LENGTH || !/^[A-Za-z0-9_-]+$/.test(token) || !["", "\n", "\r\n"].includes(ending)) throw invalidCredential(); - const afterRead = fstatSync(fd); - if (length !== afterRead.size || afterRead.mtimeMs !== opened.mtimeMs) throw invalidCredential(); + const afterRead = fstatSync(fd, { bigint: true }); + if (BigInt(length) !== afterRead.size || afterRead.mtimeNs !== opened.mtimeNs) throw invalidCredential(); assertSameNode(path, opened); assertSameNode(dir, directory, true); if (harden) { @@ -71,7 +71,7 @@ function readPinnedToken(configDir: string, harden: boolean): string { else fchmodSync(fd, 0o600); // Never chmod a followed/replaced pathname. assertSameNode(path, opened); assertSameNode(dir, directory, true); - } else if (process.platform !== "win32" && (afterRead.mode & 0o077) !== 0) { + } else if (process.platform !== "win32" && (afterRead.mode & 0o077n) !== 0n) { throw invalidCredential(); } return token; @@ -89,7 +89,7 @@ export function readClaudeInterceptProxyToken(configDir: string): string | null } } -function ensurePrivateDirectory(configDir: string): Stats { +function ensurePrivateDirectory(configDir: string): BigIntStats { const dir = join(configDir, "claude-intercept"); mkdirSync(configDir, { recursive: true, mode: 0o700 }); try { @@ -97,14 +97,14 @@ function ensurePrivateDirectory(configDir: string): Stats { } catch (error) { if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; } - const entry = lstatSync(dir); + const entry = lstatSync(dir, { bigint: true }); assertOwnedNode(entry, true); if (process.platform === "win32") { hardenSecretDir(dir, { required: true }); } else { const fd = openSync(dir, constants.O_RDONLY | (constants.O_DIRECTORY ?? 0) | NOFOLLOW); try { - const opened = fstatSync(fd); + const opened = fstatSync(fd, { bigint: true }); assertOwnedNode(opened, true); if (entry.dev !== opened.dev || entry.ino !== opened.ino) throw invalidCredential(); fchmodSync(fd, 0o700); @@ -130,7 +130,7 @@ export function ensureClaudeInterceptProxyToken(configDir: string): string { const token = randomBytes(32).toString("base64url"); const temp = join(dir, `.${TOKEN_FILE}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`); const fd = openSync(temp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | NOFOLLOW, 0o600); - const created = fstatSync(fd); + const created = fstatSync(fd, { bigint: true }); try { writeFileSync(fd, `${token}\n`, { encoding: "utf8" }); if (process.platform !== "win32") fchmodSync(fd, 0o600); diff --git a/src/codex/catalog/write-audit.ts b/src/codex/catalog/write-audit.ts index 37aeb932056..12b17934344 100644 --- a/src/codex/catalog/write-audit.ts +++ b/src/codex/catalog/write-audit.ts @@ -1,6 +1,6 @@ import { closeSync, constants, fstatSync, ftruncateSync, lstatSync, openSync, readSync, writeSync, - type Stats, + type BigIntStats, } from "node:fs"; import { join } from "node:path"; import { redactUserPath } from "../../lib/redact"; @@ -40,15 +40,15 @@ function auditLine(event: CatalogWriteAuditEvent): Buffer { })}\n`); } -function regularPrivateFile(stat: Stats): boolean { +function regularPrivateFile(stat: BigIntStats): boolean { // POSIX bits cannot prove NTFS privacy. Windows requires fresh-file ACL hardening. - return !windowsSecretAclApplies() && stat.isFile() && stat.nlink === 1 - && stat.uid === process.getuid?.() && (stat.mode & 0o777) === 0o600; + return !windowsSecretAclApplies() && stat.isFile() && stat.nlink === 1n + && stat.uid === BigInt(process.getuid?.() ?? -1) && (stat.mode & 0o777n) === 0o600n; } -function sameFile(left: Stats, right: Stats): boolean { +function sameFile(left: BigIntStats, right: BigIntStats): boolean { return left.dev === right.dev && left.ino === right.ino && left.size === right.size - && left.mtimeMs === right.mtimeMs && left.ctimeMs === right.ctimeMs; + && left.mtimeNs === right.mtimeNs && left.ctimeNs === right.ctimeNs; } /** Read at most the tail budget plus one byte to locate a complete first record. */ @@ -116,8 +116,8 @@ export function appendCatalogWriteAudit( if (line.length > CATALOG_AUDIT_MAX_EVENT_BYTES) return "skipped"; const path = codexCatalogAuditPath(codexHome); const windows = windowsSecretAclApplies(); - let before: Stats | undefined; - try { before = lstatSync(path); } + let before: BigIntStats | undefined; + try { before = lstatSync(path, { bigint: true }); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT" || !options.create) return "skipped"; } @@ -126,29 +126,29 @@ export function appendCatalogWriteAudit( const flags = constants.O_RDWR | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0) | (before ? (options.create ? 0 : constants.O_APPEND) : constants.O_CREAT | constants.O_EXCL); fd = openSync(path, flags, 0o600); - let opened = fstatSync(fd); + let opened = fstatSync(fd, { bigint: true }); if (windows) { // Existing files have already been refused: no read-only NTFS privacy verifier exists. - if (!opened.isFile() || opened.nlink !== 1 || opened.size !== 0 - || !sameFile(opened, lstatSync(path))) return "skipped"; + if (!opened.isFile() || opened.nlink !== 1n || opened.size !== 0n + || !sameFile(opened, lstatSync(path, { bigint: true }))) return "skipped"; if (!hardenSecretPath(path, { required: true, deadlineMs: AUDIT_ACL_DEADLINE_MS }).ok) return "skipped"; - const hardened = fstatSync(fd); + const hardened = fstatSync(fd, { bigint: true }); // ACL changes legitimately move ctime. Retain object identity, then take a fresh baseline. - if (!hardened.isFile() || hardened.nlink !== 1 || hardened.size !== 0 + if (!hardened.isFile() || hardened.nlink !== 1n || hardened.size !== 0n || opened.dev !== hardened.dev || opened.ino !== hardened.ino - || !sameFile(hardened, lstatSync(path))) return "skipped"; + || !sameFile(hardened, lstatSync(path, { bigint: true }))) return "skipped"; opened = hardened; } else if (!regularPrivateFile(opened) || (before && !sameFile(before, opened))) return "skipped"; - if (!options.create && opened.size + line.length > CATALOG_AUDIT_MAX_BYTES) return "skipped"; - const tail = readTail(fd, opened.size); + if (!options.create && opened.size + BigInt(line.length) > CATALOG_AUDIT_MAX_BYTES) return "skipped"; + const tail = readTail(fd, Number(opened.size)); const parsed = completeRecords(tail); - if (!sameFile(opened, fstatSync(fd)) || !sameFile(opened, lstatSync(path))) return "skipped"; + if (!sameFile(opened, fstatSync(fd, { bigint: true })) || !sameFile(opened, lstatSync(path, { bigint: true }))) return "skipped"; if (!options.create) { if (!parsed.clean || parsed.count + 1 > CATALOG_AUDIT_KEEP_RECORDS) return "skipped"; writeAll(fd, line, null); } else if (opened.size <= CATALOG_AUDIT_MAX_BYTES - line.length && parsed.clean && parsed.count < CATALOG_AUDIT_KEEP_RECORDS) { - writeAll(fd, line, opened.size); + writeAll(fd, line, Number(opened.size)); } else { const retained: Buffer[] = [line]; let bytes = line.length; diff --git a/src/config/initialize.ts b/src/config/initialize.ts index ed9d121eaae..e87a4d1fd2a 100644 --- a/src/config/initialize.ts +++ b/src/config/initialize.ts @@ -43,16 +43,16 @@ function hardenInitialConfig(fd: number, temp: string, target: string): void { } } -function identifiesDescriptor(fd: number, path: string): boolean { - const opened = fstatSync(fd); - const entry = lstatSync(path); +function identifiesDescriptor(fd: number, path: string, privateMode = false): boolean { + const opened = fstatSync(fd, { bigint: true }); + const entry = lstatSync(path, { bigint: true }); return opened.isFile() && entry.isFile() - && opened.dev === entry.dev && opened.ino === entry.ino; + && opened.dev === entry.dev && opened.ino === entry.ino + && (!privateMode || process.platform === "win32" || (opened.mode & 0o777n) === 0o600n); } function verifyPrivateTemp(fd: number, temp: string): void { - if (!identifiesDescriptor(fd, temp) - || (process.platform !== "win32" && (fstatSync(fd).mode & 0o777) !== 0o600)) { + if (!identifiesDescriptor(fd, temp, true)) { throw new Error("Initial config temporary file identity or permissions changed."); } } diff --git a/src/integrations/aside-profile-context.ts b/src/integrations/aside-profile-context.ts index f5091b4b549..5c5c170a917 100644 --- a/src/integrations/aside-profile-context.ts +++ b/src/integrations/aside-profile-context.ts @@ -74,7 +74,7 @@ function storeGuard(anchor: string, target: string): () => void { const identities = new Map(); const inspect = (path: string, directory: boolean): boolean => { try { - const stat = lstatSync(path); + const stat = lstatSync(path, { bigint: true }); if (stat.isSymbolicLink() || (directory ? !stat.isDirectory() : !stat.isFile()) || (!directory && stat.nlink > 1)) storeUnsafe(); if (directory) { const identity = `${realpathSync(path)}:${stat.dev}:${stat.ino}`; diff --git a/src/integrations/droid-settings.ts b/src/integrations/droid-settings.ts index 0c0989f8130..5bec667200e 100644 --- a/src/integrations/droid-settings.ts +++ b/src/integrations/droid-settings.ts @@ -1,5 +1,5 @@ /** Read-only guard for competing Factory settings before a managed write. */ -import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync } from "node:fs"; +import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync, type BigIntStats } from "node:fs"; import { join, win32 } from "node:path"; const isWindowsRoot = (path: string) => /^[A-Za-z]:[\\/]|^\\\\/.test(path); @@ -26,8 +26,8 @@ export function assertDroidSettingsUnambiguous(root: string, baseUrl?: string, m } for (const name of ["config.json", "settings.local.json"]) { const path = isWindowsRoot(root) ? win32.join(root, name) : join(root, name); - let stat: ReturnType; - try { stat = lstatSync(path); } + let stat: BigIntStats; + try { stat = lstatSync(path, { bigint: true }); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") continue; throw new Error(`Cannot inspect Factory ${name}`); @@ -37,7 +37,7 @@ export function assertDroidSettingsUnambiguous(root: string, baseUrl?: string, m let fd: number | undefined; try { fd = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)); - const opened = fstatSync(fd); + const opened = fstatSync(fd, { bigint: true }); if (!opened.isFile() || opened.size > 1024 * 1024 || opened.ino !== stat.ino || opened.dev !== stat.dev) { throw new Error("file changed during inspection"); } diff --git a/src/integrations/hermes-profiles.ts b/src/integrations/hermes-profiles.ts index e4bda8d2efb..3ffc0dfc3ef 100644 --- a/src/integrations/hermes-profiles.ts +++ b/src/integrations/hermes-profiles.ts @@ -16,7 +16,7 @@ * `/hermes-profiles//`, so its records, journal and snapshots * can never be confused with the default profile's. */ -import { lstatSync, readdirSync, type Stats } from "node:fs"; +import { lstatSync, readdirSync, type BigIntStats } from "node:fs"; import { homedir } from "node:os"; import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path"; import { hermesHomeDir } from "../clients/config-export"; @@ -46,8 +46,8 @@ export class HermesProfileError extends Error { } } -function lstatOrNull(path: string): Stats | null { - try { return lstatSync(path); } +function lstatOrNull(path: string): BigIntStats | null { + try { return lstatSync(path, { bigint: true }); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT" || (error as NodeJS.ErrnoException).code === "ENOTDIR") return null; throw new HermesProfileError("hermes_profiles_unavailable", 409, "Hermes profiles cannot be read"); diff --git a/src/lib/bounded-file-read.ts b/src/lib/bounded-file-read.ts index 42555c57ed2..f37045d0b2c 100644 --- a/src/lib/bounded-file-read.ts +++ b/src/lib/bounded-file-read.ts @@ -1,15 +1,9 @@ import { closeSync, constants, fstatSync, lstatSync, openSync, readSync } from "node:fs"; +import { sameFileIdentity } from "./file-identity"; const MAX_BOUNDED_READ_BYTES = 64 * 1024 * 1024; const READ_CHUNK_BYTES = 64 * 1024; -function sameIdentity( - left: { dev: bigint; ino: bigint }, - right: { dev: bigint; ino: bigint }, -): boolean { - return left.dev === right.dev && left.ino === right.ino; -} - export function readBoundedRegularFileSync(path: string, limit: number): Buffer { if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_BOUNDED_READ_BYTES) { throw new RangeError("invalid bounded file read limit"); @@ -25,7 +19,7 @@ export function readBoundedRegularFileSync(path: string, limit: number): Buffer try { const opened = fstatSync(fd, { bigint: true }); if (!opened.isFile() || opened.size > BigInt(limit)) throw new Error("file is not bounded regular data"); - if (entryBefore && (!sameIdentity(entryBefore, opened) || opened.ino === 0n)) { + if (entryBefore && (!sameFileIdentity(entryBefore, opened) || opened.ino === 0n)) { throw new Error("file entry changed while opening"); } @@ -47,7 +41,7 @@ export function readBoundedRegularFileSync(path: string, limit: number): Buffer if (entryBefore) { const entryAfter = lstatSync(path, { bigint: true }); - if (entryAfter.isSymbolicLink() || !entryAfter.isFile() || !sameIdentity(opened, entryAfter)) { + if (entryAfter.isSymbolicLink() || !entryAfter.isFile() || !sameFileIdentity(opened, entryAfter)) { throw new Error("file entry changed while reading"); } } diff --git a/src/lib/file-identity.ts b/src/lib/file-identity.ts new file mode 100644 index 00000000000..86474299131 --- /dev/null +++ b/src/lib/file-identity.ts @@ -0,0 +1,10 @@ +/** Acquire stats with { bigint: true }; never widen a Number to form an identity. */ +export type FileIdentity = Readonly<{ dev: bigint; ino: bigint }>; + +export function sameFileIdentity(a: FileIdentity, b: FileIdentity): boolean { + return a.dev === b.dev && a.ino === b.ino; +} + +export function fileIdentityKey(id: FileIdentity): string { + return `${id.dev}:${id.ino}`; +} diff --git a/src/lib/spend-pool-alias-validation.ts b/src/lib/spend-pool-alias-validation.ts index 344ad179967..e07c2dbcac2 100644 --- a/src/lib/spend-pool-alias-validation.ts +++ b/src/lib/spend-pool-alias-validation.ts @@ -1,5 +1,5 @@ import { createHash } from "node:crypto"; -import { constants, lstatSync, openSync, fstatSync, readFileSync, closeSync, type Stats } from "node:fs"; +import { constants, lstatSync, openSync, fstatSync, readFileSync, closeSync, type BigIntStats } from "node:fs"; import { join } from "node:path"; /** Read an existing single-owner salt without creating or hardening any file. */ @@ -8,14 +8,14 @@ export function readExistingSpendSalt(home: string): string | undefined { let salt: string | undefined; try { const path = join(home, "spend-ledger.salt"); - const before = lstatSync(path); - if (!before.isFile() || before.nlink !== 1) return undefined; - const matches = (stat: Stats): boolean => stat.isFile() && stat.nlink === 1 + const before = lstatSync(path, { bigint: true }); + if (!before.isFile() || before.nlink !== 1n) return undefined; + const matches = (stat: BigIntStats): boolean => stat.isFile() && stat.nlink === 1n && stat.dev === before.dev && stat.ino === before.ino; fd = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)); - if (!matches(fstatSync(fd))) return undefined; + if (!matches(fstatSync(fd, { bigint: true }))) return undefined; const contents = readFileSync(fd, "utf8").trim(); - if (matches(fstatSync(fd)) && matches(lstatSync(path)) && /^[0-9a-f]{32,}$/.test(contents)) salt = contents; + if (matches(fstatSync(fd, { bigint: true })) && matches(lstatSync(path, { bigint: true })) && /^[0-9a-f]{32,}$/.test(contents)) salt = contents; } catch { salt = undefined; } finally { if (fd !== undefined) { diff --git a/src/oauth/store.ts b/src/oauth/store.ts index 12c4339ab8b..2985194917b 100644 --- a/src/oauth/store.ts +++ b/src/oauth/store.ts @@ -429,18 +429,18 @@ function persist(store: AuthStore): void { } export class OAuthFileLockError extends Error { readonly code = "OAUTH_FILE_LOCK_UNAVAILABLE"; constructor(message: string, options?: { cause?: unknown }) { super(message, options); this.name = "OAuthFileLockError"; } } -interface LockSnapshot { bytes: string; dev: number; ino: number; mtimeMs: number; size: number } +interface LockSnapshot { bytes: string; dev: bigint; ino: bigint; mtimeMs: number; mtimeNs: bigint; size: bigint } export interface OAuthFileLockOptions { path: string; waitTimeoutMs?: number; staleAfterMs?: number; pollMinMs?: number; pollMaxMs?: number; sleep?: (ms: number) => Promise; now?: () => number; random?: () => number; beforeStaleUnlink?: () => void; beforeReleaseUnlink?: () => void; beforeFailedCreateUnlink?: () => void; writeMetadata?: (fd: number, bytes: string) => void } export interface OAuthFileLockGuard { readonly ownerId: string; release(): void } function errorCode(error: unknown): string | undefined { return error && typeof error === "object" && "code" in error ? String((error as { code?: unknown }).code) : undefined; } -function snapshot(path: string): LockSnapshot { const bytes = readFileSync(path, "utf8"); const s = statSync(path); return { bytes, dev:s.dev, ino:s.ino, mtimeMs:s.mtimeMs, size:s.size }; } -function sameSnapshot(a: LockSnapshot,b: LockSnapshot): boolean { return a.bytes===b.bytes&&a.dev===b.dev&&a.ino===b.ino&&a.mtimeMs===b.mtimeMs&&a.size===b.size; } -function sameFd(a: LockSnapshot,b: ReturnType): boolean { return a.dev===b.dev&&a.ino===b.ino&&a.mtimeMs===b.mtimeMs&&a.size===b.size; } +function snapshot(path: string): LockSnapshot { const bytes = readFileSync(path, "utf8"); const s = statSync(path, { bigint: true }); return { bytes, dev:s.dev, ino:s.ino, mtimeMs:Number(s.mtimeNs)/1e6, mtimeNs:s.mtimeNs, size:s.size }; } +function sameSnapshot(a: LockSnapshot,b: LockSnapshot): boolean { return a.bytes===b.bytes&&a.dev===b.dev&&a.ino===b.ino&&a.mtimeNs===b.mtimeNs&&a.size===b.size; } +function sameFd(a: LockSnapshot,b: import("node:fs").BigIntStats): boolean { return a.dev===b.dev&&a.ino===b.ino&&a.mtimeNs===b.mtimeNs&&a.size===b.size; } export function createOAuthFileLock(options: OAuthFileLockOptions): { acquire(): Promise } { const wait=options.waitTimeoutMs??5000, stale=options.staleAfterMs??120000, min=options.pollMinMs??25,max=options.pollMaxMs??100,sleep=options.sleep??(ms=>Bun.sleep(ms)),now=options.now??Date.now,random=options.random??Math.random,write=options.writeMetadata??((fd,b)=>writeFileSync(fd,b,"utf8")); if(wait<0||stale<=0||min<0||maxstale){options.beforeStaleUnlink?.();const b=snapshot(options.path);if(sameSnapshot(a,b))unlinkSync(options.path);continue;}}catch(e){if(errorCode(e)==="ENOENT")continue;throw new OAuthFileLockError("Could not inspect OAuth file lock",{cause:e});} const elapsed=now()-started;if(elapsed>=wait)throw new OAuthFileLockError(`Timed out after ${wait}ms waiting for OAuth file lock`);await sleep(Math.min(wait-elapsed,min+Math.floor(random()*(max-min+1)))); } } }; + return { async acquire() { hardenConfigDir(); recordOwnedConfigPath(getConfigDir(),options.path); if(!existsSync(getConfigDir())) mkdirSync(getConfigDir(),{recursive:true,mode:0o700}); const ownerId=randomUUID(),started=now(); for(;;){ let fd:number|undefined; try { fd=openSync(options.path,"wx",0o600); const bytes=`${JSON.stringify({version:1,ownerId,pid:process.pid,createdAt:now()})}\n`; write(fd,bytes); const fs=fstatSync(fd,{bigint:true}); closeSync(fd); fd=undefined; const owned=snapshot(options.path); if(owned.bytes!==bytes||!sameFd(owned,fs)) throw new OAuthFileLockError("OAuth lock changed during creation"); let released=false; return {ownerId,release(){if(released)return;released=true;try{const a=snapshot(options.path);if(!sameSnapshot(owned,a))return;options.beforeReleaseUnlink?.();const b=snapshot(options.path);if(sameSnapshot(owned,b))unlinkSync(options.path);}catch(e){if(errorCode(e)!=="ENOENT")console.warn(`[oauth] lock release failed: ${e instanceof Error?e.message:String(e)}`);}}}; } catch(e) { if(fd!==undefined){let fs;try{fs=fstatSync(fd,{bigint:true});}catch{/* best effort: no identity, no cleanup */}try{closeSync(fd);}catch{/* best effort */}if(fs)try{const a=snapshot(options.path);if(sameFd(a,fs)){options.beforeFailedCreateUnlink?.();const b=snapshot(options.path);if(sameSnapshot(a,b)&&sameFd(b,fs))unlinkSync(options.path);}}catch{/* best-effort cleanup of our own failed create */}} if(errorCode(e)!=="EEXIST")throw e instanceof OAuthFileLockError?e:new OAuthFileLockError("Could not create OAuth file lock",{cause:e}); } + try{const a=snapshot(options.path);let created=a.mtimeMs;try{const p=JSON.parse(a.bytes);if(typeof p.createdAt==="number")created=Math.max(created,p.createdAt);}catch{/* unparsable metadata: fall back to mtime */}if(now()-created>stale){options.beforeStaleUnlink?.();const b=snapshot(options.path);if(sameSnapshot(a,b))unlinkSync(options.path);continue;}}catch(e){if(errorCode(e)==="ENOENT")continue;throw new OAuthFileLockError("Could not inspect OAuth file lock",{cause:e});} const elapsed=now()-started;if(elapsed>=wait)throw new OAuthFileLockError(`Timed out after ${wait}ms waiting for OAuth file lock`);await sleep(Math.min(wait-elapsed,min+Math.floor(random()*(max-min+1)))); } } }; } /** Wait long enough for slow IdP refreshes (e.g. Cursor 15s × 3 attempts) before timing out. */ export const OAUTH_REFRESH_LOCK_WAIT_MS = 60_000; diff --git a/src/providers/cc-switch-import-source.ts b/src/providers/cc-switch-import-source.ts index 576ad2c7300..8f11dc834cf 100644 --- a/src/providers/cc-switch-import-source.ts +++ b/src/providers/cc-switch-import-source.ts @@ -971,10 +971,10 @@ function scanCcSwitchJsonBytesInternal(bytes: Uint8Array): RawExtractedRow[] { export function readSourceRows(sourcePath: string, requireSqlite = false): RawExtractedRow[] { const resolved = resolve(sourcePath); - let beforeStat: ReturnType; + let beforeStat: import("node:fs").BigIntStats; try { assertPlainLocalPath(resolved); - beforeStat = lstatSync(resolved); + beforeStat = lstatSync(resolved, { bigint: true }); } catch { throw new CcSwitchImportError("CC-Switch source path must be a plain regular local file without symlink traversal."); } @@ -993,7 +993,7 @@ export function readSourceRows(sourcePath: string, requireSqlite = false): RawEx resolved, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0), ); - const opened = fstatSync(fd); + const opened = fstatSync(fd, { bigint: true }); if (!opened.isFile()) { throw new CcSwitchImportError("CC-Switch source path must be a regular file."); } @@ -1006,7 +1006,7 @@ export function readSourceRows(sourcePath: string, requireSqlite = false): RawEx ) { throw new CcSwitchImportError("CC-Switch source file changed while opening."); } - size = opened.size; + size = Number(opened.size); const n = readSync(fd, header, 0, 16, 0); if (hasSqliteMagicBytes(header, n)) { isSqlite = true; @@ -1038,8 +1038,8 @@ export function readSourceRows(sourcePath: string, requireSqlite = false): RawEx assertNoUncheckpointedSqliteWal(resolved); } assertPlainLocalPath(resolved); - const afterStat = lstatSync(resolved); - const afterFd = fstatSync(fd); + const afterStat = lstatSync(resolved, { bigint: true }); + const afterFd = fstatSync(fd, { bigint: true }); if (isSqlite && afterFd.nlink > 1) { throw new CcSwitchImportError("CC-Switch SQLite source has multiple hard links; import the original CC-Switch database path directly."); } @@ -1051,7 +1051,7 @@ export function readSourceRows(sourcePath: string, requireSqlite = false): RawEx || afterStat.gid !== opened.gid || afterStat.mode !== opened.mode || afterStat.size !== opened.size - || afterStat.mtimeMs !== opened.mtimeMs + || afterStat.mtimeNs !== opened.mtimeNs || afterFd.dev !== opened.dev || afterFd.ino !== opened.ino || afterFd.nlink !== opened.nlink @@ -1059,7 +1059,7 @@ export function readSourceRows(sourcePath: string, requireSqlite = false): RawEx || afterFd.gid !== opened.gid || afterFd.mode !== opened.mode || afterFd.size !== opened.size - || afterFd.mtimeMs !== opened.mtimeMs + || afterFd.mtimeNs !== opened.mtimeNs ) { throw new CcSwitchImportError("CC-Switch source file changed while reading."); } diff --git a/src/server/persistent-gui-session.ts b/src/server/persistent-gui-session.ts index 0327481abfa..d45c08f2dcd 100644 --- a/src/server/persistent-gui-session.ts +++ b/src/server/persistent-gui-session.ts @@ -60,13 +60,13 @@ function readRecords(): DeviceRecord[] { try { fd = openSync(path, constants.O_RDONLY | (process.platform === "win32" ? 0 : constants.O_NOFOLLOW | constants.O_NONBLOCK)); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return []; throw error; } try { - const stat = fstatSync(fd); - const entry = lstatSync(path); - if (!stat.isFile() || stat.nlink !== 1 || stat.size > STORE_LIMIT || entry.isSymbolicLink() + const stat = fstatSync(fd, { bigint: true }); + const entry = lstatSync(path, { bigint: true }); + if (!stat.isFile() || stat.nlink !== 1n || stat.size > STORE_LIMIT || entry.isSymbolicLink() || stat.dev !== entry.dev || stat.ino !== entry.ino) throw new Error("unsafe dashboard session store"); // Size the read from the stat we validated: a store that grows mid-read overflows // the +1 sentinel byte and is rejected instead of silently truncated at a fixed limit. - const bytes = Buffer.alloc(stat.size + 1); + const bytes = Buffer.alloc(Number(stat.size) + 1); let length = 0; while (length < bytes.length) { const count = readSync(fd, bytes, length, bytes.length - length, null); if (!count) break; length += count; } if (length > stat.size) throw new Error("dashboard session store changed during read"); diff --git a/src/service/state-lock.ts b/src/service/state-lock.ts index 6bc2fabfb92..d57b85b0b69 100644 --- a/src/service/state-lock.ts +++ b/src/service/state-lock.ts @@ -12,7 +12,7 @@ import { rmdirSync, unlinkSync, writeFileSync, - type Stats, + type BigIntStats, } from "node:fs"; import { basename, dirname, join } from "node:path"; @@ -32,8 +32,8 @@ interface ServiceStateLockRecord { interface ServiceStateLockSnapshot { readonly record: ServiceStateLockRecord; readonly ownerPath: string; - readonly lockIdentity: Pick; - readonly ownerIdentity: Pick; + readonly lockIdentity: Pick; + readonly ownerIdentity: Pick; readonly mtimeMs: number; } @@ -72,8 +72,8 @@ function lockOwnerProcessAlive(pid: number): boolean { } function sameIdentity( - left: Pick, - right: Pick, + left: Pick, + right: Pick, ): boolean { return left.dev === right.dev && left.ino === right.ino; } @@ -90,10 +90,10 @@ function parseOwnerFileName(name: string): { pid: number; pathToken: string } | } function readLockSnapshot(lockPath: string): ServiceStateLockSnapshot | null { - let lockIdentity: Stats; + let lockIdentity: BigIntStats; let entries: string[]; try { - lockIdentity = lstatSync(lockPath); + lockIdentity = lstatSync(lockPath, { bigint: true }); if (!lockIdentity.isDirectory()) return null; entries = readdirSync(lockPath); } catch { @@ -102,7 +102,7 @@ function readLockSnapshot(lockPath: string): ServiceStateLockSnapshot | null { if (entries.length !== 1 || !parseOwnerFileName(entries[0]!)) return null; const ownerPath = join(lockPath, entries[0]!); try { - const ownerIdentity = lstatSync(ownerPath); + const ownerIdentity = lstatSync(ownerPath, { bigint: true }); if (!ownerIdentity.isFile() || ownerIdentity.size > 4096) return null; const value = JSON.parse(readFileSync(ownerPath, "utf8")) as Partial; if (value.version !== 1 || !Number.isSafeInteger(value.pid) || (value.pid ?? 0) <= 0 @@ -111,12 +111,12 @@ function readLockSnapshot(lockPath: string): ServiceStateLockSnapshot | null { || typeof value.createdAt !== "number" || !Number.isFinite(value.createdAt)) return null; const record = value as ServiceStateLockRecord; if (entries[0] !== ownerFileName(record)) return null; - const currentLock = lstatSync(lockPath); - const currentOwner = lstatSync(ownerPath); + const currentLock = lstatSync(lockPath, { bigint: true }); + const currentOwner = lstatSync(ownerPath, { bigint: true }); if (!currentLock.isDirectory() || !sameIdentity(lockIdentity, currentLock) || !currentOwner.isFile() || !sameIdentity(ownerIdentity, currentOwner) || currentOwner.size !== ownerIdentity.size) return null; - return { record, ownerPath, lockIdentity, ownerIdentity, mtimeMs: ownerIdentity.mtimeMs }; + return { record, ownerPath, lockIdentity, ownerIdentity, mtimeMs: Number(ownerIdentity.mtimeNs) / 1e6 }; } catch { return null; } diff --git a/tests/codex-integration/codex-catalog-write-audit.test.ts b/tests/codex-integration/codex-catalog-write-audit.test.ts index a2ce4b036c6..a75d2b1c9a2 100644 --- a/tests/codex-integration/codex-catalog-write-audit.test.ts +++ b/tests/codex-integration/codex-catalog-write-audit.test.ts @@ -595,11 +595,11 @@ describe("Windows audit privacy (r4176128642)", () => { const realFstat = fs.fstatSync; const realLstat = fs.lstatSync; // Deterministic ACL metadata transition; no dependency on filesystem clock resolution. - const postAcl = (stat: fs.Stats) => Object.assign(Object.create(Object.getPrototypeOf(stat)), stat, - { ctimeMs: stat.ctimeMs + (hardened ? 1 : 0) }) as fs.Stats; - const fstat = spyOn(fs, "fstatSync").mockImplementation(fd => postAcl(realFstat(fd))); - const lstat = spyOn(fs, "lstatSync").mockImplementation(path => - path === auditPath() ? postAcl(realLstat(path)) : realLstat(path)); + const postAcl = (stat: fs.BigIntStats) => Object.assign(Object.create(Object.getPrototypeOf(stat)), stat, + { ctimeNs: stat.ctimeNs + (hardened ? 1_000_000n : 0n) }) as fs.BigIntStats; + const fstat = spyOn(fs, "fstatSync").mockImplementation(((fd, options) => options?.bigint ? postAcl(realFstat(fd, { bigint: true })) : realFstat(fd)) as typeof fs.fstatSync); + const lstat = spyOn(fs, "lstatSync").mockImplementation(((path, options) => + path === auditPath() && options?.bigint ? postAcl(realLstat(path, { bigint: true })) : realLstat(path)) as typeof fs.lstatSync); const write = spyOn(fs, "writeSync"); const open = spyOn(fs, "openSync"); harden.mockImplementation((path, options) => { @@ -620,6 +620,25 @@ describe("Windows audit privacy (r4176128642)", () => { } finally { fstat.mockRestore(); lstat.mockRestore(); write.mockRestore(); open.mockRestore(); } }); + test("file identity preserves adjacent NTFS-sized values", () => { + const realFstat = fs.fstatSync; + const realLstat = fs.lstatSync; + const identity = 9_007_199_254_740_992n; + const fstat = spyOn(fs, "fstatSync").mockImplementation(((fd, options) => { + const stat = options?.bigint ? realFstat(fd, { bigint: true }) : realFstat(fd); + return Object.assign(Object.create(Object.getPrototypeOf(stat)), stat, { ino: options?.bigint ? identity : Number(identity) }); + }) as typeof fs.fstatSync); + const lstat = spyOn(fs, "lstatSync").mockImplementation(((path, options) => { + const stat = options?.bigint ? realLstat(path, { bigint: true }) : realLstat(path); + return path === auditPath() ? Object.assign(Object.create(Object.getPrototypeOf(stat)), stat, + { ino: options?.bigint ? identity + 1n : Number(identity + 1n) }) : stat; + }) as typeof fs.lstatSync); + try { + expect(appendUnderK()).toBe("skipped"); + expect(harden).not.toHaveBeenCalled(); + } finally { fstat.mockRestore(); lstat.mockRestore(); } + }); + for (const code of ["EICACLS", "ETIMEDOUT", "returned-failure"]) { test(`${code} leaves a blank file and preserves catalog publication`, () => { // Only the audit file's ACL fails. On real Windows the catalog writer also hardens its own diff --git a/tests/config/config-mutation-lock.test.ts b/tests/config/config-mutation-lock.test.ts index 278ed3d80a9..4b3103cc0bb 100644 --- a/tests/config/config-mutation-lock.test.ts +++ b/tests/config/config-mutation-lock.test.ts @@ -1,4 +1,5 @@ -import { afterEach, beforeEach, expect, test } from "bun:test"; +import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; +import * as fs from "node:fs"; import { closeSync, existsSync, linkSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { pathToFileURL } from "node:url"; @@ -285,6 +286,28 @@ test("real link collision preserves the winner and does not advance generation o expect(initTemps()).toEqual([]); }); +test("initial config file identity preserves adjacent NTFS-sized values", () => { + const identity = 9_007_199_254_740_992n; + let ownedFd = -1, tempPath = ""; + const realFstat = fs.fstatSync, realLstat = fs.lstatSync; + const fstat = spyOn(fs, "fstatSync").mockImplementation(((fd, options) => { + const stat = options?.bigint ? realFstat(fd, { bigint: true }) : realFstat(fd); + return fd === ownedFd ? Object.assign(Object.create(Object.getPrototypeOf(stat)), stat, + { ino: options?.bigint ? identity : Number(identity) }) : stat; + }) as typeof fs.fstatSync); + const lstat = spyOn(fs, "lstatSync").mockImplementation(((path, options) => { + const stat = options?.bigint ? realLstat(path, { bigint: true }) : realLstat(path); + return path === tempPath ? Object.assign(Object.create(Object.getPrototypeOf(stat)), stat, + { ino: options?.bigint ? identity + 1n : Number(identity + 1n) }) : stat; + }) as typeof fs.lstatSync); + try { + expect(() => publishInitialConfigNoReplace(getConfigPath(), "candidate bytes", { + harden(fd, temp) { ownedFd = fd; tempPath = temp; }, + })).toThrow(InitialConfigPublicationError); + expect(existsSync(getConfigPath())).toBe(false); + } finally { fstat.mockRestore(); lstat.mockRestore(); } +}); + test("exclusive temp collision does not remove or modify somebody else's file", () => { const sequence = nextAtomicTempSequence() + 1; const occupied = `${getConfigPath()}.ocx.${process.pid}.${sequence}.tmp`; diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 7939d5f9243..b97dd7e3e9c 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -132,6 +132,8 @@ "messaging-remote-enrollment-gaps.test.ts": "codex-integration", "messages-request-id-headers.test.ts": "claude-integration", "bounded-file-read.test.ts": "lib", + "file-identity.test.ts": "lib", + "file-identity-callers.test.ts": "lib", "responses-undeclared-tool-guard-inbound.test.ts": "responses", "messages-request-id-endpoint.test.ts": "claude-integration", "anthropic-revoked-token.test.ts": "adapters/anthropic", diff --git a/tests/lib/file-identity-callers.test.ts b/tests/lib/file-identity-callers.test.ts new file mode 100644 index 00000000000..9adb4fea11c --- /dev/null +++ b/tests/lib/file-identity-callers.test.ts @@ -0,0 +1,191 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { repoPath } from "../helpers/repo-root"; + +// NTFS file IDs exceed 2^53; these modules compare file identity, which must come from stat +// calls made with { bigint: true }. A lexical tripwire over the named declarations, not a +// full parser: it catches the regression forms below and fails closed on scopes it cannot find. +const scopes: Record = { + "src/config/initialize.ts": ["identifiesDescriptor"], + "src/service/state-lock.ts": ["readLockSnapshot"], + "src/oauth/store.ts": ["snapshot", "createOAuthFileLock"], + "src/claude/intercept/proxy-auth.ts": ["assertSameNode", "readPinnedToken", "ensurePrivateDirectory", "ensureClaudeInterceptProxyToken"], + "src/claude/desktop-remote-store-io.ts": ["read"], + "src/server/persistent-gui-session.ts": ["readRecords"], + "src/lib/spend-pool-alias-validation.ts": ["readExistingSpendSalt"], + "src/integrations/droid-settings.ts": ["assertDroidSettingsUnambiguous"], + "src/integrations/aside-profile-context.ts": ["storeGuard"], + "src/integrations/hermes-profiles.ts": ["lstatOrNull"], + "src/providers/cc-switch-import-source.ts": ["readSourceRows"], + "src/adapters/command-code-project-context.ts": ["openedFileIsConfined"], + "src/codex/catalog/write-audit.ts": ["appendCatalogWriteAudit"], +}; + +const REGEX_PRECEDES = new Set(["", "(", ",", "=", ":", "[", "!", "&", "|", "?", "{", "}", ";", "+", "-", "*", "%", "<", ">", "~", "^"]); +const REGEX_KEYWORDS = /(?:^|[^\w$])(?:return|typeof|instanceof|in|of|new|delete|void|throw|case|do|else|yield|await)$/; + +/** + * Blank literal text (strings, template text, regex literals, comments) keeping offsets, so only + * code is balanced or matched. Template ${...} expressions stay code. + */ +function maskNonCode(source: string): string { + const out = source.split(""); + const blank = (from: number, to: number) => { for (let at = from; at < to; at++) if (out[at] !== "\n") out[at] = " "; }; + const regexMayStart = (at: number): boolean => { + let back = at - 1; + while (back >= 0 && /\s/.test(out[back]!)) back--; + if (back < 0 || REGEX_PRECEDES.has(out[back]!)) return true; + return REGEX_KEYWORDS.test(out.slice(Math.max(0, back - 11), back + 1).join("")); + }; + const templateDepths: number[] = []; + let braces = 0; + let index = 0; + const scanTemplate = (from: number): number => { + let at = from; + while (at < source.length) { + if (source[at] === "\\") { at += 2; continue; } + if (source[at] === "`") { blank(from, at); return at + 1; } + if (source[at] === "$" && source[at + 1] === "{") { blank(from, at); templateDepths.push(braces); braces++; return at + 2; } + at++; + } + blank(from, at); + return at; + }; + while (index < source.length) { + const char = source[index]!; + const next = source[index + 1]; + if (char === "/" && next === "/") { const end = source.indexOf("\n", index); const stop = end < 0 ? source.length : end; blank(index, stop); index = stop; continue; } + if (char === "/" && next === "*") { const end = source.indexOf("*/", index + 2); const stop = end < 0 ? source.length : end + 2; blank(index, stop); index = stop; continue; } + if (char === "'" || char === '"') { + let at = index + 1; + while (at < source.length && source[at] !== char && source[at] !== "\n") at += source[at] === "\\" ? 2 : 1; + blank(index + 1, at); index = at + 1; continue; + } + if (char === "`") { index = scanTemplate(index + 1); continue; } + if (char === "/" && regexMayStart(index)) { + let at = index + 1; + let inClass = false; + while (at < source.length && source[at] !== "\n" && (inClass || source[at] !== "/")) { + if (source[at] === "\\") { at += 2; continue; } + if (source[at] === "[") inClass = true; + else if (source[at] === "]") inClass = false; + at++; + } + blank(index + 1, at); index = at + 1; continue; + } + if (char === "{") braces++; + if (char === "}") { + braces--; + if (templateDepths.length > 0 && braces === templateDepths[templateDepths.length - 1]) { + templateDepths.pop(); index = scanTemplate(index + 1); continue; + } + } + index++; + } + return out.join(""); +} + +function matching(code: string, open: number, left: string, right: string): number { + let depth = 0; + for (let at = open; at < code.length; at++) { + if (code[at] === left) depth++; + else if (code[at] === right && --depth === 0) return at; + } + return -1; +} + +/** The body of NAME's declaration: a function, or a method head whose body opens on the same line. */ +function declarationBody(code: string, name: string): [number, number] | null { + const head = new RegExp(`(?:\\bfunction\\s+|^[ \\t]*(?:(?:export|async|public|private|protected|static|override)\\s+)*)${name}\\s*\\(`, "gm"); + for (const match of code.matchAll(head)) { + const close = matching(code, match.index! + match[0].length - 1, "(", ")"); + if (close < 0) continue; + let at = close + 1; + while (code[at] === " " || code[at] === "\t") at++; + if (code[at] === ":") { + at++; + while (/\s/.test(code[at] ?? "")) at++; + if (code[at] === "{") at = matching(code, at, "{", "}") + 1; + while (at < code.length && code[at] !== "{" && code[at] !== ";" && code[at] !== "\n") at++; + } + if (code[at] !== "{") continue; + const end = matching(code, at, "{", "}"); + if (end > 0) return [at, end + 1]; + } + return null; +} + +/** The last top-level argument of a call's masked argument text. */ +function lastArgument(args: string): string { + let depth = 0; + let start = 0; + for (let at = 0; at < args.length; at++) { + const char = args[at]!; + if ("([{".includes(char)) depth++; + else if (")]}".includes(char)) depth--; + else if (char === "," && depth === 0) start = at + 1; + } + return args.slice(start).trim(); +} + +function inspect(source: string, names: string[], droid = false): string[] { + const code = maskNonCode(source); + const problems: string[] = []; + for (const name of names) { + const range = declarationBody(code, name); + if (!range) { problems.push(`missing declaration: ${name}`); continue; } + let observed = 0; + for (const match of code.slice(range[0], range[1]).matchAll(/\b(?:lstatSync|fstatSync|statSync|lstat|stat|handle\.stat)\s*\(/g)) { + const start = range[0] + match.index!; + const close = matching(code, start + match[0].length - 1, "(", ")"); + if (close < 0) { problems.push(`unbalanced stat call in: ${name}`); continue; } + const call = source.slice(start, close + 1); + if (droid && /^lstatSync\(root\)$/.test(call)) continue; + observed++; + if (!/^\{\s*bigint\s*:\s*true\s*\}$/.test(lastArgument(code.slice(start + match[0].length, close)))) problems.push(`numeric stat: ${call}`); + } + if (observed === 0) problems.push(`no stat call in: ${name}`); + } + if (/\bNumber\s*\([^)]*\.(?:ino|dev)\b/.test(code)) problems.push("numeric identity"); + return problems; +} + +describe("file identity caller stats", () => { + test("detects a numeric identity observation", () => { + expect(inspect("function check() { const s = lstatSync(path); return s.ino === 1; }", ["check"])) + .toEqual(["numeric stat: lstatSync(path)"]); + expect(inspect("function check() { const s = lstatSync(join(root, name)); return s.ino === 1; }", ["check"])) + .toEqual(["numeric stat: lstatSync(join(root, name))"]); + expect(inspect("function check() { return lstatSync(join(root, name), { bigint: true }).ino; }", ["check"])).toEqual([]); + expect(inspect("function check() { return lstatSync(p, convert({ bigint: true })).ino; }", ["check"])) + .toEqual(["numeric stat: lstatSync(p, convert({ bigint: true }))"]); + expect(inspect("function check() { const s = lstatSync(path, { bigint: true }); return Number(s.ino); }", ["check"])) + .toEqual(["numeric identity"]); + }); + + test("strings, templates, regexes and comments cannot hide or invent a call", () => { + expect(inspect('function check() { const a = lstatSync(join(root, "(")); const b = lstatSync(p, { bigint: true }); }', ["check"])) + .toEqual(['numeric stat: lstatSync(join(root, "("))']); + expect(inspect('function check() { const b = lstatSync(join(root, ")"), { bigint: true }); /* lstatSync(x) */ }', ["check"])).toEqual([]); + expect(inspect("function check() { const b = lstatSync(p, { bigint: true }); return `${lstatSync(q).ino}`; }", ["check"])) + .toEqual(["numeric stat: lstatSync(q)"]); + expect(inspect("function check() { const r = /'(/; const a = lstatSync(p); // '\n}", ["check"])) + .toEqual(["numeric stat: lstatSync(p)"]); + expect(inspect("function check() { lstatSync(p, { bigint: true }); if (x) return /'/.test(y); lstatSync(q); // '\n}", ["check"])) + .toEqual(["numeric stat: lstatSync(q)"]); + expect(inspect("function check() { const half = a / b; const s = lstatSync(p, { bigint: true }); return half / 2; }", ["check"])).toEqual([]); + }); + + test("resolves the declaration, not an earlier call, and needs a real observation", () => { + expect(inspect("function caller() { return check(); }\nfunction check() { return lstatSync(path).ino; }", ["check"])) + .toEqual(["numeric stat: lstatSync(path)"]); + expect(inspect("check()\n{ lstatSync(p, { bigint: true }); }\nfunction check() { return lstatSync(path).ino; }", ["check"])) + .toEqual(["numeric stat: lstatSync(path)"]); + expect(inspect("function check() { return 1; }", ["check"])).toEqual(["no stat call in: check"]); + expect(inspect("function check() { lstatSync(root); }", ["check"], true)).toEqual(["no stat call in: check"]); + }); + + for (const [path, names] of Object.entries(scopes)) { + test(path, () => expect(inspect(readFileSync(repoPath(path), "utf8"), names, path.endsWith("droid-settings.ts"))).toEqual([])); + } +}); diff --git a/tests/lib/file-identity.test.ts b/tests/lib/file-identity.test.ts new file mode 100644 index 00000000000..cb4e63afe42 --- /dev/null +++ b/tests/lib/file-identity.test.ts @@ -0,0 +1,12 @@ +import { expect, test } from "bun:test"; +import { fileIdentityKey, sameFileIdentity } from "../../src/lib/file-identity"; + +test("compares and formats adjacent large file IDs", () => { + const a = { dev: 7n, ino: 2n ** 53n }; + const b = { dev: 7n, ino: a.ino + 1n }; + expect(Number(a.ino)).toBe(Number(b.ino)); + expect(sameFileIdentity(a, a)).toBe(true); + expect(sameFileIdentity(a, b)).toBe(false); + expect(sameFileIdentity(a, { ...a, dev: 8n })).toBe(false); + expect(fileIdentityKey(b).split(":").map(BigInt)).toEqual([b.dev, b.ino]); +}); diff --git a/tests/providers/cc-switch-import.test.ts b/tests/providers/cc-switch-import.test.ts index a4608177aba..e308da771d2 100644 --- a/tests/providers/cc-switch-import.test.ts +++ b/tests/providers/cc-switch-import.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; import { Database } from "bun:sqlite"; +import * as fs from "node:fs"; import { existsSync, linkSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, statSync, unlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -24,6 +25,7 @@ import { setCcSwitchSourceOpenHookForTests, } from "../../src/providers/cc-switch-import"; import { cmdProviderCcSwitchImport } from "../../src/cli/provider-cc-switch-import"; +import { readSourceRows } from "../../src/providers/cc-switch-import-source"; import { removeTreeWithRetry } from "../helpers/remove-tree"; // Runtime-assembled canary so privacy:scan never sees a literal token prefix in the source file. @@ -105,6 +107,27 @@ afterEach(async () => { }); describe("CC-Switch keyless provider import", () => { + test("file identity preserves adjacent NTFS-sized values", () => { + const path = join(sourceDir, "source.json"); + writeFileSync(path, JSON.stringify({ codex: { providers: {} } })); + const identity = 9_007_199_254_740_992n; + let opening = false; + const realLstat = fs.lstatSync, realFstat = fs.fstatSync; + const lstat = spyOn(fs, "lstatSync").mockImplementation(((target, options) => { + const stat = options?.bigint ? realLstat(target, { bigint: true }) : realLstat(target); + return target === path ? Object.assign(Object.create(Object.getPrototypeOf(stat)), stat, + { ino: options?.bigint ? identity : Number(identity) }) : stat; + }) as typeof fs.lstatSync); + const fstat = spyOn(fs, "fstatSync").mockImplementation(((fd, options) => { + const stat = options?.bigint ? realFstat(fd, { bigint: true }) : realFstat(fd); + return opening ? Object.assign(Object.create(Object.getPrototypeOf(stat)), stat, + { ino: options?.bigint ? identity + 1n : Number(identity + 1n) }) : stat; + }) as typeof fs.fstatSync); + setCcSwitchSourceOpenHookForTests(() => { opening = true; }); + try { expect(() => readSourceRows(path)).toThrow("CC-Switch source file changed while opening."); } + finally { fstat.mockRestore(); lstat.mockRestore(); } + }); + test("SQLite preview and apply strip TOML comments, drop free-text names, keep source read-only, and never materialize secrets", async () => { const codexToml = [ `model = "gpt-5.6-sol" # inline_comment_token = "${CANARY_SECRET}" and ${PREFIXLESS_HEX_SECRET}`, diff --git a/tests/providers/command-code-project-context.test.ts b/tests/providers/command-code-project-context.test.ts index c0b37b38dfd..cd1deee5f69 100644 --- a/tests/providers/command-code-project-context.test.ts +++ b/tests/providers/command-code-project-context.test.ts @@ -80,6 +80,35 @@ afterEach(() => { }); describe("loadCommandCodeProjectContext", () => { + test("file identity preserves adjacent NTFS-sized values", async () => { + const root = makeTempDir("ocx-cc-ctx-identity-"); + const agentsPath = join(root, "AGENTS.md"); + const identity = 9_007_199_254_740_992n; + try { + writeFileSync(agentsPath, "project memory", "utf8"); + lstatMock.mockImplementation((async (path: Parameters[0], options?: { bigint?: boolean }) => { + const stat = options?.bigint ? await realLstat(path, { bigint: true }) : await realLstat(path); + return String(path) === agentsPath ? Object.assign(Object.create(Object.getPrototypeOf(stat)), stat, + { ino: options?.bigint ? identity + 1n : Number(identity + 1n) }) : stat; + }) as typeof realLstat); + openMock.mockImplementation(async path => { + const handle = await realOpen(path); + if (String(path) !== agentsPath) return handle; + return { ...handle, read: handle.read.bind(handle), close: handle.close.bind(handle), + stat: async (options?: { bigint?: boolean }) => { + const stat = options?.bigint ? await handle.stat({ bigint: true }) : await handle.stat(); + return Object.assign(Object.create(Object.getPrototypeOf(stat)), stat, + { ino: options?.bigint ? identity : Number(identity) }); + }, + } as Awaited>; + }); + expect((await loadCommandCodeProjectContext(root)).memory).toBe(""); + } finally { + openMock.mockImplementation(realOpen); + rmSync(root, { recursive: true, force: true }); + } + }); + test("undefined cwd returns empty context", async () => { const result = await loadCommandCodeProjectContext(undefined); expect(result).toEqual(EMPTY_COMMAND_CODE_PROJECT_CONTEXT); @@ -594,10 +623,12 @@ describe("loadCommandCodeProjectContext", () => { const root = makeTempDir("ocx-cc-ctx-read-timeout-"); const agentsPath = join(root, "AGENTS.md"); let closeCalls = 0; + let readCalls = 0; let releaseRead: () => void = () => {}; const hangingFile = { - stat: async () => statSync(agentsPath), + stat: async (options?: { bigint?: boolean }) => options?.bigint ? statSync(agentsPath, { bigint: true }) : statSync(agentsPath), read: () => new Promise<{ bytesRead: number; buffer: Buffer }>(resolve => { + readCalls++; releaseRead = () => resolve({ bytesRead: 0, buffer: Buffer.alloc(0) }); }), close: async () => { @@ -618,6 +649,7 @@ describe("loadCommandCodeProjectContext", () => { expect(result).not.toBe("timeout"); expect(result).toEqual(EMPTY_COMMAND_CODE_PROJECT_CONTEXT); + expect(readCalls).toBe(1); expect(closeCalls).toBe(1); } finally { openMock.mockImplementation(realOpen); diff --git a/tests/service/service-ownership-state.test.ts b/tests/service/service-ownership-state.test.ts index 6b8be911d31..ee4565599c6 100644 --- a/tests/service/service-ownership-state.test.ts +++ b/tests/service/service-ownership-state.test.ts @@ -7,10 +7,12 @@ * therefore lasted until the next repair — from a tray helper, from `ocx update`, from a * doctor suggestion — and nothing said it had gone. */ -import { describe, expect, test, beforeEach, afterEach } from "bun:test"; +import { describe, expect, test, beforeEach, afterEach, spyOn } from "bun:test"; +import * as fs from "node:fs"; import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, unlinkSync, utimesSync, writeFileSync } from "node:fs"; import { createTempHome, type TempHome } from "../helpers/temp-home"; import { repoPath } from "../helpers/repo-root"; +import { withServiceStateLocks } from "../../src/service/state-lock"; import { desktopOwnsService, inspectServiceStateEvidence, @@ -587,6 +589,24 @@ describe("the anchor lock", () => { expect(existsSync(serviceStatePath())).toBe(false); }); + test("lock identity preserves adjacent NTFS-sized values", () => { + const statePath = serviceStatePath(); + const lockPath = `${statePath}.lock`; + const identity = 9_007_199_254_740_992n; + let changed = false; + const realLstat = fs.lstatSync; + const lstat = spyOn(fs, "lstatSync").mockImplementation(((path, options) => { + const stat = options?.bigint ? realLstat(path, { bigint: true }) : realLstat(path); + const ino = identity + (changed ? 1n : 0n); + return path === lockPath ? Object.assign(Object.create(Object.getPrototypeOf(stat)), stat, + { ino: options?.bigint ? ino : Number(ino) }) : stat; + }) as typeof fs.lstatSync); + try { + withServiceStateLocks([statePath], () => {}, { hooks: { beforeRelease: () => { changed = true; } } }); + expect(existsSync(lockPath)).toBe(true); + } finally { lstat.mockRestore(); } + }); + test("a late release cannot remove a successor lock", () => { const statePath = serviceStatePath(); const lockPath = `${statePath}.lock`;