diff --git a/packages/adapter-utils/src/acpx-engine/execute-identity.test.ts b/packages/adapter-utils/src/acpx-engine/execute-identity.test.ts index b182d61242..365f49cff7 100644 --- a/packages/adapter-utils/src/acpx-engine/execute-identity.test.ts +++ b/packages/adapter-utils/src/acpx-engine/execute-identity.test.ts @@ -7,7 +7,11 @@ // never runs a synthetic value. import { describe, expect, it } from "vitest"; -import { buildSessionFingerprint, finalizeLaunchEnvironment } from "./execute.js"; +import { + buildSessionFingerprint, + finalizeLaunchEnvironment, + projectAcpxInheritedHostEnvironment, +} from "./execute.js"; import type { LaunchEnvironment, RunScopedContribution, @@ -58,9 +62,15 @@ describe("acpx identity split and launch environment", () => { it("test_finalize_launch_environment_is_sole_constructor_of_branded_environment", () => { // The finalizer merges every contribution and freezes the launch env. const baseEnv: Record = { LAUNCH_ENV_TEST_BASE: "base" }; - const launchEnvironment = finalizeLaunchEnvironment(baseEnv, [ - { scope: "session", env: { LAUNCH_ENV_TEST_CONTRIB: "contrib" } }, - ]); + const launchEnvironment = finalizeLaunchEnvironment( + baseEnv, + [{ scope: "session", env: { LAUNCH_ENV_TEST_CONTRIB: "contrib" } }], + { + acpxAgent: "claude", + inheritHostEnvironment: true, + inheritedEnv: {}, + }, + ); expect(launchEnvironment.env.LAUNCH_ENV_TEST_BASE).toBe("base"); expect(launchEnvironment.env.LAUNCH_ENV_TEST_CONTRIB).toBe("contrib"); expect(Object.isFrozen(launchEnvironment.env)).toBe(true); @@ -92,4 +102,162 @@ describe("acpx identity split and launch environment", () => { void _assertNotReusable; expect(true).toBe(true); }); + + it("inherits only safe host context and the selected provider's credentials", () => { + const inherited = { + PATH: "/usr/bin", + LC_ALL: "C.UTF-8", + HTTPS_PROXY: "https://proxy.example", + OPENAI_API_KEY: "openai-host-secret", + ANTHROPIC_API_KEY: "anthropic-host-secret", + ANTHROPIC_AUTH_TOKEN: "anthropic-auth-host-secret", + CLAUDE_CODE_OAUTH_TOKEN: "claude-oauth-host-secret", + ANTHROPIC_BASE_URL: "https://anthropic.example", + ANTHROPIC_MODEL: "claude-test", + ANTHROPIC_SMALL_FAST_MODEL: "claude-fast-test", + CLAUDE_CONFIG_DIR: "/host/claude", + CLAUDE_CODE_USE_BEDROCK: "true", + ANTHROPIC_BEDROCK_BASE_URL: "https://bedrock.example", + AWS_BEARER_TOKEN_BEDROCK: "bedrock-host-secret", + OPENROUTER_API_KEY: "openrouter-host-secret", + GOOGLE_GENAI_USE_GCA: "true", + KIMI_MODEL_NAME: "kimi-code/test", + KIMI_MODEL_API_KEY: "kimi-host-secret", + KIMI_MODEL_BASE_URL: "https://kimi.example", + KIMI_MODEL_PROVIDER_TYPE: "openai_legacy", + KIMI_CODE_HOME: "/host/kimi", + PAPERCLIP_NATIVE_MCP_TOKEN: "native-mcp-host-secret", + PAPERCLIP_ACPX_CODEX_AUTH_JSON_SECRET: "managed-auth-host-secret", + UNRELATED_SECRET: "unrelated-host-secret", + NODE_OPTIONS: "--require /tmp/host-hook.cjs", + }; + + expect(projectAcpxInheritedHostEnvironment(inherited, "codex", true)).toEqual({ + PATH: "/usr/bin", + LC_ALL: "C.UTF-8", + HTTPS_PROXY: "https://proxy.example", + OPENAI_API_KEY: "openai-host-secret", + }); + expect(projectAcpxInheritedHostEnvironment(inherited, "claude", true)).toEqual({ + PATH: "/usr/bin", + LC_ALL: "C.UTF-8", + HTTPS_PROXY: "https://proxy.example", + ANTHROPIC_API_KEY: "anthropic-host-secret", + ANTHROPIC_AUTH_TOKEN: "anthropic-auth-host-secret", + CLAUDE_CODE_OAUTH_TOKEN: "claude-oauth-host-secret", + ANTHROPIC_BASE_URL: "https://anthropic.example", + ANTHROPIC_MODEL: "claude-test", + ANTHROPIC_SMALL_FAST_MODEL: "claude-fast-test", + CLAUDE_CONFIG_DIR: "/host/claude", + CLAUDE_CODE_USE_BEDROCK: "true", + ANTHROPIC_BEDROCK_BASE_URL: "https://bedrock.example", + AWS_BEARER_TOKEN_BEDROCK: "bedrock-host-secret", + }); + expect(projectAcpxInheritedHostEnvironment(inherited, "pi", true)).toEqual({ + PATH: "/usr/bin", + LC_ALL: "C.UTF-8", + HTTPS_PROXY: "https://proxy.example", + OPENROUTER_API_KEY: "openrouter-host-secret", + }); + expect(projectAcpxInheritedHostEnvironment(inherited, "gemini", true)).toEqual({ + PATH: "/usr/bin", + LC_ALL: "C.UTF-8", + HTTPS_PROXY: "https://proxy.example", + GOOGLE_GENAI_USE_GCA: "true", + }); + expect(projectAcpxInheritedHostEnvironment(inherited, "kimi", true)).toEqual({ + PATH: "/usr/bin", + LC_ALL: "C.UTF-8", + HTTPS_PROXY: "https://proxy.example", + KIMI_MODEL_NAME: "kimi-code/test", + KIMI_MODEL_API_KEY: "kimi-host-secret", + KIMI_MODEL_BASE_URL: "https://kimi.example", + KIMI_MODEL_PROVIDER_TYPE: "openai_legacy", + KIMI_CODE_HOME: "/host/kimi", + }); + }); + + it("does not project any ambient host environment across a remote boundary", () => { + const inherited = { + PATH: "/host/bin", + OPENAI_API_KEY: "ambient-provider-secret", + PAPERCLIP_NATIVE_MCP_TOKEN: "ambient-native-mcp-secret", + PAPERCLIP_RUNNER_BOOTSTRAP_TICKET: "ambient-bootstrap-secret", + }; + + expect(projectAcpxInheritedHostEnvironment(inherited, "codex", false)).toEqual({}); + }); + + it("keeps explicit remote adapter and run contributions while rejecting ambient authority", () => { + const launchEnvironment = finalizeLaunchEnvironment( + { + OPENAI_API_KEY: "explicit-provider-secret", + EXPLICIT_ADAPTER_SECRET: "adapter-secret", + PAPERCLIP_RUNTIME_API_URL: "http://paperclip.internal/api", + }, + [ + { + scope: "session", + env: { PAPERCLIP_NATIVE_MCP_TOKEN: "explicit-run-contribution" }, + }, + ], + { + acpxAgent: "codex", + inheritHostEnvironment: false, + inheritedEnv: { + PATH: "/host/bin", + OPENAI_API_KEY: "ambient-provider-secret", + PAPERCLIP_NATIVE_MCP_TOKEN: "ambient-native-mcp-secret", + PAPERCLIP_RUNNER_BOOTSTRAP_TICKET: "ambient-bootstrap-secret", + }, + }, + ); + + expect(launchEnvironment.env).toMatchObject({ + OPENAI_API_KEY: "explicit-provider-secret", + EXPLICIT_ADAPTER_SECRET: "adapter-secret", + PAPERCLIP_RUNTIME_API_URL: "http://paperclip.internal/api", + PAPERCLIP_NATIVE_MCP_TOKEN: "explicit-run-contribution", + }); + expect(launchEnvironment.env).not.toHaveProperty("PATH"); + expect(launchEnvironment.env).not.toHaveProperty("PAPERCLIP_RUNNER_BOOTSTRAP_TICKET"); + }); + + it("preserves an explicit remote PATH instead of synthesizing a host fallback", () => { + const launchEnvironment = finalizeLaunchEnvironment( + { PATH: "/sandbox/bin" }, + [], + { + acpxAgent: "codex", + inheritHostEnvironment: false, + inheritedEnv: { PATH: "/host/bin" }, + }, + ); + + expect(launchEnvironment.env).toEqual({ PATH: "/sandbox/bin" }); + }); + + it("lets explicit Windows env values suppress differently-cased ambient values", () => { + const launchEnvironment = finalizeLaunchEnvironment( + { + openai_api_key: "", + Path: "C:\\explicit\\bin", + }, + [], + { + acpxAgent: "codex", + inheritHostEnvironment: true, + inheritedEnv: { + OPENAI_API_KEY: "ambient-provider-secret", + PATH: "C:\\ambient\\bin", + }, + platform: "win32", + }, + ); + + expect(launchEnvironment.env).toEqual({ + openai_api_key: "", + Path: "C:\\explicit\\bin", + }); + }); }); diff --git a/packages/adapter-utils/src/acpx-engine/execute.ts b/packages/adapter-utils/src/acpx-engine/execute.ts index 6ae17d80f1..c1de4487a3 100644 --- a/packages/adapter-utils/src/acpx-engine/execute.ts +++ b/packages/adapter-utils/src/acpx-engine/execute.ts @@ -505,6 +505,118 @@ export function buildSessionKey(identity: SessionKeyIdentity, fingerprint: strin return `paperclip:${identity.companyId}:${identity.agentId}:${identity.taskKey}:${fingerprint}`; } +// ACPX runs inside the long-lived Paperclip server process. A local child needs +// a small amount of host context (PATH, locale, certificate/proxy settings, and +// provider authentication), but it must not inherit the server's complete +// environment. A runner-backed remote sandbox inherits no ambient host context +// at all. In particular, native-runner bootstrap and MCP credentials are host +// authority, not provider credentials. +const ACPX_INHERITED_HOST_ENV_KEYS = new Set([ + "PATH", + "PATHEXT", + "SYSTEMROOT", + "WINDIR", + "COMSPEC", + "HOME", + "USERPROFILE", + "HOMEDRIVE", + "HOMEPATH", + "USER", + "USERNAME", + "LOGNAME", + "SHELL", + "LANG", + "LANGUAGE", + "TZ", + "TMPDIR", + "TEMP", + "TMP", + "XDG_CONFIG_HOME", + "XDG_CACHE_HOME", + "XDG_DATA_HOME", + "SSL_CERT_FILE", + "SSL_CERT_DIR", + "NODE_EXTRA_CA_CERTS", + "HTTP_PROXY", + "HTTPS_PROXY", + "NO_PROXY", + "ALL_PROXY", +]); + +const ACPX_INHERITED_PROVIDER_ENV_KEYS: Readonly>> = { + codex: new Set([ + "OPENAI_API_KEY", + "CODEX_API_KEY", + ]), + claude: new Set([ + "ANTHROPIC_API_KEY", + "ANTHROPIC_AUTH_TOKEN", + "CLAUDE_CODE_OAUTH_TOKEN", + "ANTHROPIC_BASE_URL", + "ANTHROPIC_MODEL", + "ANTHROPIC_SMALL_FAST_MODEL", + "CLAUDE_CONFIG_DIR", + "CLAUDE_CODE_USE_BEDROCK", + "ANTHROPIC_BEDROCK_BASE_URL", + "AWS_BEARER_TOKEN_BEDROCK", + "AWS_ACCESS_KEY_ID", + "AWS_SECRET_ACCESS_KEY", + "AWS_SESSION_TOKEN", + "AWS_REGION", + "AWS_DEFAULT_REGION", + "AWS_PROFILE", + "AWS_CONFIG_FILE", + "AWS_SHARED_CREDENTIALS_FILE", + ]), + pi: new Set(["OPENROUTER_API_KEY"]), + gemini: new Set([ + "GEMINI_API_KEY", + "GOOGLE_API_KEY", + "GOOGLE_APPLICATION_CREDENTIALS", + "GOOGLE_GENAI_USE_GCA", + ]), + kimi: new Set([ + "KIMI_API_KEY", + "MOONSHOT_API_KEY", + "KIMI_MODEL_NAME", + "KIMI_MODEL_API_KEY", + "KIMI_MODEL_BASE_URL", + "KIMI_MODEL_PROVIDER_TYPE", + "KIMI_CODE_HOME", + ]), + grok: new Set(["XAI_API_KEY"]), +}; + +/** + * Project the server environment onto the closed set a host ACPX provider may + * inherit. A runner-backed remote sandbox passes `false` and projects nothing. + * Explicit adapter/runtime env is merged later and is intentionally not + * restricted by this host projection. + */ +export function projectAcpxInheritedHostEnvironment( + inheritedEnv: NodeJS.ProcessEnv, + acpxAgent: string, + inheritHostEnvironment: boolean, +): Record { + // A runner-backed remote sandbox crosses a serialization boundary. Ambient + // server state is never part of that contract: provider auth/config must be + // supplied through adapter config, resolved runtime env, or a contribution. + if (!inheritHostEnvironment) return {}; + + const providerKeys = ACPX_INHERITED_PROVIDER_ENV_KEYS[acpxAgent]; + const projected: Record = {}; + for (const [key, value] of Object.entries(inheritedEnv)) { + if (typeof value !== "string") continue; + const normalizedKey = key.toUpperCase(); + const allowed = + ACPX_INHERITED_HOST_ENV_KEYS.has(normalizedKey) || + /^LC_[A-Z0-9_]{1,32}$/.test(normalizedKey) || + providerKeys?.has(normalizedKey) === true; + if (allowed) projected[key] = value; + } + return projected; +} + /** * Build the single branded launch environment for a run. This is the sole * constructor of `LaunchEnvironment`. It applies each contribution into the base @@ -519,11 +631,19 @@ export function buildSessionKey(identity: SessionKeyIdentity, fingerprint: strin export function finalizeLaunchEnvironment( baseEnv: Record, contributions: readonly LaunchEnvironmentContribution[], + options: { + acpxAgent: string; + inheritHostEnvironment: boolean; + inheritedEnv?: NodeJS.ProcessEnv; + platform?: typeof process.platform; + }, ): LaunchEnvironment { for (const contribution of contributions) { Object.assign(baseEnv, contribution.env); } - const env = Object.freeze(resolveRuntimeEnv(baseEnv)); + const env = Object.freeze( + resolveRuntimeEnv(baseEnv, options.acpxAgent, options), + ); return { env } as unknown as LaunchEnvironment; } @@ -1890,10 +2010,20 @@ async function buildRuntime(input: { }); let agentCommand = configuredCommand || builtInCommand?.command || null; let agentCommandShell = configuredCommand || builtInCommand?.shellCommand || ""; + // A runner-backed remote sandbox is the only lane that crosses the staging + // and serialized-launch-env seam. Runner-less ACP→CLI fallback, SSH, and + // local runs keep their historical host-provider compatibility behavior. + const useRemoteProcessSession = + executionTarget?.kind === "remote" && + executionTarget.transport === "sandbox" && + Boolean(executionTarget.runner) && + Boolean(agentCommandShell); if (acpxAgent === "gemini" && agentCommandShell) { const normalized = await normalizeGeminiAcpCommandShell( agentCommandShell, - ensurePathInEnv({ ...process.env, ...env }), + resolveRuntimeEnv(env, acpxAgent, { + inheritHostEnvironment: !useRemoteProcessSession, + }), ); if (normalized !== agentCommandShell) { agentCommandShell = normalized; @@ -1902,15 +2032,6 @@ async function buildRuntime(input: { } const childStderrDir = path.join(stateDir, "run-stderr"); const childStderrLogPath = agentCommand ? path.join(childStderrDir, `${runId}.log`) : null; - // A runner-backed remote sandbox is the only lane that crosses the staging - // seam: the runner-less ACP→CLI fallback (no `runner`) and local runs keep - // their historical behavior untouched. This is the single gate shared by the - // workspace stage and both sandbox bridges. - const useRemoteProcessSession = - executionTarget?.kind === "remote" && - executionTarget.transport === "sandbox" && - Boolean(executionTarget.runner) && - Boolean(agentCommandShell); // Stream the agent output through the persistent session log stream instead of // the host output-file poll. The decision comes from the effective capability // snapshot alone: the provider must declare and verify incremental session @@ -2158,7 +2279,11 @@ async function buildRuntime(input: { }), measureBridgeStep: (step, run) => measureStartupStep(input.ctx, nowMs, step, run, concurrentBridgeStepMetrics), - finalizeLaunchEnv: (contributions) => finalizeLaunchEnvironment(env, contributions).env, + finalizeLaunchEnv: (contributions) => + finalizeLaunchEnvironment(env, contributions, { + acpxAgent, + inheritHostEnvironment: !useRemoteProcessSession, + }).env, onPaperclipBridgeLog: () => input.ctx.onLog("stdout", "[paperclip] Sandbox ACP API callback bridge enabled for this run.\n"), stopBridges: async ({ controlBridge, agentBridge }) => { @@ -2207,7 +2332,10 @@ async function buildRuntime(input: { // Local / runner-less lanes never start a bridge, so they add no // contribution. `finalizeLaunchEnvironment` still produces the one branded // launch env the prepared runtime and the log builder read. - runtimeEnv = finalizeLaunchEnvironment(env, []).env; + runtimeEnv = finalizeLaunchEnvironment(env, [], { + acpxAgent, + inheritHostEnvironment: !useRemoteProcessSession, + }).env; } } catch (err) { // On a partial concurrent bring-up failure, ONE bridge may have started while @@ -2270,7 +2398,7 @@ async function buildRuntime(input: { workspaceId, workspaceRepoUrl, workspaceRepoRef, - env, + env: runtimeEnv, loggedEnv, stateDir, permissionMode, @@ -2360,19 +2488,72 @@ async function applySessionConfigOptions(input: { } /** - * Build the process-session launch env: the host env overlaid with the run's - * `env` (so the merged paperclip bridge vars win) and a guaranteed `PATH`, - * narrowed to string values. Shared by the remote concurrent bring-up and the - * local / runner-less lane so both resolve the runtime env identically. + * Build the process-session launch env: the target-specific host projection + * overlaid with the run's explicit `env` (so adapter config, runtime variables, + * and bridge contributions win), narrowed to string values. Host-side launches + * get the closed projection plus a default `PATH` when the host did not provide + * one. Runner-backed remote sandbox launches get no ambient host state or + * synthesized host `PATH`; omitting `PATH` preserves the sandbox-native value. */ -function resolveRuntimeEnv(env: Record): Record { +function resolveRuntimeEnv( + env: Record, + acpxAgent: string, + options: { + inheritHostEnvironment: boolean; + inheritedEnv?: NodeJS.ProcessEnv; + platform?: typeof process.platform; + }, +): Record { + const inheritedEnv = options.inheritedEnv ?? process.env; + const projectedHostEnv = projectAcpxInheritedHostEnvironment( + inheritedEnv, + acpxAgent, + options.inheritHostEnvironment, + ); + const inheritedLaunchEnv = options.inheritHostEnvironment + ? ensurePathInEnv(projectedHostEnv) + : projectedHostEnv; + const mergedEnv = mergeRuntimeEnvironment( + inheritedLaunchEnv, + env, + (options.platform ?? process.platform) === "win32", + ); return Object.fromEntries( - Object.entries(ensurePathInEnv({ ...process.env, ...env })).filter( + Object.entries(mergedEnv).filter( (entry): entry is [string, string] => typeof entry[1] === "string", ), ); } +function mergeRuntimeEnvironment( + inheritedEnv: NodeJS.ProcessEnv, + explicitEnv: Record, + caseInsensitiveKeys: boolean, +): NodeJS.ProcessEnv { + if (!caseInsensitiveKeys) { + return { ...inheritedEnv, ...explicitEnv }; + } + + const merged: NodeJS.ProcessEnv = {}; + const keyByCanonicalName = new Map(); + const apply = (source: NodeJS.ProcessEnv): void => { + for (const [key, value] of Object.entries(source)) { + if (typeof value !== "string") continue; + const canonicalName = key.toUpperCase(); + const previousKey = keyByCanonicalName.get(canonicalName); + if (previousKey !== undefined && previousKey !== key) { + delete merged[previousKey]; + } + merged[key] = value; + keyByCanonicalName.set(canonicalName, key); + } + }; + + apply(inheritedEnv); + apply(explicitEnv); + return merged; +} + // Stop both host-side bridges in one `allSettled`. This is the settlement // `stopTransport` effect. The bridge tokens are run-scoped, so they die with the // bridges here (Amendment B). @@ -3857,6 +4038,10 @@ export function createAcpxEngineExecutor(deps: AcpxEngineExecutorOptions = {}) { // and custom agents already emit their own per-tool output and don't // benefit from doubling the log volume. verbose: prepared.acpxAgent === "claude", + // The engine passes a complete, sanitized launch environment. ACPX + // must not merge the Paperclip server's ambient environment back in + // when it spawns the provider child. + inheritProcessEnv: false, onAgentStderr: prepared.childStderrLogPath ? (chunk) => routeChildStderr(childStderrState, chunk) : undefined, diff --git a/packages/adapter-utils/src/execution-target-sandbox.test.ts b/packages/adapter-utils/src/execution-target-sandbox.test.ts index 54dea1c5aa..47e7a276e4 100644 --- a/packages/adapter-utils/src/execution-target-sandbox.test.ts +++ b/packages/adapter-utils/src/execution-target-sandbox.test.ts @@ -448,6 +448,75 @@ describe("sandbox adapter execution targets", () => { } }); + it.each([ + { outputMode: "polled", streamOutputViaSession: false }, + { outputMode: "streamed", streamOutputViaSession: true }, + ])( + "preserves an explicit remote PATH equal to host PATH in $outputMode mode", + async ({ outputMode, streamOutputViaSession }) => { + const rootDir = await mkdtemp( + path.join(os.tmpdir(), `paperclip-process-session-${outputMode}-path-`), + ); + cleanupDirs.push(rootDir); + const childPath = path.join(rootDir, "print-path-child.mjs"); + await writeFile( + childPath, + 'process.stdout.write(process.env.PATH ?? "");\n', + "utf8", + ); + + const nodeBinDir = path.dirname(process.execPath); + const explicitHostPath = `${nodeBinDir}:/explicit-host-bin`; + const sandboxNativePath = `/usr/bin:/bin:${nodeBinDir}`; + vi.stubEnv("PATH", explicitHostPath); + + const delegate = createLocalSandboxRunner(); + const runner = { + execute: vi.fn( + async (input: Parameters[0]) => + delegate.execute({ + ...input, + // The local fake otherwise inherits the test host PATH. Give the + // wrapper a distinct sandbox-native PATH so the child proves the + // explicit equal-to-host value survived payload serialization. + env: { ...input.env, PATH: sandboxNativePath }, + }), + ), + }; + const target: AdapterSandboxExecutionTarget = { + kind: "remote", + transport: "sandbox", + providerKey: "local-test", + remoteCwd: rootDir, + timeoutMs: 30_000, + runner, + }; + + const bridge = await startAdapterExecutionTargetProcessSessionBridge({ + runId: `run-process-session-${outputMode}-path`, + target, + runtimeRootDir: path.posix.join(rootDir, ".paperclip-runtime", "acpx"), + adapterKey: "acpx", + command: process.execPath, + args: [childPath], + cwd: rootDir, + env: { PATH: explicitHostPath }, + timeoutSec: 5, + onLog: async () => {}, + streamOutputViaSession, + }); + expect(bridge).not.toBeNull(); + + try { + const result = await runProxyWithInput(bridge!.agentCommand, ""); + expect(result.code).toBe(0); + expect(result.stdout).toBe(explicitHostPath); + } finally { + await bridge?.stop(); + } + }, + ); + it("test_process_session_poll_exec_parents_to_run_context", async () => { // The poll timer runs run-time execs for the whole run. Its `sandbox.exec` // span must parent to the live run span, not to the ended startup step. The diff --git a/packages/adapter-utils/src/execution-target.ts b/packages/adapter-utils/src/execution-target.ts index 5d08f246fc..845c65aed8 100644 --- a/packages/adapter-utils/src/execution-target.ts +++ b/packages/adapter-utils/src/execution-target.ts @@ -1712,7 +1712,11 @@ export async function startAdapterExecutionTargetProcessSessionBridge(input: { command: input.command, args: input.args, cwd: input.cwd || target.remoteCwd, - env: sanitizeRemoteExecutionEnv(launchEnv), + // The ACP engine has already projected this launch env from explicit + // adapter/runtime inputs and registered contributions. Compare against an + // empty inherited baseline so an explicit identity value (notably PATH) + // is not reclassified as ambient merely because it equals the host value. + env: sanitizeRemoteExecutionEnv(launchEnv, {}), }), "utf8").toString("base64"); // Legacy poll path: background the wrapper with `nohup` and read its output @@ -2013,7 +2017,9 @@ export async function startAdapterExecutionTargetProcessSessionBridge(input: { command: input.command, args: input.args, cwd: input.cwd || target.remoteCwd, - env: sanitizeRemoteExecutionEnv(launchEnvForStream), + // Same provenance-clean contract as the polled payload above. Preserve + // every explicit identity override even when it equals the host value. + env: sanitizeRemoteExecutionEnv(launchEnvForStream, {}), }), "utf8").toString("base64"); await onLog( "stdout", diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/bin/fake-codex-app-server.rs b/packages/paperclip-runner/runner/crates/runner-core/src/bin/fake-codex-app-server.rs index 3e92e418af..521a5aa86d 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/bin/fake-codex-app-server.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/bin/fake-codex-app-server.rs @@ -288,9 +288,14 @@ fn send_question(state: &FakeState) -> io::Result<()> { })) } -fn send_runtime_request_flood(state: &FakeState, interrupt_count: u64) -> io::Result<()> { +fn send_runtime_request_flood( + state: &FakeState, + interrupt_count: u64, + count: u64, + question: &str, +) -> io::Result<()> { let turn_id = state.active_turn_id.as_deref().unwrap_or("provider-turn-1"); - for index in 0..160_u64 { + for index in 0..count { send(json!({ "id": format!("runtime-flood-{interrupt_count}-{index}"), "method": "item/tool/requestUserInput", @@ -303,7 +308,7 @@ fn send_runtime_request_flood(state: &FakeState, interrupt_count: u64) -> io::Re "questions": [{ "id": "environment", "header": "Environment", - "question": "Where should we deploy?", + "question": question, "options": [{"label": "Staging", "description": "Deploy safely."}], }], }, @@ -412,6 +417,9 @@ fn run() -> Result<(), Box> { let flood_runtime_requests_on_interrupt = args .iter() .any(|value| value == "--flood-runtime-requests-on-interrupt"); + let flood_large_runtime_requests_on_interrupt = args + .iter() + .any(|value| value == "--flood-large-runtime-requests-on-interrupt"); let interrupt_terminal_delay_ms = argument(&args, "--interrupt-terminal-delay-ms") .map(|value| value.parse::()) .transpose()?; @@ -884,7 +892,16 @@ fn run() -> Result<(), Box> { } else { send(json!({"id": id, "result": {"accepted": true}}))?; if flood_runtime_requests_on_interrupt { - send_runtime_request_flood(&state, interrupt_count)?; + send_runtime_request_flood( + &state, + interrupt_count, + 160, + "Where should we deploy?", + )?; + } + if flood_large_runtime_requests_on_interrupt { + let question = "x".repeat(2 * 1024 * 1024); + send_runtime_request_flood(&state, interrupt_count, 3, &question)?; } if !accept_interrupt_without_terminal && !(accept_interrupt_without_terminal_once diff --git a/packages/paperclip-runner/runner/crates/runner-core/tests/codex_provider.rs b/packages/paperclip-runner/runner/crates/runner-core/tests/codex_provider.rs index 4e8050f1b1..baacff1e21 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/tests/codex_provider.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/tests/codex_provider.rs @@ -1963,19 +1963,27 @@ fn accepted_replacement_turn_revokes_prior_authority_before_idle_crash() { provider .start_turn("Start genuinely new provider work.", &config.cwd) .expect("start second provider turn"); - let second_exit = (0..64).find_map(|_| match provider.poll().expect("poll second turn") { - Some(CodexProviderEvent::Exited { - success, - completed_turn_authoritative, - completion_reconciles_exit, - .. - }) => Some(( - success, - completed_turn_authoritative, - completion_reconciles_exit, - )), - _ => None, - }); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + let second_exit = loop { + if std::time::Instant::now() >= deadline { + break None; + } + match provider.poll().expect("poll second turn") { + Some(CodexProviderEvent::Exited { + success, + completed_turn_authoritative, + completion_reconciles_exit, + .. + }) => { + break Some(( + success, + completed_turn_authoritative, + completion_reconciles_exit, + )); + } + _ => std::thread::sleep(std::time::Duration::from_millis(1)), + } + }; assert_eq!(second_exit, Some((false, false, false))); fs::remove_dir_all(directory).expect("remove Codex integration-test directory"); @@ -3680,6 +3688,55 @@ fn receipt_limit_polls_an_authoritative_terminal_with_unacknowledged_events() { fs::remove_dir_all(directory).expect("remove Codex integration-test directory"); } +#[test] +fn pending_runtime_request_count_limit_rejects_the_overflowing_request() { + let directory = temporary_directory("runtime-request-count-limit"); + let config = provider_config( + &directory, + &[ + "--hold-turn", + "--accept-interrupt-without-terminal", + "--flood-runtime-requests-on-interrupt", + ], + ); + let mut provider = CodexProvider::start(&config, None).expect("start Codex provider"); + provider + .start_turn("Bound pending runtime requests by count.", &config.cwd) + .expect("start held provider turn"); + provider + .interrupt_turn() + .expect("request the runtime-request flood"); + + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + let mut accepted = 0; + let mut rejected_at_capacity = false; + while !rejected_at_capacity && std::time::Instant::now() < deadline { + match provider + .poll() + .expect("the pending runtime-request count remains pollable") + { + Some(CodexProviderEvent::RuntimeRequest { .. }) => accepted += 1, + Some(CodexProviderEvent::Notification { method, params }) + if method == "warning" + && params["message"] + == "rejected a Codex runtime request at the bounded pending-input limit" => + { + rejected_at_capacity = true; + } + None => std::thread::sleep(std::time::Duration::from_millis(1)), + _ => {} + } + } + assert_eq!(accepted, 128); + assert!( + rejected_at_capacity, + "production rejects the 129th pending runtime request" + ); + + provider.shutdown().expect("stop Codex provider"); + fs::remove_dir_all(directory).expect("remove Codex integration-test directory"); +} + #[test] fn receipt_limit_polling_bounds_and_rejects_runtime_request_floods() { let directory = temporary_directory("receipt-limit-runtime-request-flood"); @@ -3690,7 +3747,7 @@ fn receipt_limit_polling_bounds_and_rejects_runtime_request_floods() { "--hold-turn", "--emit-tool-call-on-resume", "--accept-interrupt-without-terminal", - "--flood-runtime-requests-on-interrupt", + "--flood-large-runtime-requests-on-interrupt", ], ); let runner_config = durable_config(&directory); diff --git a/packages/paperclip-runner/src/drivers/acpx/environment.test.ts b/packages/paperclip-runner/src/drivers/acpx/environment.test.ts new file mode 100644 index 0000000000..03e7d00aae --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/environment.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; + +import { createSanitizedAcpxSpawnInput } from "./environment.js"; + +describe("ACPX launch environment", () => { + it("projects only the selected agent's credentials and runtime allowlist", () => { + const source = { + PATH: "/bin", + LC_ALL: "C.UTF-8", + HTTPS_PROXY: "https://proxy.example", + OPENAI_API_KEY: "openai-secret", + ANTHROPIC_API_KEY: "anthropic-secret", + OPENROUTER_API_KEY: "openrouter-secret", + PAPERCLIP_ACPX_CODEX_AUTH_JSON_SECRET: + '{"tokens":{"access_token":"managed-secret"}}', + PAPERCLIP_RUNNER_BOOTSTRAP_TICKET: "transport-secret", + PAPERCLIP_NATIVE_MCP_TOKEN: "bridge-secret", + UNRELATED_SECRET: "not-visible", + }; + + const codex = createSanitizedAcpxSpawnInput(source, "codex"); + expect(codex.env).toEqual({ + PATH: "/bin", + LC_ALL: "C.UTF-8", + HTTPS_PROXY: "https://proxy.example", + OPENAI_API_KEY: "openai-secret", + }); + expect(createSanitizedAcpxSpawnInput(source, "claude").env).toEqual({ + PATH: "/bin", + LC_ALL: "C.UTF-8", + HTTPS_PROXY: "https://proxy.example", + ANTHROPIC_API_KEY: "anthropic-secret", + }); + expect(createSanitizedAcpxSpawnInput(source, "pi").env).toEqual({ + PATH: "/bin", + LC_ALL: "C.UTF-8", + HTTPS_PROXY: "https://proxy.example", + OPENROUTER_API_KEY: "openrouter-secret", + }); + expect(codex.env).not.toHaveProperty("PAPERCLIP_NATIVE_MCP_TOKEN"); + expect(codex.env).not.toHaveProperty( + "PAPERCLIP_ACPX_CODEX_AUTH_JSON_SECRET", + ); + expect(Object.isFrozen(codex)).toBe(true); + expect(Object.isFrozen(codex.env)).toBe(true); + }); + + it("rejects unsafe or unbounded retained values", () => { + expect(() => + createSanitizedAcpxSpawnInput({ PATH: "bad\0path" }, "codex"), + ).toThrow("null byte"); + expect(() => + createSanitizedAcpxSpawnInput( + { + OPENAI_API_KEY: "x".repeat(64 * 1024), + }, + "codex", + ), + ).toThrow("bounded launch size"); + }); +}); diff --git a/packages/paperclip-runner/src/drivers/acpx/environment.ts b/packages/paperclip-runner/src/drivers/acpx/environment.ts new file mode 100644 index 0000000000..57fef1b4e3 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/environment.ts @@ -0,0 +1,77 @@ +import type { QualifiedAcpxAgent } from "./qualified-profiles.js"; + +declare const sanitizedAcpxSpawnInputBrand: unique symbol; + +/** + * Opaque child-process input produced only after the host environment crosses + * the ACPX credential allowlist. Future ACPX launchers accept this boundary + * object rather than an arbitrary `process.env`-shaped value. + */ +export interface SanitizedAcpxSpawnInput { + readonly env: Readonly; + readonly [sanitizedAcpxSpawnInputBrand]: true; +} + +/** + * Build the only host-environment input that may cross an ACPX child-process + * launch boundary. Agent-specific homes are added by the later runtime sandbox; + * Paperclip transport and native MCP credentials are never inherited from the + * host process. + */ +export function createSanitizedAcpxSpawnInput( + environment: NodeJS.ProcessEnv | undefined, + agent: QualifiedAcpxAgent, +): SanitizedAcpxSpawnInput { + const source = environment ?? process.env; + const result: NodeJS.ProcessEnv = {}; + const credentialNames = + agent === "pi" + ? ["OPENROUTER_API_KEY"] + : agent === "claude" + ? ["ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN"] + : [ + "OPENAI_API_KEY", + "CODEX_API_KEY", + ]; + const allowed = new Set([ + "PATH", + "LANG", + "LANGUAGE", + "TZ", + "TMPDIR", + "TEMP", + "TMP", + "SSL_CERT_FILE", + "SSL_CERT_DIR", + "NODE_EXTRA_CA_CERTS", + "HTTP_PROXY", + "HTTPS_PROXY", + "NO_PROXY", + "ALL_PROXY", + "http_proxy", + "https_proxy", + "no_proxy", + "all_proxy", + "RUST_BACKTRACE", + "PAPERCLIP_NATIVE_MCP_NAME", + "PAPERCLIP_NATIVE_MCP_URL", + ...credentialNames, + ]); + let retainedBytes = 0; + for (const [key, value] of Object.entries(source)) { + if (typeof value !== "string") continue; + if (!allowed.has(key) && !/^LC_[A-Z0-9_]{1,32}$/.test(key)) continue; + if (key.includes("\0") || value.includes("\0")) { + throw new Error("ACPX environment contains a null byte"); + } + const entryBytes = Buffer.byteLength(key) + Buffer.byteLength(value); + if (entryBytes > 64 * 1024 || retainedBytes + entryBytes > 256 * 1024) { + throw new Error("ACPX environment exceeds its bounded launch size"); + } + retainedBytes += entryBytes; + result[key] = value; + } + return Object.freeze({ + env: Object.freeze(result), + }) as SanitizedAcpxSpawnInput; +} diff --git a/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.test.ts b/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.test.ts new file mode 100644 index 0000000000..142115db99 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; + +import { + QUALIFIED_ACPX_PROFILES, + resolveQualifiedAcpxProfile, +} from "./qualified-profiles.js"; + +describe("qualified ACPX profiles", () => { + it("binds each agent to one immutable package and model declaration", () => { + for (const agent of ["pi", "claude", "codex"] as const) { + const profile = QUALIFIED_ACPX_PROFILES[agent]; + expect(profile.agent).toBe(agent); + expect(profile.commandDigest).toMatch(/^sha256:[a-f0-9]{64}$/); + expect(Object.isFrozen(profile)).toBe(true); + expect( + resolveQualifiedAcpxProfile(agent, profile.qualificationModel), + ).toEqual(profile); + } + }); + + it("rejects unqualified model substitutions", () => { + expect(() => + resolveQualifiedAcpxProfile("codex", "some-other-model"), + ).toThrow("requires exact model"); + }); +}); diff --git a/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts b/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts new file mode 100644 index 0000000000..403a334110 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts @@ -0,0 +1,110 @@ +export const QUALIFIED_ACPX_VERSION = "0.13.1" as const; +export const ACPX_DRIVER_KIND = "acpx_runtime" as const; +export const ACPX_DRIVER_PROTOCOL_VERSION = 1 as const; + +import type { NativeAcpxAgent } from "../../contracts/native-execution.js"; + +export type QualifiedAcpxAgent = NativeAcpxAgent; + +export interface QualifiedAcpxProfile { + readonly driverKind: typeof ACPX_DRIVER_KIND; + readonly protocolVersion: typeof ACPX_DRIVER_PROTOCOL_VERSION; + readonly acpxVersion: typeof QUALIFIED_ACPX_VERSION; + readonly agent: QualifiedAcpxAgent; + readonly agentProfileVersion: 1; + readonly agentServerPackage: string; + readonly agentServerVersion: string; + readonly agentRuntimePackage: string | null; + readonly agentRuntimeVersion: string | null; + readonly commandDigest: string; + readonly qualificationModel: string; + /** + * Model identifier the pinned ACP server reports after accepting the exact + * qualification model. Most agents echo the requested model. Claude's ACP + * server deliberately exposes its stable SDK selector (`sonnet`) while the + * SDK resolves that selector to the canonical wire model + * (`claude-sonnet-5`). Paperclip verifies the exact request was accepted + * before treating this identifier as the qualified effective model. + */ + readonly reportedModelId: string; + readonly permissionPolicy: "interactive"; +} + +/** + * Digests bind the closed profile declaration (package, version, runtime and + * model), not a caller-controlled executable. The environment probe separately + * verifies the resolved package files before a billable prompt is admitted. + */ +export const QUALIFIED_ACPX_PROFILES: Readonly< + Record +> = deepFreeze({ + pi: { + driverKind: ACPX_DRIVER_KIND, + protocolVersion: ACPX_DRIVER_PROTOCOL_VERSION, + acpxVersion: QUALIFIED_ACPX_VERSION, + agent: "pi", + agentProfileVersion: 1, + agentServerPackage: "pi-acp", + agentServerVersion: "0.0.33", + agentRuntimePackage: "@earendil-works/pi-coding-agent", + agentRuntimeVersion: "0.84.2", + commandDigest: + "sha256:8c696f38296d53d0061fa11534570c5ddd951b63532aed30e0f1fcc676dc169f", + qualificationModel: "openrouter/deepseek/deepseek-v4-flash-0731", + reportedModelId: "openrouter/deepseek/deepseek-v4-flash-0731", + permissionPolicy: "interactive", + }, + claude: { + driverKind: ACPX_DRIVER_KIND, + protocolVersion: ACPX_DRIVER_PROTOCOL_VERSION, + acpxVersion: QUALIFIED_ACPX_VERSION, + agent: "claude", + agentProfileVersion: 1, + agentServerPackage: "@agentclientprotocol/claude-agent-acp", + agentServerVersion: "0.70.0", + agentRuntimePackage: null, + agentRuntimeVersion: null, + commandDigest: + "sha256:9d73d1f0f121fb96cc8badb28c22d5bff02d8582eb2e40360a81c189e1b9422a", + qualificationModel: "claude-sonnet-5", + reportedModelId: "sonnet", + permissionPolicy: "interactive", + }, + codex: { + driverKind: ACPX_DRIVER_KIND, + protocolVersion: ACPX_DRIVER_PROTOCOL_VERSION, + acpxVersion: QUALIFIED_ACPX_VERSION, + agent: "codex", + agentProfileVersion: 1, + agentServerPackage: "@agentclientprotocol/codex-acp", + agentServerVersion: "1.6.2", + agentRuntimePackage: null, + agentRuntimeVersion: null, + commandDigest: + "sha256:94049b3e3c3aee87de62703786e4fa81d031d7bd979f99bdf516d84f28791a79", + qualificationModel: "gpt-5.6-sol", + reportedModelId: "gpt-5.6-sol", + permissionPolicy: "interactive", + }, +}); + +export function resolveQualifiedAcpxProfile( + agent: QualifiedAcpxAgent, + requestedModel: string, +): QualifiedAcpxProfile { + const profile = QUALIFIED_ACPX_PROFILES[agent]; + if (requestedModel !== profile.qualificationModel) { + throw new Error( + `ACPX ${agent} profile requires exact model ${profile.qualificationModel}; received ${requestedModel}`, + ); + } + return structuredClone(profile); +} + +function deepFreeze(value: T): T { + if (typeof value !== "object" || value === null || Object.isFrozen(value)) + return value; + Object.freeze(value); + for (const child of Object.values(value)) deepFreeze(child); + return value; +} diff --git a/packages/paperclip-runner/src/drivers/acpx/sidecar-protocol.test.ts b/packages/paperclip-runner/src/drivers/acpx/sidecar-protocol.test.ts new file mode 100644 index 0000000000..eff7054af1 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/sidecar-protocol.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from "vitest"; + +import { + ACPX_SIDECAR_MAX_FRAME_BYTES, + boundedSidecarValue, + parseAcpxSidecarRequest, + sanitizeAcpxPlanEntries, +} from "./sidecar-protocol.js"; + +describe("ACPX sidecar request parsing", () => { + it("accepts only bounded, versioned, generated commands", () => { + expect( + parseAcpxSidecarRequest({ + protocolVersion: 2, + id: 1, + command: "session.open", + params: { cwd: "/workspace" }, + }), + ).toEqual({ + protocolVersion: 2, + id: 1, + command: "session.open", + params: { cwd: "/workspace" }, + }); + expect(() => + parseAcpxSidecarRequest({ + protocolVersion: 3, + id: 1, + command: "session.open", + params: {}, + }), + ).toThrow("unsupported ACPX sidecar protocol version"); + expect(() => + parseAcpxSidecarRequest({ + protocolVersion: 2, + id: 1, + command: "session.destroy", + params: {}, + }), + ).toThrow("unsupported ACPX sidecar command"); + expect(() => + parseAcpxSidecarRequest({ + protocolVersion: 2, + id: 1, + command: "session.open", + params: [], + }), + ).toThrow("params must be an object"); + expect(() => + parseAcpxSidecarRequest({ + protocolVersion: 2, + id: 1, + command: "session.open", + params: {}, + extra: true, + }), + ).toThrow("unknown field"); + }); + + it("bounds and safely sanitizes arbitrary values", () => { + expect(() => + parseAcpxSidecarRequest({ + protocolVersion: 2, + id: 1, + command: "initialize", + params: { value: "x".repeat(ACPX_SIDECAR_MAX_FRAME_BYTES) }, + }), + ).toThrow("frame limit"); + const cyclic: Record = {}; + cyclic.self = cyclic; + expect(boundedSidecarValue(cyclic)).toEqual({ + omitted: true, + reason: "serialization_failed", + }); + expect(boundedSidecarValue(["not", "an", "object"])).toEqual({ + omitted: true, + reason: "object_required", + }); + }); +}); + +describe("ACPX sidecar structured plans", () => { + it("preserves every valid ordered entry while bounding and sanitizing the snapshot", () => { + const entries = sanitizeAcpxPlanEntries([ + { content: " Inspect ", status: "completed", priority: "high" }, + { content: "Implement", status: "in_progress", priority: "medium" }, + { content: "Verify", status: "pending", priority: "low" }, + { content: "Invalid status", status: "failed" }, + { content: " ", status: "pending" }, + { + content: "x".repeat(5_000), + status: "pending", + priority: "p".repeat(100), + }, + ]); + + expect(entries.slice(0, 3)).toEqual([ + { content: "Inspect", status: "completed", priority: "high" }, + { content: "Implement", status: "in_progress", priority: "medium" }, + { content: "Verify", status: "pending", priority: "low" }, + ]); + expect(entries).toHaveLength(4); + expect(entries[3]?.content).toHaveLength(4_000); + expect(entries[3]?.priority).toHaveLength(80); + expect( + sanitizeAcpxPlanEntries( + Array.from({ length: 300 }, (_, index) => ({ + content: `Step ${index}`, + status: "pending", + })), + ), + ).toHaveLength(256); + }); +}); diff --git a/packages/paperclip-runner/src/drivers/acpx/sidecar-protocol.ts b/packages/paperclip-runner/src/drivers/acpx/sidecar-protocol.ts new file mode 100644 index 0000000000..912d076a98 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/sidecar-protocol.ts @@ -0,0 +1,172 @@ +import type { QualifiedAcpxAgent } from "./qualified-profiles.js"; +import type { NativeRuntimeContextSnapshot } from "../../contracts/runtime-context.js"; +import type { NativeAcpxPermissionMode } from "../../contracts/native-execution.js"; +import { + GENERATED_ACPX_SIDECAR_COMMANDS, + GENERATED_ACPX_SIDECAR_PROTOCOL_VERSION, + type GeneratedAcpxSidecarCommand, + type GeneratedAcpxSidecarEventType, +} from "./generated-sidecar-contract.js"; + +export const ACPX_SIDECAR_PROTOCOL_VERSION = + GENERATED_ACPX_SIDECAR_PROTOCOL_VERSION; +export const ACPX_SIDECAR_MAX_FRAME_BYTES = 1024 * 1024; + +export interface AcpxSidecarRequest { + protocolVersion: typeof ACPX_SIDECAR_PROTOCOL_VERSION; + id: number; + command: GeneratedAcpxSidecarCommand; + params: Record; +} + +export interface AcpxSidecarResponse { + protocolVersion: typeof ACPX_SIDECAR_PROTOCOL_VERSION; + id: number; + ok: boolean; + result?: Record; + error?: { code: string; message: string; retryable: boolean }; +} + +export interface AcpxSidecarEvent { + protocolVersion: typeof ACPX_SIDECAR_PROTOCOL_VERSION; + sequence: number; + eventType: GeneratedAcpxSidecarEventType; + runId: string | null; + turnId: string | null; + payload: Record; +} + +export interface AcpxSidecarOpenParams { + runtimeDirectory: string; + normalizedSessionId: string; + workingDirectory: string; + agent: QualifiedAcpxAgent; + model: string; + permissionMode: NativeAcpxPermissionMode; + permissionModePinned: boolean; + systemInstructions: string; + runtimeContext: NativeRuntimeContextSnapshot | null; + tools: readonly Readonly>[]; + providerSessionKey?: string; + expectedIdentity?: AcpxExpectedSessionIdentity; +} + +export interface AcpxExpectedSessionIdentity { + kind: "acpx"; + normalizedSessionId: string; + acpxRecordId: string; + backendSessionId: string; + agentSessionId: string; + profileDigest: string; + workspaceDigest: string; + requestedModel: string; + effectiveModel: string; + permissionMode?: NativeAcpxPermissionMode; +} + +export function parseAcpxSidecarRequest(value: unknown): AcpxSidecarRequest { + let serialized: string; + try { + serialized = JSON.stringify(value); + } catch { + throw new Error("ACPX sidecar request is not JSON serializable"); + } + if ( + !serialized || + Buffer.byteLength(serialized) > ACPX_SIDECAR_MAX_FRAME_BYTES + ) { + throw new Error("ACPX sidecar request exceeds the frame limit"); + } + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error("ACPX sidecar request must be an object"); + } + const request = value as Record; + if ( + Object.keys(request).some( + (key) => !["protocolVersion", "id", "command", "params"].includes(key), + ) + ) { + throw new Error("ACPX sidecar request contains an unknown field"); + } + if (request.protocolVersion !== ACPX_SIDECAR_PROTOCOL_VERSION) { + throw new Error("unsupported ACPX sidecar protocol version"); + } + if (!Number.isSafeInteger(request.id) || Number(request.id) < 1) + throw new Error("sidecar request id must be a positive integer"); + const command = text(request.command); + if (!(GENERATED_ACPX_SIDECAR_COMMANDS as readonly string[]).includes(command)) + throw new Error("unsupported ACPX sidecar command"); + if ( + typeof request.params !== "object" || + request.params === null || + Array.isArray(request.params) + ) { + throw new Error("ACPX sidecar request params must be an object"); + } + return { + protocolVersion: ACPX_SIDECAR_PROTOCOL_VERSION, + id: Number(request.id), + command: command as AcpxSidecarRequest["command"], + params: structuredClone(request.params as Record), + }; +} + +export function boundedSidecarValue( + value: unknown, + maxBytes = 64 * 1024, +): Record { + if (!Number.isSafeInteger(maxBytes) || maxBytes < 1) { + throw new Error("ACPX sidecar value limit must be a positive integer"); + } + try { + const serialized = JSON.stringify(value); + if (!serialized || Buffer.byteLength(serialized) > maxBytes) { + return { omitted: true, reason: "payload_limit" }; + } + const parsed = JSON.parse(serialized); + return typeof parsed === "object" && + parsed !== null && + !Array.isArray(parsed) + ? (parsed as Record) + : { omitted: true, reason: "object_required" }; + } catch { + return { omitted: true, reason: "serialization_failed" }; + } +} + +export function sanitizeAcpxPlanEntries(value: unknown): Array<{ + content: string; + status: "pending" | "in_progress" | "completed"; + priority: string | null; +}> { + if (!Array.isArray(value)) return []; + return value.slice(0, 256).flatMap((candidate) => { + const entry = record(candidate); + const content = text(entry.content).trim().slice(0, 4_000); + const status = text(entry.status); + if ( + !content || + (status !== "pending" && + status !== "in_progress" && + status !== "completed") + ) + return []; + return [ + { + content, + status, + priority: text(entry.priority).slice(0, 80) || null, + }, + ]; + }); +} + +export function record(value: unknown): Record { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : {}; +} + +export function text(value: unknown, fallback = ""): string { + return typeof value === "string" ? value : fallback; +} diff --git a/patches/acpx@0.12.0.patch b/patches/acpx@0.12.0.patch index 8d5500a705..033460fad5 100644 --- a/patches/acpx@0.12.0.patch +++ b/patches/acpx@0.12.0.patch @@ -20,6 +20,62 @@ index 243c9d13bcba520923b63adfddad75cf2d94362d..336a1699b80b9e99416f9da4346ca73e current: state.current, quote: state.quote, escaping: true, +@@ -2914,15 +2914,15 @@ + } +-function buildAgentEnvironment(authCredentials, sessionEnv) { +- const env = { ...process.env }; +- const protectedAuthEnvKeys = promotePrefixedAuthEnvironment(env); +- if (authCredentials) for (const [methodId, credential] of Object.entries(authCredentials)) { +- addAuthCredentialEnvKeys(protectedAuthEnvKeys, methodId, credential); +- assignAuthCredentialEnv(env, methodId, credential); +- } +- if (sessionEnv) for (const [key, value] of Object.entries(sessionEnv)) { +- if (typeof value !== "string" || protectedAuthEnvKeys.has(protectedEnvKey(key))) continue; +- assignSessionEnv(env, key, value); +- } +- return env; +-} ++function buildAgentEnvironment(authCredentials, sessionEnv, inheritProcessEnv = true) { ++ const env = inheritProcessEnv ? { ...process.env } : {}; ++ const protectedAuthEnvKeys = promotePrefixedAuthEnvironment(env); ++ if (authCredentials) for (const [methodId, credential] of Object.entries(authCredentials)) { ++ addAuthCredentialEnvKeys(protectedAuthEnvKeys, methodId, credential); ++ assignAuthCredentialEnv(env, methodId, credential); ++ } ++ if (sessionEnv) for (const [key, value] of Object.entries(sessionEnv)) { ++ if (typeof value !== "string" || protectedAuthEnvKeys.has(protectedEnvKey(key))) continue; ++ assignSessionEnv(env, key, value); ++ } ++ return env; ++} + function assignSessionEnv(env, key, value) { +@@ -2957,14 +2957,14 @@ + } +-function buildAgentSpawnOptions(cwd, authCredentials, sessionEnv) { +- return { +- cwd, +- env: buildAgentEnvironment(authCredentials, sessionEnv), +- stdio: [ +- "pipe", +- "pipe", +- "pipe" +- ], +- windowsHide: true +- }; +-} ++function buildAgentSpawnOptions(cwd, authCredentials, sessionEnv, inheritProcessEnv) { ++ return { ++ cwd, ++ env: buildAgentEnvironment(authCredentials, sessionEnv, inheritProcessEnv), ++ stdio: [ ++ "pipe", ++ "pipe", ++ "pipe" ++ ], ++ windowsHide: true ++ }; ++} + //#endregion @@ -3959,7 +3959,24 @@ var AcpClient = class { - this.attachAgentLifecycleObservers(child); + this.attachAgentLifecycleObservers(child); @@ -51,7 +107,7 @@ index 243c9d13bcba520923b63adfddad75cf2d94362d..336a1699b80b9e99416f9da4346ca73e copilotAcp: isCopilotAcpCommand(spawnCommand, args), claudeAcp: isClaudeAcpCommand(spawnCommand, args), - spawnOptions: buildAgentSpawnOptions(this.options.cwd, this.options.authCredentials, this.options.sessionOptions?.env) -+ spawnOptions: buildAgentSpawnOptions(this.options.spawnCwd ?? this.options.cwd, this.options.authCredentials, this.options.sessionOptions?.env) ++ spawnOptions: buildAgentSpawnOptions(this.options.spawnCwd ?? this.options.cwd, this.options.authCredentials, this.options.sessionOptions?.env, this.options.inheritProcessEnv) }; } logAgentLaunch(plan) { @@ -59,13 +115,14 @@ diff --git a/dist/runtime.d.ts b/dist/runtime.d.ts index ccdbe5b032521518022223733049b8b38793473b..3d4e04231e78efeecd8540735b08e1e43547ff2d 100644 --- a/dist/runtime.d.ts +++ b/dist/runtime.d.ts -@@ -266,6 +266,9 @@ type AcpRuntimeOptions = { +@@ -266,6 +266,10 @@ type AcpRuntimeOptions = { timeoutMs?: number; probeAgent?: string; verbose?: boolean; + onAgentStderr?: (chunk: string) => void; + onAgentSpawn?: (meta: { pid: number; startedAt: string }) => Promise; + spawnCwd?: string; ++ inheritProcessEnv?: boolean; onPermissionRequest?: (req: AcpPermissionRequest, ctx: { signal: AbortSignal; }) => Promise; @@ -78,7 +135,7 @@ index 6c9cc999e50a11c399c68b3a0f1b7af4bc2317c0..33b5054b2906502d1d4b512bfa259bd2 } createClient(options) { - return this.deps.clientFactory?.(options) ?? new AcpClient(options); -+ const clientOptions = { ...options, onAgentStderr: this.options.onAgentStderr, onAgentSpawn: this.options.onAgentSpawn, spawnCwd: this.options.spawnCwd }; ++ const clientOptions = { ...options, onAgentStderr: this.options.onAgentStderr, onAgentSpawn: this.options.onAgentSpawn, spawnCwd: this.options.spawnCwd, inheritProcessEnv: this.options.inheritProcessEnv }; + return this.deps.clientFactory?.(clientOptions) ?? new AcpClient(clientOptions); } async readPendingPersistentClient(record, options) { @@ -87,13 +144,14 @@ diff --git a/dist/session-options-jkYbBxGE.d.ts b/dist/session-options-jkYbBxGE. index 9d37f377fb6a0828e0d2bc5a48754f3aa71509a4..680bc080fc5d6ffd266ed1b27d3d5056add9d980 100644 --- a/dist/session-options-jkYbBxGE.d.ts +++ b/dist/session-options-jkYbBxGE.d.ts -@@ -84,6 +84,9 @@ type AcpClientOptions = { +@@ -84,6 +84,10 @@ type AcpClientOptions = { terminal?: boolean; suppressSdkConsoleErrors?: boolean; verbose?: boolean; + onAgentStderr?: (chunk: string) => void; + onAgentSpawn?: (meta: { pid: number; startedAt: string }) => Promise; + spawnCwd?: string; ++ inheritProcessEnv?: boolean; sessionOptions?: { model?: string; allowedTools?: string[];