fix(runner): expose Pi semantic tools and preserve projected Git credentials

This commit is contained in:
Dotta 2026-09-08 15:05:28 -05:00
parent f50fa19eed
commit 56daf4664d
9 changed files with 309 additions and 7 deletions

View File

@ -37,6 +37,11 @@ launcher. It does not make a model request. Live staging must still verify the
qualified OpenRouter model, work folders, saves, and recovery. The same image
exposes this Pi executable to the legacy adapter. Provider-pack shims resolve
links before locating their runtime so task-local launch paths remain valid.
Because this pinned Pi ACP adapter does not forward MCP tools, the native runner
stages a private Pi extension that reads the authenticated run-owned tool catalog.
It preserves tool schemas, idempotent call IDs, cancellation, and the bridge's
private-tool boundary. The bridge credential is injected at launch and excluded
from persisted environment records; project extensions remain untrusted.
Sandbox runs use the operating-system user's home directory. Both legacy
adapters and the native runner enter the same host-owned lifecycle before
@ -46,7 +51,9 @@ in the sandbox home. For API-key Codex ACP runs, the adapter writes the explicit
key to an owner-only login file in the staging copy; host credentials stay unchanged.
Per-run GitHub launchers declare their own CommonJS package scope so warm runs
inside ES-module repositories can still execute Git and GitHub CLI commands. Native runner launches carry the validated scoped paths
through runnerd to ACPX; CLI configuration remains in its private runtime directories.
through runnerd to ACPX, including the explicitly controller-projected GitHub
broker environment. ACPX does not inherit ambient host GitHub credentials or
shell startup hooks. CLI configuration remains in its private runtime directories.
Warm sandbox task bindings persist independently of the experimental isolated
workspace setting. Only the active host run can establish that binding; the
setting still controls user-configurable worktree operations.

View File

@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { createSanitizedAcpxSpawnInput } from "./environment.js";
@ -45,6 +45,41 @@ describe("ACPX launch environment", () => {
expect(Object.isFrozen(codex.env)).toBe(true);
});
it.each(["codex", "claude", "pi"] as const)(
"preserves controller-projected GitHub credentials for %s",
(agent) => {
const projected = {
PAPERCLIP_GITHUB_BROKER_URL: "http://127.0.0.1:1234",
PAPERCLIP_GITHUB_BROKER_TOKEN: "run-scoped-secret",
PAPERCLIP_GITHUB_LAUNCHER_DIR: "/private/github",
BASH_ENV: "/private/github/bashrc",
GIT_CONFIG_COUNT: "1",
GIT_CONFIG_KEY_0: "credential.helper",
GIT_CONFIG_VALUE_0: "/private/helper",
};
const input = createSanitizedAcpxSpawnInput({
...projected,
GIT_CONFIG_KEY_1: "not.projected",
GIT_CONFIG_VALUE_1: "not-visible",
}, agent);
expect(input.env).toEqual(projected);
},
);
it("does not inherit ambient host GitHub credentials or shell hooks", () => {
vi.stubEnv("PAPERCLIP_GITHUB_BROKER_TOKEN", "host-secret");
vi.stubEnv("GITHUB_TOKEN", "host-secret");
vi.stubEnv("BASH_ENV", "/host/startup");
try {
const { env } = createSanitizedAcpxSpawnInput(undefined, "pi");
expect(env).not.toHaveProperty("PAPERCLIP_GITHUB_BROKER_TOKEN");
expect(env).not.toHaveProperty("GITHUB_TOKEN");
expect(env).not.toHaveProperty("BASH_ENV");
} finally {
vi.unstubAllEnvs();
}
});
it("rejects unsafe or unbounded retained values", () => {
expect(() =>
createSanitizedAcpxSpawnInput({ PATH: "bad\0path" }, "codex"),

View File

@ -1,3 +1,4 @@
import { githubCredentialEnvironment } from "../../github-credential-environment.js";
import type { QualifiedAcpxAgent } from "./qualified-profiles.js";
import { externalWorkFolderEnvironment } from "../../work-folder-environment.js";
@ -25,6 +26,9 @@ export function createSanitizedAcpxSpawnInput(
): SanitizedAcpxSpawnInput {
const source = environment ?? process.env;
const scoped = externalWorkFolderEnvironment(source);
// Only explicit controller input may carry repository credentials. Never
// discover GitHub credentials or shell startup hooks in the host environment.
const github = environment ? githubCredentialEnvironment(environment) : {};
const result: NodeJS.ProcessEnv = {};
const credentialNames =
agent === "pi"
@ -59,6 +63,7 @@ export function createSanitizedAcpxSpawnInput(
"PAPERCLIP_NATIVE_MCP_URL",
...credentialNames,
...Object.keys(scoped),
...Object.keys(github),
]);
let retainedBytes = 0;
for (const [key, value] of Object.entries({ ...source, ...scoped })) {

View File

@ -0,0 +1,126 @@
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { DefaultResourceLoader } from "@earendil-works/pi-coding-agent";
import { startRunnerToolBridge, type RunnerToolBridge } from "../runner-tool-bridge.js";
import { PRP_COMPLETION_TOOL_NAME } from "../../contracts/completion-result.js";
import { PI_RUNNER_TOOL_EXTENSION } from "./pi-tool-extension.js";
const directories: string[] = [];
const bridges: RunnerToolBridge[] = [];
afterEach(async () => {
vi.unstubAllEnvs();
await Promise.all(bridges.splice(0).map(bridge => bridge.close()));
await Promise.all(directories.splice(0).map(dir => rm(dir, { recursive: true, force: true })));
});
async function loadExtension(bridge?: RunnerToolBridge) {
const root = await mkdtemp(join(tmpdir(), "paperclip-pi-tools-"));
directories.push(root);
const agentDir = join(root, "private-pi");
const cwd = join(root, "workspace");
await mkdir(join(agentDir, "extensions"), { recursive: true });
await mkdir(join(cwd, ".pi", "extensions"), { recursive: true });
await writeFile(join(agentDir, "extensions", "paperclip-runner-tools.js"), PI_RUNNER_TOOL_EXTENSION);
await writeFile(join(agentDir, "settings.json"), JSON.stringify({ defaultProjectTrust: "never" }));
await writeFile(join(cwd, ".pi", "extensions", "untrusted.js"), 'export default function() { throw new Error("PROJECT_EXTENSION_LOADED"); }');
vi.stubEnv("PAPERCLIP_PI_TOOL_BRIDGE_URL", bridge?.url);
vi.stubEnv("PAPERCLIP_PI_TOOL_BRIDGE_TOKEN", bridge?.secret);
const loader = new DefaultResourceLoader({ cwd, agentDir, noSkills: true, noPromptTemplates: true, noThemes: true, noContextFiles: true });
await loader.reload({ resolveProjectTrust: async () => false });
return loader.getExtensions();
}
const definition = {
name: "read_cached_file",
description: "Read a cached file",
inputSchema: {
type: "object", properties: { path: { type: "string" } },
required: ["path"], additionalProperties: false,
},
};
describe("Pi runner tool extension", () => {
it("loads through the pinned Pi runtime, preserves schemas and uses the authenticated bridge", async () => {
const handler = vi.fn(async () => ({ bytes: "saved-content" }));
const bridge = await startRunnerToolBridge({
tools: [definition],
privateTools: [{ ...definition, name: "private_control" }],
handler,
});
bridges.push(bridge);
const loaded = await loadExtension(bridge);
expect(loaded.errors).toEqual([]);
expect(loaded.extensions).toHaveLength(1);
const tools = loaded.extensions[0]!.tools;
expect(tools.has("private_control")).toBe(false);
expect([...tools.keys()]).toContain(PRP_COMPLETION_TOOL_NAME);
const tool = tools.get(definition.name)!.definition;
expect(tool.parameters).toEqual(definition.inputSchema);
const execute = (id: string, args: unknown) => tool.execute(id, args, undefined, undefined, {} as never);
expect(await execute("same-call", { path: "file.txt" })).toMatchObject({
content: [{ type: "text", text: '{"bytes":"saved-content"}' }],
});
await execute("same-call", { path: "file.txt" });
expect(handler).toHaveBeenCalledTimes(1);
await expect(execute("same-call", { path: "different.txt" })).rejects.toThrow("Duplicate call identity conflict");
await expect(execute("invalid-input", { path: 123 })).rejects.toThrow("Invalid tool input");
expect(handler).toHaveBeenCalledTimes(1);
await bridge.close();
await expect(execute("closed-bridge", { path: "file.txt" })).rejects.toThrow();
});
it("cancels the admitted server operation when Pi aborts the tool", async () => {
let started!: () => void;
const admitted = new Promise<void>(resolve => { started = resolve; });
let serverSignal: AbortSignal | undefined;
const bridge = await startRunnerToolBridge({ tools: [definition], handler: async ({ signal }) => {
serverSignal = signal;
started();
return new Promise((_resolve, reject) => signal.addEventListener("abort", () => reject(new Error("cancelled")), { once: true }));
} });
bridges.push(bridge);
const loaded = await loadExtension(bridge);
expect(loaded.errors).toEqual([]);
const tool = loaded.extensions[0]!.tools.get(definition.name)!.definition;
const controller = new AbortController();
const call = tool.execute("cancel-me", { path: "file.txt" }, controller.signal, undefined, {} as never);
const rejected = expect(call).rejects.toThrow();
await admitted;
controller.abort();
await rejected;
await vi.waitFor(() => expect(serverSignal?.aborted).toBe(true));
});
it("rejects oversized requests and responses with bounded reads", async () => {
const handler = vi.fn(async () => ({ data: "x".repeat(4 * 1024 * 1024) }));
const bridge = await startRunnerToolBridge({ tools: [definition], handler });
bridges.push(bridge);
const loaded = await loadExtension(bridge);
expect(loaded.errors).toEqual([]);
const tool = loaded.extensions[0]!.tools.get(definition.name)!.definition;
await expect(tool.execute("large-request", { path: "x".repeat(1024 * 1024) }, undefined, undefined, {} as never))
.rejects.toThrow("request is too large");
expect(handler).not.toHaveBeenCalled();
await expect(tool.execute("large-response", { path: "file.txt" }, undefined, undefined, {} as never))
.rejects.toThrow("response is too large");
expect(handler).toHaveBeenCalledOnce();
});
it("fails loading with invalid credentials without exposing the credential", async () => {
const bridge = await startRunnerToolBridge({ tools: [definition], handler: async () => ({}) });
bridges.push(bridge);
const loaded = await loadExtension({ ...bridge, secret: "invalid-secret" });
expect(loaded.extensions).toHaveLength(0);
expect(loaded.errors).toHaveLength(1);
expect(loaded.errors[0]!.error).toContain("HTTP 401");
expect(loaded.errors[0]!.error).not.toContain("invalid-secret");
});
it("registers nothing without a run-owned bridge", async () => {
const loaded = await loadExtension();
expect(loaded.errors).toEqual([]);
expect(loaded.extensions[0]!.tools.size).toBe(0);
});
});

View File

@ -0,0 +1,92 @@
/**
* pi-acp 0.0.33 does not forward MCP servers to Pi. Install this runner-owned
* extension in Pi's private global extension directory instead. The source is
* self-contained so the verified provider needs no additional package imports.
* Credentials are supplied only in the admitted child environment, never here.
*/
export const PI_RUNNER_TOOL_EXTENSION = String.raw`
export default async function paperclipRunnerTools(pi) {
const endpoint = process.env.PAPERCLIP_PI_TOOL_BRIDGE_URL;
const token = process.env.PAPERCLIP_PI_TOOL_BRIDGE_TOKEN;
if (!endpoint && !token) return;
const url = new URL(endpoint);
if (url.protocol !== "http:" || url.hostname !== "127.0.0.1" ||
url.pathname !== "/mcp" || url.search || url.hash ||
url.username || url.password || !token) {
throw new Error("Invalid private Paperclip tool bridge");
}
const headers = { "Authorization": "Bearer " + token, "Content-Type": "application/json" };
const maxBytes = 4 * 1024 * 1024;
async function rpc(method, params, id, signal) {
const body = JSON.stringify({ jsonrpc: "2.0", id, method, params });
if (Buffer.byteLength(body) > 1024 * 1024) throw new Error("Paperclip tool request is too large");
const deadline = AbortSignal.timeout(120000);
const abort = signal ? AbortSignal.any([signal, deadline]) : deadline;
const cancel = () => {
if (method !== "tools/call") return;
// Cancel the server operation as well as the HTTP read. A fresh short
// deadline lets cancellation reach the bridge after the caller aborts.
void fetch(url, {
method: "POST", headers, redirect: "error", signal: AbortSignal.timeout(5000),
body: JSON.stringify({ jsonrpc: "2.0", method: "notifications/cancelled", params: { requestId: id } }),
}).then(response => response.body?.cancel()).catch(() => {});
};
abort.throwIfAborted();
abort.addEventListener("abort", cancel, { once: true });
let reader;
try {
const response = await fetch(url, { method: "POST", headers, body, signal: abort, redirect: "error" });
if (!response.ok || !response.body) {
await response.body?.cancel();
throw new Error("Paperclip tool bridge request failed (HTTP " + response.status + ")");
}
reader = response.body.getReader();
let size = 0;
const chunks = [];
for (;;) {
const { done, value } = await reader.read();
if (done) break;
size += value.byteLength;
if (size > maxBytes) throw new Error("Paperclip tool response is too large");
chunks.push(value);
}
const message = JSON.parse(Buffer.concat(chunks).toString("utf8"));
if (message.jsonrpc !== "2.0" || message.id !== id || message.error || !message.result) {
throw new Error("Paperclip tool bridge returned an invalid response");
}
return message.result;
} finally {
abort.removeEventListener("abort", cancel);
await reader?.cancel().catch(() => {});
}
}
const catalog = await rpc("tools/list", {}, "pi-catalog");
if (!Array.isArray(catalog.tools) || catalog.tools.length > 256) {
throw new Error("Invalid Paperclip tool catalog");
}
const names = new Set();
for (const tool of catalog.tools) {
if (typeof tool.name !== "string" || !tool.name || names.has(tool.name) ||
!tool.inputSchema || tool.inputSchema.type !== "object") {
throw new Error("Invalid Paperclip tool definition");
}
names.add(tool.name);
}
for (const tool of catalog.tools) {
pi.registerTool({
name: tool.name,
label: tool.name,
description: tool.description || tool.name,
parameters: tool.inputSchema,
async execute(callId, args, signal) {
const result = await rpc("tools/call", { name: tool.name, arguments: args }, callId, signal);
if (!Array.isArray(result.content)) throw new Error("Invalid Paperclip tool result");
if (result.isError) {
throw new Error(result.content.filter(item => item.type === "text").map(item => item.text).join("\n"));
}
return { content: result.content, details: {} };
},
});
}
}
`;

View File

@ -452,7 +452,7 @@ describe("ACPX runtime host", () => {
expect(fixture.commandClose).toHaveBeenCalledOnce();
});
it("owns an authenticated semantic bridge without persisting its secret", async () => {
it.each(["codex", "pi"] as const)("owns a %s semantic bridge without persisting its secret", async (agent) => {
const fixture = await hostFixture();
const handler = vi.fn(async ({ tool }) => ({ tool, ok: true }));
let bridge:
@ -461,8 +461,8 @@ describe("ACPX runtime host", () => {
const host = await AcpxRuntimeHost.open(
{
...fixture.options,
agent: "codex",
model: "gpt-5.6-sol",
agent,
model: resolveQualifiedAcpxProfile(agent, agent === "pi" ? "openrouter/deepseek/deepseek-v4-flash-0731" : "gpt-5.6-sol").qualificationModel,
permissionMode: "deny-all",
environment: { PAPERCLIP_ACPX_CODEX_AUTH_JSON_SECRET: "{}" },
semanticTools: {
@ -484,7 +484,16 @@ describe("ACPX runtime host", () => {
fixture.dependencies({
openRuntime: async (options) => {
bridge = options.mcpServers[0];
return runtimePort();
if (agent === "pi") {
expect(options.launchEnvironment.PAPERCLIP_PI_TOOL_BRIDGE_URL).toBe(bridge!.url);
expect(options.launchEnvironment.PAPERCLIP_PI_TOOL_BRIDGE_TOKEN).toBe(bridge!.bearerToken);
} else {
expect(options.launchEnvironment.PAPERCLIP_PI_TOOL_BRIDGE_TOKEN).toBeUndefined();
}
return runtimePort({ getStatus: async () => ({ models: {
currentModelId: options.profile.reportedModelId,
availableModelIds: [options.profile.reportedModelId],
} }) });
},
}),
);

View File

@ -398,7 +398,13 @@ export class AcpxRuntimeHost {
permissionPolicy: acpxRuntimePermissionPolicy(
binding.permissionMode,
),
launchEnvironment: sandbox.launchEnvironment,
launchEnvironment: profile.agent === "pi" && toolBridge
? Object.freeze({
...sandbox.launchEnvironment,
PAPERCLIP_PI_TOOL_BRIDGE_URL: toolBridge.url,
PAPERCLIP_PI_TOOL_BRIDGE_TOKEN: toolBridge.secret,
})
: sandbox.launchEnvironment,
credentialFenceFds: admittedLifetime.lifetimeFenceFds,
activateCredentialFenceOwner:
admittedLifetime.activateLifetimeOwner.bind(admittedLifetime),

View File

@ -53,6 +53,8 @@ describe("ACPX runtime sandbox", () => {
PAPERCLIP_NATIVE_MCP_URL:
"https://mcp.example.test/connect?ticket=secret",
PAPERCLIP_NATIVE_MCP_TOKEN: "native-secret",
PAPERCLIP_GITHUB_BROKER_TOKEN: "github-secret",
PAPERCLIP_PI_TOOL_BRIDGE_TOKEN: "untrusted-bridge-secret",
},
});
@ -75,6 +77,9 @@ describe("ACPX runtime sandbox", () => {
);
expect(Object.isFrozen(sandbox.launchEnvironment)).toBe(true);
expect(sandbox.persistedEnvironment[credentialName]).toBeUndefined();
expect(sandbox.launchEnvironment.PAPERCLIP_GITHUB_BROKER_TOKEN).toBe("github-secret");
expect(sandbox.persistedEnvironment.PAPERCLIP_GITHUB_BROKER_TOKEN).toBeUndefined();
expect(sandbox.launchEnvironment.PAPERCLIP_PI_TOOL_BRIDGE_TOKEN).toBeUndefined();
expect(sandbox.persistedEnvironment.HTTPS_PROXY).toBeUndefined();
expect(
sandbox.persistedEnvironment.PAPERCLIP_NATIVE_MCP_URL,
@ -109,6 +114,14 @@ describe("ACPX runtime sandbox", () => {
);
}
if (agent === "pi") {
const extensionPath = join(sandbox.agentHomeDirectory, "extensions", "paperclip-runner-tools.js");
const extension = await readFile(extensionPath, "utf8");
expect(extension).toContain("pi.registerTool");
expect(extension).not.toContain("github-secret");
expect(extension).not.toContain("untrusted-bridge-secret");
if (process.platform !== "win32") {
expect((await stat(extensionPath)).mode & 0o777).toBe(0o600);
}
await expect(
readFile(join(sandbox.agentHomeDirectory, "settings.json"), "utf8"),
).resolves.toContain('"defaultProjectTrust":"never"');

View File

@ -28,6 +28,7 @@ import {
resolve,
} from "node:path";
import { PI_RUNNER_TOOL_EXTENSION } from "./pi-tool-extension.js";
import { createSanitizedAcpxSpawnInput } from "./environment.js";
import type { QualifiedAcpxAgent } from "./qualified-profiles.js";
import {
@ -368,6 +369,14 @@ export async function prepareAcpxRuntimeSandbox(input: {
`${input.binding.workspacePath}\n`,
);
if (input.agent === "pi") {
const extensionsDirectory = await ensurePrivateDirectory(
join(agentHomeDirectory, "extensions"),
agentHomeDirectory,
);
await writePrivateFile(
join(extensionsDirectory, "paperclip-runner-tools.js"),
PI_RUNNER_TOOL_EXTENSION,
);
await writePrivateFile(
join(agentHomeDirectory, "settings.json"),
`${JSON.stringify({