diff --git a/packages/adapter-utils/src/paperclip-runner-permissions.test.ts b/packages/adapter-utils/src/paperclip-runner-permissions.test.ts new file mode 100644 index 0000000000..ec6c7bec5f --- /dev/null +++ b/packages/adapter-utils/src/paperclip-runner-permissions.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; + +import { + isPaperclipRunnerProvider, + resolvePaperclipRunnerPermissionMode, +} from "./paperclip-runner-permissions.js"; + +describe("Paperclip Runner permission defaults", () => { + it("uses interactive defaults for dormant non-Codex providers", () => { + expect(resolvePaperclipRunnerPermissionMode("opencode", undefined)).toBe( + "ask", + ); + expect(resolvePaperclipRunnerPermissionMode("acpx", undefined)).toBe( + "approve-reads", + ); + }); + + it("recognizes only exact provider identifiers", () => { + expect(isPaperclipRunnerProvider("codex")).toBe(true); + expect(isPaperclipRunnerProvider("opencode")).toBe(true); + expect(isPaperclipRunnerProvider("acpx")).toBe(true); + expect(isPaperclipRunnerProvider("toString")).toBe(false); + expect(isPaperclipRunnerProvider("__proto__")).toBe(false); + }); +}); diff --git a/packages/adapter-utils/src/paperclip-runner-permissions.ts b/packages/adapter-utils/src/paperclip-runner-permissions.ts index 36bb7aa29a..85ab429b21 100644 --- a/packages/adapter-utils/src/paperclip-runner-permissions.ts +++ b/packages/adapter-utils/src/paperclip-runner-permissions.ts @@ -49,7 +49,7 @@ export const PAPERCLIP_RUNNER_PERMISSION_CAPABILITIES = { opencode: { configurable: true, configKey: "opencodePermissionMode", - defaultMode: "allow", + defaultMode: "ask", description: "Controls OpenCode tool permissions inside the assigned Paperclip environment.", options: [ { value: "allow", label: "Full auto (allow)", description: "Allow OpenCode operations without approval pauses." }, @@ -60,7 +60,7 @@ export const PAPERCLIP_RUNNER_PERMISSION_CAPABILITIES = { acpx: { configurable: true, configKey: "acpxPermissionMode", - defaultMode: "approve-all", + defaultMode: "approve-reads", description: "Controls ACPX agent operations inside the assigned Paperclip environment.", options: [ { value: "approve-all", label: "Full auto (approve all)", description: "Approve ACPX operations without approval pauses." }, @@ -71,7 +71,7 @@ export const PAPERCLIP_RUNNER_PERMISSION_CAPABILITIES = { } as const satisfies Record; export function isPaperclipRunnerProvider(value: unknown): value is PaperclipRunnerProvider { - return typeof value === "string" && value in PAPERCLIP_RUNNER_PERMISSION_CAPABILITIES; + return value === "codex" || value === "opencode" || value === "acpx"; } export function resolvePaperclipRunnerPermissionMode( diff --git a/packages/paperclip-runner/src/cli/opencode-app-server-proxy.ts b/packages/paperclip-runner/src/cli/opencode-app-server-proxy.ts index 8aad11eb2c..980b2e265e 100644 --- a/packages/paperclip-runner/src/cli/opencode-app-server-proxy.ts +++ b/packages/paperclip-runner/src/cli/opencode-app-server-proxy.ts @@ -17,6 +17,7 @@ import { assertOpenCodeProxyCollaborationMode, openCodeProxyCollaborationModes, } from "./opencode-proxy-collaboration-mode.js"; +import { parseOpenCodeProxyPermissionMode } from "./opencode-proxy-permission-mode.js"; type RpcMessage = { id?: string | number; method?: string; params?: unknown; result?: unknown; error?: unknown }; @@ -78,6 +79,9 @@ async function open(params: Record, resume: boolean): Promise { + it.each(["allow", "ask", "deny"] as const)( + "admits the exact %s mode", + (mode) => { + expect(parseOpenCodeProxyPermissionMode(mode)).toBe(mode); + }, + ); + + it("defaults an unset or empty mode to ask", () => { + expect(parseOpenCodeProxyPermissionMode(undefined)).toBe("ask"); + expect(parseOpenCodeProxyPermissionMode(" ")).toBe("ask"); + }); + + it("rejects an unknown mode", () => { + expect(() => parseOpenCodeProxyPermissionMode("approve-all")).toThrow( + "PAPERCLIP_OPENCODE_PERMISSION_MODE is invalid", + ); + }); +}); diff --git a/packages/paperclip-runner/src/cli/opencode-proxy-permission-mode.ts b/packages/paperclip-runner/src/cli/opencode-proxy-permission-mode.ts new file mode 100644 index 0000000000..16048074c7 --- /dev/null +++ b/packages/paperclip-runner/src/cli/opencode-proxy-permission-mode.ts @@ -0,0 +1,16 @@ +export type OpenCodeProxyPermissionMode = "allow" | "ask" | "deny"; + +export function parseOpenCodeProxyPermissionMode( + value: string | undefined, +): OpenCodeProxyPermissionMode { + const configured = value?.trim(); + if (!configured) return "ask"; + if ( + configured === "allow" + || configured === "ask" + || configured === "deny" + ) { + return configured; + } + throw new Error("PAPERCLIP_OPENCODE_PERMISSION_MODE is invalid"); +} diff --git a/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.test.ts b/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.test.ts index 9b1ab8fedf..af7aae9b87 100644 --- a/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.test.ts +++ b/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.test.ts @@ -13,7 +13,11 @@ import { describe, expect, it } from "vitest"; import { validatePrpEvent } from "../protocol/replay-contract.js"; import { digestPaperclipSemanticContent } from "../semantic-tools/receipts.js"; -import { DurablePrpControlPlane } from "./durable-prp-control-plane.js"; +import { + DurablePrpControlPlane, + spawnRunner, + type RunnerProcessLaunchSpec, +} from "./durable-prp-control-plane.js"; import type { DurableRecoveryIdentity } from "./prp-transport-types.js"; const identity: DurableRecoveryIdentity = { @@ -27,6 +31,59 @@ const identity: DurableRecoveryIdentity = { const expectedRunnerVersion = "0.3.0"; const expectedRunnerDigest = `sha256:${"a".repeat(64)}`; +it("preserves an explicit OpenCode permission mode at the runner spawn boundary", () => { + const launches: RunnerProcessLaunchSpec[] = []; + spawnRunner({ + connection: { mode: "connect", connectUrl: "ws://127.0.0.1:43127" }, + stateDirectory: "/tmp/paperclip-runner-test", + identity, + ticket: "bootstrap-ticket", + maxOutboxBytes: 256 * 1024, + p0ReserveBytes: 64 * 1024, + runnerVersion: expectedRunnerVersion, + runnerDigest: expectedRunnerDigest, + environment: { + PATH: "/bin", + OPENROUTER_API_KEY: "provider-key", + PAPERCLIP_OPENCODE_COMMAND: "/provider-pack/opencode", + PAPERCLIP_OPENCODE_PERMISSION_MODE: "deny", + PAPERCLIP_OPENCODE_RUNTIME_DIR: "/runner/opencode", + DATABASE_URL: "must-not-reach-runnerd", + PAPERCLIP_API_KEY: "must-not-reach-runnerd", + NODE_OPTIONS: "--require=/untrusted/bootstrap.cjs", + }, + processLauncher: (spec) => { + launches.push(spec); + return { + child: { + pid: 42, + exitCode: null, + signalCode: null, + kill: () => true, + }, + completion: Promise.resolve({ + code: 0, + signal: null, + stdout: "", + stderr: "", + }), + }; + }, + }); + + expect(launches).toHaveLength(1); + expect(launches[0]!.environment).toMatchObject({ + PATH: "/bin", + OPENROUTER_API_KEY: "provider-key", + PAPERCLIP_OPENCODE_COMMAND: "/provider-pack/opencode", + PAPERCLIP_OPENCODE_PERMISSION_MODE: "deny", + PAPERCLIP_OPENCODE_RUNTIME_DIR: "/runner/opencode", + }); + expect(launches[0]!.environment.DATABASE_URL).toBeUndefined(); + expect(launches[0]!.environment.PAPERCLIP_API_KEY).toBeUndefined(); + expect(launches[0]!.environment.NODE_OPTIONS).toBeUndefined(); +}); + function domainDigest(domain: string, parts: readonly Buffer[]): Buffer { const digest = createHash("sha256") .update(domain) diff --git a/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.ts b/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.ts index b0f4e16ee9..e7cb078e5d 100644 --- a/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.ts +++ b/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.ts @@ -1876,6 +1876,7 @@ const runnerExplicitProviderEnvironmentKeys = [ "AWS_CONTAINER_CREDENTIALS_FULL_URI", "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", "PAPERCLIP_OPENCODE_COMMAND", + "PAPERCLIP_OPENCODE_PERMISSION_MODE", "PAPERCLIP_OPENCODE_RUNTIME_DIR", "PAPERCLIP_RUNNER_INSTANCE_ID", "PAPERCLIP_RUN_ID", diff --git a/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts b/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts index 2a86bef373..dc1a9d9aa7 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts @@ -62,7 +62,16 @@ it("preserves OpenCode runtime bindings when a durable runner is respawned", () options: { provider: "opencode", stateDirectory: "/isolated/session", - environment: { PATH: "/bin", OPENROUTER_API_KEY: "test-provider-key" }, + opencodePermissionMode: "deny", + environment: { + PATH: "/bin", + OPENROUTER_API_KEY: "test-provider-key", + HOME: "/host/home", + CODEX_HOME: "/host/codex-home", + DATABASE_URL: "must-not-reach-runnerd", + PAPERCLIP_API_KEY: "must-not-reach-runnerd", + NODE_OPTIONS: "--require=/untrusted/bootstrap.cjs", + }, opencodeCommand: "/provider-pack/opencode", opencodeRuntimeDirectory: "/isolated/session/opencode", }, @@ -80,6 +89,7 @@ it("preserves OpenCode runtime bindings when a durable runner is respawned", () }); expect(environment).toMatchObject({ PAPERCLIP_OPENCODE_COMMAND: "/provider-pack/opencode", + PAPERCLIP_OPENCODE_PERMISSION_MODE: "deny", PAPERCLIP_OPENCODE_RUNTIME_DIR: "/isolated/session/opencode", PAPERCLIP_RUNNER_INSTANCE_ID: "runner-1", PAPERCLIP_RUN_ID: "run-1", @@ -87,6 +97,35 @@ it("preserves OpenCode runtime bindings when a durable runner is respawned", () PAPERCLIP_NATIVE_RUNTIME_CONTEXT_PATH: "/isolated/runtime-context.json", OPENROUTER_API_KEY: "test-provider-key", }); + expect(environment.HOME).toBeUndefined(); + expect(environment.CODEX_HOME).toBeUndefined(); + expect(environment.DATABASE_URL).toBeUndefined(); + expect(environment.PAPERCLIP_API_KEY).toBeUndefined(); + expect(environment.NODE_OPTIONS).toBeUndefined(); + + const defaultPermissionEnvironment = + createCapabilityRunnerdProviderEnvironment({ + provider: "opencode", + options: { + provider: "opencode", + stateDirectory: "/isolated/session", + environment: { PATH: "/bin" }, + }, + identity: { + runnerInstanceId: "runner-1", + environmentLeaseId: "lease-1", + runId: "run-1", + normalizedSessionId: "session-1", + turnId: "turn-1", + itemId: "item-1", + }, + codexHome: "/isolated/codex-home", + runtimeContextPath: "/isolated/runtime-context.json", + hasRuntimeContext: false, + }); + expect(defaultPermissionEnvironment.PAPERCLIP_OPENCODE_PERMISSION_MODE).toBe( + "ask", + ); }); it("passes the configured Codex API key only through the provider process environment", () => { diff --git a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts index 9af69af497..819b4104a4 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts @@ -42,7 +42,10 @@ import { } from "../drivers/acpx/qualified-profiles.js"; import { createSanitizedAcpxSpawnInput } from "../drivers/acpx/environment.js"; import type { NativeRuntimeContextSnapshot } from "../contracts/runtime-context.js"; -import type { NativeAcpxPermissionMode } from "../contracts/native-execution.js"; +import type { + NativeAcpxPermissionMode, + NativeOpenCodePermissionMode, +} from "../contracts/native-execution.js"; import { nativeMcpLaunchBinding } from "../drivers/native-mcp.js"; import { prepareIsolatedCodexHome, @@ -235,6 +238,7 @@ export interface CapabilityRunnerdProcessEvidence { export interface CapabilityRunnerdCodexTransportOptions { provider?: "codex" | "opencode" | "acpx"; + opencodePermissionMode?: NativeOpenCodePermissionMode; acpxAgent?: QualifiedAcpxAgent; acpxPermissionMode?: NativeAcpxPermissionMode; acpxPermissionModePinned?: boolean; @@ -828,9 +832,10 @@ export function createCapabilityRunnerdProviderEnvironment(input: { }; if (input.provider === "opencode") { return { - ...process.env, - ...input.options.environment, + ...createSanitizedOpenCodeRunnerEnvironment(input.options.environment), PAPERCLIP_OPENCODE_COMMAND: input.options.opencodeCommand ?? "opencode", + PAPERCLIP_OPENCODE_PERMISSION_MODE: + input.options.opencodePermissionMode ?? "ask", PAPERCLIP_OPENCODE_RUNTIME_DIR: input.options.opencodeRuntimeDirectory ?? resolve(input.options.stateDirectory ?? tmpdir(), "opencode"), @@ -865,6 +870,51 @@ export function createCapabilityRunnerdProviderEnvironment(input: { return environment; } +const OPEN_CODE_RUNNER_ENVIRONMENT_KEYS = new Set([ + "PATH", + "LANG", + "LANGUAGE", + "LC_ALL", + "LC_CTYPE", + "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", + "SystemRoot", + "PATHEXT", + "WINDIR", + "RUST_BACKTRACE", + "OPENROUTER_API_KEY", + "PAPERCLIP_NATIVE_MCP_NAME", + "PAPERCLIP_NATIVE_MCP_URL", + "PAPERCLIP_NATIVE_MCP_TOKEN", +]); + +function createSanitizedOpenCodeRunnerEnvironment( + source: NodeJS.ProcessEnv | undefined, +): NodeJS.ProcessEnv { + const candidate = { ...process.env, ...source }; + return Object.fromEntries( + Object.entries(candidate).filter( + ([key, value]) => + typeof value === "string" + && (OPEN_CODE_RUNNER_ENVIRONMENT_KEYS.has(key) + || /^LC_[A-Z0-9_]{1,32}$/.test(key)), + ), + ); +} + export function resolveSourceCodexHome( environment: NodeJS.ProcessEnv | undefined, ): string | null { @@ -1003,7 +1053,9 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { options.environment, options.acpxAgent ?? "codex", ).env - : createSanitizedCodexEnvironment(options.environment), + : options.provider === "opencode" + ? createSanitizedOpenCodeRunnerEnvironment(options.environment) + : createSanitizedCodexEnvironment(options.environment), ).sort(), diagnostics: ["lab transport selected authenticated durable PRP"], }; diff --git a/server/src/services/native-runtime/native-execution-input.ts b/server/src/services/native-runtime/native-execution-input.ts index bcd0d40ef4..64988a37b4 100644 --- a/server/src/services/native-runtime/native-execution-input.ts +++ b/server/src/services/native-runtime/native-execution-input.ts @@ -69,7 +69,7 @@ export function buildNativeExecutionInput(input: { ?? (input.issue.workMode === "planning" ? "plan" : "default"); const acpxProfile = input.provider === "acpx" ? resolveQualifiedAcpxProfile( - input.acpxAgent ?? "pi", + input.acpxAgent ?? "codex", input.model ?? "", ) : null; @@ -119,7 +119,7 @@ export function buildNativeExecutionInput(input: { kind: "acpx", agent: acpxProfile!.agent, model: input.model, - permissionMode: input.acpxPermissionMode ?? "approve-all", + permissionMode: input.acpxPermissionMode ?? "approve-reads", profile: { driverKind: acpxProfile!.driverKind, protocolVersion: acpxProfile!.protocolVersion, @@ -137,7 +137,7 @@ export function buildNativeExecutionInput(input: { ? { kind: "opencode", model: input.model, - permissionMode: input.opencodePermissionMode ?? "allow", + permissionMode: input.opencodePermissionMode ?? "ask", } : { kind: "codex", diff --git a/server/src/services/native-runtime/native-session-resume.test.ts b/server/src/services/native-runtime/native-session-resume.test.ts index a6d95541bf..206aaf3be6 100644 --- a/server/src/services/native-runtime/native-session-resume.test.ts +++ b/server/src/services/native-runtime/native-session-resume.test.ts @@ -221,6 +221,16 @@ describe("buildNativeExecutionInput wake projection", () => { model: "claude-sonnet-5", acpxPermissionMode: "deny-all", }); + const defaultOpenCode = buildNativeExecutionInput({ + ...common, + provider: "opencode", + model: "openrouter/z-ai/glm-5.2", + }); + const defaultAcpx = buildNativeExecutionInput({ + ...common, + provider: "acpx", + model: "gpt-5.6-sol", + }); expect(codex).toMatchObject({ schema: "paperclip.native-execution-input.v4", @@ -234,6 +244,16 @@ describe("buildNativeExecutionInput wake projection", () => { schema: "paperclip.native-execution-input.v4", provider: { kind: "acpx", permissionMode: "deny-all" }, }); + expect(defaultOpenCode).toMatchObject({ + provider: { kind: "opencode", permissionMode: "ask" }, + }); + expect(defaultAcpx).toMatchObject({ + provider: { + kind: "acpx", + agent: "codex", + permissionMode: "approve-reads", + }, + }); expect(JSON.stringify([codex, opencode, acpx])) .not.toMatch(/OPENAI_API_KEY|ANTHROPIC_API_KEY|AWS_SECRET_ACCESS_KEY|PAPERCLIP_API_KEY/); });