fix(runner): forward configured bindings into OpenCode (#19319)

Forward controller-selected task bindings into OpenCode shell commands. Keep OpenCode runtime configuration isolated. Redact raw and JSON-escaped binding values while preserving the non-secret environment manifest.

Fixes #19257

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-11-05 14:01:12 -06:00
1 parent 86a9d3ec6c
commit 62a5aa7705
5 files changed
+164 -1

No files matched your search

+7
View File
@@ -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.
@@ -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;
}
@@ -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-"));
@@ -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<unknown>;
}): Promise<OpenCodeRuntime> {
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[] {
@@ -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";