diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index e518a616a0b..bbb5e191cb8 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -2084,6 +2084,13 @@ If a repository is detached or its source configuration changes, its previous ta Agent, project, environment, secret, skill, and workspace config edits are sampled at the next run boundary. A heartbeat that is already running finishes with the config it started with. Native runners project explicitly configured task environment variables into provider processes and tool commands, including custom `PAPERCLIP_*` names such as `PAPERCLIP_PAGE_BUCKET`. Adding, changing, or removing a projected variable replaces a retained provider process at the next run boundary. Unchanged variables permit process reuse. The projection contains names only; secret values stay in the child environment. Ambient host secrets are not projected. Native projections allow up to 128 variable names, 64 KiB per entry, and 256 KiB of values in total. These native launch limits do not apply to legacy adapter configuration. Runtime authority, provider login credentials, and process-loader settings use their existing restricted paths. +Native OpenCode applies that projection when starting `opencode serve`, so its +shell tools receive both plain bindings and resolved company secrets from +`adapterConfig.env`. Forwarded values participate in diagnostic and provider-trace +redaction. The isolated HOME, provider credential proxy, and GitHub broker retain +their existing launch rules. OpenCode owns the `OPENCODE_*` and `XDG_*` +configuration variables; task bindings cannot override them. + When effective run config changes, Paperclip may intentionally skip a saved adapter session, refresh persisted workspace runtime config, replace a reused execution workspace, or avoid reusing a sandbox/environment lease. Fresh execution can lose adapter-specific session, workspace, or sandbox state; correctness of the next run's config takes priority over continuity. Plain environment values affect freshness through value hashes; run result JSON and workspace operation logs expose only the non-sensitive freshness decision categories, without storing secret values, full env maps, provider credentials, or private path details. An explicit `reuse_existing` Git workspace keeps its recorded source repository, including after its project-workspace row is deleted or the project primary changes. The server verifies the original company, project, repository origin, and worktree registration before reuse. It does not attach a deleted association to the new primary. A conflicting explicit project-workspace selection, missing source identity, or an unverified legacy local checkout blocks the run. Restore the original source. Before intentionally clearing the existing-workspace binding to choose a new repository, review and preserve its retained work. Missing worktrees may be reconstructed only from the verified original source; current project defaults are not a fallback. diff --git a/packages/paperclip-runner/src/drivers/opencode/environment.ts b/packages/paperclip-runner/src/drivers/opencode/environment.ts new file mode 100644 index 00000000000..c0afa5405ed --- /dev/null +++ b/packages/paperclip-runner/src/drivers/opencode/environment.ts @@ -0,0 +1,14 @@ +import { CONFIGURED_ENVIRONMENT_KEYS, configuredEnvironment } from "../../configured-environment.js"; + +export function configuredOpenCodeEnvironment(source: NodeJS.ProcessEnv | undefined): NodeJS.ProcessEnv { + const environment = configuredEnvironment(source); + for (const key of Object.keys(environment)) { + if (/^(?:OPENCODE_|XDG_)/i.test(key)) delete environment[key]; + } + if (environment[CONFIGURED_ENVIRONMENT_KEYS] !== undefined) { + environment[CONFIGURED_ENVIRONMENT_KEYS] = JSON.stringify( + Object.keys(environment).filter(key => key !== CONFIGURED_ENVIRONMENT_KEYS).sort(), + ); + } + return environment; +} diff --git a/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.test.ts b/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.test.ts index dff8d69d3e2..5a2c95e2ad0 100644 --- a/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.test.ts +++ b/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.test.ts @@ -30,6 +30,7 @@ import { type NativeRuntimeContextSnapshot, } from "../../contracts/runtime-context.js"; import { createCodexTaskEnvelope } from "../../contracts/codex.js"; +import { CONFIGURED_ENVIRONMENT_KEYS, configuredEnvironmentProjection } from "../../configured-environment.js"; import { localIntegrityBoundaryGolden } from "../../../test-support/local-integrity-boundary-golden.js"; import type { PrpEvent } from "../../protocol/replay-contract.js"; import { @@ -153,6 +154,126 @@ afterAll(async () => { }); describe("OpenCodeServerDriver", () => { + it("forwards configured bindings to the provider shell and redacts their values", async () => { + const root = await mkdtemp(join(tmpdir(), "paperclip-opencode-configured-env-")); + roots.push(root); + const bindings = { PROBE_PLAIN: "fixture-plain-binding", PROBE_SECRET: 'fixture-"quoted"\\path\nmultiline-binding' }; + const command = join(root, "probe-opencode.mjs"); + await writeFile(command, [ + "#!/usr/bin/env node", + 'import { execFileSync } from "node:child_process";', + 'import { writeFile } from "node:fs/promises";', + 'import { join } from "node:path";', + 'const lengths = execFileSync("/bin/sh", ["-c", \'printf "%s,%s" "${#PROBE_PLAIN}" "${#PROBE_SECRET}"\'], { encoding: "utf8" });', + 'await writeFile(join(process.env.XDG_DATA_HOME, "shell-probe.json"), JSON.stringify({ lengths, keys: JSON.parse(process.env.PAPERCLIP_CONFIGURED_ENV_KEYS ?? "[]") }));', + 'process.stderr.write(`plain binding: ${process.env.PROBE_PLAIN}\\nresolved binding: ${process.env.PROBE_SECRET}\\n`);', + `await import(${JSON.stringify(fixture)});`, + ].join("\n"), { mode: 0o755 }); + const diagnostics: string[] = []; + const tracePath = join(root, "provider-trace.ndjson"); + const driver = new OpenCodeServerDriver({ + model: "openrouter/test-model", command, runtimeDirectory: root, + environment: { + PATH: process.env.PATH, + ...configuredEnvironmentProjection({ + ...bindings, + OPENCODE_CONFIG: "/untrusted/opencode.json", + OPENCODE_CONFIG_CONTENT: '{"permission":{"*":"allow"}}', + OPENCODE_CONFIG_DIR: "/untrusted/opencode", + OPENCODE_PERMISSION: '{"*":"allow"}', + OPENCODE_DISABLE_PROJECT_CONFIG: "false", + XDG_CONFIG_HOME: "/untrusted/config", + XDG_DATA_HOME: "/untrusted/data", + XDG_CACHE_HOME: "/untrusted/cache", + XDG_STATE_HOME: "/untrusted/state", + }), + DATABASE_URL: "unbound-host-database", + PAPERCLIP_API_KEY: "controller-key", + NODE_OPTIONS: "--require=/untrusted/bootstrap.js", + PAPERCLIP_PROVIDER_TRACE_PATH: tracePath, + }, + onDiagnostic: message => diagnostics.push(message), + }); + const session = await driver.openSession({ runId: "bindings", normalizedSessionId: "bindings", workingDirectory: root }); + let persisted = ""; + try { + const probe = JSON.parse(await readFile(join(root, "bindings", "data", "shell-probe.json"), "utf8")); + expect(probe).toEqual({ lengths: `${bindings.PROBE_PLAIN.length},${bindings.PROBE_SECRET.length}`, keys: Object.keys(bindings).sort() }); + const child = JSON.parse(await readFile(join(root, "bindings", "data", "fake-environment.json"), "utf8")); + expect(child.keys).toEqual(expect.arrayContaining(Object.keys(bindings))); + for (const key of ["DATABASE_URL", "PAPERCLIP_API_KEY", "NODE_OPTIONS", "OPENCODE_CONFIG", + "OPENCODE_CONFIG_CONTENT", "OPENCODE_CONFIG_DIR", "OPENCODE_PERMISSION", "XDG_STATE_HOME"]) { + expect(child.keys).not.toContain(key); + } + expect(child.projectConfigDisabled).toBe("true"); + await session.startTurn({ message: { role: "user", text: "configured-environment-output" } }); + const events = await collectTurnEvents(session.events()); + expect(events.at(-1)?.eventType).toBe("turn.completed"); + persisted = JSON.stringify(await session.snapshot()) + + await readFile(join(root, "bindings", "config", "opencode", "opencode.json"), "utf8"); + } finally { + await session.close({ reason: "test" }); + } + const trace = await readFile(tracePath, "utf8"); + const frames = trace.trim().split("\n").map(line => JSON.parse(line)) + .filter(entry => entry.kind === "frame") + .map(entry => Buffer.from(entry.rawBase64, "base64").toString("utf8")); + expect(frames.find(frame => frame.includes("part-configured-binding-output"))).toContain('"output":"[REDACTED]"'); + persisted += trace + frames.join("\n"); + expect(diagnostics.join("\n")).toContain("plain binding: [REDACTED]"); + expect(diagnostics.join("\n")).toContain("resolved binding: [REDACTED]"); + for (const value of Object.values(bindings)) { + expect(diagnostics.join("\n")).not.toContain(value); + expect(persisted).not.toContain(value); + expect(persisted).not.toContain(JSON.stringify(value).slice(1, -1)); + } + }); + + it("preserves empty arrays in traces when all configured bindings are runtime-owned", async () => { + await chmod(fixture, 0o755); + const root = await mkdtemp(join(tmpdir(), "paperclip-opencode-empty-projection-")); + roots.push(root); + const tracePath = join(root, "provider-trace.ndjson"); + const driver = new OpenCodeServerDriver({ + model: "openrouter/test-model", command: fixture, runtimeDirectory: root, + environment: { PATH: process.env.PATH, PAPERCLIP_PROVIDER_TRACE_PATH: tracePath, + ...configuredEnvironmentProjection({ OPENCODE_CONFIG_CONTENT: "{}" }) }, + }); + const session = await driver.openSession({ runId: "empty", normalizedSessionId: "empty", workingDirectory: root }); + try { + expect(await session.read()).toMatchObject({ messages: [] }); + } finally { + await session.close({ reason: "test" }); + } + const entries = (await readFile(tracePath, "utf8")).trim().split("\n").map(line => JSON.parse(line)); + const frame = entries.find(entry => entry.kind === "frame" && entry.direction === "provider_to_client" + && entry.nativeMethod === "GET /session/ses_fake_1/message 200"); + expect(frame).toBeDefined(); + expect(Buffer.from(frame.rawBase64, "base64").toString("utf8")).toBe("[]"); + }); + + it.each([false, true])("ignores an ambient configured-environment projection (explicit environment: %s)", async explicit => { + await chmod(fixture, 0o755); + const root = await mkdtemp(join(tmpdir(), "paperclip-opencode-ambient-env-")); + roots.push(root); + vi.stubEnv(CONFIGURED_ENVIRONMENT_KEYS, '["PROBE_SECRET"]'); + vi.stubEnv("PROBE_SECRET", "ambient-binding-must-not-cross"); + try { + const driver = new OpenCodeServerDriver({ model: "openrouter/test-model", command: fixture, runtimeDirectory: root, + ...(explicit ? { environment: { PATH: process.env.PATH } } : {}) }); + const session = await driver.openSession({ runId: "ambient", normalizedSessionId: "ambient", workingDirectory: root }); + try { + const child = JSON.parse(await readFile(join(root, "ambient", "data", "fake-environment.json"), "utf8")); + expect(child.keys).not.toContain("PROBE_SECRET"); + expect(child.keys).not.toContain(CONFIGURED_ENVIRONMENT_KEYS); + } finally { + await session.close({ reason: "test" }); + } + } finally { + vi.unstubAllEnvs(); + } + }); + it.each([false, true])("uses the native MCP client ceiling only for durable tool requests (%s)", async durableToolRequests => { await chmod(fixture, 0o755); const root = await mkdtemp(join(tmpdir(), "paperclip-opencode-tool-budget-")); diff --git a/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.ts b/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.ts index bf623db3f9d..d0d992ffc4a 100644 --- a/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.ts +++ b/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.ts @@ -30,6 +30,8 @@ import { startOpenCodePerformanceDiagnostics } from "./performance-diagnostics.j import { PROVIDER_PERFORMANCE_ENV } from "../../contracts/provider-performance.js"; import { annotateOmittedProviderText, parseOpenCodeSseFrames } from "./sse-frames.js"; import { githubCredentialEnvironment } from "../../github-credential-environment.js"; +import { configuredOpenCodeEnvironment } from "./environment.js"; +import { CONFIGURED_ENVIRONMENT_KEYS } from "../../configured-environment.js"; import { CODEX_SKILLLESS_BASE_INSTRUCTIONS, @@ -2429,6 +2431,7 @@ async function startRuntime(input: { arguments: unknown; }) => Promise; }): Promise { + const taskEnvironment = configuredOpenCodeEnvironment(input.options.environment); const port = await reservePort(); const password = randomBytes(32).toString("base64url"); const username = "paperclip"; @@ -2475,6 +2478,8 @@ async function startRuntime(input: { const providerEnvironment = input.options.environment ?? process.env; const sensitiveValues = [ ...identityValues, + ...Object.entries(taskEnvironment).flatMap(([key, value]) => + key !== CONFIGURED_ENVIRONMENT_KEYS && value ? [value, JSON.stringify(value).slice(1, -1)] : []), password, authHeader, bridge.secret, @@ -3126,7 +3131,7 @@ function sanitizedEnvironment( const candidate = source ?? process.env; for (const key of allowed) if (candidate[key] !== undefined) result[key] = candidate[key]; - return { ...result, ...githubCredentialEnvironment(source ?? {}), ...overrides }; + return { ...result, ...configuredOpenCodeEnvironment(source), ...githubCredentialEnvironment(source ?? {}), ...overrides }; } function sanitizedEnvironmentKeys(): string[] { diff --git a/packages/paperclip-runner/test/fixtures/fake-opencode-server.mjs b/packages/paperclip-runner/test/fixtures/fake-opencode-server.mjs index f9891b9ec4a..7ed45de8fc3 100644 --- a/packages/paperclip-runner/test/fixtures/fake-opencode-server.mjs +++ b/packages/paperclip-runner/test/fixtures/fake-opencode-server.mjs @@ -495,6 +495,22 @@ const server = createServer(async (request, response) => { } setTimeout(async () => { const parsedPrompt = parsedPromptText(promptPayload); + if (String(parsedPrompt.message ?? parsedPrompt.task?.prompt ?? "").includes("configured-environment-output")) { + const output = await new Promise((resolve, reject) => { + execFile("/bin/sh", ["-c", 'printf "%s" "$PROBE_SECRET"'], { timeout: 5000 }, (error, stdout) => { + if (error) reject(error); else resolve(stdout); + }); + }); + const messageID = "message-configured-binding-output"; + emit({ type: "message.updated", properties: { sessionID: session.id, info: { id: messageID, role: "assistant" } } }); + emit({ type: "message.part.updated", properties: { sessionID: session.id, part: { + id: "part-configured-binding-output", messageID, type: "tool", tool: "bash", + state: { status: "completed", output }, + } } }); + await callTerminalTool(promptPayload); + emit({ type: "session.idle", properties: { sessionID: session.id } }); + return; + } const backlogTool = String(parsedPrompt.message ?? parsedPrompt.task?.prompt ?? "").match(/stream-backlog-(discovery|completion|durable)/)?.[1]; if (backlogTool) { const messageID = "message-stream-backlog";