From c9787aa040e88f11ed21a76b78fd7b4eea0ef193 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 12 Oct 2026 06:24:27 +0000 Subject: [PATCH] security: harden filesystem and routing boundaries Co-Authored-By: Epinephrine --- desktop/src-tauri/src/cli_command_posix.rs | 68 ++++++++++++- .../windows-version-control/src/control.rs | 1 + scripts/test-layout/layout.json | 2 + scripts/test-layout/seeds.json | 7 +- src/claude/desktop-3p-library.ts | 23 ++++- src/claude/desktop-3p.ts | 16 ++- src/claude/intercept/picker-listener.ts | 86 +++++++++++++++- src/claude/outbound.ts | 10 +- src/clients/config-export.ts | 3 +- src/codex/config-write-lock.ts | 32 ++++++ src/codex/inject/config-write-section.ts | 5 +- src/codex/inject/remove.ts | 17 +++- src/integrations/config-io.ts | 6 +- src/lib/bounded-file-read.ts | 51 ++++++++++ src/lib/bun-path-runtime.d.mts | 11 ++- src/lib/bun-path-runtime.mjs | 64 +++++++++++- src/server/lifecycle.ts | 18 +++- src/server/responses-undeclared-tool-guard.ts | 6 +- src/server/responses/core-replay.ts | 11 ++- src/server/responses/passthrough-delivery.ts | 11 +-- src/server/responses/request-transport.ts | 12 +++ structure/clients/claude-desktop.md | 19 ++-- structure/clients/integrations.md | 2 +- structure/codex-home.md | 4 +- structure/desktop-terminal-command.md | 2 + structure/runtime.md | 4 +- structure/transports/responses.md | 6 +- ...claude-native-reasoning-provenance.test.ts | 54 ++++++++++ .../claude-picker-upload.test.ts | 70 ++++++++++++- .../claude-prompt-too-long.test.ts | 22 +++++ tests/cli/ocx-launcher-runtime.test.ts | 99 ++++++++++++++++++- .../active-turn-disconnect.test.ts | 65 +++++++----- tests/codex-integration/codex-journal.test.ts | 28 +++++- .../native-main-owner-lifetime.test.ts | 7 ++ tests/fixtures/test-layout-expected.json | 2 + tests/helpers/native-main-owner-child.ts | 24 ++++- tests/lib/bounded-file-read.test.ts | 53 ++++++++++ .../github-copilot-auto-runtime.test.ts | 48 ++++++++- .../responses-undeclared-tool-guard.test.ts | 39 +++++++- ...active-turn-cancellation-retention.test.ts | 40 ++++++++ 40 files changed, 940 insertions(+), 108 deletions(-) create mode 100644 src/lib/bounded-file-read.ts create mode 100644 tests/lib/bounded-file-read.test.ts create mode 100644 tests/server/active-turn-cancellation-retention.test.ts diff --git a/desktop/src-tauri/src/cli_command_posix.rs b/desktop/src-tauri/src/cli_command_posix.rs index acad8798c87..ea5e25466a0 100644 --- a/desktop/src-tauri/src/cli_command_posix.rs +++ b/desktop/src-tauri/src/cli_command_posix.rs @@ -94,6 +94,24 @@ pub fn render_shim(cli: &Path, owner: &str) -> Result { pub fn render_helper(bin: &Path) -> Result { Ok(HELPER.replace("@BIN@", "e(bin)?)) } +fn is_temporary_bundle_path(path: &Path, temp_dir: &Path) -> bool { + [ + Path::new("/tmp"), + Path::new("/private/tmp"), + Path::new("/var/tmp"), + Path::new("/private/var/tmp"), + Path::new("/var/folders"), + Path::new("/private/var/folders"), + ] + .into_iter() + .chain(std::iter::once(temp_dir)) + .any(|root| path.starts_with(root)) + || path.starts_with("/Volumes") +} +fn has_app_translocation_marker(path: &Path) -> bool { + path.components() + .any(|component| component.as_os_str().to_str() == Some("AppTranslocation")) +} pub fn block(shell: &str, root: &Path) -> Result { let body = if shell == "fish" { format!( @@ -148,10 +166,9 @@ pub fn stable_bundle(exe: &Path, debug: bool, version: &str) -> Result { if debug { return Err("development-launch".into()); } - let raw = text(exe)?; - if raw.starts_with("/Volumes/") - || raw.starts_with("/private/var/folders/") && raw.contains("/AppTranslocation/") - || raw.contains("/tmp/.mount_") + text(exe)?; + if has_app_translocation_marker(exe) + || is_temporary_bundle_path(exe, &std::env::temp_dir()) || std::env::var_os("APPIMAGE").is_some() || std::env::var_os("APPDIR").is_some() { @@ -159,7 +176,8 @@ pub fn stable_bundle(exe: &Path, debug: bool, version: &str) -> Result { } let real = fs::canonicalize(exe).map_err(|_| "bundle-unavailable")?; let s = text(&real)?; - if s.starts_with("/Volumes/") || s.contains("/AppTranslocation/") || s.contains("/tmp/.mount_") + if has_app_translocation_marker(&real) + || is_temporary_bundle_path(&real, &std::env::temp_dir()) { return Err("temporary-bundle".into()); } @@ -206,6 +224,46 @@ pub fn stable_bundle(exe: &Path, debug: bool, version: &str) -> Result { version: version.into(), }) } + +#[cfg(test)] +mod temporary_bundle_path_tests { + use super::{has_app_translocation_marker, is_temporary_bundle_path}; + use std::path::Path; + + #[test] + fn temporary_bundle_roots_are_component_aware() { + let temp_dir = Path::new("/var/folders/test/app"); + for path in [ + "/tmp/App.app/Contents/MacOS/app", + "/private/tmp/App.app/Contents/MacOS/app", + "/var/tmp/App.app/Contents/MacOS/app", + "/private/var/tmp/App.app/Contents/MacOS/app", + "/var/folders/xx/App.app/Contents/MacOS/app", + "/private/var/folders/xx/App.app/Contents/MacOS/app", + "/Volumes/Installer/App.app/Contents/MacOS/app", + ] { + assert!(is_temporary_bundle_path(Path::new(path), temp_dir), "{path}"); + } + for path in [ + "/Applications/OpenCodex.app/Contents/MacOS/app", + "/Users/alice/Applications/OpenCodex.app/Contents/MacOS/app", + "/tmp-old/OpenCodex.app/Contents/MacOS/app", + "/private/var/folder-old/OpenCodex.app/Contents/MacOS/app", + ] { + assert!(!is_temporary_bundle_path(Path::new(path), temp_dir), "{path}"); + } + assert!(is_temporary_bundle_path( + Path::new("/var/folders/test/app/OpenCodex.app/Contents/MacOS/app"), + temp_dir, + )); + assert!(has_app_translocation_marker(Path::new( + "/Volumes/Installer/AppTranslocation/OpenCodex.app/Contents/MacOS/app" + ))); + assert!(!has_app_translocation_marker(Path::new( + "/Volumes/Installer/AppTranslocation-old/OpenCodex.app/Contents/MacOS/app" + ))); + } +} // Return exact block range, including its line terminator. Reject malformed or changed markers. fn range(bytes: &[u8], expected: &str) -> Result>> { let s = std::str::from_utf8(bytes).map_err(|_| "rc-not-utf8")?; diff --git a/scripts/diagnostics/windows-version-control/src/control.rs b/scripts/diagnostics/windows-version-control/src/control.rs index 3de54930fac..0214c801162 100644 --- a/scripts/diagnostics/windows-version-control/src/control.rs +++ b/scripts/diagnostics/windows-version-control/src/control.rs @@ -370,6 +370,7 @@ pub fn run() -> Result<(), Box> { .env("OPENCODEX_ADMIN_AUTH_TOKEN", "version-fixture-admin") .env("OPENCODEX_API_AUTH_TOKEN", "version-fixture-data") .env("CODEX_CI", "1") + .env("OCX_TEST_HOME_GUARD", "1") .args(["--no-env-file", "--no-orphans", "--cpu-prof"]) .arg(format!("--cpu-prof-dir={}", root.display())) .arg("--cpu-prof-name=version.cpuprofile") diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index b11337aa855..7c33d26933f 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -98,6 +98,7 @@ "pnpm-command-isolation.test.ts": "update", "provider-antigravity-quota-retry.test.ts": "providers", "project-config-warning-snapshot.test.ts": "codex-integration", "codex-quota-auto-refresh-generation.test.ts": "codex-integration", "codex-account-clear-paused.test.ts": "codex-integration", "low-quota-protection.test.ts": "codex-integration", "bun-runtime-preflight.test.ts": "lib", + "bounded-file-read.test.ts": "lib", "responses-compaction-recovery.test.ts": "responses", "responses-canonical-nonstream.test.ts": "responses", "compaction-recovery-settings.test.ts": "config", "responses-compaction-recovery-policy.test.ts": "responses", "plugin-loader.test.ts": "lib", "plugin-upstream-hooks.test.ts": "lib", "cli-kiro-auto-selection.test.ts": "cli", "codebuddy-live-models.test.ts": "providers", "kiro-auto-selection.test.ts": "providers/kiro", "kiro-quota-metrics.test.ts": "providers/kiro", "management-provider-request-pacing.test.ts": "server", "desktop-supervised-restart.test.ts": "clients", "cli-restart-handoff.test.ts": "cli", @@ -131,6 +132,7 @@ "core-link-boundary.test.ts": "lab", "acl-error-classification.test.ts": "lib", "active-registry-admission.test.ts": "codex-integration", "active-turn-disconnect.test.ts": "codex-integration", + "active-turn-cancellation-retention.test.ts": "server", "adapter-buffered-tool-conformance.test.ts": "adapters", "adapter-error-inline.test.ts": "adapters", "adapter-event-oauth-failover.test.ts": "oauth", diff --git a/scripts/test-layout/seeds.json b/scripts/test-layout/seeds.json index 45792a9c4bc..6dfe7a926b3 100644 --- a/scripts/test-layout/seeds.json +++ b/scripts/test-layout/seeds.json @@ -32,7 +32,7 @@ "codex-integration": { "match": [ "^context-compat\\.test\\.ts$", - "^(?:active|app|bearer|catalog|combos\\.test\\.ts|doctor\\.test\\.ts|effort|gather|history|injection|issue|multi|native|parallel|project|selected|slug|ultrafast|warmup\\.test\\.ts)-" + "^(?!active-turn-cancellation-retention\\.test\\.ts$)(?:active|app|bearer|catalog|combos\\.test\\.ts|doctor\\.test\\.ts|effort|gather|history|injection|issue|multi|native|parallel|project|selected|slug|ultrafast|warmup\\.test\\.ts)-" ] }, "server": { @@ -40,7 +40,8 @@ "^context-history\\.test\\.ts$", "^aside-profiles-routes", "^update-async-routes\\.test\\.ts$", - "^(?:account|alias|bounded|cancel|config\\.test\\.ts|consume|data|debug|error|errors|fetch|health|input|loopback|management|memory|outbound|owned|passive|port|ports\\.test\\.ts|proxy|relay|response|retry|server|session|sidebar|stream|v2)-", + "^active-turn-cancellation-retention\\.test\\.ts$", + "^(?!bounded-file-read\\.test\\.ts$)(?:account|alias|bounded|cancel|config\\.test\\.ts|consume|data|debug|error|errors|fetch|health|input|loopback|management|memory|outbound|owned|passive|port|ports\\.test\\.ts|proxy|relay|response|retry|server|session|sidebar|stream|v2)-", "^subagent-model-force-api\\.test\\.ts$" ] }, @@ -106,7 +107,7 @@ }, "lib": { "match": [ - "^(?:acl|clearable|credential|debug\\.test\\.ts|optional|redact\\.test\\.ts|remove|self|stall|strict|transient)-" + "^(?:acl|bounded|clearable|credential|debug\\.test\\.ts|optional|redact\\.test\\.ts|remove|self|stall|strict|transient)-" ] }, "clients": { diff --git a/src/claude/desktop-3p-library.ts b/src/claude/desktop-3p-library.ts index 392bf025441..d4cefce3d0e 100644 --- a/src/claude/desktop-3p-library.ts +++ b/src/claude/desktop-3p-library.ts @@ -1,6 +1,6 @@ -import { existsSync, readFileSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; +import { readBoundedRegularFileSync } from "../lib/bounded-file-read"; import { claudeDesktopConfigLibraryDir, resolveConfigLibraryDir } from "./desktop-3p-paths"; export interface Desktop3pConfigLibraryOptions { @@ -43,10 +43,17 @@ export interface Desktop3pMetadata { } export const DESKTOP_PICKER_ENTRY_NAME = "opencodex-picker"; +export const MAX_DESKTOP_PROFILE_FILE_BYTES = 4 * 1024 * 1024; export function parseMetadata(path: string): Desktop3pMetadata { - if (!existsSync(path)) return { entries: [] }; - const parsed = JSON.parse(readFileSync(path, "utf8")) as Partial; + let bytes: Buffer; + try { + bytes = readBoundedRegularFileSync(path, MAX_DESKTOP_PROFILE_FILE_BYTES); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return { entries: [] }; + throw error; + } + const parsed = JSON.parse(bytes.toString("utf8")) as Partial; if (!Array.isArray(parsed.entries)) throw new Error("Claude Desktop 3P _meta.json has no entries array"); return { ...parsed, entries: parsed.entries }; } @@ -81,8 +88,14 @@ export const OPENCODEX_DESKTOP_PROFILE_KEYS = new Set([ ]); export function readDesktopProfileForeignKeys(path: string): Record { - if (!existsSync(path)) return {}; - const parsed = JSON.parse(readFileSync(path, "utf8")) as unknown; + let bytes: Buffer; + try { + bytes = readBoundedRegularFileSync(path, MAX_DESKTOP_PROFILE_FILE_BYTES); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return {}; + throw error; + } + const parsed = JSON.parse(bytes.toString("utf8")) as unknown; if (!isRecord(parsed)) throw new Error("Claude Desktop 3P profile is not a JSON object"); return Object.fromEntries( Object.entries(parsed).filter(([key]) => !OPENCODEX_DESKTOP_PROFILE_KEYS.has(key)), diff --git a/src/claude/desktop-3p.ts b/src/claude/desktop-3p.ts index 79c9fb312e6..8bee5e54caf 100644 --- a/src/claude/desktop-3p.ts +++ b/src/claude/desktop-3p.ts @@ -6,12 +6,14 @@ import { canonicalDirectory } from "./desktop-remote-store-io"; import { resolveDesktop3pConfigLibraryPath, parseMetadata, SAFE_DESKTOP_PROFILE_ID, isRecord, isOwnedDesktopEntry, isOwnedDesktopGatewayEntry, profilePath, readDesktopProfileForeignKeys, + MAX_DESKTOP_PROFILE_FILE_BYTES, type Desktop3pConfigLibraryOptions, type Desktop3pMetadata, type Desktop3pMetadataEntry, } from "./desktop-3p-library"; export { resolveDesktop3pConfigLibraryPath, type Desktop3pConfigLibraryOptions } from "./desktop-3p-library"; import { createHash, randomUUID } from "node:crypto"; -import { copyFileSync, existsSync, mkdirSync, readFileSync, lstatSync, unlinkSync } from "node:fs"; +import { copyFileSync, existsSync, mkdirSync, lstatSync, unlinkSync } from "node:fs"; import { join } from "node:path"; +import { readBoundedRegularFileSync } from "../lib/bounded-file-read"; import { atomicWriteFile, readConfigDiagnostics, withConfigMutationLockSync } from "../config"; import { atomicWriteFileNoFollow } from "../config/atomic-write"; import { claudeDesktopIntegrationEnabled } from "../codex/desired-state"; @@ -476,7 +478,7 @@ export function inspectDesktop3pConfigLibrary( let profile: Record; let fingerprint: string; try { - const source = readFileSync(selectedProfilePath, "utf8"); + const source = readBoundedRegularFileSync(selectedProfilePath, MAX_DESKTOP_PROFILE_FILE_BYTES).toString("utf8"); const parsed = JSON.parse(source) as unknown; if (!isRecord(parsed)) return { kind: "broken", libraryPath, selectedProfilePath, appliedId, residualPaths, ownedProfileActive }; profile = parsed; @@ -709,7 +711,10 @@ export function writeDesktop3pConfig( const metadata = parseMetadata(join(inspected.libraryPath, "_meta.json")); if (!isOwnedDesktopGatewayEntry(metadata.entries.find(entry => entry.id === inspected.appliedId))) return refused; try { - const selected = JSON.parse(readFileSync(inspected.selectedProfilePath, "utf8")) as Record; + const selected = JSON.parse(readBoundedRegularFileSync( + inspected.selectedProfilePath, + MAX_DESKTOP_PROFILE_FILE_BYTES, + ).toString("utf8")) as Record; mode = selected.modelDiscoveryEnabled !== true ? "static" : Array.isArray(selected.inferenceModels) ? "hybrid" : "discovery"; } catch { return refused; } @@ -825,7 +830,10 @@ function writeDesktop3pConfigWithGenerator( const freshMetadata = parseMetadata(metadataPath); if (freshMetadata.appliedId !== refreshOnly.requiredId || !isOwnedDesktopGatewayEntry(freshMetadata.entries.find(entry => entry.id === refreshOnly.requiredId)) - || createHash("sha256").update(readFileSync(configPath)).digest("hex").slice(0, 16) !== refreshOnly.expectedFingerprint) { + || createHash("sha256").update(readBoundedRegularFileSync( + configPath, + MAX_DESKTOP_PROFILE_FILE_BYTES, + )).digest("hex").slice(0, 16) !== refreshOnly.expectedFingerprint) { throw new Error(); } } catch { throw new Error("desktop_refresh_only_skipped"); } diff --git a/src/claude/intercept/picker-listener.ts b/src/claude/intercept/picker-listener.ts index 23e8f5e1c8d..c5a0f7d836d 100644 --- a/src/claude/intercept/picker-listener.ts +++ b/src/claude/intercept/picker-listener.ts @@ -34,6 +34,10 @@ export interface PickerListenerOptions { maxEncodedBytes?: number; /** Test seam: concurrent upstream requests; production uses PICKER_MAX_ACTIVE_UPSTREAMS. */ maxActiveUpstreams?: number; + /** Test seam: maximum time for a client upload to finish. */ + uploadDeadlineMs?: number; + /** Test seam: maximum time without upload progress. */ + uploadIdleTimeoutMs?: number; log?: (line: string) => void; } export interface PickerListenerHandle { port: number; close(): Promise } @@ -51,6 +55,9 @@ export const PICKER_MAX_ACTIVE_UPSTREAMS = 256; export const PICKER_MAX_CONCURRENT_STREAMS = 100; /** A connection that has not sent a complete ClientHello by then is dropped. */ const CLIENT_HELLO_TIMEOUT_MS = 10_000; +const PICKER_UPLOAD_DEADLINE_MS = 60_000; +const PICKER_UPLOAD_IDLE_TIMEOUT_MS = 15_000; +const MAX_TIMER_DELAY_MS = 2_147_483_647; const HOP_HEADERS = new Set([ "connection", "keep-alive", "proxy-connection", "proxy-authenticate", "proxy-authorization", @@ -152,6 +159,13 @@ export async function startPickerListener(options: PickerListenerOptions): Promi const upstream = options.upstream ?? { host: "claude.ai", port: 443, servername: "claude.ai" }; const cap = options.maxEncodedBytes ?? BOOTSTRAP_MAX_ENCODED_BYTES; const maxActiveUpstreams = options.maxActiveUpstreams ?? PICKER_MAX_ACTIVE_UPSTREAMS; + const uploadDeadlineMs = options.uploadDeadlineMs ?? PICKER_UPLOAD_DEADLINE_MS; + const uploadIdleTimeoutMs = options.uploadIdleTimeoutMs ?? PICKER_UPLOAD_IDLE_TIMEOUT_MS; + for (const [name, value] of [["uploadDeadlineMs", uploadDeadlineMs], ["uploadIdleTimeoutMs", uploadIdleTimeoutMs]] as const) { + if (!Number.isFinite(value) || value <= 0 || value > MAX_TIMER_DELAY_MS) { + throw new RangeError(`${name} must be a positive finite timer duration`); + } + } let activeUpstreams = 0; const upgrades = new Set(); const spliced = new Set(); @@ -209,10 +223,50 @@ export async function startPickerListener(options: PickerListenerOptions): Promi const refuse = (status: 400 | 503) => { // Do not leave a refused, still-uploading stream behind after delivering its empty reply. if (uploading && !req.complete) { - req.once("error", () => res.destroy()); - responseWritable.once("finish", () => closeInput(true)); + if (h2) { + responseWritable.once("finish", () => closeInput(true)); + } else { + let settled = false; + let deadlineTimer: NodeJS.Timeout | undefined; + let idleTimer: NodeJS.Timeout | undefined; + const cleanup = () => { + if (settled) return; + settled = true; + if (deadlineTimer) clearTimeout(deadlineTimer); + if (idleTimer) clearTimeout(idleTimer); + req.off("data", onProgress); + req.off("end", onBodyEnd); + req.off("close", onBodyEnd); + req.off("error", onBodyEnd); + responseWritable.off("finish", onReplyFinished); + }; + const closeOnTimeout = () => { + cleanup(); + closeInput(true); + }; + const onProgress = () => { + if (settled || !deadlineTimer) return; + if (idleTimer) clearTimeout(idleTimer); + idleTimer = setTimeout(closeOnTimeout, uploadIdleTimeoutMs); + idleTimer.unref?.(); + }; + const onBodyEnd = () => cleanup(); + const onReplyFinished = () => { + if (settled) return; + if (req.complete) { cleanup(); return; } + deadlineTimer = setTimeout(closeOnTimeout, uploadDeadlineMs); + deadlineTimer.unref?.(); + onProgress(); + }; + req.on("data", onProgress); + req.once("end", onBodyEnd); + req.once("close", onBodyEnd); + req.once("error", onBodyEnd); + responseWritable.once("finish", onReplyFinished); + req.resume(); + } } - res.writeHead(status, { "Content-Length": "0", ...(!h2 && uploading && !req.complete ? { Connection: "close" } : {}) }); + res.writeHead(status, { "Content-Length": "0" }); res.end(); options.log?.(`picker request refused ${status}`); }; @@ -329,9 +383,25 @@ export async function startPickerListener(options: PickerListenerOptions): Promi if (h2) (req as Http2ServerRequest).stream.once("close", onClientClose); if (uploading) { let uploadFinished = false; + let deadlineTimer: NodeJS.Timeout | undefined; + let idleTimer: NodeJS.Timeout | undefined; + let expireUpload: () => void = () => {}; + const clearUploadTimers = () => { + if (deadlineTimer) clearTimeout(deadlineTimer); + if (idleTimer) clearTimeout(idleTimer); + deadlineTimer = undefined; + idleTimer = undefined; + }; + const onUploadProgress = () => { + if (idleTimer) clearTimeout(idleTimer); + idleTimer = setTimeout(expireUpload, uploadIdleTimeoutMs); + idleTimer.unref?.(); + }; const finishUpload = () => { if (uploadFinished) return; uploadFinished = true; + clearUploadTimers(); + req.off("data", onUploadProgress); req.off("end", finishUpload); req.off("close", finishUpload); upReq.off("close", onUpstreamClose); @@ -349,16 +419,26 @@ export async function startPickerListener(options: PickerListenerOptions): Promi upReq.destroy(); } }; + expireUpload = () => { + if (uploadFinished) return; + clientGone = true; + stopUnfinishedUpload(); + release(); + }; const onResponseFinish = () => stopUnfinishedUpload(true); const onUpstreamClose = () => { if (!upstreamResponded && !res.writableEnded) stopUnfinishedUpload(); }; req.once("end", finishUpload); req.once("close", finishUpload); + req.on("data", onUploadProgress); upReq.once("close", onUpstreamClose); responseWritable.once("finish", onResponseFinish); res.once("close", stopUnfinishedUpload); if (h2) (req as Http2ServerRequest).stream.once("close", finishUpload); + deadlineTimer = setTimeout(expireUpload, uploadDeadlineMs); + deadlineTimer.unref?.(); + onUploadProgress(); } req.pipe(upReq); }; diff --git a/src/claude/outbound.ts b/src/claude/outbound.ts index 60924c92def..10a19ae26ed 100644 --- a/src/claude/outbound.ts +++ b/src/claude/outbound.ts @@ -81,12 +81,16 @@ export function anthropicErrorType(status: number): string { * (devlog/_plan/261009_claude_1m_default/010). */ const CLAUDE_PROMPT_TOO_LONG_RE = /prompt is too long|input is too long for requested model/i; -const STATED_CONTEXT_COUNTS_RE = /maximum context length is (\d+) tokens[\s\S]*?(?:resulted in|you requested) (\d+) tokens/i; +const MAX_OVERFLOW_MESSAGE_CHARS = 64 * 1024; +const STATED_CONTEXT_LIMIT_RE = /maximum context length is (\d+) tokens/i; +const STATED_REQUEST_COUNT_RE = /(?:resulted in|you requested) (\d+) tokens/i; export function claudePromptTooLongMessage(message: string): string { + message = message.slice(0, MAX_OVERFLOW_MESSAGE_CHARS); if (CLAUDE_PROMPT_TOO_LONG_RE.test(message)) return message; - const counts = STATED_CONTEXT_COUNTS_RE.exec(message); - if (counts) return `prompt is too long: ${counts[2]} tokens > ${counts[1]} maximum`; + const limit = STATED_CONTEXT_LIMIT_RE.exec(message); + const requested = limit && STATED_REQUEST_COUNT_RE.exec(message.slice(limit.index + limit[0].length)); + if (limit && requested) return `prompt is too long: ${requested[1]} tokens > ${limit[1]} maximum`; return `prompt is too long: ${message}`; } diff --git a/src/clients/config-export.ts b/src/clients/config-export.ts index e28ed0b4242..e841a61a2e1 100644 --- a/src/clients/config-export.ts +++ b/src/clients/config-export.ts @@ -22,6 +22,7 @@ import { homedir } from "node:os"; import { existsSync, lstatSync, readFileSync, realpathSync, statSync } from "node:fs"; import { basename, dirname, isAbsolute, join, resolve, win32 } from "node:path"; +import { readBoundedRegularFileSync } from "../lib/bounded-file-read"; import { shouldInjectApiAuthHeader, standaloneCodexRoutingTarget } from "../codex/inject"; import { FORMAT_MEDIA_TYPE, serializeDocument, type ConfigFormat } from "../integrations/serialize"; import { canonicalizeReasoningEfforts } from "../reasoning-effort"; @@ -476,7 +477,7 @@ export type ZcodePathIO = { }; const zcodePathIO: ZcodePathIO = { readText: path => { - try { return { kind: "text", text: readFileSync(path, "utf8") }; } + try { return { kind: "text", text: readBoundedRegularFileSync(path, 1024 * 1024).toString("utf8") }; } catch (error) { return (error as NodeJS.ErrnoException).code === "ENOENT" ? { kind: "missing" } : { kind: "failed" }; } }, }; diff --git a/src/codex/config-write-lock.ts b/src/codex/config-write-lock.ts index de12142fdb9..5ca68e04663 100644 --- a/src/codex/config-write-lock.ts +++ b/src/codex/config-write-lock.ts @@ -317,6 +317,38 @@ export function publishConfigWriteTarget( }); } +export function removeConfigWriteEntry(configPath: string, held: LockHandle, path: string): void { + assertConfigWriteDestination(configPath, held); + watchConfigWriteTargets(held, [path]); + const watched = targets.get(held)!; + const expected = watched.get(path)!; + const current = destination(path); + if (expected.canonical !== current.canonical || expected.inode !== current.inode + || current.canonical !== join(realpathSync.native(dirname(resolve(path))), basename(resolve(path)))) { + throw new Error("Codex artifact destination changed; files and recovery evidence were preserved."); + } + if (expected.inode === null) return; + + const entry = join(realpathSync.native(dirname(resolve(path))), basename(resolve(path))); + const identity = (file: { dev: bigint; ino: bigint }) => `${file.dev}:${file.ino}`; + const before = lstatSync(entry, { bigint: true }); + if (before.isSymbolicLink() || !before.isFile() || identity(before) !== expected.inode) { + throw new Error("Codex artifact destination changed; files and recovery evidence were preserved."); + } + assertConfigWriteDestination(configPath, held); + const now = lstatSync(entry, { bigint: true }); + if (now.isSymbolicLink() || !now.isFile() || identity(now) !== expected.inode) { + throw new Error("Codex artifact destination changed; files and recovery evidence were preserved."); + } + unlinkSync(entry); + assertConfigWriteDestination(configPath, held); + const after = destination(path); + if (after.inode !== null || after.canonical !== expected.canonical) { + throw new Error("Codex artifact destination changed; recovery evidence was preserved."); + } + watched.set(path, after); +} + export function configWriteLockFailureMessage(failure: Exclude): string { if (failure.error === "missing-parent") return `Codex configuration parent is missing or unresolved at ${failure.detail}; no files were changed.`; return failure.error === "unsafe" diff --git a/src/codex/inject/config-write-section.ts b/src/codex/inject/config-write-section.ts index 0b39f07c9e8..e343103effb 100644 --- a/src/codex/inject/config-write-section.ts +++ b/src/codex/inject/config-write-section.ts @@ -1,7 +1,7 @@ import type { AtomicWriteHooks } from "../../config/atomic-write"; import { existsSync, readFileSync } from "node:fs"; import { withConfigMutationLockSync } from "../../config"; -import { ConfigWriteLockRefusal, assertConfigWriteDestination, publishConfigWriteTarget, watchConfigWriteTargets, withConfigWriteLockHeld, type LockHandle } from "../config-write-lock"; +import { ConfigWriteLockRefusal, assertConfigWriteDestination, publishConfigWriteTarget, removeConfigWriteEntry, watchConfigWriteTargets, withConfigWriteLockHeld, type LockHandle } from "../config-write-lock"; import { CODEX_CONFIG_PATH, CODEX_PROFILE_PATH } from "../paths"; import { getCodexHome } from "../paths"; import { codexHomeIsAbsent } from "../codex-home-owner"; @@ -16,6 +16,9 @@ export function publishCodexArtifact(path: string, held: LockHandle | undefin if (!held) throw new Error("Codex artifact publication requires a held configuration lock"); return publishConfigWriteTarget(CODEX_CONFIG_PATH, held, path, run); } +export function removeCodexProfileEntry(held: LockHandle): void { + removeConfigWriteEntry(CODEX_CONFIG_PATH, held, CODEX_PROFILE_PATH); +} /** Courtesy cleanup re-reads provider ownership inside the same config section. */ export function cleanExternalProviderJournal(beforeClientWrite?: () => void): void { if (codexHomeIsAbsent(getCodexHome())) return; diff --git a/src/codex/inject/remove.ts b/src/codex/inject/remove.ts index cdba5430d3e..74b704e594a 100644 --- a/src/codex/inject/remove.ts +++ b/src/codex/inject/remove.ts @@ -1,5 +1,5 @@ -import { beginCodexWriteSection, publishCodexArtifact } from "./config-write-section"; -import { existsSync, readFileSync, unlinkSync } from "node:fs"; +import { beginCodexWriteSection, publishCodexArtifact, removeCodexProfileEntry } from "./config-write-section"; +import { existsSync, lstatSync, readFileSync } from "node:fs"; import { normalizeStructuralWhitespace, rootAssignmentKey, rootSourceLines, sourceAssignment, sourceText } from "../toml-source-lines"; import { atomicWriteFile } from "../../config"; import { ConfigWriteLockRefusal, configWriteLockFailureMessage, withConfigWriteLockHeld } from "../config-write-lock"; @@ -206,9 +206,18 @@ export function removeCodexConfig( } const locked = withConfigWriteLockHeld(CODEX_CONFIG_PATH, options.heldConfigWriteLock, held => { beginCodexWriteSection(held); + if (!options.preserveProfile) { + try { + if (lstatSync(CODEX_PROFILE_PATH).isSymbolicLink()) { + return { success: false, message: "Codex profile is a symbolic link; configuration and recovery evidence were preserved." }; + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + } if (!existsSync(CODEX_CONFIG_PATH)) { if (!options.preserveProfile && existsSync(CODEX_PROFILE_PATH)) - publishCodexArtifact(CODEX_PROFILE_PATH, held, (path, hooks) => { hooks.validateBeforeRename?.(path); unlinkSync(path); hooks.afterRename?.(path); }); + removeCodexProfileEntry(held); return { success: true, message: `Codex config not found; no native restore was needed${options.preserveProfile ? "." : ", and the opencodex profile was removed if present."}`, @@ -248,7 +257,7 @@ export function removeCodexConfig( publishCodexArtifact(CODEX_CONFIG_PATH, held, (path, hooks) => atomicWriteFile(path, applyEol(finalContent, eol), undefined, hooks)); } if (!options.preserveProfile && existsSync(CODEX_PROFILE_PATH)) - publishCodexArtifact(CODEX_PROFILE_PATH, held, (path, hooks) => { hooks.validateBeforeRename?.(path); unlinkSync(path); hooks.afterRename?.(path); }); + removeCodexProfileEntry(held); const retainedNote = retainedBlock === null ? "" : " Kept [model_providers.opencodex] so conversations already tagged opencodex still open;" diff --git a/src/integrations/config-io.ts b/src/integrations/config-io.ts index 45f5458cf25..e57ab1442c3 100644 --- a/src/integrations/config-io.ts +++ b/src/integrations/config-io.ts @@ -7,8 +7,9 @@ * * Design of record: devlog/_fin/260802_client_toggle_api/021 §5-6. */ -import { lstatSync, mkdirSync, readFileSync, rmSync, statSync } from "node:fs"; +import { lstatSync, mkdirSync, rmSync, statSync } from "node:fs"; import type { ConfigFormat } from "../clients/config-export"; +import { readBoundedRegularFileSync } from "../lib/bounded-file-read"; import { canonicalizeJsonc } from "../lib/jsonc"; import { MAX_JSON_NESTING } from "./serialize"; import { atomicWriteFileNoFollow, isMissingPathError } from "../config/atomic-write"; @@ -24,6 +25,7 @@ import type { IntegrationClientId } from "./registry"; * then overwritten. */ export const PARSE_FAILED = Symbol("parse-failed"); +const MAX_INTEGRATION_CONFIG_FILE_BYTES = 4 * 1024 * 1024; /** * What `JSON.parse` has already discarded by the time we hold the parsed value, @@ -285,7 +287,7 @@ export function fileIO(): Omit { try { - return { kind: "text", text: readFileSync(path, "utf8") }; + return { kind: "text", text: readBoundedRegularFileSync(path, MAX_INTEGRATION_CONFIG_FILE_BYTES).toString("utf8") }; } catch (error) { const code = (error as NodeJS.ErrnoException).code; return code === "ENOENT" ? { kind: "missing" } : { kind: "failed", ...(code ? { code } : {}) }; diff --git a/src/lib/bounded-file-read.ts b/src/lib/bounded-file-read.ts new file mode 100644 index 00000000000..06fb36ef1bd --- /dev/null +++ b/src/lib/bounded-file-read.ts @@ -0,0 +1,51 @@ +import { closeSync, constants, fstatSync, lstatSync, openSync, readSync } from "node:fs"; + +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"); + } + + const windows = process.platform === "win32"; + const flags = constants.O_RDONLY | (windows ? 0 : constants.O_NONBLOCK | constants.O_NOFOLLOW); + const entryBefore = windows ? lstatSync(path, { bigint: true }) : undefined; + if (entryBefore && (entryBefore.isSymbolicLink() || !entryBefore.isFile())) { + throw new Error("not a regular file"); + } + const fd = openSync(path, flags); + 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)) { + throw new Error("file entry changed while opening"); + } + + const bytes = Buffer.alloc(limit + 1); + let length = 0; + while (length < bytes.length) { + const count = readSync(fd, bytes, length, Math.min(READ_CHUNK_BYTES, bytes.length - length), null); + if (count === 0) break; + length += count; + } + if (length > limit) throw new Error("file exceeds byte limit"); + + if (entryBefore) { + const entryAfter = lstatSync(path, { bigint: true }); + if (entryAfter.isSymbolicLink() || !entryAfter.isFile() || !sameIdentity(opened, entryAfter)) { + throw new Error("file entry changed while reading"); + } + } + return bytes.subarray(0, length); + } finally { + closeSync(fd); + } +} diff --git a/src/lib/bun-path-runtime.d.mts b/src/lib/bun-path-runtime.d.mts index 88d3aa94d75..bb0bdd31108 100644 --- a/src/lib/bun-path-runtime.d.mts +++ b/src/lib/bun-path-runtime.d.mts @@ -5,7 +5,16 @@ export interface DesktopCliIo { export interface PathBunIo extends DesktopCliIo { now?: () => number; realpath?: (path: string) => string; - stat?: (path: string) => { isFile(): boolean; mode: number }; + stat?: (path: string) => { isFile(): boolean; mode: number; uid?: number }; + lstat?: (path: string) => { + isFile(): boolean; + isDirectory(): boolean; + isSymbolicLink(): boolean; + mode: number; + uid?: number; + }; + readlink?: (path: string) => string; + geteuid?: () => number | undefined; isRealBunBinary?: (path: string) => boolean; spawnSync?: (path: string, args: string[], options: { env: NodeJS.ProcessEnv; diff --git a/src/lib/bun-path-runtime.mjs b/src/lib/bun-path-runtime.mjs index 729e86901cd..54b70b7f262 100644 --- a/src/lib/bun-path-runtime.mjs +++ b/src/lib/bun-path-runtime.mjs @@ -1,5 +1,5 @@ import { spawnSync } from "node:child_process"; -import { accessSync, constants, realpathSync, statSync } from "node:fs"; +import { accessSync, constants, lstatSync, readlinkSync, realpathSync, statSync } from "node:fs"; import { homedir } from "node:os"; import { posix, win32 } from "node:path"; import { performance } from "node:perf_hooks"; @@ -7,6 +7,7 @@ import { isRealBunBinary } from "./bun-binary-validator.mjs"; const STABLE = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/; const MAX_VERSION_BYTES = 4096; +const MAX_SYMLINK_EXPANSIONS = 40; const IDENTITY_PROBE = "process.stdout.write(String(typeof Bun==='object'&&Bun.version))"; function stableVersion(value) { @@ -17,6 +18,61 @@ function stableVersion(value) { return numbers.every(Number.isSafeInteger) ? numbers : null; } +function trustedPosixCandidate(candidate, canonical, io) { + const lstat = io.lstat ?? lstatSync; + const readlink = io.readlink ?? readlinkSync; + const uid = io.geteuid?.() ?? process.geteuid?.(); + if (!Number.isSafeInteger(uid) || uid < 0) return false; + + const walk = path => { + let pending = path.slice(1).split("/"); + let current = "/"; + let expansions = 0; + while (pending.length) { + const [component, ...remaining] = pending; + pending = remaining; + if (!component || component === ".") continue; + if (component === "..") { + current = posix.dirname(current); + continue; + } + const entry = posix.join(current, component); + const info = lstat(entry); + if (!Number.isSafeInteger(info.uid) || (info.uid !== 0 && info.uid !== uid)) return null; + if (info.isSymbolicLink()) { + if (++expansions > MAX_SYMLINK_EXPANSIONS) return null; + const target = readlink(entry); + pending = target.startsWith("/") + ? [...target.slice(1).split("/"), ...pending] + : [ + ...(current === "/" ? [] : current.slice(1).split("/")), + ...target.split("/"), + ...pending, + ]; + current = "/"; + continue; + } + if (remaining.length || pending.length) { + if (!info.isDirectory()) return null; + const stickyRootAncestor = info.uid === 0 && (info.mode & 0o1000) !== 0; + if ((info.mode & 0o022) !== 0 && !stickyRootAncestor) return null; + } else if (!info.isFile() || (info.mode & 0o022) !== 0) { + return null; + } + current = entry; + } + return current; + }; + + const resolved = walk(candidate); + if (!resolved || resolved !== canonical) return false; + const parent = lstat(posix.dirname(canonical)); + return parent.isDirectory() + && Number.isSafeInteger(parent.uid) + && (parent.uid === 0 || parent.uid === uid) + && (parent.mode & 0o022) === 0; +} + /** Discover the first absolute PATH Bun passing version-policy and identity checks. */ export function findPathBun({ env = process.env, platform = process.platform, pinnedVersion, @@ -54,14 +110,16 @@ export function findPathBun({ if (now() >= end) break; if (!directory || !absolute(directory)) continue; try { - const path = realpath(paths.join(directory, platform === "win32" ? "bun.exe" : "bun")); + const candidatePath = paths.join(directory, platform === "win32" ? "bun.exe" : "bun"); + const path = realpath(candidatePath); if (!absolute(path)) continue; + if (platform !== "win32" && !trustedPosixCandidate(candidatePath, path, io)) continue; const identity = platform === "win32" ? path.toLowerCase() : path; if (seen.has(identity)) continue; seen.add(identity); const file = stat(path); if (!file.isFile()) continue; - if (platform !== "win32" && ((file.mode & 0o022) || (stat(paths.dirname(path)).mode & 0o022))) continue; + if (platform !== "win32" && (file.mode & 0o022)) continue; access(path, constants.X_OK); if (!validate(path)) continue; const version = probe(path, ["--version"]); diff --git a/src/server/lifecycle.ts b/src/server/lifecycle.ts index addb0c0fa23..d27a95b84c1 100644 --- a/src/server/lifecycle.ts +++ b/src/server/lifecycle.ts @@ -172,6 +172,7 @@ export function resetLifecycleDrainStateForTests(): void { } export function tryAdmitTurn(sessionLaneId?: string, clientSignal?: AbortSignal): ActiveTurnLease | null { if (isDraining()) return null; + if (clientSignal?.aborted) return null; const opaqueSessionLaneId = sessionLaneId ? createHash("sha256").update(sessionLaneId).digest("hex").slice(0, SESSION_LANE_ID_BYTES) : undefined; @@ -195,6 +196,7 @@ export function tryAdmitTurn(sessionLaneId?: string, clientSignal?: AbortSignal) const attachedLeases = new Set(); const abortListeners = new Map void>(); let cancellationReason: unknown; + let cancelled = false; let active = true; let transferred = false; let nativeMainClaimed = false; @@ -206,10 +208,12 @@ export function tryAdmitTurn(sessionLaneId?: string, clientSignal?: AbortSignal) bindAbortSignal(signal) { if (!active || abortListeners.has(signal)) return () => {}; const onAbort = () => { + if (cancelled) return; + cancelled = true; cancellationReason = signal.reason; - const boundControllers = [...controllers]; - lease.release(); - for (const controller of boundControllers) controller.abort(cancellationReason); + for (const controller of controllers) { + if (!controller.signal.aborted) controller.abort(cancellationReason); + } }; abortListeners.set(signal, onAbort); signal.addEventListener("abort", onAbort, { once: true }); @@ -229,17 +233,21 @@ export function tryAdmitTurn(sessionLaneId?: string, clientSignal?: AbortSignal) transferred = true; controllers.add(ac); activeTurns.set(ac, lease); + if (cancelled) { + ac.abort(cancellationReason ?? new Error("turn cancelled")); + return; + } lease.bindAbortSignal(ac.signal); }, beginCodexAccountSelection() { const mainProfileDraining = nativeMainDrainOwners.size > 0; - let selectionActive = active && !mainProfileDraining; + let selectionActive = active && !cancelled && !mainProfileDraining; let released = false; if (selectionActive) nativeMainSelections += 1; const selection: CodexAccountSelectionAdmission = { mainProfileDraining, claimMainProfile() { - if (released || mainProfileDraining || !active) return false; + if (released || mainProfileDraining || !active || cancelled) return false; if (!nativeMainClaimed) { nativeMainClaimed = true; nativeMainTurns.add(lease); diff --git a/src/server/responses-undeclared-tool-guard.ts b/src/server/responses-undeclared-tool-guard.ts index 905454044af..52cc338e509 100644 --- a/src/server/responses-undeclared-tool-guard.ts +++ b/src/server/responses-undeclared-tool-guard.ts @@ -703,7 +703,6 @@ function failedBlocks(name: string, newline: string): readonly string[] { * @param providerExecutedCallTypes - Call types executed by the provider. * @param declaredBare - Explicitly declared bare tool names without namespace provenance. * @param declaredCustom - Current bare custom declarations eligible for code-mode recovery. - * @param enforceDeclaredToolNames - Refuse undeclared calls; normalization remains active when false. * @returns An SSE block rewrite function. */ export function createUndeclaredToolCallGuardBlockRewrite( @@ -712,7 +711,6 @@ export function createUndeclaredToolCallGuardBlockRewrite( providerExecutedCallTypes: ProviderExecutedCallTypes = EMPTY_PROVIDER_EXECUTED_CALL_TYPES, declaredBare?: ReadonlySet, declaredCustom?: ReadonlySet, - enforceDeclaredToolNames = true, ): SseBlockRewrite { let tripped = false; return (block: string) => { @@ -725,9 +723,7 @@ export function createUndeclaredToolCallGuardBlockRewrite( } catch { return [block]; } - const name = enforceDeclaredToolNames - ? undeclaredToolCallName(parsed, declared, declaredNamelessClientCallTypes, providerExecutedCallTypes, declaredBare, declaredCustom) - : undefined; + const name = undeclaredToolCallName(parsed, declared, declaredNamelessClientCallTypes, providerExecutedCallTypes, declaredBare, declaredCustom); if (name !== undefined) { tripped = true; return failedBlocks(name, block.includes("\r\n") ? "\r\n" : "\n"); diff --git a/src/server/responses/core-replay.ts b/src/server/responses/core-replay.ts index d842deefb51..66fdfa819ed 100644 --- a/src/server/responses/core-replay.ts +++ b/src/server/responses/core-replay.ts @@ -125,11 +125,13 @@ export function bindRouteReasoningReplayScope(args: { if (owner) parsed._nativeReasoningBoundOwner = owner; else delete parsed._nativeReasoningBoundOwner; if (parsed._nativeReasoningReplay?.size) { + const rejected = new Set(); for (const [blob, tag] of parsed._nativeReasoningReplay) { if (!nativeReasoningTagMatches(nativeReasoningTag(owner, blob), tag)) { - stripNativeReasoningBlob(parsed, blob); + rejected.add(blob); } } + if (rejected.size) stripNativeReasoningBlobs(parsed, rejected); } const continuationDestinationIdentity = providerContinuationDestinationIdentity(parsed, provider); const continuationOwner = providerContinuationOwnerFromReplayIdentity( @@ -169,11 +171,11 @@ export function bindRouteReasoningReplayScope(args: { bindProviderContinuationForRoute(parsed, continuationOwner); } -function stripNativeReasoningBlob(parsed: OcxParsedRequest, blob: string): void { +function stripNativeReasoningBlobs(parsed: OcxParsedRequest, rejected: ReadonlySet): void { const input = (parsed._rawBody as { input?: unknown } | undefined)?.input; if (Array.isArray(input)) { for (const item of input) { - if (item && typeof item === "object" && item.type === "reasoning" && item.encrypted_content === blob) { + if (item && typeof item === "object" && item.type === "reasoning" && rejected.has(item.encrypted_content)) { delete item.encrypted_content; delete item.id; } @@ -187,7 +189,8 @@ function stripNativeReasoningBlob(parsed: OcxParsedRequest, blob: string): void const item: unknown = JSON.parse(part.signature); if (!item || typeof item !== "object" || Array.isArray(item)) continue; const reasoning = item as Record; - if (reasoning.type !== "reasoning" || reasoning.encrypted_content !== blob) continue; + if (reasoning.type !== "reasoning" || typeof reasoning.encrypted_content !== "string" + || !rejected.has(reasoning.encrypted_content)) continue; delete reasoning.encrypted_content; delete reasoning.id; part.signature = JSON.stringify(reasoning); diff --git a/src/server/responses/passthrough-delivery.ts b/src/server/responses/passthrough-delivery.ts index 184e2b1eabb..5053cd84a9d 100644 --- a/src/server/responses/passthrough-delivery.ts +++ b/src/server/responses/passthrough-delivery.ts @@ -436,8 +436,7 @@ export async function deliverPassthroughResponse( normalizeFunctionCompletionJson, } = nativeExchange; const { commitReasoningReplayServingRoute, recordTerminalOutcomes } = responseEffects; - const { parsed, subagentQuotaFailureModel, clientRequestedStream, translatorBudget, inboundWire } = requestState; - const enforceDeclaredToolNames = inboundWire !== "chat" && inboundWire !== "anthropic"; + const { parsed, subagentQuotaFailureModel, clientRequestedStream, translatorBudget } = requestState; const { openAiSidecar } = sidecarState; const { requestBindings } = transportState; @@ -861,7 +860,6 @@ export async function deliverPassthroughResponse( providerExecutedCallTypes, declaredBareWireToolNames, recoverableBareCustomWireToolNames, - enforceDeclaredToolNames, ) : undefined, grokUpstreamEchoEnabled @@ -1319,10 +1317,7 @@ export async function deliverPassthroughResponse( if (grokUpstreamEchoEnabled) { clientJson = stripGrokUpstreamEnvelopeEchoFromResponsesJson(clientJson); } - // #1700: same inbound-wire refusal policy as the SSE relay above. Both the plain JSON answer and - // the reframed-SSE branch below are built from this body, so one check covers them. This - // runs BEFORE the continuation cache write below: a refused turn must not become state a - // later `previous_response_id` replay can expand from. + // Both response formats use this body, before a refused turn can enter continuation state. if (nativeExchange.undeclaredToolGuardActive) { const undeclared = (() => { try { @@ -1338,7 +1333,7 @@ export async function deliverPassthroughResponse( return undefined; } })(); - if (enforceDeclaredToolNames && undeclared !== undefined) { + if (undeclared !== undefined) { return formatErrorResponse(502, "upstream_error", undeclaredToolCallMessage(undeclared)); } clientJson = normalizeDefaultNamespaceInJson( diff --git a/src/server/responses/request-transport.ts b/src/server/responses/request-transport.ts index f91751f14c3..a4ad48855f6 100644 --- a/src/server/responses/request-transport.ts +++ b/src/server/responses/request-transport.ts @@ -55,6 +55,12 @@ import { stampOAuthAccountLabel, usesApiKeyAccount } from "../../providers/label import { resolveProviderTransport } from "../../providers/xai-transport"; import { captureRouteStaticPolicy } from "../../router"; import { CopilotAutoHttpError, resolveCopilotAuto } from "../../providers/github-copilot-auto"; +import { + AdmissionModelDeniedError, + admissionModelDeniedResponse, + assertRouteAllowedByScope, + resolveAdmissionModelScope, +} from "../admission-model-scope"; import { renameRoutedIdentityInContext } from "../../adapters/identity"; import { resolveCopilotApiBaseUrl } from "../../oauth/github-copilot"; import { @@ -296,6 +302,7 @@ export async function prepareResponsesTransport( const copilotRequestedModel = route.modelId; /** Project only bounded Copilot refusal metadata; upstream bodies and session tokens never reach the client. */ const copilotRefusalResponse = (error: unknown): Response | undefined => { + if (error instanceof AdmissionModelDeniedError) return admissionModelDeniedResponse(error); if (!(error instanceof CopilotAutoHttpError) || ![401, 403, 429].includes(error.status)) return; const response = formatErrorResponse(error.status, error.status === 429 ? "rate_limit_error" : "authentication_error", error.message); @@ -356,6 +363,11 @@ export async function prepareResponsesTransport( replayOAuthCredentialSnapshot = { accountId: refreshed.accountId, generation: refreshed.generation }; selected = await negotiate(); } + assertRouteAllowedByScope( + resolveAdmissionModelScope(config, options.admission), + copilotRequestedModel, + { providerName: route.providerName, modelId: selected.modelId }, + ); copilotSessionExpiresAt = selected.expiresAt ?? Infinity; route.provider = selected.provider; route.modelId = selected.modelId; diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index 42bdab0c1a2..a63d00edf73 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -92,7 +92,7 @@ traffic through a local interception proxy, which Anthropic may treat as a terms owned settings are still observed; otherwise the field is `null`. `/api/sync` and roster-update auto-apply never write a gateway profile while the resolved mode is first-party; both re-resolve after model discovery before writing. -Background refresh preserves the selected owned gateway profile's mode, refuses symlinked library, metadata, profile or backup paths and non-regular profiles, and rechecks admission, the selected owned metadata entry and its applied fingerprint in the no-follow writer's pre-rename hook; it leaves metadata and backup bytes unchanged. +Background refresh preserves the selected owned gateway profile's mode, refuses symlinked library, metadata, profile or backup paths and non-regular profiles, and rechecks admission, the selected owned metadata entry and its applied fingerprint in the no-follow writer's pre-rename hook; it leaves metadata and backup bytes unchanged. Observer, metadata, foreign-key, refresh and fingerprint reads use no-follow bounded regular-file reads with a 4 MiB cap. Mode switches establish the replacement before removing the previous connection. A failed first-party apply (disabled intercept, CA failure, unreadable settings or foreign env) preserves @@ -220,20 +220,21 @@ natively, counting name + value + 32 bytes per field and rejecting an oversized The relay retains its 256-request aggregate ceiling across both HTTP versions and all sessions. Upload framing, not the method, determines whether input cleanup is needed: HTTP/2 headers without END_STREAM, or HTTP/1.1 transfer encoding or a positive Content-Length. -Rejected unfinished uploads close after their empty reply's writable finishes; HTTP/2 uses the +Rejected unfinished uploads preserve the empty reply before cleanup; HTTP/2 uses the underlying stream's finish event rather than the compatibility response's finish event, which -waits for both stream halves to close. HTTP/1.1 refusals advertise `Connection: close` only while input is unfinished. +waits for both stream halves to close. Early replies stop relaying unfinished input only after the downstream writable finishes or the response closes; upstream request closure alone does not prove a buffered reply was delivered. After a complete response, HTTP/2 closes only that stream with `RST_STREAM NO_ERROR` (RFC 9113 §8.1), including a complete generated 502. Without a complete downstream response, cleanup uses `CANCEL` for a premature downstream close or an upstream close without a reply. -HTTP/1.1 refusals, generated 502s and early replies advertise `Connection: close` only while input is unfinished; after the writable finishes, -the listener unpipes the upstream upload, reads and discards remaining input, and ends the socket -gracefully so queued response bytes flush. Errors and premature downstream closes still destroy -the request. Draining adds no upload deadline or byte cap and does not wait for request-body -completion before ending the response side. Body completion removes the input-cleanup listeners, -leaving long-lived responses and SSE subscriptions independent of upload cleanup. Completed HTTP/1.1 uploads retain keep-alive on generated 502s and can reuse the same client socket. +An early HTTP/1.1 refusal preserves its reply, then unpipes upstream and drains unfinished input +only until the absolute or idle deadline before graceful close. Errors and premature downstream +closes still destroy the request. The 60-second absolute and 15-second idle-progress deadlines +close client input and upstream work and release the aggregate slot. Body +completion removes the input-cleanup listeners and both timers, leaving long-lived responses and +SSE subscriptions independent of upload cleanup. Completed HTTP/1.1 uploads retain keep-alive on +generated 502s and can reuse the same client socket. `tests/claude-integration/claude-picker-upload.test.ts` covers headers-only refusals on both protocols, byte-exact early replies under backpressure, completed uploads with SSE, cancellation, failure and shutdown cleanup, and healthy sibling streams. diff --git a/structure/clients/integrations.md b/structure/clients/integrations.md index 88146d21725..a6f817692a7 100644 --- a/structure/clients/integrations.md +++ b/structure/clients/integrations.md @@ -31,7 +31,7 @@ parsing and ownership rules below. | `src/clients/config-export.ts` | Pure per-client config builders and the exact managed fragments each client receives. It never writes files. | | `src/integrations/registry.ts` | Canonical config/detection paths, current-provider-store declarations, source-preserving YAML declarations, writer-lock behavior, and client IDs. | | `src/integrations/target.ts` | Which file one operation reads, writes and records, and whether a write there reaches the client. | -| `src/integrations/config-io.ts` | Bounded file loading and parsing. Values that cannot round-trip through the target serializer are rejected before mutation. | +| `src/integrations/config-io.ts` | Bounded regular-file loading and parsing; production reads reject symlinks and stop after 4 MiB. Values that cannot round-trip through the target serializer are rejected before mutation. | | `src/integrations/kilo-candidates.ts` | Inspects all Kilo global config candidates for unsafe files and a competing `provider.opencodex` block before status or any operation that adds or replaces a block. | | `src/integrations/state.ts` | The single `absent` / `current` / `stale` / `conflict` / `unsafe` classifier used by status and every writer operation. | | `src/integrations/ownership.ts` | Durable ownership records: file, generated contribution, protected contribution, exact fragment paths, and operation identity. | diff --git a/structure/codex-home.md b/structure/codex-home.md index 4e89e552e1f..911311ad08a 100644 --- a/structure/codex-home.md +++ b/structure/codex-home.md @@ -78,7 +78,9 @@ and absence is distinct from an empty file. The injector checks a retained hashl the same `baselineContent` it snapshots, plus the current profile, before writing or assigning a new injected hash. Native content can establish a fresh snapshot; routed content cannot promote an unverified older original. Existing hash-backed edit preservation and external-provider opt-out -remain separate paths. +remain separate paths. Removal with `preserveProfile: false` refuses a final-component profile +symlink before changing config or journal state; ordinary profile removal unlinks only the checked +entry under its canonical parent, never the target of a resolved symlink. `src/codex/journal.ts` distinguishes restored state from actual writes with `configRewritten` and `profileRewritten`; removing a generated profile counts as a write, while already-original diff --git a/structure/desktop-terminal-command.md b/structure/desktop-terminal-command.md index 7a1b6c1ad68..1bfef75b73c 100644 --- a/structure/desktop-terminal-command.md +++ b/structure/desktop-terminal-command.md @@ -27,6 +27,8 @@ and final managed zsh/bash/fish blocks. Reconcile separates desired rc targets f ownership; remove walks the recorded list and preserves modified blocks and unsafe files. An rc file with an ACL is never rewritten (`rc-acl-present`). The shim captures exported Anthropic slot names with an argv-bound random proof before dotenv; failure keeps stripping. +Persistent bundle registration rejects raw and canonical locations under system and OS temporary +directories, while ordinary `/Applications` and user Applications bundles remain eligible. `desktop/src-tauri/src/cli_command_windows.rs` preserves raw HKCU Path text/type, changes only the owned entry and broadcasts Environment; a top-level `notifyPending` debt survives crashes and diff --git a/structure/runtime.md b/structure/runtime.md index 5a33ffbc381..3014c02fea4 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -1,6 +1,6 @@ # Runtime -The minute sweep checks persisted activation deadlines locally; only missing deadlines trigger metadata discovery. See the [quota activation contract](providers/openai-tiers.md#public-provider-contract). Codex control-plane ids are defined in the dependency-free `src/codex/control-plane-models.ts`; `src/router.ts` routes these exact ids unchanged to canonical OpenAI, retaining the existing error when no OpenAI provider is enabled. They do not widen the public native roster. Catalog requests and scheduled source refreshes follow the [nonblocking catalog observation contract](catalog.md#shared-catalog). Async runtime persistence rejects observed selection/epoch changes and returns detached frozen results; bounded exec rejects promptly and releases output readers on deadline or abort, independently awaits actual child exit, and escalates SIGTERM to SIGKILL after the shared 2-second grace (Windows termination is forceful). +The minute sweep checks persisted activation deadlines locally; only missing deadlines trigger metadata discovery. See the [quota activation contract](providers/openai-tiers.md#public-provider-contract). Codex control-plane ids are defined in the dependency-free `src/codex/control-plane-models.ts`; `src/router.ts` routes these exact ids unchanged to canonical OpenAI, retaining the existing error when no OpenAI provider is enabled. They do not widen the public native roster. Catalog requests and scheduled source refreshes follow the [nonblocking catalog observation contract](catalog.md#shared-catalog). Async runtime persistence rejects observed selection/epoch changes and returns detached frozen results; bounded exec rejects promptly and releases output readers on deadline or abort, independently awaits actual child exit, and escalates SIGTERM to SIGKILL after the shared 2-second grace (Windows termination is forceful). Claude overflow normalization examines at most 64 Ki characters, extracts stated context/request counts in separate forward scans, retains the bounded rewritten prefix, and leaves other error classifications intact; the managed native Messages SSE route follows the same contract (`tests/claude-integration/claude-prompt-too-long.test.ts`). ## Bun runtime and test runner @@ -26,7 +26,7 @@ Native result continuations and function-result injection follow [the mode-speci Native steering follows [the shared WebSocket contract](transports/streaming-health.md#experimental-native-mid-turn-steering); this surface's defaults remain unchanged. -Responses admission and finalization are composed through the [core module ownership](transports/responses.md#core-module-ownership). Kiro's optional account-load admission is process-local and request-owned; its slot ends with the response body or cancellation. Other providers retain their admission path. Cancellation after a slot grant and before the request resumes releases the slot and returns cancellation without dispatch. +Responses admission and finalization are composed through the [core module ownership](transports/responses.md#core-module-ownership). Kiro's optional account-load admission is process-local and request-owned; its slot ends with the response body or cancellation. Other providers retain their admission path. Cancellation after a slot grant and before the request resumes releases the slot and returns cancellation without dispatch. In `src/server/lifecycle.ts`, cancellation promptly aborts bound controllers but retains active-turn, session-lane and workflow/native ownership until the owner finalizes; late controllers abort immediately, cancelled turns cannot select native accounts, and shutdown may release turns while admission is fenced. Catalog HTTP acquisition follows the [proxy-routing contract](catalog.md#remote-catalog-http-proxy-routing). CLI model lists expose shared catalog estimates as `price`; `models price` retains the saved override in `cost` and adds `effectiveCost`. Explicit zero overrides win; unknown prices remain null, and automatic defaults are never persisted to `modelCosts`. Covered by `tests/cli/cli-models.test.ts` and `tests/cli/cli-models-price.test.ts`. Ordinary model discovery, startup sync, and explicit sync apply the [new-arrival catalog contract](catalog.md#shared-catalog) before publishing rows. The scoped persisted decision also updates the live config merge baseline, so subsequent visibility writes preserve manual re-enablement. A file-loaded inventory that already differs from disk can publish HTTP rows through a detached policy projection without adding unsaved live disables; retained catalog writes still refuse stale inventory, and its load provenance follows the [config contract](config.md). diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 1608a832257..bdcf77e6513 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -14,7 +14,7 @@ When a successful streamed native response has a missing or unrecognized non-JSO HTTP ingress applies the [caller header contract](../data-planes/inbound-compat.md#http-caller-conversation-identity) before admission and dispatch. Optional Codex memory selection enters `src/server/responses/request-prepare.ts` for HTTP and WebSocket frames, as specified in [memory phase routing](responses-failover.md#memory-phase-routing); an unset phase keeps its existing route and starts no background work. Responses request preparation stabilizes incoming `` under `skills.catalog_refresh`: `per_session` (default) reuses the first received catalog for a conversation; `per_turn` leaves the supplied catalog unchanged. Other instruction sections and user/tool content remain untouched. Requests without a reliable conversation identity bypass snapshots; shared prompt-cache cohorts are not conversation identities. Only a body with exactly one catalog block across its instructions and developer/system content takes part; two or more pass through unchanged. A known snapshot is substituted before parsing, but a new catalog is stored only when preparation reaches its success return, so a request rejected by parsing or admission pins nothing. Without a named principal, snapshots are shared by conversation id only on a server that requires no data-plane auth. Snapshots are process-local, expire after four idle hours, and use bounded LRU retention; oversized blocks bypass caching. The dashboard's `src/codex/prompt-layers.ts` and `src/codex/prompt-text-probe.ts` continue observing current files for previews and do not own session snapshots. `/v1/responses` is the main Codex-facing endpoint. The server parses Responses input, routes to a provider, lets the selected adapter speak the upstream protocol, then bridges adapter events back to -Responses-compatible streaming output. 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). +Responses-compatible streaming output. 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). GitHub Copilot Auto admission rechecks the negotiated provider/model against the original inbound selector's key scope before route mutation or dispatch, on initial and renewal negotiation; denial returns the standard `model_not_allowed_for_key` 403 without disclosing the destination. The active native passthrough undeclared-tool guard covers Chat, Anthropic and Responses inbound wires for both SSE and buffered JSON, refusing undeclared client-executed calls before continuation state is stored. The `openai-responses` adapter preserves caller `User-Agent` unless provider headers own it, in either auth mode. `forwardClientHeaders` additionally opts into `originator`, `x-client-request-id`, `x-codex-app-version`, or `user-agent`. @@ -340,8 +340,8 @@ ChatGPT forward surface, the official OpenAI API, or a provider with the explici than one backend, including OpenAI and xAI, mints native blobs: a destination can decode its own blob without being able to decode the previous backend's. The same serving-identity mismatch signal therefore strips reasoning `encrypted_content` and degrades native compaction blobs through the -existing opaque-note path. Claude-native reasoning adds a per-blob tag check at every route binding: -only the matching durable endpoint and generation-bearing credential retain raw and parsed carriers. Without recorded thread identity, the destination gate remains. Noncanonical forward providers +existing opaque-note path. Claude-native reasoning rechecks blob tags for every route binding, collects rejected blobs before one raw/context-history scrub pass, and parses each signature at most once per binding, including switches and rebindings. +Only the matching durable endpoint and generation-bearing credential retain raw and parsed carriers. Without recorded thread identity, the destination gate remains. Noncanonical forward providers receive no caller credentials and may point at any backend. On any other routed destination the blob also degrades to the same opaque note the bridged parser uses, because forwarding it there fails the turn and the item outlives the failure in the client transcript, repeating on every later turn diff --git a/tests/claude-integration/claude-native-reasoning-provenance.test.ts b/tests/claude-integration/claude-native-reasoning-provenance.test.ts index 659624ed2f2..66568cb4edc 100644 --- a/tests/claude-integration/claude-native-reasoning-provenance.test.ts +++ b/tests/claude-integration/claude-native-reasoning-provenance.test.ts @@ -137,6 +137,60 @@ describe("Claude native reasoning provenance", () => { expect(nativeReasoningOwnerForRoute({ provider: oauth, oauthCredentialSnapshot: { accountId: "slot" } })).toBeUndefined(); }); + test("rejects many distinct blobs in one history pass and preserves valid replay until a route switch", () => { + const selected = provider(); + const owner = nativeReasoningOwnerForRoute({ provider: selected }); + const payload = turn(); + for (let i = 0; i < 128; i++) { + const blob = `ciphertext-${i}`; + payload.messages.push({ role: "assistant", content: [ + { type: "thinking", thinking: "Visible plan.", signature: encodeReasoningEnvelope({ nat: { + enc: blob, model: MODEL, id: `rs_${i}`, tag: i === 0 ? nativeReasoningTag(owner, blob)! : "invalid", + } }) }, + { type: "text", text: "Done." }, + ] } as typeof payload.messages[number]); + } + const translated = anthropicToResponsesTranslation(payload); + const parsed = parseRequest(translated.body); + parsed._nativeReasoningReplay = translated.nativeReasoningReplay; + expect(parsed._nativeReasoningReplay?.size).toBe(128); + let signatureReads = 0; + let carriers = 0; + for (const message of parsed.context.messages) { + if (message.role !== "assistant" || !Array.isArray(message.content)) continue; + for (const part of message.content) { + if (part.type !== "thinking" || typeof part.signature !== "string") continue; + carriers++; + let value = part.signature; + Object.defineProperty(part, "signature", { + enumerable: true, configurable: true, + get() { signatureReads++; return value; }, + set(next: string) { value = next; }, + }); + } + } + expect(carriers).toBe(128); + const bind = (target: OcxProviderConfig) => { + signatureReads = 0; + bindRouteReasoningReplayScope({ parsed, providerName: "a", provider: target, adapterName: "openai-responses" }); + expect(signatureReads).toBeLessThanOrEqual(2 * carriers); + }; + bind(selected); + for (const history of [parsed._rawBody, parsed.context]) { + const encoded = JSON.stringify(history); + expect(encoded).toContain("ciphertext-0"); + expect(encoded).not.toContain("ciphertext-1"); + } + bind(provider(undefined, "key-b")); + bind(selected); + for (const history of [parsed._rawBody, parsed.context]) { + const encoded = JSON.stringify(history); + expect(encoded).not.toContain("ciphertext-"); + expect(encoded).not.toContain("rs_127"); + expect(encoded).toContain("Visible plan."); + } + }); + test("OAuth generation changes strip both replay carriers while the same generation keeps them", () => { const oauth = { ...provider(), authMode: "oauth" } as OcxProviderConfig; const minted = nativeReasoningOwnerForRoute({ provider: oauth, oauthCredentialSnapshot: { accountId: "slot", generation: 1 } })!; diff --git a/tests/claude-integration/claude-picker-upload.test.ts b/tests/claude-integration/claude-picker-upload.test.ts index 18c4ea0eec5..b411a046662 100644 --- a/tests/claude-integration/claude-picker-upload.test.ts +++ b/tests/claude-integration/claude-picker-upload.test.ts @@ -70,13 +70,15 @@ function h2Result(stream: ClientHttp2Stream) { } function h1Result(req: ClientRequest) { const result = Promise.withResolvers<{ status: number; text: string }>(); + let responseStarted = false; req.once("response", res => { + responseStarted = true; const chunks: Buffer[] = []; res.on("data", chunk => chunks.push(Buffer.from(chunk))); res.once("end", () => result.resolve({ status: res.statusCode!, text: Buffer.concat(chunks).toString() })); res.once("error", result.reject); }); - req.once("error", result.reject); + req.once("error", error => { if (!responseStarted) result.reject(error); }); void result.promise.catch(() => {}); return result.promise; } @@ -89,7 +91,7 @@ function observeClose(stream: ClientRequest | ClientHttp2Stream | ClientHttp2Ses function observeSocketClose(req: ClientRequest) { const closed = Promise.withResolvers(); req.once("socket", socket => { socket.once("close", () => closed.resolve()); }); - req.on("error", () => req.destroy()); + req.on("error", () => {}); return closed.promise; } function normalH2(session: ClientHttp2Session, path = "/ok") { @@ -109,7 +111,7 @@ for (const protocol of ["h1", "h2"] as const) { if (req.url === "/held") { req.once("close", () => left.resolve()); held.resolve(); } else { relayed++; res.end("ordinary"); } - }, { maxActiveUpstreams: 1 }); + }, { maxActiveUpstreams: 1, uploadIdleTimeoutMs: 250 }); try { const session = f.h2(); let blocker: ClientHttp2Stream | undefined; @@ -131,7 +133,7 @@ for (const protocol of ["h1", "h2"] as const) { } if (input === "buffered") upload.write(Buffer.alloc(4 * 1024 * 1024, 0x61)); expect(await wait(result)).toEqual({ status, text: "" }); - if (protocol === "h1") expect(connection).toBe("close"); + if (protocol === "h1") expect(connection).not.toBe("close"); await wait(closed); if (protocol === "h2") expect((upload as ClientHttp2Stream).rstCode).toBe(constants.NGHTTP2_NO_ERROR); expect(relayed).toBe(0); @@ -251,6 +253,66 @@ for (const protocol of ["h1", "h2"] as const) { }); } +bounded("unfinished uploads share the aggregate budget across HTTP/2 sessions and recover on idle timeout", async wait => { + const arrived = Promise.withResolvers(); + let requests = 0; + const f = await fixture((req, res) => { + requests++; + if (req.url === "/stall") { + req.on("data", () => {}); + req.once("end", () => res.end("uploaded")); + arrived.resolve(); + } else res.end("ordinary"); + }, { maxActiveUpstreams: 1, uploadDeadlineMs: 1_000, uploadIdleTimeoutMs: 350 }); + try { + const firstSession = f.h2(); + const secondSession = f.h2(); + const upload = firstSession.request({ ":method": "POST", ":path": "/stall", ":authority": "claude.ai" }); + const uploadClosed = observeClose(upload); + upload.write("partial"); + await wait(arrived.promise); + const blocked = await wait(normalH2(secondSession, "/blocked")); + expect(blocked.status).toBe(503); + await wait(uploadClosed); + expect((await wait(normalH2(secondSession, "/ok"))).status).toBe(200); + expect(requests).toBe(2); + } finally { await wait(f.close()); } +}); + +bounded("absolute upload deadline expires despite continued progress", async wait => { + const arrived = Promise.withResolvers(); + const f = await fixture((req, res) => { + req.on("data", () => {}); + arrived.resolve(); + req.once("end", () => res.end("uploaded")); + }, { uploadDeadlineMs: 400, uploadIdleTimeoutMs: 250 }); + let progress: NodeJS.Timeout | undefined; + try { + const session = f.h2(); + const upload = session.request({ ":method": "POST", ":path": "/trickle", ":authority": "claude.ai" }); + const closed = observeClose(upload); + progress = setInterval(() => upload.write("x"), 80); + upload.write("start"); + await wait(arrived.promise); + await wait(closed); + expect(upload.rstCode).toBe(constants.NGHTTP2_CANCEL); + } finally { if (progress) clearInterval(progress); await wait(f.close()); } +}); + +bounded("completed uploads are not timed out while a long response is pending", async wait => { + const f = await fixture((req, res) => { + req.resume(); + req.once("end", () => setTimeout(() => res.end("slow response"), 120)); + }, { uploadDeadlineMs: 50, uploadIdleTimeoutMs: 25 }); + try { + const session = f.h2(); + const upload = session.request({ ":method": "POST", ":path": "/complete", ":authority": "claude.ai" }); + const result = h2Result(upload); + upload.end("complete"); + expect(await wait(result)).toEqual({ status: 200, text: "slow response" }); + } finally { await wait(f.close()); } +}); + for (const protocol of ["h1", "h2"] as const) { bounded(`${protocol} backpressure preserves every byte of a large early reply`, async wait => { // Exceed the h2 receive window and the h1 socket buffers, including a non-text tail. diff --git a/tests/claude-integration/claude-prompt-too-long.test.ts b/tests/claude-integration/claude-prompt-too-long.test.ts index 9c093f6bba7..8cc6cb5f655 100644 --- a/tests/claude-integration/claude-prompt-too-long.test.ts +++ b/tests/claude-integration/claude-prompt-too-long.test.ts @@ -58,6 +58,15 @@ describe("claudePromptTooLongMessage", () => { expect(CLAUDE_CODE_GAP_RE.test(message)).toBe(false); }); + test("bounds repeated context prefixes and scans a request count only after the first limit", () => { + const repeated = "maximum context length is 1 tokens; ".repeat(4_000); + expect(claudePromptTooLongMessage(repeated)).toBe(`prompt is too long: ${repeated.slice(0, 64 * 1024)}`); + expect(claudePromptTooLongMessage("you requested 99 tokens; maximum context length is 10 tokens")) + .not.toContain("99 tokens > 10 maximum"); + expect(claudePromptTooLongMessage("maximum context length is 10 tokens; maximum context length is 20 tokens; you requested 30 tokens")) + .toBe("prompt is too long: 30 tokens > 10 maximum"); + }); + test("leaves Anthropic wording unchanged", () => { for (const text of ["prompt is too long: 1000 tokens > 900 maximum", "Input is too long for requested model."]) { expect(claudePromptTooLongMessage(text)).toBe(text); @@ -223,6 +232,19 @@ describe("native Messages lane", () => { expect(data.error.message).toBe("prompt is too long: Your request exceeded model token limit: 262144"); }); + test("bounds oversized overflow messages on the managed native SSE route", async () => { + const message = "maximum context length is 1 tokens; ".repeat(4_000); + const frame = `event: error\ndata: ${JSON.stringify({ type: "error", error: { + type: "invalid_request_error", message, + } })}\n\n`; + const { response, text } = await send(nativeConfig(() => new Response(frame, { + headers: { "content-type": "text/event-stream" }, + })), true); + expect(response.status).toBe(200); + const data = JSON.parse(text.split("\n").find(line => line.startsWith("data:"))!.slice(5)); + expect(data.error.message).toBe(`prompt is too long: ${message.slice(0, 64 * 1024)}`); + }); + test("stall detection still times raw upstream bytes, not whole rewritten frames", async () => { // One large delta frame arrives in fragments over ~1.6s with a 1s stall limit. Each fragment // must reset the deadline; a frame-buffering rewrite placed before the tap would not. diff --git a/tests/cli/ocx-launcher-runtime.test.ts b/tests/cli/ocx-launcher-runtime.test.ts index 330f9dd04c3..9f7a0c42ffb 100644 --- a/tests/cli/ocx-launcher-runtime.test.ts +++ b/tests/cli/ocx-launcher-runtime.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import { spawn, spawnSync, type ChildProcess } from "node:child_process"; -import { chmodSync, copyFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { chmodSync, copyFileSync, cpSync, existsSync, lstatSync, mkdirSync, mkdtempSync, realpathSync, readlinkSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs"; import { createServer } from "node:net"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; @@ -402,6 +402,15 @@ function pathProbeFixture(platform: NodeJS.Platform = "linux", stdout = "1.4.0\n now: () => clock, realpath: path => path, stat: () => ({ isFile: () => true, mode: 0o755 }), + lstat: path => ({ + isFile: () => path.endsWith("/bun"), + isDirectory: () => !path.endsWith("/bun") && !path.endsWith("/alias"), + isSymbolicLink: () => path.endsWith("/alias"), + mode: path.endsWith("/bun") ? 0o755 : path.endsWith("/alias") ? 0o777 : 0o755, + uid: 0, + }), + readlink: () => "/first", + geteuid: () => 1000, access: () => {}, isRealBunBinary: () => true, spawnSync: (path, args, options) => { @@ -516,6 +525,33 @@ describe("real PATH Bun validation", () => { expect(fixture.calls).toHaveLength(0); }); + test("rejects a writable original PATH symlink directory before spawning", () => { + const fixture = pathProbeFixture(); + fixture.io.realpath = path => path === "/alias/bun" ? "/untrusted/bun" : path; + fixture.io.readlink = () => "/untrusted"; + fixture.io.lstat = path => path === "/alias" + ? { isFile: () => false, isDirectory: () => false, isSymbolicLink: () => true, mode: 0o777, uid: 1000 } + : path === "/untrusted" + ? { isFile: () => false, isDirectory: () => true, isSymbolicLink: () => false, mode: 0o777, uid: 1000 } + : { + isFile: () => path.endsWith("/bun"), + isDirectory: () => !path.endsWith("/bun"), + isSymbolicLink: () => false, + mode: 0o755, + uid: 0, + }; + expect(fixture.find({ PATH: "/alias" })).toBeNull(); + expect(fixture.calls).toHaveLength(0); + }); + + test("accepts a trusted original PATH symlink directory", () => { + const fixture = pathProbeFixture(); + fixture.io.realpath = path => path === "/alias/bun" ? "/trusted/bun" : path; + fixture.io.readlink = () => "/trusted"; + expect(fixture.find({ PATH: "/alias" })?.path).toBe("/trusted/bun"); + expect(fixture.calls).toHaveLength(2); + }); + test("Windows does not interpret POSIX write mode bits", () => { const fixture = pathProbeFixture("win32"); fixture.io.stat = () => ({ isFile: () => true, mode: 0o777 }); @@ -605,6 +641,67 @@ describe("real PATH Bun validation", () => { } finally { removeTree(root); } }); + test.skipIf(process.platform === "win32")("writable original PATH directory is rejected before spawning its symlinked Bun", () => { + const root = mkdtempSync(join(tmpdir(), "ocx-path-symlink-dir-")); + try { + const trusted = join(root, "trusted"); + const writable = join(root, "writable"); + mkdirSync(trusted, { mode: 0o700 }); + mkdirSync(writable, { mode: 0o700 }); + const binary = join(trusted, "bun"); + const link = join(writable, "bun"); + writeFileSync(binary, "fixture"); + chmodSync(binary, 0o755); + chmodSync(writable, 0o777); + symlinkSync(binary, link); + let spawns = 0; + const selected = findPathBun({ + env: { PATH: writable }, + pinnedVersion: "1.4.2", + io: { + lstat: lstatSync, + readlink: readlinkSync, + realpath: realpathSync.native, + geteuid: () => process.geteuid?.(), + spawnSync: () => { spawns++; return { status: 0, stdout: "1.4.2" }; }, + }, + }); + expect(selected).toBeNull(); + expect(spawns).toBe(0); + } finally { removeTree(root); } + }); + + test.skipIf(process.platform === "win32")("trusted Bun symlink is accepted", () => { + const root = mkdtempSync(join(tmpdir(), "ocx-path-trusted-symlink-")); + try { + const directory = join(root, "bin"); + mkdirSync(directory, { mode: 0o700 }); + const binary = join(root, "bun-real"); + const link = join(directory, "bun"); + writeFileSync(binary, "fixture"); + chmodSync(binary, 0o755); + symlinkSync(binary, link); + const selected = findPathBun({ + env: { PATH: directory }, + pinnedVersion: "1.4.2", + io: { + lstat: lstatSync, + readlink: readlinkSync, + realpath: realpathSync.native, + stat: statSync, + geteuid: () => process.geteuid?.(), + access: () => {}, + isRealBunBinary: () => true, + spawnSync: (_path, args) => ({ + status: 0, + stdout: args[0] === "--version" ? "1.4.2" : "1.4.2", + }), + }, + }); + expect(selected).toEqual({ path: realpathSync.native(binary), version: "1.4.2" }); + } finally { removeTree(root); } + }); + test("real PATH validation rejects the small stub and accepts the complete executable", () => { const root = mkdtempSync(join(tmpdir(), "ocx-path-bun-")); try { diff --git a/tests/codex-integration/active-turn-disconnect.test.ts b/tests/codex-integration/active-turn-disconnect.test.ts index 12eeff30283..2cfa526b09d 100644 --- a/tests/codex-integration/active-turn-disconnect.test.ts +++ b/tests/codex-integration/active-turn-disconnect.test.ts @@ -114,17 +114,34 @@ async function disconnectedTurn(protocol: Protocol, phase: Phase) { await until(() => getActiveTurnCount() === before.turns + 1, "HTTP admitted"); client.destroy(); } - await until(() => getActiveTurnCount() === before.turns && sessionLaneMetrics().active === before.lanes, `${protocol}/${phase} released`); + if (phase === "selection") { + await until(() => capturedSignal?.aborted === true, `${protocol}/${phase} cancelled`); + expect(getActiveTurnCount()).toBe(before.turns + 1); + expect(sessionLaneMetrics().active).toBe(before.lanes + 1); + if (protocol === "responses-http") expect(workflowBudgetSnapshot(root)?.active ?? 0).toBeGreaterThan(0); + expect(upstreamRequests).toBe(0); + } else { + await until(() => getActiveTurnCount() === before.turns && sessionLaneMetrics().active === before.lanes, `${protocol}/${phase} released`); + } console.log(JSON.stringify({ protocol, phase, before, after: { turns: getActiveTurnCount(), lanes: sessionLaneMetrics().active }, upstreamRequests, clientSignalAborted: capturedSignal?.aborted })); - expect(getActiveTurnCount()).toBe(before.turns); - expect(sessionLaneMetrics().active).toBe(before.lanes); - if (protocol !== "responses-ws") expect(workflowBudgetSnapshot(root)?.active ?? 0).toBe(0); + if (phase === "selection") { + expect(getActiveTurnCount()).toBe(before.turns + 1); + expect(sessionLaneMetrics().active).toBe(before.lanes + 1); + if (protocol === "responses-http") expect(workflowBudgetSnapshot(root)?.active ?? 0).toBeGreaterThan(0); + } else { + expect(getActiveTurnCount()).toBe(before.turns); + expect(sessionLaneMetrics().active).toBe(before.lanes); + if (protocol !== "responses-ws") expect(workflowBudgetSnapshot(root)?.active ?? 0).toBe(0); + } } catch (error) { console.log(JSON.stringify({ protocol, phase, before, after: { turns: getActiveTurnCount(), lanes: sessionLaneMetrics().active }, upstreamRequests, clientSignalAborted: capturedSignal?.aborted })); throw error; } finally { closeClient?.(); unblock.resolve(); + if (phase === "selection") { + await until(() => getActiveTurnCount() === before.turns && sessionLaneMetrics().active === before.lanes, `${protocol}/${phase} finalized`); + } prepare.mockRestore(); abortAndReleaseAllTurns(new Error("fixture teardown")); await server.stop(true); @@ -140,11 +157,11 @@ for (const protocol of ["responses-http", "responses-ws", "messages"] as const) } } for (const protocol of ["responses-http", "responses-ws"] as const) { - test(`${protocol} releases while account selection stays pending`, () => disconnectedTurn(protocol, "selection"), 15_000); + test(`${protocol} retains admission while account selection stays pending`, () => disconnectedTurn(protocol, "selection"), 15_000); } test("compact releases its lane after disconnect before upstream headers", () => disconnectedTurn("compact", "silent"), 15_000); -test("an aborted registered controller releases once while its work stays pending", () => { +test("an aborted registered controller retains ownership while its work stays pending", () => { const before = activeRegistryMetrics().activeTurns; const laneBefore = sessionLaneMetrics().active; const lease = tryAdmitTurn("fixture-controller")!; @@ -154,23 +171,26 @@ test("an aborted registered controller releases once while its work stays pendin lease.bindAbortController(ac); try { ac.abort(new Error("fixture client closed")); - expect(getActiveTurnCount()).toBe(before.active); - expect(sessionLaneMetrics().active).toBe(laneBefore); - expect(released).toBe(1); + expect(getActiveTurnCount()).toBe(before.active + 1); + expect(sessionLaneMetrics().active).toBe(laneBefore + 1); + expect(released).toBe(0); unregisterTurn(ac); lease.release(); + expect(getActiveTurnCount()).toBe(before.active); + expect(sessionLaneMetrics().active).toBe(laneBefore); expect(released).toBe(1); expect(activeRegistryMetrics().activeTurns.releaseMisses).toBe(before.releaseMisses); } finally { lease.release(); } }); -test("ingress cancellation releases pending selection and all bound controllers", () => { +test("ingress cancellation retains admission until pending selection unwinds", () => { const before = getActiveTurnCount(); const nativeBefore = getNativeMainProfileRequestCount(); const client = new AbortController(); const lease = tryAdmitTurn("fixture-selection", client.signal)!; const selection = lease.beginCodexAccountSelection(); + const duringSelection = getNativeMainProfileRequestCount(); const first = new AbortController(); const second = new AbortController(); lease.bindAbortController(first); @@ -178,8 +198,8 @@ test("ingress cancellation releases pending selection and all bound controllers" lease.bindAbortController(first); const reason = new Error("fixture ingress closed"); client.abort(reason); - expect(getActiveTurnCount()).toBe(before); - expect(getNativeMainProfileRequestCount()).toBe(nativeBefore); + expect(getActiveTurnCount()).toBe(before + 1); + expect(getNativeMainProfileRequestCount()).toBe(duringSelection); expect(first.signal.reason).toBe(reason); expect(second.signal.reason).toBe(reason); expect(selection.claimMainProfile()).toBe(false); @@ -194,18 +214,15 @@ test("ingress cancellation releases pending selection and all bound controllers" expect(released).toBe(1); }); -test("already aborted ingress cannot retain admission or selection", () => { +test("already aborted ingress cannot acquire admission or selection", () => { const before = getActiveTurnCount(); const nativeBefore = getNativeMainProfileRequestCount(); const client = new AbortController(); client.abort(new Error("fixture already closed")); - const lease = tryAdmitTurn("fixture-pre-aborted", client.signal)!; + const lease = tryAdmitTurn("fixture-pre-aborted", client.signal); expect(getActiveTurnCount()).toBe(before); - const selection = lease.beginCodexAccountSelection(); - expect(selection.claimMainProfile()).toBe(false); + expect(lease).toBeNull(); expect(getNativeMainProfileRequestCount()).toBe(nativeBefore); - selection.release(); - lease.release(); }); test("normal completion detaches cancellation listeners", () => { @@ -222,7 +239,7 @@ test("normal completion detaches cancellation listeners", () => { }); -test("internal decision cancellation releases admission while dispatch remains pending", async () => { +test("internal decision cancellation retains admission while dispatch remains pending", async () => { const before = getActiveTurnCount(); const entered = deferred(); const unblock = deferred(); @@ -239,7 +256,7 @@ test("internal decision cancellation releases admission while dispatch remains p expect(getActiveTurnCount()).toBe(before + 1); try { client.abort(new Error("fixture decision closed")); - expect(getActiveTurnCount()).toBe(before); + expect(getActiveTurnCount()).toBe(before + 1); } finally { unblock.resolve(new Response(null, { status: 499 })); await settled; @@ -247,7 +264,7 @@ test("internal decision cancellation releases admission while dispatch remains p }); -test("Live upgrade disconnect releases admission while resolution remains pending", async () => { +test("Live upgrade disconnect retains admission while resolution remains pending", async () => { const previousHome = process.env.OPENCODEX_HOME; const home = mkdtempSync(join(tmpdir(), "ocx-upgrade-disconnect-")); process.env.OPENCODEX_HOME = home; @@ -269,7 +286,9 @@ test("Live upgrade disconnect releases admission while resolution remains pendin await entered.promise; expect(getActiveTurnCount()).toBe(before + 1); socket.close(); - await until(() => getActiveTurnCount() === before, "Live upgrade admission released"); + expect(getActiveTurnCount()).toBe(before + 1); + unblock.resolve(new Response(null, { status: 499 })); + await until(() => getActiveTurnCount() === before, "Live upgrade admission finalized"); } finally { unblock.resolve(new Response(null, { status: 499 })); resolve.mockRestore(); @@ -295,5 +314,7 @@ test("upgrade transfer detaches ingress cancellation and keeps connection owners expect(getActiveTurnCount()).toBe(before + 1); expect(connection.signal.aborted).toBe(false); connection.abort(new Error("fixture socket closed")); + expect(getActiveTurnCount()).toBe(before + 1); + lease.release(); expect(getActiveTurnCount()).toBe(before); }); diff --git a/tests/codex-integration/codex-journal.test.ts b/tests/codex-integration/codex-journal.test.ts index 4af3e35ab70..4125e429602 100644 --- a/tests/codex-integration/codex-journal.test.ts +++ b/tests/codex-integration/codex-journal.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test, beforeEach, afterEach, setDefaultTimeout } from "bun:test"; import { createHash } from "node:crypto"; -import { mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync } from "node:fs"; +import { mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync, symlinkSync } from "node:fs"; import { spawnSync } from "node:child_process"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; @@ -420,6 +420,32 @@ describe("codex-journal", () => { expect(existsSync(join(testDir, "opencodex.config.toml"))).toBe(false); }); + test.skipIf(process.platform === "win32")("removeCodexConfig refuses a symlinked profile without changing its target or config", () => { + const configPath = join(testDir, "config.toml"); + const profilePath = join(testDir, "opencodex.config.toml"); + const targetPath = join(testDir, "external-profile.toml"); + const journalPath = join(testDir, "opencodex-journal.json"); + const config = '# Auto-injected by opencodex\nopenai_base_url = "http://127.0.0.1:10100/v1"\n'; + const target = 'model_provider = "openai"\nmodel = "external-profile"\n'; + const journal = '{"recovery":"sentinel"}\n'; + writeFileSync(configPath, config, "utf8"); + writeFileSync(targetPath, target, "utf8"); + writeFileSync(journalPath, journal, "utf8"); + symlinkSync(targetPath, profilePath, "file"); + + const r = runScript(testDir, ` + const { removeCodexConfig } = require("./src/codex/inject"); + console.log(JSON.stringify(removeCodexConfig())); + `); + + expect(r.status).toBe(0); + expect(JSON.parse(r.stdout).success).toBe(false); + expect(readFileSync(configPath, "utf8")).toBe(config); + expect(readFileSync(targetPath, "utf8")).toBe(target); + expect(readFileSync(journalPath, "utf8")).toBe(journal); + expect(existsSync(profilePath)).toBe(true); + }); + test("removeCodexConfig reports damaged managed-default cleanup and preserves the ambiguous value", () => { writeFileSync(join(testDir, "config.toml"), [ "# Auto-injected by opencodex", diff --git a/tests/codex-integration/native-main-owner-lifetime.test.ts b/tests/codex-integration/native-main-owner-lifetime.test.ts index 018afa750ad..9125e9a4536 100644 --- a/tests/codex-integration/native-main-owner-lifetime.test.ts +++ b/tests/codex-integration/native-main-owner-lifetime.test.ts @@ -461,6 +461,13 @@ describe("native-main process owner lease", () => { expect((direct.lastReceipt as { authorization?: string }).authorization).toBe("Bearer direct-test-key"); expect((await support.command("request", { kind: "health" })).status).toBe(200); expect((await support.command("request", { kind: "management-list" })).status).toBe(200); + const restartState = await support.command("request", { kind: "management-restart-state" }); + expect(restartState).toMatchObject({ status: 200 }); + expect(restartState.text).toContain('"state":"unknown"'); + const restart = await support.command("request", { kind: "management-restart" }); + expect(restart.status).toBe(200); + expect(restart.text).toContain('"success":false'); + expect(restart.text).toContain('"code":"enumeration_unavailable"'); await support.stop(); const before = await second.command("snapshot"); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 3da23ddfff2..c7f8c2e8881 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1,4 +1,6 @@ { + "bounded-file-read.test.ts": "lib", + "active-turn-cancellation-retention.test.ts": "server", "messages-request-id-headers.test.ts": "claude-integration", "messages-request-id-endpoint.test.ts": "claude-integration", "anthropic-revoked-token.test.ts": "adapters/anthropic", diff --git a/tests/helpers/native-main-owner-child.ts b/tests/helpers/native-main-owner-child.ts index b70db3dde04..d9b32e51a72 100644 --- a/tests/helpers/native-main-owner-child.ts +++ b/tests/helpers/native-main-owner-child.ts @@ -137,7 +137,22 @@ function start(home = codexHome): ReturnType { } : undefined, }, - managementApi: { nativeProfileApi: { manager } }, + managementApi: { + nativeProfileApi: { manager }, + codexRestartService: { + readState: async () => ({ state: "unknown", runningCount: 0 }), + performRestart: async () => ({ + success: false, + stateBefore: "unknown", + synced: false, + requested: [], + stopped: [], + surviving: [], + failed: [], + code: "enumeration_unavailable", + }), + }, + }, }); servers.push(server); return server; @@ -168,6 +183,13 @@ async function request(port: number, kind: string): Promise<{ status: number; te }); return { status: response.status, text: await response.text() }; } + if (kind === "management-restart-state" || kind === "management-restart") { + const response = await fetch(`http://127.0.0.1:${port}/api/system/${kind === "management-restart-state" ? "codex-app-server" : "codex-restart"}`, { + method: kind === "management-restart" ? "POST" : "GET", + headers: { "x-opencodex-api-key": process.env.OPENCODEX_ADMIN_AUTH_TOKEN ?? "" }, + }); + return { status: response.status, text: await response.text() }; + } if (kind === "direct") { const response = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, { method: "POST", diff --git a/tests/lib/bounded-file-read.test.ts b/tests/lib/bounded-file-read.test.ts new file mode 100644 index 00000000000..cc05e2f9f3d --- /dev/null +++ b/tests/lib/bounded-file-read.test.ts @@ -0,0 +1,53 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { repoPath } from "../helpers/repo-root"; +import { readBoundedRegularFileSync } from "../../src/lib/bounded-file-read"; + +const repoRoot = repoPath(); +let root = ""; + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "ocx-bounded-read-")); +}); + +afterEach(() => { + rmSync(root, { recursive: true, force: true }); +}); + +describe("readBoundedRegularFileSync", () => { + test("reads a bounded regular file", () => { + const path = join(root, "config.json"); + writeFileSync(path, '{"ok":true}'); + expect(readBoundedRegularFileSync(path, 32).toString("utf8")).toBe(readFileSync(path, "utf8")); + }); + + test("rejects oversized files and final symlinks", () => { + const path = join(root, "large.json"); + writeFileSync(path, "x".repeat(33)); + expect(() => readBoundedRegularFileSync(path, 32)).toThrow(); + + const link = join(root, "linked.json"); + symlinkSync(path, link, "file"); + expect(() => readBoundedRegularFileSync(link, 64)).toThrow(); + }); + + test.skipIf(process.platform === "win32")("rejects a FIFO without blocking", () => { + const fifo = join(root, "input.pipe"); + const created = spawnSync("mkfifo", [fifo], { timeout: 2_000 }); + expect(created.status).toBe(0); + const result = spawnSync(process.execPath, ["--eval", ` + const { readBoundedRegularFileSync } = require("./src/lib/bounded-file-read.ts"); + try { readBoundedRegularFileSync(process.env.BOUNDED_READ_FIFO, 1024); process.exitCode = 2; } + catch { process.exitCode = 0; } + `], { + cwd: repoRoot, + env: { ...process.env, BOUNDED_READ_FIFO: fifo }, + timeout: 2_000, + }); + expect(result.error).toBeUndefined(); + expect(result.status).toBe(0); + }); +}); diff --git a/tests/providers/github-copilot/github-copilot-auto-runtime.test.ts b/tests/providers/github-copilot/github-copilot-auto-runtime.test.ts index 824f0e31fcd..8f893931ee8 100644 --- a/tests/providers/github-copilot/github-copilot-auto-runtime.test.ts +++ b/tests/providers/github-copilot/github-copilot-auto-runtime.test.ts @@ -41,6 +41,11 @@ function request(input: unknown, stream = true): Request { body: JSON.stringify({ model: "github-copilot/auto", input, stream, tools: [{ type: "function", name: "lookup", parameters: { type: "object", properties: { name: { type: "string" } } } }] }) }); } + +function scopedAdmission(config: OcxConfig, allowedModels: string[]) { + config.apiKeys = [{ id: "copilot-scope", name: "fixture", allowedModels }] as OcxConfig["apiKeys"]; + return { kind: "configured" as const, keyId: "copilot-scope", source: "bearer" as const }; +} /** Seed two synthetic OAuth accounts in the test-owned home, select A, and return store IDs for rotation assertions. */ async function accounts() { for (const account of ["a", "b"]) await saveCredential("github-copilot", { @@ -54,7 +59,7 @@ async function accounts() { return { a, b }; } /** Return an Auto provider config and send ledger; options drive refusal, expiry, rotation, and tool events through both inference wires. */ -function fixture(options: { nativeExpiry?: boolean; nativeFirst?: boolean; native429?: boolean; onNativeCancel?: () => void; chatRefresh?: boolean; closedNativeBody?: boolean; renewedChatRefusal?: boolean; rotate?: boolean; tool?: boolean; key?: boolean; refusal?: number; negotiationRefusal?: number; negotiationRefusalAt?: number; alwaysRefuse?: boolean; advanceAfterIntent?: () => void; onSession?: (count: number) => void; malformedSessionAt?: number; negotiationRetryAfter?: string } = {}) { +function fixture(options: { nativeExpiry?: boolean; nativeFirst?: boolean; native429?: boolean; onNativeCancel?: () => void; chatRefresh?: boolean; closedNativeBody?: boolean; renewedChatRefusal?: boolean; rotate?: boolean; tool?: boolean; key?: boolean; refusal?: number; negotiationRefusal?: number; negotiationRefusalAt?: number; alwaysRefuse?: boolean; advanceAfterIntent?: () => void | Promise; onSession?: (count: number) => void | Promise; malformedSessionAt?: number; negotiationRetryAfter?: string } = {}) { const sent: Array<{ host: string; path: string; token: string | null; body: any; signal?: AbortSignal | null }> = []; let inferenceCount = 0; let sessionCount = 0; @@ -84,7 +89,7 @@ function fixture(options: { nativeExpiry?: boolean; nativeFirst?: boolean; nativ supported_endpoints: [options.nativeExpiry ? (expiredNative ? "/chat/completions" : "/responses") : options.native429 ? (account === "a" ? "/responses" : "/chat/completions") : options.chatRefresh ? (renewed ? "/responses" : "/chat/completions") : options.nativeFirst ? (headers.get("authorization")!.includes("-renewed") ? "/chat/completions" : "/responses") : account === "a" ? "/chat/completions" : "/responses"] }] }); if (destination.pathname === "/models/session") { sessionCount++; - options.onSession?.(sessionCount); + await options.onSession?.(sessionCount); if (sessionCount === options.malformedSessionAt) return Response.json({ session_token: "must-not-echo" }); if (options.negotiationRefusal && (sessionCount === (options.negotiationRefusalAt ?? 1) || (options.alwaysRefuse && sessionCount >= (options.negotiationRefusalAt ?? 1)))) return Response.json({ token: "must-not-echo" }, { status: options.negotiationRefusal, headers: { "retry-after": options.negotiationRetryAfter ?? "12" } }); @@ -92,7 +97,7 @@ function fixture(options: { nativeExpiry?: boolean; nativeFirst?: boolean; nativ } if (destination.pathname === "/models/session/intent") { intentCount++; - if (intentCount === 1) options.advanceAfterIntent?.(); + if (intentCount === 1) await options.advanceAfterIntent?.(); return Response.json({ candidate_models: [model] }); } expect(body.model).toBe(model); @@ -427,6 +432,41 @@ describe("Copilot Auto through the Responses pipeline", () => { expect(await response.text()).not.toContain("must-not-echo"); expect(sent.filter(call => call.path === "/chat/completions")).toHaveLength(0); }); + test("Auto negotiation is denied when its selected model is outside the admission scope", async () => { + await accounts(); + const { config, sent } = fixture(); + const admission = scopedAdmission(config, ["github-copilot/auto"]); + const response = await handleResponses(request("hello"), config, { model: "", provider: "" }, { admission }); + + expect(response.status).toBe(403); + const body = await response.text(); + expect(body).toContain("model_not_allowed_for_key"); + expect(body).not.toContain("gpt-4o"); + expect(sent.filter(call => ["/chat/completions", "/responses"].includes(call.path))).toHaveLength(0); + }); + test("Auto negotiation dispatches a model admitted by scope", async () => { + await accounts(); + const { config, sent } = fixture(); + const admission = scopedAdmission(config, ["github-copilot/auto", "gpt-4o"]); + const response = await handleResponses(request("hello"), config, { model: "", provider: "" }, { admission }); + + expect(response.status).toBe(200); + expect(sent.filter(call => call.path === "/chat/completions").map(call => call.body.model)).toEqual(["gpt-4o"]); + }); + test("dispatch-retried Auto selection outside admission scope returns 403", async () => { + const { b } = await accounts(); + const { config, sent } = fixture({ + advanceAfterIntent: async () => { await setActiveAccount("github-copilot", b); }, + }); + const admission = scopedAdmission(config, ["github-copilot/auto", "gpt-4o"]); + const response = await handleResponses(request("hello", false), config, { model: "", provider: "" }, { admission }); + + expect(response.status).toBe(403); + const body = await response.text(); + expect(body).toContain("model_not_allowed_for_key"); + expect(body).not.toContain("gpt-5.4"); + expect(sent.filter(call => ["/chat/completions", "/responses"].includes(call.path))).toHaveLength(0); + }); test.each([401, 429])("key pool %s rotation reacquires a new session and endpoint", async status => { const { config, sent } = fixture({ key: true, rotate: true, refusal: status }); await saveConfig(config); @@ -617,5 +657,3 @@ for (const bDiscovery of ["named", "outage"] as const) test(`leaving Auto on acc expect(named.token).toBeNull(); expect(named.apiVersion).not.toBe("2026-08-01"); }); - - diff --git a/tests/responses/responses-undeclared-tool-guard.test.ts b/tests/responses/responses-undeclared-tool-guard.test.ts index 99a50b2b1a7..dad43811b7a 100644 --- a/tests/responses/responses-undeclared-tool-guard.test.ts +++ b/tests/responses/responses-undeclared-tool-guard.test.ts @@ -1375,6 +1375,7 @@ describe("empty and absent tool catalogs", () => { model = "fixture/deepseek-v4-flash", previousResponseId?: string, toolChoice?: unknown, + inboundWire?: "chat" | "anthropic", ) { const savedFetch = globalThis.fetch; globalThis.fetch = (async () => upstream()) as typeof fetch; @@ -1397,7 +1398,7 @@ describe("empty and absent tool catalogs", () => { ...(tools === undefined ? {} : { tools }), ...(toolChoice === undefined ? {} : { tool_choice: toolChoice }), }), - }), requestConfig, { model: "", provider: "" }); + }), requestConfig, { model: "", provider: "" }, { inboundWire }); } finally { globalThis.fetch = savedFetch; } @@ -1437,6 +1438,42 @@ describe("empty and absent tool catalogs", () => { expect(body).toContain("response.completed"); }); + for (const inboundWire of ["chat", "anthropic"] as const) { + for (const stream of [false, true]) { + test(`${inboundWire} passthrough refuses undeclared Write calls (${stream ? "streaming" : "JSON"})`, async () => { + const writeCall = { ...call, name: "Write" }; + const upstream = stream + ? () => new Response([ + frame("response.output_item.added", { output_index: 0, item: writeCall }), + frame("response.completed", { response: { id: "resp_1", status: "completed", output: [writeCall] } }), + ].join("\n\n") + "\n\n", { headers: { "content-type": "text/event-stream" } }) + : () => new Response( + JSON.stringify({ id: "resp_1", status: "completed", output: [writeCall] }), + { headers: { "content-type": "application/json" } }, + ); + const response = await post( + stream, + [{ type: "function", name: "Read", parameters: { type: "object" } }], + upstream, + undefined, + config, + [], + "fixture/deepseek-v4-flash", + undefined, + undefined, + inboundWire, + ); + + if (stream) { + expect(await response.text()).toContain(UNDECLARED_TOOL_CALL_ERROR_CODE); + } else { + expect(response.status).toBe(502); + expect(await response.text()).toContain("undeclared"); + } + }); + } + } + test("non-streaming, an unreadable top-level catalog — relayed, not refused", async () => { const response = await post(false, [{ type: "function" }], jsonUpstream); diff --git a/tests/server/active-turn-cancellation-retention.test.ts b/tests/server/active-turn-cancellation-retention.test.ts new file mode 100644 index 00000000000..ef81f632eda --- /dev/null +++ b/tests/server/active-turn-cancellation-retention.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from "bun:test"; +import { getActiveTurnCount, tryAdmitTurn } from "../../src/server/lifecycle"; + +describe("active-turn cancellation ownership", () => { + test("retains saturated admission until stalled work settles without aborting sibling work", () => { + const leases = []; + try { + for (let index = 0; index < 512; index += 1) { + const lease = tryAdmitTurn(); + if (!lease) break; + leases.push(lease); + } + expect(leases.length).toBeGreaterThan(1); + const cancelled = leases[0]!; + const sibling = leases[1]!; + const cancellation = new AbortController(); + const work = new AbortController(); + const siblingWork = new AbortController(); + cancelled.bindAbortSignal(cancellation.signal); + cancelled.bindAbortController(work); + sibling.bindAbortController(siblingWork); + + cancellation.abort(new Error("client disconnected")); + + expect(work.signal.aborted).toBe(true); + expect(siblingWork.signal.aborted).toBe(false); + expect(getActiveTurnCount()).toBe(leases.length); + expect(tryAdmitTurn()).toBeNull(); + + const lateWork = new AbortController(); + cancelled.bindAbortController(lateWork); + expect(lateWork.signal.aborted).toBe(true); + const selection = cancelled.beginCodexAccountSelection(); + expect(selection.claimMainProfile()).toBe(false); + selection.release(); + } finally { + for (const lease of leases) lease.release(); + } + }); +});