mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-30 21:49:18 +01:00
Add pinned Pi rich ACP wrapper and owned runtime extension
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
f063fbf2bc
commit
d975d4ceb7
10 files changed
+1865
No files matched your search
@@ -0,0 +1,133 @@
|
||||
# Pi rich ACP runtime
|
||||
|
||||
Status: implementation candidate, 2026-09-28. Deterministic protocol tests and an
|
||||
uncredentialed admission probe pass. Paid local inference and Linux x64 Daytona
|
||||
qualification are pending; this document does not promote the candidate to a
|
||||
qualified production runtime.
|
||||
|
||||
The runner pins `pi-acp@0.0.33` and
|
||||
`@earendil-works/pi-coding-agent@0.84.2`. The candidate model is
|
||||
`openrouter/deepseek/deepseek-v4-flash-0731`; only an explicitly bound OpenRouter
|
||||
credential may reach this profile. `patches/pi-acp@0.0.33.patch` repairs the ACP
|
||||
wrapper. `pi-runtime-extension.ts` supplies the runner-owned semantic bridge and
|
||||
pre-execution native tool policy. This keeps the upstream session, model,
|
||||
streaming, diff, retry, and compaction implementation while making its missing
|
||||
boundaries explicit.
|
||||
|
||||
## Capabilities and differences from Codex app-server
|
||||
|
||||
| Surface | Pi implementation and boundary |
|
||||
| --- | --- |
|
||||
| Sessions | Native Pi JSONL create/load; resume is confined to the execution's private session home and original workspace. Cold `prompt` cannot implicitly load an arbitrary session. |
|
||||
| Text and reasoning | Upstream streams text and thought chunks. The common runner's private reasoning policy still applies; provider support does not authorize retention or display. |
|
||||
| Native tools | `read`, `grep`, `find`, `ls`, `write`, `edit`, and `bash` pass the immutable extension gate before execution. File roots and read-only mode are checked before and after a permission wait. The host sandbox remains authoritative for shell commands and races. |
|
||||
| Permissions | Native tool approval uses ACP `session/request_permission`, with allow once, allow for the session, and deny. Only offered options are accepted. Questions never become approvals. |
|
||||
| Questions | Pi `select`, `confirm`, `input`, and `editor` map to ACP form elicitation with typed schemas. Decline, cancellation, timeout, malformed replies, and duplicate/late replies cannot become accepted answers. |
|
||||
| Semantic tools | Runner-owned loopback HTTP MCP catalogs register under exact `mcp__<server>__<tool>` names. Calls retain the native tool call ID and cancellation signal. Authenticated PRP tool handling owns semantic authorization and durable interactions. Ambient MCP and external MCP servers are not admitted. |
|
||||
| Plans and artifacts | Pi has no native structured plan or artifact channel. Paperclip plan and artifact semantic tools remain available through the MCP bridge; native file edits retain bounded, workspace-confined ACP diff projection. Tool text/image results are preserved, and resource blocks are recorded without following URLs. |
|
||||
| Steering | Capability-negotiated `pi/steer` issues native RPC `steer` during an active turn. `pi/follow_up` explicitly queues native RPC `follow_up`. Neither is inferred from a second ACP prompt. Each takes `{sessionId, message}` and returns `{accepted: true}`. |
|
||||
| Usage | Prompt results sum actual assistant message usage receipts across continuations. Input, output, cache reads/writes, total tokens and provider-reported cost have provenance. Context-window occupancy is not billed usage. No receipt means no usage assertion. |
|
||||
| Retry and compaction | Upstream retry/compaction notices are retained. `agent_settled`, rather than a transient `agent_end`, settles a prompt. A final provider error remains a failed prompt and does not become successful completion. |
|
||||
| Images | Upstream ACP image prompt blocks are passed to native Pi RPC. Model-specific image support still requires live qualification. |
|
||||
| Cancellation and death | Cancellation expires live UI waits and calls native abort. Pi process exit rejects pending RPC requests and all active/queued turns. Partial RPC frames, oversized frames, and malformed JSON fail closed. |
|
||||
| Fork, durable goal, native plan | Unavailable. Do not advertise Codex parity for these features. |
|
||||
|
||||
`initialize` advertises `_meta.paperclipPi.version = 1`, `steering`,
|
||||
`queuedFollowUp`, the four question methods, `nativePermissions`, `promptUsage`,
|
||||
`nativePlan: false`, and `pendingRequestRecovery: "live-process-only"`. The common
|
||||
host must inspect this advertisement before sending provider extension requests.
|
||||
|
||||
## Lifetime and recovery
|
||||
|
||||
A provider UI request is a live Pi RPC promise. Paperclip's durable interaction
|
||||
can survive a control-plane connection loss while the runner, wrapper, and Pi
|
||||
process remain alive. A response is forwarded once to that exact pending request.
|
||||
If Pi or the wrapper dies, the interaction must expire; neither a new process nor
|
||||
a loaded JSONL session has the original promise. Do not replay an approval into a
|
||||
replacement process. Normal conversation continuation can load the private JSONL
|
||||
session after a separately admitted restart.
|
||||
|
||||
The wrapper attaches a per-turn generation to settlement work, fails queued
|
||||
turns on provider death, and bounds RPC request waits. This does not constitute a
|
||||
claim of generic provider-process handoff or exactly-once external side effects.
|
||||
|
||||
## Verified launch and packaging contract
|
||||
|
||||
The wrapper rejects ambient launch unless `PAPERCLIP_ACPX_ISOLATED_CONTEXT=1`.
|
||||
It executes an absolute bound Node executable with an absolute bound Pi CLI path,
|
||||
without a shell or PATH lookup. The common command lease must supply:
|
||||
|
||||
- `PAPERCLIP_PI_NODE_EXECUTABLE`, `PAPERCLIP_PI_ENTRYPOINT`, and
|
||||
`PAPERCLIP_PI_EXTENSION_PATH`: files in the immutable private provider snapshot.
|
||||
- `PI_CODING_AGENT_DIR`: isolated persisted session/auth/settings home.
|
||||
- `PAPERCLIP_PI_READ_ONLY`: exactly `0` or `1`.
|
||||
- `PAPERCLIP_PI_READ_ROOTS` and `PAPERCLIP_PI_PROTECTED_ROOTS`: JSON arrays of
|
||||
absolute allowed skill/read roots and protected runtime/config roots.
|
||||
- `PAPERCLIP_PI_SYSTEM_INSTRUCTIONS`: admitted instructions, bounded to 32 KiB.
|
||||
|
||||
The wrapper synthesizes `PAPERCLIP_PI_RUNTIME_CONFIGURATION` for its child,
|
||||
including only session-bound MCP servers. The owned extension captures and deletes
|
||||
this configuration environment variable before model shell execution. Credentials
|
||||
remain accessible only through the deliberately admitted runtime mechanisms.
|
||||
|
||||
Pi starts with `--no-extensions --no-skills --no-prompt-templates --no-themes
|
||||
--no-approve --offline`, followed by the explicit immutable extension and admitted
|
||||
skill paths. Project trust is denied; user-bash RPC and terminal login are disabled.
|
||||
Only the controlled `/compact`, `/session`, and `/autocompact` slash commands are
|
||||
accepted. Startup package update checks are disabled.
|
||||
|
||||
Pi can continue after a broken extension. Therefore the wrapper probes
|
||||
`get_commands` and requires the exact readiness sentinel
|
||||
`paperclip-runtime-ready-v1` / `Paperclip runtime gate v1`. The extension registers
|
||||
this command only after gates and every MCP catalog finish initialization. A
|
||||
missing sentinel kills the subprocess before the first model prompt.
|
||||
|
||||
`pi-verified-runtime.ts` inventories and checks the complete graph, including
|
||||
Node, wrapper helper, extension, package metadata, native libraries, WASM and
|
||||
resources. The manifest schema is `paperclip.pi-runtime-files.v1`, with relative
|
||||
`node`, `piEntrypoint`, `extension`, `wrapperEntrypoint` fields and a complete
|
||||
`files` array of SHA-256-bound regular files or contained relative symlinks.
|
||||
Hardlinked files, escaping links, changed files, and undeclared files fail
|
||||
admission. Its verifier returns the three launch environment bindings and a
|
||||
manifest digest. Admission verification alone is not a command lease: the shared
|
||||
host must retain its immutable snapshot and process guardian through termination.
|
||||
|
||||
The published Pi package has a shrinkwrap and a nested dependency graph. Provider
|
||||
pack creation must copy the complete installed graph and use the same pinned Pi
|
||||
family dependencies, rather than reconstructing the graph from `cli.js` imports.
|
||||
The emitted `dist/drivers/acpx/pi-runtime-extension.js` must be included in every
|
||||
macOS arm64/x64 and Linux x64 pack. The wrapper's `dist/paperclip-runtime.js` is
|
||||
part of its verified package and must never be omitted from a copy or hash.
|
||||
|
||||
## Verification and maintenance
|
||||
|
||||
The three colocated Vitest suites exercise questions, permissions, timeouts,
|
||||
framing, usage, file policies, MCP behavior, and graph verification. The Node test
|
||||
`test/pi-acp-package-contract.test.mjs` launches the actual patched npm wrapper
|
||||
against a deterministic RPC fixture. It proves initialization, streaming,
|
||||
terminal usage, question versus permission routing, explicit steering, provider
|
||||
exit, required sentinel, and typed failure settlement. It requires the exact
|
||||
installed pinned package; an absent or unpatched dependency is a failure.
|
||||
`PAPERCLIP_TEST_PI_ACP_PACKAGE` can select a separately installed pinned fixture.
|
||||
These fixtures invoke no model or paid API.
|
||||
|
||||
A no-key probe against the real Pi 0.84.2 CLI loaded the compiled owned extension
|
||||
and returned the exact readiness command. This checks the extension ABI and
|
||||
explicit loading path. It does not prove authenticated inference, native tool
|
||||
execution, MCP model invocation, credential renewal, or remote sandbox behavior.
|
||||
Those remain required local and Daytona qualification evidence under the shared
|
||||
live-spend cap. No live inference spend was incurred by these deterministic tests.
|
||||
|
||||
The helper in `patches/pi-acp@0.0.33.patch` is generated from
|
||||
`src/drivers/acpx/pi-acp-runtime.ts` with Node `stripTypeScriptTypes`, trimming
|
||||
trailing whitespace. The package contract checks that these bytes match. When
|
||||
updating the upstream version, rebase the wrapper patch and rerun the actual
|
||||
package tests; do not preserve an old qualified command hash after changing bytes.
|
||||
|
||||
Primary references reviewed:
|
||||
|
||||
- [Harness priorities report](https://pages.paperclip.ing/2026-09-25-harness-priorities/report.md)
|
||||
- [pi-acp source](https://github.com/svkozak/pi-acp)
|
||||
- [Pi RPC protocol](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/rpc.md)
|
||||
- [Pi extensions](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/extensions.md)
|
||||
- [ACP protocol and SDK](https://github.com/agentclientprotocol/typescript-sdk)
|
||||
@@ -0,0 +1,96 @@
|
||||
import { mkdtemp, mkdir, rm, symlink, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createPiLaunchSpec, PiRpcFrames, PiTurnUsage, PiUiBridge } from "./pi-acp-runtime.js";
|
||||
|
||||
const temporary: string[] = [];
|
||||
afterEach(async () => { vi.useRealTimers(); for (const path of temporary.splice(0)) await rm(path, { recursive: true, force: true }); });
|
||||
function fixture() {
|
||||
const connection = { requestPermission: vi.fn(), unstable_createElicitation: vi.fn() };
|
||||
const process = { sendExtensionUiResponse: vi.fn().mockResolvedValue(undefined) };
|
||||
return { connection, process, bridge: new PiUiBridge("session-a", connection, process) };
|
||||
}
|
||||
|
||||
describe("Pi ACP bridge", () => {
|
||||
it.each([
|
||||
["select", { options: ["Red", "Blue"] }, "Blue", { value: "Blue" }],
|
||||
["confirm", { message: "Proceed?" }, false, { confirmed: false }],
|
||||
["input", { placeholder: "Name" }, "Ada", { value: "Ada" }],
|
||||
["editor", { prefill: "Old\nText" }, "New\nText", { value: "New\nText" }],
|
||||
])("round-trips %s as questions independently of permission auto-approval", async (method, extra, answer, expected) => {
|
||||
const f = fixture(); f.connection.unstable_createElicitation.mockResolvedValue({ action: "accept", content: { answer } });
|
||||
await f.bridge.handle({ id: "q1", method, title: "Choice", ...extra });
|
||||
expect(f.connection.requestPermission).not.toHaveBeenCalled();
|
||||
expect(f.connection.unstable_createElicitation).toHaveBeenCalledWith(expect.objectContaining({ sessionId: "session-a", mode: "form", requestedSchema: expect.objectContaining({ required: ["answer"] }) }));
|
||||
expect(f.process.sendExtensionUiResponse).toHaveBeenCalledExactlyOnceWith({ id: "q1", ...expected });
|
||||
});
|
||||
|
||||
it("sends only offered native permission choices and keeps questions separate", async () => {
|
||||
const f = fixture(); f.connection.requestPermission.mockResolvedValue({ outcome: { outcome: "selected", optionId: "allow_always" } });
|
||||
const event = { id: "p1", method: "select", title: 'paperclip.pi.permission.v1:{"toolCallId":"tool-a","toolName":"bash","input":{"command":"pwd"}}' };
|
||||
await f.bridge.handle(event);
|
||||
expect(f.connection.unstable_createElicitation).not.toHaveBeenCalled();
|
||||
expect(f.process.sendExtensionUiResponse).toHaveBeenCalledExactlyOnceWith({ id: "p1", value: "Allow for this session" });
|
||||
expect(f.connection.requestPermission.mock.calls[0]![0]).toMatchObject({ toolCall: { toolCallId: "tool-a", kind: "execute" }, options: [{ kind: "allow_once" }, { kind: "allow_always" }, { kind: "reject_once" }] });
|
||||
await f.bridge.handle(event); expect(f.connection.requestPermission).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("expires pending requests and discards a late answer exactly once", async () => {
|
||||
const f = fixture(); let answer!: (value: unknown) => void;
|
||||
f.connection.unstable_createElicitation.mockImplementation(() => new Promise((resolve) => { answer = resolve; }));
|
||||
const request = f.bridge.handle({ id: "q", method: "input", title: "Question" });
|
||||
f.bridge.cancelAll(); f.bridge.cancelAll();
|
||||
answer({ action: "accept", content: { answer: "stale" } }); await request;
|
||||
expect(f.process.sendExtensionUiResponse).toHaveBeenCalledExactlyOnceWith({ id: "q", cancelled: true });
|
||||
});
|
||||
|
||||
it("honors Pi dialog deadlines and rejects invalid answers", async () => {
|
||||
vi.useFakeTimers(); const f = fixture(); let answer!: (value: unknown) => void;
|
||||
f.connection.unstable_createElicitation.mockImplementation(() => new Promise((resolve) => { answer = resolve; }));
|
||||
const request = f.bridge.handle({ id: "q", method: "select", title: "Question", options: ["A"], timeout: 100 });
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
answer({ action: "accept", content: { answer: "A" } }); await request;
|
||||
expect(f.process.sendExtensionUiResponse).toHaveBeenCalledExactlyOnceWith({ id: "q", cancelled: true });
|
||||
f.connection.unstable_createElicitation.mockResolvedValue({ action: "accept", content: { answer: "other" } });
|
||||
await f.bridge.handle({ id: "q2", method: "select", title: "Question", options: ["A"] });
|
||||
expect(f.process.sendExtensionUiResponse).toHaveBeenLastCalledWith({ id: "q2", cancelled: true });
|
||||
});
|
||||
|
||||
it("uses LF framing across UTF-8 chunks without splitting Unicode separators", () => {
|
||||
const seen: unknown[] = []; const frames = new PiRpcFrames((value) => seen.push(value));
|
||||
const bytes = Buffer.from(JSON.stringify({ text: "🌒\u2028\u2029" }) + "\r\n");
|
||||
for (const byte of bytes) frames.write(Uint8Array.of(byte)); frames.end();
|
||||
expect(seen).toEqual([{ text: "🌒\u2028\u2029" }]);
|
||||
expect(() => new PiRpcFrames(() => {}, 2).write(Buffer.from("xxx"))).toThrow("bound");
|
||||
const truncated = new PiRpcFrames(() => {}); truncated.write(Buffer.from('{"x":1}'));
|
||||
expect(() => truncated.end()).toThrow("inside");
|
||||
});
|
||||
|
||||
it("reports actual incremental usage and resets the failure after a successful retry", () => {
|
||||
const usage = new PiTurnUsage(); const failed = { role: "assistant", timestamp: 1, stopReason: "error", usage: { input: 4, output: 1, cacheRead: 2, cacheWrite: 3, cost: { total: 0.01 } } };
|
||||
usage.accept(failed); usage.accept(failed);
|
||||
expect(usage.response()).toMatchObject({ usage: { totalTokens: 10 }, _meta: { jetbrains: { air: { sessionFailure: { severity: "error" } } } } });
|
||||
usage.accept({ ...failed, timestamp: 2, stopReason: "stop" });
|
||||
expect(usage.response()).toMatchObject({ usage: { inputTokens: 8, totalTokens: 20 } });
|
||||
expect(usage.response()._meta).toBeUndefined();
|
||||
usage.reset(); expect(usage.response()).toEqual({});
|
||||
});
|
||||
|
||||
it("launches verified paths only and rejects session escapes", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "paperclip-pi-launch-")); temporary.push(root);
|
||||
await mkdir(join(root, "sessions"));
|
||||
for (const name of ["node", "cli.js", "extension.js"]) await writeFile(join(root, name), "verified");
|
||||
const environment = {
|
||||
PAPERCLIP_ACPX_ISOLATED_CONTEXT: "1", PAPERCLIP_PI_READ_ONLY: "0",
|
||||
PAPERCLIP_PI_NODE_EXECUTABLE: join(root, "node"), PAPERCLIP_PI_ENTRYPOINT: join(root, "cli.js"), PAPERCLIP_PI_EXTENSION_PATH: join(root, "extension.js"),
|
||||
PI_CODING_AGENT_DIR: root,
|
||||
};
|
||||
const launch = createPiLaunchSpec({ cwd: root, mcpServers: [] }, environment);
|
||||
expect(launch.command).toBe(join(root, "node"));
|
||||
expect(launch.args).toEqual(expect.arrayContaining(["--no-extensions", "--no-approve", "--no-skills", "--offline"]));
|
||||
expect(() => createPiLaunchSpec({ cwd: root }, { ...environment, PAPERCLIP_PI_NODE_EXECUTABLE: "pi" })).toThrow("binding");
|
||||
await symlink(join(root, "cli.js"), join(root, "sessions", "escape.jsonl"));
|
||||
expect(() => createPiLaunchSpec({ cwd: root, sessionPath: join(root, "sessions", "escape.jsonl") }, environment)).toThrow("escaped");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,248 @@
|
||||
/** Source for the helper embedded in patches/pi-acp@0.0.33.patch. */
|
||||
import { StringDecoder } from "node:string_decoder";
|
||||
import { isAbsolute, relative, resolve, sep } from "node:path";
|
||||
import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync, realpathSync } from "node:fs";
|
||||
|
||||
const PERMISSION_PREFIX = "paperclip.pi.permission.v1:";
|
||||
const PERMISSION_CHOICES = [
|
||||
{ optionId: "allow_once", name: "Allow once", kind: "allow_once" },
|
||||
{ optionId: "allow_always", name: "Allow for this session", kind: "allow_always" },
|
||||
{ optionId: "reject_once", name: "Deny", kind: "reject_once" },
|
||||
];
|
||||
type RecordValue = Record<string, unknown>;
|
||||
function record(value: unknown): RecordValue {
|
||||
if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error("Invalid Pi runtime record");
|
||||
return value as RecordValue;
|
||||
}
|
||||
function text(value: unknown, max = 16_384): string {
|
||||
if (typeof value !== "string" || Buffer.byteLength(value) > max) throw new Error("Invalid Pi runtime text");
|
||||
return value;
|
||||
}
|
||||
|
||||
export const PI_ACP_FEATURES = Object.freeze({
|
||||
version: 1, steering: true, queuedFollowUp: true,
|
||||
questions: ["select", "confirm", "input", "editor"],
|
||||
nativePermissions: true, promptUsage: true, nativePlan: false,
|
||||
pendingRequestRecovery: "live-process-only",
|
||||
});
|
||||
|
||||
/** Strict LF framing: Unicode line separators inside JSON are ordinary text. */
|
||||
export class PiRpcFrames {
|
||||
private decoder = new StringDecoder("utf8");
|
||||
private pending = "";
|
||||
private readonly receive: (value: RecordValue) => void;
|
||||
private readonly limit: number;
|
||||
constructor(receive: (value: RecordValue) => void, limit = 4 * 1024 * 1024) { this.receive = receive; this.limit = limit; }
|
||||
write(chunk: Uint8Array): void {
|
||||
this.pending += this.decoder.write(Buffer.from(chunk));
|
||||
for (;;) {
|
||||
const index = this.pending.indexOf("\n");
|
||||
if (index < 0) break;
|
||||
const line = this.pending.slice(0, index).replace(/\r$/, "");
|
||||
this.pending = this.pending.slice(index + 1);
|
||||
if (Buffer.byteLength(line) > this.limit) throw new Error("Pi RPC frame exceeds its bound");
|
||||
if (line.trim()) this.receive(record(JSON.parse(line)));
|
||||
}
|
||||
if (Buffer.byteLength(this.pending) > this.limit) throw new Error("Pi RPC frame exceeds its bound");
|
||||
}
|
||||
end(): void {
|
||||
this.pending += this.decoder.end();
|
||||
if (this.pending.trim()) throw new Error("Pi RPC stream ended inside a frame");
|
||||
}
|
||||
}
|
||||
|
||||
function requiredFile(environment: NodeJS.ProcessEnv, name: string): string {
|
||||
const path = environment[name];
|
||||
if (!path || !isAbsolute(path) || /[\0\r\n]/.test(path)) throw new Error(`Missing verified Pi launch binding: ${name}`);
|
||||
// This checks the handoff shape. The runner command lease verifies all bytes,
|
||||
// retains their directory identity, and owns the subprocess lifetime.
|
||||
if (!lstatSync(path).isFile()) throw new Error(`Invalid verified Pi launch binding: ${name}`);
|
||||
return path;
|
||||
}
|
||||
function pathArray(raw: string | undefined): string[] {
|
||||
const paths: unknown = JSON.parse(raw ?? "[]");
|
||||
if (!Array.isArray(paths) || paths.length > 128 || paths.some((p) => typeof p !== "string" || !isAbsolute(p) || /[\0\r\n]/.test(p))) throw new Error("Invalid Pi runtime paths");
|
||||
return paths;
|
||||
}
|
||||
|
||||
/** Provider tool events are untrusted; diff projection cannot read host files. */
|
||||
export function snapshotPiWorkspaceFile(cwd: string, path: string, environment: NodeJS.ProcessEnv = process.env): string {
|
||||
const root = realpathSync(cwd);
|
||||
const logical = resolve(root, path);
|
||||
const physical = realpathSync(logical);
|
||||
const within = (base: string, target: string): boolean => {
|
||||
const suffix = relative(base, target);
|
||||
return !isAbsolute(suffix) && suffix !== ".." && !suffix.startsWith(`..${sep}`);
|
||||
};
|
||||
if (!within(root, logical) || !within(root, physical)) throw new Error("Pi diff escaped its workspace");
|
||||
for (const protectedPath of pathArray(environment.PAPERCLIP_PI_PROTECTED_ROOTS)) {
|
||||
if (within(protectedPath, logical)) throw new Error("Pi diff targets protected state");
|
||||
let protectedPhysical: string;
|
||||
try { protectedPhysical = realpathSync(protectedPath); } catch { continue; }
|
||||
if (within(protectedPhysical, physical)) throw new Error("Pi diff targets protected state");
|
||||
}
|
||||
const fd = openSync(physical, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
||||
try {
|
||||
const before = fstatSync(fd, { bigint: true });
|
||||
if (!before.isFile() || before.nlink !== 1n || before.size > 4n * 1024n * 1024n) throw new Error("Pi diff file is unsupported");
|
||||
const result = readFileSync(fd, "utf8");
|
||||
const after = fstatSync(fd, { bigint: true });
|
||||
const named = lstatSync(physical, { bigint: true });
|
||||
if (before.ino !== after.ino || before.dev !== after.dev || before.size !== after.size || before.ctimeNs !== after.ctimeNs || named.ino !== before.ino || named.dev !== before.dev || realpathSync(logical) !== physical) throw new Error("Pi diff file changed during capture");
|
||||
return result;
|
||||
} finally { closeSync(fd); }
|
||||
}
|
||||
|
||||
export function createPiLaunchSpec(
|
||||
params: { cwd: string; mcpServers?: unknown[]; sessionPath?: string },
|
||||
environment: NodeJS.ProcessEnv,
|
||||
): { command: string; args: string[]; env: NodeJS.ProcessEnv } {
|
||||
if (environment.PAPERCLIP_ACPX_ISOLATED_CONTEXT !== "1") throw new Error("Pi requires an isolated runner launch");
|
||||
const command = requiredFile(environment, "PAPERCLIP_PI_NODE_EXECUTABLE");
|
||||
const entrypoint = requiredFile(environment, "PAPERCLIP_PI_ENTRYPOINT");
|
||||
const extension = requiredFile(environment, "PAPERCLIP_PI_EXTENSION_PATH");
|
||||
if (environment.PAPERCLIP_PI_READ_ONLY !== "0" && environment.PAPERCLIP_PI_READ_ONLY !== "1") throw new Error("Pi requires an explicit read-only policy");
|
||||
const readRoots = pathArray(environment.PAPERCLIP_PI_READ_ROOTS);
|
||||
const protectedRoots = pathArray(environment.PAPERCLIP_PI_PROTECTED_ROOTS);
|
||||
const workspace = realpathSync(params.cwd);
|
||||
const configuration = JSON.stringify({
|
||||
workspace, servers: params.mcpServers ?? [],
|
||||
readOnly: environment.PAPERCLIP_PI_READ_ONLY === "1",
|
||||
readRoots, protectedRoots,
|
||||
instructions: environment.PAPERCLIP_PI_SYSTEM_INSTRUCTIONS ?? "",
|
||||
});
|
||||
if (Buffer.byteLength(configuration) > 64 * 1024) throw new Error("Pi launch configuration exceeds its bound");
|
||||
const args = [entrypoint, "--mode", "rpc", "--no-extensions", "--no-skills", "--no-prompt-templates", "--no-themes", "--no-approve", "--offline", "-e", extension];
|
||||
for (const root of readRoots) args.push("--skill", root);
|
||||
if (params.sessionPath) {
|
||||
const home = environment.PI_CODING_AGENT_DIR;
|
||||
if (!home) throw new Error("Pi session home is missing");
|
||||
const sessionRoot = realpathSync(resolve(home, "sessions"));
|
||||
const sessionPath = realpathSync(params.sessionPath);
|
||||
const suffix = relative(sessionRoot, sessionPath);
|
||||
if (!suffix || isAbsolute(suffix) || suffix === ".." || suffix.startsWith(`..${sep}`) || !lstatSync(params.sessionPath).isFile()) throw new Error("Pi session escaped its private home");
|
||||
args.push("--session", sessionPath);
|
||||
}
|
||||
return { command, args, env: { ...environment, PAPERCLIP_PI_RUNTIME_CONFIGURATION: configuration } };
|
||||
}
|
||||
|
||||
interface UiConnection {
|
||||
requestPermission(params: RecordValue): Promise<unknown>;
|
||||
unstable_createElicitation(params: RecordValue): Promise<unknown>;
|
||||
}
|
||||
interface UiProcess { sendExtensionUiResponse(response: RecordValue): Promise<unknown> }
|
||||
|
||||
/** Live promises never become authority to replay an answer after provider death. */
|
||||
export class PiUiBridge {
|
||||
private readonly pending = new Map<string, { cancel(): void }>();
|
||||
private readonly seen = new Set<string>();
|
||||
private readonly sessionId: string;
|
||||
private readonly connection: UiConnection;
|
||||
private readonly process: UiProcess;
|
||||
constructor(sessionId: string, connection: UiConnection, process: UiProcess) { this.sessionId = sessionId; this.connection = connection; this.process = process; }
|
||||
|
||||
cancelAll(): void { for (const value of this.pending.values()) value.cancel(); }
|
||||
|
||||
async handle(event: RecordValue): Promise<void> {
|
||||
const id = text(event.id, 256);
|
||||
const method = text(event.method, 64);
|
||||
if (!["select", "confirm", "input", "editor"].includes(method)) return;
|
||||
if (this.seen.has(id)) return;
|
||||
if (this.seen.size >= 4096) throw new Error("Pi UI request history exceeds its bound");
|
||||
this.seen.add(id);
|
||||
let done = false;
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
const finish = async (response: RecordValue): Promise<void> => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
if (timeout) clearTimeout(timeout);
|
||||
this.pending.delete(id);
|
||||
await this.process.sendExtensionUiResponse({ id, ...response });
|
||||
};
|
||||
this.pending.set(id, { cancel: () => { void finish({ cancelled: true }).catch(() => {}); } });
|
||||
if (typeof event.timeout === "number" && Number.isFinite(event.timeout)) {
|
||||
timeout = setTimeout(() => { void finish({ cancelled: true }).catch(() => {}); }, Math.max(0, Math.min(event.timeout, 2_147_483_647)));
|
||||
timeout.unref?.();
|
||||
}
|
||||
try {
|
||||
const title = text(event.title ?? "Additional information needed", 65_536);
|
||||
if (method === "select" && title.startsWith(PERMISSION_PREFIX)) {
|
||||
const permission = record(JSON.parse(title.slice(PERMISSION_PREFIX.length)));
|
||||
const toolCallId = text(permission.toolCallId, 256);
|
||||
const toolName = text(permission.toolName, 128);
|
||||
const input = record(permission.input);
|
||||
const result = record(await this.connection.requestPermission({
|
||||
sessionId: this.sessionId,
|
||||
toolCall: { toolCallId, title: `Pi ${toolName}`, kind: toolName === "bash" ? "execute" : ["write", "edit"].includes(toolName) ? "edit" : "read", status: "pending", rawInput: input },
|
||||
options: PERMISSION_CHOICES,
|
||||
}));
|
||||
const outcome = record(result.outcome);
|
||||
const selected = outcome.outcome === "selected" ? PERMISSION_CHOICES.find((option) => option.optionId === outcome.optionId) : undefined;
|
||||
await finish(selected ? { value: selected.name } : { cancelled: true });
|
||||
return;
|
||||
}
|
||||
const property: RecordValue = { type: method === "confirm" ? "boolean" : "string", title };
|
||||
if (method === "select") {
|
||||
if (!Array.isArray(event.options) || event.options.length < 1 || event.options.length > 128) throw new Error("Invalid Pi question options");
|
||||
property.enum = event.options.map((option) => text(option, 4096));
|
||||
if (new Set(property.enum as string[]).size !== event.options.length) throw new Error("Ambiguous Pi question options");
|
||||
}
|
||||
if (method === "input" && typeof event.placeholder === "string") property.description = text(event.placeholder);
|
||||
if (method === "editor" && typeof event.prefill === "string") property.default = text(event.prefill);
|
||||
const result = record(await this.connection.unstable_createElicitation({
|
||||
sessionId: this.sessionId, mode: "form", message: typeof event.message === "string" ? text(event.message) : title,
|
||||
requestedSchema: { type: "object", title, properties: { answer: property }, required: ["answer"] },
|
||||
_meta: { paperclipPi: { version: 1, requestId: id, method } },
|
||||
}));
|
||||
if (result.action !== "accept") { await finish({ cancelled: true }); return; }
|
||||
const answer = record(result.content).answer;
|
||||
if (method === "confirm") {
|
||||
if (typeof answer !== "boolean") throw new Error("Invalid Pi confirmation response");
|
||||
await finish({ confirmed: answer });
|
||||
} else {
|
||||
const value = text(answer, 65_536);
|
||||
if (method === "select" && !(property.enum as string[]).includes(value)) throw new Error("Invalid Pi question response");
|
||||
await finish({ value });
|
||||
}
|
||||
} catch { await finish({ cancelled: true }).catch(() => {}); }
|
||||
}
|
||||
}
|
||||
|
||||
/** Sum actual assistant receipts, never context occupancy or estimated cost. */
|
||||
export class PiTurnUsage {
|
||||
private seen = new Set<string>();
|
||||
private total = { inputTokens: 0, outputTokens: 0, cachedReadTokens: 0, cachedWriteTokens: 0, totalTokens: 0 };
|
||||
private cost = 0;
|
||||
private failed = false;
|
||||
private observed = false;
|
||||
reset(): void { this.seen.clear(); this.total = { inputTokens: 0, outputTokens: 0, cachedReadTokens: 0, cachedWriteTokens: 0, totalTokens: 0 }; this.cost = 0; this.failed = false; this.observed = false; }
|
||||
accept(value: unknown): void {
|
||||
const message = record(value);
|
||||
if (message.role !== "assistant") return;
|
||||
this.failed = message.stopReason === "error";
|
||||
if (!message.usage || typeof message.usage !== "object") return;
|
||||
const key = JSON.stringify([message.timestamp, message.id, message.usage, message.content]);
|
||||
if (this.seen.has(key)) return;
|
||||
if (this.seen.size >= 8192) throw new Error("Pi usage receipts exceed their bound");
|
||||
this.seen.add(key);
|
||||
const usage = record(message.usage);
|
||||
const numbers = ["input", "output", "cacheRead", "cacheWrite"].map((name) => usage[name] ?? 0);
|
||||
if (numbers.some((value) => typeof value !== "number" || !Number.isSafeInteger(value) || value < 0)) return;
|
||||
this.observed = true;
|
||||
for (const [index, field] of ["inputTokens", "outputTokens", "cachedReadTokens", "cachedWriteTokens"].entries()) {
|
||||
const name = field as keyof typeof this.total;
|
||||
this.total[name] += numbers[index] as number;
|
||||
}
|
||||
this.total.totalTokens = this.total.inputTokens + this.total.outputTokens + this.total.cachedReadTokens + this.total.cachedWriteTokens;
|
||||
if (usage.cost && typeof usage.cost === "object") {
|
||||
const cost = record(usage.cost).total;
|
||||
if (typeof cost === "number" && Number.isFinite(cost) && cost >= 0) this.cost += cost;
|
||||
}
|
||||
}
|
||||
response(): RecordValue {
|
||||
return {
|
||||
...(this.observed ? { usage: { ...this.total, _meta: { paperclipPi: { provenance: "assistant_message_receipts", costUsd: this.cost } } } } : {}),
|
||||
...(this.failed ? { _meta: { jetbrains: { air: { version: 1, sessionFailure: { severity: "error", category: "service", title: "Pi provider request failed" } } } } } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { createServer } from "node:http";
|
||||
import { mkdtemp, mkdir, rm, symlink, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
checkPiNativeTool, installPiRuntimeExtension, piMcpRequest, readPiRuntimeConfiguration,
|
||||
PI_PERMISSION_TITLE_PREFIX, type PiExtensionApi, type PiRuntimeConfiguration, type PiToolDefinition,
|
||||
} from "./pi-runtime-extension.js";
|
||||
|
||||
const directories: string[] = [];
|
||||
afterEach(async () => { for (const path of directories.splice(0)) await rm(path, { recursive: true, force: true }); });
|
||||
async function workspace() {
|
||||
const root = await mkdtemp(join(tmpdir(), "paperclip-pi-extension-")); directories.push(root);
|
||||
await mkdir(join(root, "workspace")); await mkdir(join(root, "skills")); await mkdir(join(root, "private"));
|
||||
return { root, config: {
|
||||
workspace: join(root, "workspace"), readOnly: false,
|
||||
readRoots: [join(root, "skills")], protectedRoots: [join(root, "private")],
|
||||
instructions: "Use the assigned Paperclip tools", servers: [],
|
||||
} satisfies PiRuntimeConfiguration };
|
||||
}
|
||||
function harness() {
|
||||
const handlers = new Map<string, (...args: any[]) => any>();
|
||||
const tools: PiToolDefinition[] = [];
|
||||
const api = { on: (event: string, handler: (...args: any[]) => any) => handlers.set(event, handler), registerTool: (tool: PiToolDefinition) => tools.push(tool), registerCommand: vi.fn() } as unknown as PiExtensionApi;
|
||||
return { api, handlers, tools };
|
||||
}
|
||||
|
||||
describe("owned Pi runtime extension", () => {
|
||||
it("requires explicit isolated configuration and loopback authentication", () => {
|
||||
expect(() => readPiRuntimeConfiguration({})).toThrow("missing");
|
||||
const value = { workspace: "/work/project", readOnly: false, readRoots: [], protectedRoots: [], instructions: "", servers: [{ type: "http", name: "paperclip", url: "http://127.0.0.1:1234/mcp", headers: [{ name: "Authorization", value: "Bearer 1234567890123456" }] }] };
|
||||
const parse = () => readPiRuntimeConfiguration({ PAPERCLIP_PI_RUNTIME_CONFIGURATION: JSON.stringify(value) });
|
||||
expect(parse().servers).toHaveLength(1);
|
||||
value.servers[0]!.url = "https://127.0.0.1.evil.test/mcp";
|
||||
expect(parse).toThrow("loopback");
|
||||
value.servers[0]!.url = "http://user:password@127.0.0.1/mcp";
|
||||
expect(parse).toThrow("loopback");
|
||||
});
|
||||
|
||||
it("denies escapes and read-only mutations before requesting provider permission", async () => {
|
||||
const { root, config } = await workspace(); const h = harness();
|
||||
await symlink(join(root, "private"), join(config.workspace, "escape"));
|
||||
await installPiRuntimeExtension(h.api, config);
|
||||
const select = vi.fn().mockResolvedValue("Allow once");
|
||||
const context = { cwd: config.workspace, ui: { select } };
|
||||
const tool = h.handlers.get("tool_call")!;
|
||||
expect(await tool({ toolName: "write", toolCallId: "t1", input: { path: "escape/new-file" } }, context)).toMatchObject({ block: true });
|
||||
expect(await tool({ toolName: "read", toolCallId: "t2", input: { path: "../../outside" } }, context)).toMatchObject({ block: true });
|
||||
config.readOnly = true;
|
||||
expect(await tool({ toolName: "bash", toolCallId: "t3", input: { command: "touch result" } }, context)).toMatchObject({ block: true });
|
||||
expect(select).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("revalidates paths after a human wait and never treats cancellation as approval", async () => {
|
||||
const { root, config } = await workspace(); const h = harness();
|
||||
await installPiRuntimeExtension(h.api, config);
|
||||
const context = { cwd: config.workspace, ui: { select: vi.fn(async () => {
|
||||
await symlink(join(root, "private"), join(config.workspace, "target"));
|
||||
return "Allow once";
|
||||
}) } };
|
||||
expect(await h.handlers.get("tool_call")!({ toolName: "write", toolCallId: "t", input: { path: "target/new" } }, context)).toMatchObject({ block: true });
|
||||
expect(context.ui.select.mock.calls[0]![0]).toMatch(PI_PERMISSION_TITLE_PREFIX);
|
||||
context.ui.select = vi.fn().mockResolvedValue(undefined);
|
||||
expect(await h.handlers.get("tool_call")!({ toolName: "bash", toolCallId: "b", input: { command: "pwd" } }, context)).toMatchObject({ block: true });
|
||||
});
|
||||
|
||||
it("makes assigned skills readable but never writable", async () => {
|
||||
const { config } = await workspace();
|
||||
await writeFile(join(config.readRoots[0]!, "SKILL.md"), "assigned");
|
||||
const context = { cwd: config.workspace, ui: { select: vi.fn() } };
|
||||
expect(await checkPiNativeTool({ toolName: "read", toolCallId: "r", input: { path: join(config.readRoots[0]!, "SKILL.md") } }, context, config)).toBeNull();
|
||||
expect(await checkPiNativeTool({ toolName: "write", toolCallId: "w", input: { path: join(config.readRoots[0]!, "SKILL.md") } }, context, config)).toMatch("outside");
|
||||
});
|
||||
|
||||
it("registers exact authenticated MCP tools with stable idempotency and abort signal", async () => {
|
||||
const { config } = await workspace(); const h = harness();
|
||||
config.servers = [{ type: "http", name: "paperclip", url: "http://127.0.0.1:1234", headers: [] }];
|
||||
const request = vi.fn(async (_server, method) => method === "tools/list"
|
||||
? { tools: [{ name: "report_progress", description: "Report progress", inputSchema: { type: "object", properties: {} } }] }
|
||||
: method === "tools/call" ? { content: [{ type: "text", text: "recorded" }] } : {});
|
||||
await installPiRuntimeExtension(h.api, config, request);
|
||||
const tool = h.tools[0]!; expect(tool.name).toBe("mcp__paperclip__report_progress");
|
||||
const signal = new AbortController().signal;
|
||||
await expect(tool.execute("call-1", { text: "done" }, signal)).resolves.toMatchObject({ content: [{ text: "recorded" }] });
|
||||
expect(request).toHaveBeenLastCalledWith(config.servers[0], "tools/call", { name: "report_progress", arguments: { text: "done" } }, "call-1", signal);
|
||||
const select = vi.fn(); const context = { cwd: config.workspace, ui: { select } };
|
||||
expect(await h.handlers.get("tool_call")!({ toolName: tool.name, toolCallId: "m", input: {} }, context)).toBeUndefined();
|
||||
expect(await h.handlers.get("tool_call")!({ toolName: "mcp__paperclip__invented", toolCallId: "i", input: {} }, context)).toMatchObject({ block: true });
|
||||
expect(select).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects incomplete catalogs and tool-result errors without exposing auth", async () => {
|
||||
const { config } = await workspace(); const h = harness();
|
||||
config.servers = [{ type: "http", name: "paperclip", url: "http://127.0.0.1:1234", headers: [] }];
|
||||
const request = vi.fn(async (_server, method) => method === "tools/list" ? { tools: [], nextCursor: "more" } : {});
|
||||
await expect(installPiRuntimeExtension(h.api, config, request)).rejects.toThrow("incomplete");
|
||||
});
|
||||
|
||||
it("does not follow MCP redirects or forward credentials to another origin", async () => {
|
||||
const server = createServer((_req, res) => { res.writeHead(302, { Location: "http://127.0.0.1:1/steal" }); res.end(); });
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const address = server.address() as { port: number };
|
||||
try {
|
||||
await expect(piMcpRequest({ type: "http", name: "paperclip", url: `http://127.0.0.1:${address.port}`, headers: [{ name: "Authorization", value: "Bearer secret" }] }, "tools/list", {}, "1")).rejects.toThrow();
|
||||
} finally { server.closeAllConnections(); await new Promise<void>((resolve) => server.close(() => resolve())); }
|
||||
});
|
||||
|
||||
it("validates response IDs and bounds tool output", async () => {
|
||||
const server = { type: "http" as const, name: "paperclip", url: "http://127.0.0.1:1", headers: [] };
|
||||
const fetch_ = vi.fn(async () => new Response(JSON.stringify({ jsonrpc: "2.0", id: "other", result: {} })));
|
||||
await expect(piMcpRequest(server, "tools/list", {}, "1", undefined, fetch_)).rejects.toThrow("identity");
|
||||
fetch_.mockImplementation(async () => new Response("x".repeat(4 * 1024 * 1024 + 1)));
|
||||
await expect(piMcpRequest(server, "tools/list", {}, "1", undefined, fetch_)).rejects.toThrow("oversized");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* The only extension admitted to the qualified Pi process. The launcher loads
|
||||
* this immutable module explicitly; project/global extension discovery is off.
|
||||
* Structural types keep this module independent of Pi's optional UI packages.
|
||||
*/
|
||||
import { realpath } from "node:fs/promises";
|
||||
import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
|
||||
|
||||
export const PI_PERMISSION_TITLE_PREFIX = "paperclip.pi.permission.v1:";
|
||||
export const PI_PERMISSION_OPTIONS = ["Allow once", "Allow for this session", "Deny"] as const;
|
||||
const MAX_CONFIGURATION_BYTES = 64 * 1024;
|
||||
const MAX_RESPONSE_BYTES = 4 * 1024 * 1024;
|
||||
const MAX_TOOLS = 512;
|
||||
|
||||
interface PiUi {
|
||||
select(title: string, options: string[]): Promise<string | undefined>;
|
||||
}
|
||||
|
||||
export interface PiExtensionContext { cwd: string; ui: PiUi }
|
||||
export interface PiToolEvent {
|
||||
toolName: string;
|
||||
toolCallId: string;
|
||||
input: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface PiToolDefinition {
|
||||
name: string;
|
||||
label: string;
|
||||
description: string;
|
||||
parameters: Record<string, unknown>;
|
||||
execute(
|
||||
toolCallId: string,
|
||||
arguments_: Record<string, unknown>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<{ content: Array<Record<string, unknown>>; details?: unknown }>;
|
||||
}
|
||||
|
||||
export interface PiExtensionApi {
|
||||
registerTool(tool: PiToolDefinition): void;
|
||||
registerCommand(name: string, command: { description: string; handler(): Promise<void> }): void;
|
||||
on(event: "tool_call", handler: (
|
||||
event: PiToolEvent, context: PiExtensionContext,
|
||||
) => Promise<{ block: true; reason: string } | undefined>): void;
|
||||
on(event: "user_bash", handler: () => { result: { output: string; exitCode: number; cancelled: boolean; truncated: boolean } }): void;
|
||||
on(event: "project_trust", handler: () => { trusted: "no"; remember: false }): void;
|
||||
on(event: "before_agent_start", handler: (event: { systemPrompt: string }) => { systemPrompt: string } | undefined): void;
|
||||
}
|
||||
|
||||
export interface PiMcpServer {
|
||||
type: "http";
|
||||
name: string;
|
||||
url: string;
|
||||
headers: Array<{ name: string; value: string }>;
|
||||
}
|
||||
|
||||
export interface PiRuntimeConfiguration {
|
||||
servers: PiMcpServer[];
|
||||
workspace: string;
|
||||
readOnly: boolean;
|
||||
readRoots: string[];
|
||||
protectedRoots: string[];
|
||||
instructions: string;
|
||||
}
|
||||
|
||||
/** This environment is synthesized by the verified wrapper, never the model. */
|
||||
export function readPiRuntimeConfiguration(environment: NodeJS.ProcessEnv): PiRuntimeConfiguration {
|
||||
const raw = environment.PAPERCLIP_PI_RUNTIME_CONFIGURATION;
|
||||
if (!raw || Buffer.byteLength(raw) > MAX_CONFIGURATION_BYTES) throw new Error("Pi runtime configuration is missing or oversized");
|
||||
const value = asRecord(JSON.parse(raw));
|
||||
const workspace = absolutePath(value.workspace, "workspace");
|
||||
if (typeof value.readOnly !== "boolean") throw new Error("Pi read-only policy is missing");
|
||||
if (!Array.isArray(value.servers) || value.servers.length > 16) throw new Error("Pi MCP configuration is invalid");
|
||||
const names = new Set<string>();
|
||||
const servers = value.servers.map((entry): PiMcpServer => {
|
||||
const server = asRecord(entry);
|
||||
if (server.type !== "http" || typeof server.name !== "string" || !/^[a-zA-Z0-9_-]{1,64}$/.test(server.name) || names.has(server.name)) {
|
||||
throw new Error("Pi MCP server identity is invalid");
|
||||
}
|
||||
names.add(server.name);
|
||||
if (typeof server.url !== "string") throw new Error("Pi MCP endpoint is invalid");
|
||||
const url = new URL(server.url);
|
||||
if (!['http:', 'https:'].includes(url.protocol) || !['127.0.0.1', '[::1]'].includes(url.hostname) || url.username || url.password || url.hash) {
|
||||
throw new Error("Pi MCP endpoint must be a runner-owned loopback endpoint");
|
||||
}
|
||||
if (!Array.isArray(server.headers) || server.headers.length !== 1) throw new Error("Pi MCP authentication is invalid");
|
||||
const header = asRecord(server.headers[0]);
|
||||
if (header.name !== "Authorization" || typeof header.value !== "string" || !/^Bearer [A-Za-z0-9._~-]{16,4096}$/.test(header.value)) {
|
||||
throw new Error("Pi MCP authentication is invalid");
|
||||
}
|
||||
return { type: "http", name: server.name, url: url.href, headers: [{ name: "Authorization", value: header.value }] };
|
||||
});
|
||||
if (typeof value.instructions !== "string" || Buffer.byteLength(value.instructions) > 32 * 1024) throw new Error("Pi runtime instructions are invalid");
|
||||
return {
|
||||
servers, workspace, readOnly: value.readOnly,
|
||||
readRoots: pathList(value.readRoots, "read roots"),
|
||||
protectedRoots: pathList(value.protectedRoots, "protected roots"),
|
||||
instructions: value.instructions,
|
||||
};
|
||||
}
|
||||
|
||||
function pathList(value: unknown, label: string): string[] {
|
||||
if (!Array.isArray(value) || value.length > 128) throw new Error(`Pi ${label} are invalid`);
|
||||
return value.map((path) => absolutePath(path, label));
|
||||
}
|
||||
|
||||
function absolutePath(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || !isAbsolute(value) || /[\0\r\n]/.test(value) || value === "/") throw new Error(`Pi ${label} path is invalid`);
|
||||
return resolve(value);
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error("Pi runtime record is invalid");
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function inside(root: string, target: string): boolean {
|
||||
const suffix = relative(root, target);
|
||||
return suffix === "" || (!isAbsolute(suffix) && suffix !== ".." && !suffix.startsWith(`..${sep}`));
|
||||
}
|
||||
|
||||
/** Resolve every existing ancestor, including symlinks, before creating files. */
|
||||
async function physicalPath(path: string): Promise<string> {
|
||||
try { return await realpath(path); }
|
||||
catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT" || dirname(path) === path) throw error;
|
||||
return resolve(await physicalPath(dirname(path)), relative(dirname(path), path));
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkPiNativeTool(
|
||||
event: PiToolEvent, context: PiExtensionContext, config: PiRuntimeConfiguration,
|
||||
): Promise<string | null> {
|
||||
if (await realpath(context.cwd) !== await realpath(config.workspace)) return "Pi workspace changed after admission";
|
||||
const readTools = new Set(["read", "grep", "find", "ls"]);
|
||||
const fileTools = new Set([...readTools, "edit", "write"]);
|
||||
if (!fileTools.has(event.toolName) && event.toolName !== "bash") return "Unregistered Pi tool is not admitted";
|
||||
if (config.readOnly && !readTools.has(event.toolName)) return "This execution permits reading only";
|
||||
// Shell syntax is not a filesystem authorization language. The host sandbox
|
||||
// remains authoritative for commands; every command still crosses ACP policy.
|
||||
if (event.toolName === "bash") return null;
|
||||
const pathValue = event.input.path ?? (readTools.has(event.toolName) ? "." : undefined);
|
||||
if (typeof pathValue !== "string" || /[\0\r\n]/.test(pathValue)) return "Pi tool path is invalid";
|
||||
const logical = resolve(config.workspace, pathValue);
|
||||
const target = await physicalPath(logical);
|
||||
for (const root of config.protectedRoots) {
|
||||
if (inside(root, logical) || inside(await physicalPath(root), target)) return "Pi tool targets protected runtime state";
|
||||
}
|
||||
const roots = readTools.has(event.toolName) ? [config.workspace, ...config.readRoots] : [config.workspace];
|
||||
for (const root of roots) {
|
||||
if (inside(root, logical) && inside(await realpath(root), target)) return null;
|
||||
}
|
||||
return "Pi tool path is outside its assigned workspace";
|
||||
}
|
||||
|
||||
/** Bounded JSON MCP client for runner-owned HTTP bridges. No redirects or files. */
|
||||
export async function piMcpRequest(
|
||||
server: PiMcpServer,
|
||||
method: string,
|
||||
params: Record<string, unknown>,
|
||||
id: string,
|
||||
signal?: AbortSignal,
|
||||
fetch_: typeof fetch = fetch,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const request = JSON.stringify({ jsonrpc: "2.0", id, method, params });
|
||||
if (Buffer.byteLength(request) > 1_048_576) throw new Error("Pi MCP request is oversized");
|
||||
const timeout = AbortSignal.timeout(method === "tools/call" ? 24 * 60 * 60 * 1000 : 30_000);
|
||||
const response = await fetch_(server.url, {
|
||||
method: "POST", redirect: "error",
|
||||
headers: { "Content-Type": "application/json", Accept: "application/json, text/event-stream", ...Object.fromEntries(server.headers.map((h) => [h.name, h.value])) },
|
||||
body: request, signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
|
||||
});
|
||||
if (!response.ok || !response.body) throw new Error(`Pi MCP ${method} failed (HTTP ${response.status})`);
|
||||
const reader = response.body.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let length = 0;
|
||||
try {
|
||||
for (;;) {
|
||||
const item = await reader.read();
|
||||
if (item.done) break;
|
||||
length += item.value.byteLength;
|
||||
if (length > MAX_RESPONSE_BYTES) throw new Error("Pi MCP response is oversized");
|
||||
chunks.push(item.value);
|
||||
}
|
||||
} finally { await reader.cancel().catch(() => {}); reader.releaseLock(); }
|
||||
const message = asRecord(JSON.parse(Buffer.concat(chunks).toString("utf8")));
|
||||
if (message.jsonrpc !== "2.0" || message.id !== id) throw new Error("Pi MCP response identity mismatch");
|
||||
if (message.error) throw new Error(`Pi MCP ${method} was rejected`);
|
||||
return asRecord(message.result);
|
||||
}
|
||||
|
||||
export async function installPiRuntimeExtension(
|
||||
pi: PiExtensionApi,
|
||||
config: PiRuntimeConfiguration,
|
||||
request: typeof piMcpRequest = piMcpRequest,
|
||||
): Promise<void> {
|
||||
// Registered names are held in this closure; provider-originated metadata
|
||||
// cannot promote a tool to the authenticated Paperclip bridge.
|
||||
const bridgeTools = new Set<string>();
|
||||
pi.on("project_trust", () => ({ trusted: "no", remember: false }));
|
||||
pi.on("user_bash", () => ({ result: { output: "Interactive shell commands are disabled in Paperclip Runner", exitCode: 1, cancelled: false, truncated: false } }));
|
||||
pi.on("before_agent_start", (event) => config.instructions
|
||||
? { systemPrompt: `${event.systemPrompt}\n\n${config.instructions}` } : undefined);
|
||||
pi.on("tool_call", async (event, context) => {
|
||||
if (bridgeTools.has(event.toolName)) return;
|
||||
try {
|
||||
const denial = await checkPiNativeTool(event, context, config);
|
||||
if (denial) return { block: true, reason: denial };
|
||||
const detail = JSON.stringify({ toolCallId: event.toolCallId, toolName: event.toolName, input: event.input });
|
||||
if (Buffer.byteLength(detail) > 48 * 1024) return { block: true, reason: "Pi permission request is oversized" };
|
||||
const selection = await context.ui.select(`${PI_PERMISSION_TITLE_PREFIX}${detail}`, [...PI_PERMISSION_OPTIONS]);
|
||||
if (selection !== "Allow once" && selection !== "Allow for this session") return { block: true, reason: "Pi operation was denied or cancelled" };
|
||||
// Re-check file bindings after a human wait; approval never freezes paths.
|
||||
const changed = await checkPiNativeTool(event, context, config);
|
||||
return changed ? { block: true, reason: changed } : undefined;
|
||||
} catch { return { block: true, reason: "Pi permission boundary is unavailable" }; }
|
||||
});
|
||||
let count = 0;
|
||||
for (const server of config.servers) {
|
||||
await request(server, "initialize", { protocolVersion: "2025-03-26", capabilities: {}, clientInfo: { name: "paperclip-pi", version: "1" } }, `pi-init-${server.name}`);
|
||||
const catalog = await request(server, "tools/list", {}, `pi-tools-${server.name}`);
|
||||
if (!Array.isArray(catalog.tools) || catalog.nextCursor) throw new Error("Pi MCP catalog is incomplete");
|
||||
for (const rawTool of catalog.tools) {
|
||||
const tool = asRecord(rawTool);
|
||||
if (typeof tool.name !== "string" || !/^[A-Za-z0-9_.-]{1,128}$/.test(tool.name)) throw new Error("Pi MCP tool name is invalid");
|
||||
const nativeName = `mcp__${server.name}__${tool.name}`;
|
||||
if (bridgeTools.has(nativeName) || ++count > MAX_TOOLS) throw new Error("Pi MCP catalog is ambiguous or oversized");
|
||||
const schema = asRecord(tool.inputSchema);
|
||||
if (schema.type !== "object") throw new Error("Pi MCP tool parameters must be an object");
|
||||
const sourceName = tool.name;
|
||||
const definition: PiToolDefinition = {
|
||||
name: nativeName, label: `${server.name}: ${sourceName}`,
|
||||
description: typeof tool.description === "string" ? tool.description : sourceName,
|
||||
parameters: structuredClone(schema),
|
||||
async execute(callId, arguments_, signal) {
|
||||
const result = await request(server, "tools/call", { name: sourceName, arguments: arguments_ }, callId, signal);
|
||||
if (result.isError === true) throw new Error("Paperclip tool request was rejected");
|
||||
if (!Array.isArray(result.content)) throw new Error("Pi MCP tool result is invalid");
|
||||
const content = result.content.map((item) => {
|
||||
const block = asRecord(item);
|
||||
if (block.type === "text" && typeof block.text === "string") return { type: "text", text: block.text };
|
||||
if (block.type === "image" && typeof block.data === "string" && typeof block.mimeType === "string") return { type: "image", data: block.data, mimeType: block.mimeType };
|
||||
// Resource links and structured outputs stay visible as data. They
|
||||
// never cause the extension to fetch a URL or open a returned path.
|
||||
return { type: "text", text: JSON.stringify(block) };
|
||||
});
|
||||
return { content, ...(result.structuredContent === undefined ? {} : { details: result.structuredContent }) };
|
||||
},
|
||||
};
|
||||
pi.registerTool(definition);
|
||||
bridgeTools.add(nativeName);
|
||||
}
|
||||
}
|
||||
// Pi reports extension-load failures and may continue. The wrapper requires
|
||||
// this exact sentinel before admitting prompts, so a failed extension cannot
|
||||
// silently leave native tools unguarded. Register only after every MCP binds.
|
||||
pi.registerCommand("paperclip-runtime-ready-v1", {
|
||||
description: "Paperclip runtime gate v1",
|
||||
async handler() {},
|
||||
});
|
||||
}
|
||||
|
||||
export default async function paperclipPiExtension(pi: PiExtensionApi): Promise<void> {
|
||||
const configuration = readPiRuntimeConfiguration(process.env);
|
||||
// Do not let the model's shell tool inherit authenticated MCP credentials.
|
||||
delete process.env.PAPERCLIP_PI_RUNTIME_CONFIGURATION;
|
||||
await installPiRuntimeExtension(pi, configuration);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { link, mkdir, mkdtemp, realpath, rm, symlink, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { inventoryPiRuntimeFiles, PI_RUNTIME_MANIFEST_SCHEMA, verifyPiRuntimeManifest, type PiRuntimeManifest } from "./pi-verified-runtime.js";
|
||||
|
||||
const temporary: string[] = [];
|
||||
afterEach(async () => { for (const root of temporary.splice(0)) await rm(root, { recursive: true, force: true }); });
|
||||
async function fixture() {
|
||||
const root = await mkdtemp(join(tmpdir(), "paperclip-pi-verified-")); temporary.push(root);
|
||||
await mkdir(join(root, "assets"));
|
||||
for (const name of ["node", "pi.js", "extension.js", "wrapper.js", "assets/native.node", "assets/image.wasm"]) await writeFile(join(root, name), `pinned:${name}`);
|
||||
const manifest: PiRuntimeManifest = { schema: PI_RUNTIME_MANIFEST_SCHEMA, node: "node", piEntrypoint: "pi.js", extension: "extension.js", wrapperEntrypoint: "wrapper.js", files: await inventoryPiRuntimeFiles(root) };
|
||||
return { root, manifest };
|
||||
}
|
||||
|
||||
describe("Pi complete runtime manifest", () => {
|
||||
it("binds interpreter, Pi, extension, wrapper, native modules and resources", async () => {
|
||||
const { root, manifest } = await fixture();
|
||||
const result = await verifyPiRuntimeManifest(root, manifest);
|
||||
expect(result.environment.PAPERCLIP_PI_EXTENSION_PATH).toBe(await realpath(join(root, "extension.js")));
|
||||
expect(result.manifestDigest).toMatch(/^sha256:[a-f0-9]{64}$/);
|
||||
await writeFile(join(root, "assets/image.wasm"), "replacement");
|
||||
await expect(verifyPiRuntimeManifest(root, manifest)).rejects.toThrow("package graph");
|
||||
});
|
||||
|
||||
it("refuses unrecorded resources and duplicate manifest entries", async () => {
|
||||
const { root, manifest } = await fixture();
|
||||
await writeFile(join(root, "extra.js"), "untrusted");
|
||||
await expect(verifyPiRuntimeManifest(root, manifest)).rejects.toThrow("package graph");
|
||||
manifest.files.push(manifest.files[0]!);
|
||||
await expect(verifyPiRuntimeManifest(root, manifest)).rejects.toThrow("entry");
|
||||
});
|
||||
|
||||
it("allows only in-pack relative links and rejects hardlinked mutable files", async () => {
|
||||
const { root, manifest } = await fixture();
|
||||
await symlink("assets", join(root, "resources"));
|
||||
manifest.files = await inventoryPiRuntimeFiles(root);
|
||||
await expect(verifyPiRuntimeManifest(root, manifest)).resolves.toHaveProperty("manifestDigest");
|
||||
await symlink(tmpdir(), join(root, "escape"));
|
||||
await expect(inventoryPiRuntimeFiles(root)).rejects.toThrow("escapes");
|
||||
await rm(join(root, "escape"));
|
||||
await link(join(root, "pi.js"), join(root, "hardlink.js"));
|
||||
await expect(inventoryPiRuntimeFiles(root)).rejects.toThrow("writable name");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { constants } from "node:fs";
|
||||
import { lstat, open, readdir, readlink, realpath } from "node:fs/promises";
|
||||
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
||||
|
||||
export const PI_RUNTIME_MANIFEST_SCHEMA = "paperclip.pi-runtime-files.v1" as const;
|
||||
export interface PiRuntimeFile {
|
||||
path: string;
|
||||
kind: "file" | "symlink";
|
||||
sha256: string;
|
||||
/** A link target is data in the manifest and must remain inside the pack. */
|
||||
target?: string;
|
||||
}
|
||||
export interface PiRuntimeManifest {
|
||||
schema: typeof PI_RUNTIME_MANIFEST_SCHEMA;
|
||||
node: string;
|
||||
piEntrypoint: string;
|
||||
extension: string;
|
||||
wrapperEntrypoint: string;
|
||||
files: PiRuntimeFile[];
|
||||
}
|
||||
|
||||
function contained(root: string, path: string): boolean {
|
||||
const suffix = relative(root, path);
|
||||
return suffix !== ".." && !suffix.startsWith(`..${sep}`) && !isAbsolute(suffix);
|
||||
}
|
||||
function safeRelative(path: string): boolean {
|
||||
return path.length > 0 && !isAbsolute(path) && !/[\0\r\n\\]/.test(path) && path.split("/").every((part) => part !== "" && part !== "." && part !== "..");
|
||||
}
|
||||
function hash(bytes: Uint8Array | string): string { return `sha256:${createHash("sha256").update(bytes).digest("hex")}`; }
|
||||
|
||||
/**
|
||||
* Inventory a complete provider pack, not just Pi's cli.js. Native modules,
|
||||
* WASM, data, package metadata, and the owned extension all participate.
|
||||
* Used while building a pack; the caller records/signs this immutable result.
|
||||
*/
|
||||
export async function inventoryPiRuntimeFiles(root: string): Promise<PiRuntimeFile[]> {
|
||||
const physicalRoot = await realpath(root);
|
||||
if ((await lstat(root)).isSymbolicLink()) throw new Error("Pi runtime root must not be a symlink");
|
||||
const files: PiRuntimeFile[] = [];
|
||||
const visit = async (directory: string): Promise<void> => {
|
||||
const entries = (await readdir(directory)).sort();
|
||||
for (const name of entries) {
|
||||
const path = join(directory, name);
|
||||
const rel = relative(physicalRoot, path).split(sep).join("/");
|
||||
if (!safeRelative(rel)) throw new Error("Pi runtime contains an invalid filename");
|
||||
const stat = await lstat(path);
|
||||
if (stat.isDirectory()) { await visit(path); continue; }
|
||||
if (files.length >= 100_000) throw new Error("Pi runtime file inventory exceeds its bound");
|
||||
if (stat.isSymbolicLink()) {
|
||||
const target = await readlink(path);
|
||||
if (isAbsolute(target) || !contained(physicalRoot, resolve(dirname(path), target)) || !contained(physicalRoot, await realpath(path))) throw new Error("Pi runtime link escapes its pack");
|
||||
files.push({ path: rel, kind: "symlink", target, sha256: hash(target) });
|
||||
} else if (stat.isFile()) {
|
||||
if (stat.nlink !== 1) throw new Error("Pi runtime file has another writable name");
|
||||
files.push({ path: rel, kind: "file", sha256: await hashFile(path) });
|
||||
} else throw new Error("Pi runtime contains a non-file resource");
|
||||
}
|
||||
};
|
||||
await visit(physicalRoot);
|
||||
return files;
|
||||
}
|
||||
|
||||
async function hashFile(path: string): Promise<string> {
|
||||
const file = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
||||
try {
|
||||
const before = await file.stat({ bigint: true });
|
||||
if (!before.isFile() || before.nlink !== 1n || before.size > 512n * 1024n * 1024n) throw new Error("Pi runtime file identity is invalid");
|
||||
const digest = createHash("sha256");
|
||||
for await (const chunk of file.createReadStream({ autoClose: false })) digest.update(chunk);
|
||||
const after = await file.stat({ bigint: true });
|
||||
if (before.ino !== after.ino || before.dev !== after.dev || before.size !== after.size || before.mtimeNs !== after.mtimeNs || before.ctimeNs !== after.ctimeNs) throw new Error("Pi runtime file changed during verification");
|
||||
return `sha256:${digest.digest("hex")}`;
|
||||
} finally { await file.close(); }
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify an immutable private snapshot at admission. This returns only launch
|
||||
* paths; the common command lease must continue holding its snapshot and
|
||||
* process guardian. This function alone is deliberately not a command lease.
|
||||
*/
|
||||
export async function verifyPiRuntimeManifest(
|
||||
root: string, manifest: PiRuntimeManifest,
|
||||
): Promise<{ environment: Record<string, string>; manifestDigest: string }> {
|
||||
if (manifest.schema !== PI_RUNTIME_MANIFEST_SCHEMA || !Array.isArray(manifest.files) || manifest.files.length === 0 || manifest.files.length > 100_000) throw new Error("Pi runtime manifest is invalid");
|
||||
const unique = new Set<string>();
|
||||
for (const entry of manifest.files) {
|
||||
if (!safeRelative(entry.path) || unique.has(entry.path) || !/^sha256:[a-f0-9]{64}$/.test(entry.sha256) || !["file", "symlink"].includes(entry.kind)) throw new Error("Pi runtime manifest entry is invalid");
|
||||
if (entry.kind === "symlink" ? typeof entry.target !== "string" : entry.target !== undefined) throw new Error("Pi runtime manifest link is invalid");
|
||||
unique.add(entry.path);
|
||||
}
|
||||
const sorted = manifest.files.map((entry): PiRuntimeFile => entry.kind === "file"
|
||||
? { path: entry.path, kind: entry.kind, sha256: entry.sha256 }
|
||||
: { path: entry.path, kind: entry.kind, target: entry.target, sha256: entry.sha256 })
|
||||
.sort((a, b) => a.path.localeCompare(b.path, "en"));
|
||||
const actual = (await inventoryPiRuntimeFiles(root)).sort((a, b) => a.path.localeCompare(b.path, "en"));
|
||||
if (JSON.stringify(actual) !== JSON.stringify(sorted)) throw new Error("Pi runtime package graph differs from its qualified manifest");
|
||||
const physicalRoot = await realpath(root);
|
||||
const boundPath = async (path: string): Promise<string> => {
|
||||
if (!safeRelative(path) || !unique.has(path)) throw new Error("Pi executable is absent from its manifest");
|
||||
const value = await realpath(join(physicalRoot, path));
|
||||
if (!contained(physicalRoot, value) || !(await lstat(value)).isFile()) throw new Error("Pi executable escaped its verified snapshot");
|
||||
return value;
|
||||
};
|
||||
await boundPath(manifest.wrapperEntrypoint);
|
||||
return {
|
||||
environment: {
|
||||
PAPERCLIP_PI_NODE_EXECUTABLE: await boundPath(manifest.node),
|
||||
PAPERCLIP_PI_ENTRYPOINT: await boundPath(manifest.piEntrypoint),
|
||||
PAPERCLIP_PI_EXTENSION_PATH: await boundPath(manifest.extension),
|
||||
},
|
||||
manifestDigest: hash(JSON.stringify({ ...manifest, files: sorted })),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
// Protocol fixture: no provider network calls and no secrets.
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { createInterface } from "node:readline";
|
||||
const home = process.env.PI_CODING_AGENT_DIR;
|
||||
mkdirSync(join(home, "sessions"), { recursive: true });
|
||||
const sessionFile = join(home, "sessions", "fixture.jsonl");
|
||||
writeFileSync(sessionFile, JSON.stringify({ type: "session", cwd: process.cwd() }) + "\n");
|
||||
const output = (value) => process.stdout.write(JSON.stringify(value) + "\n");
|
||||
let active = false;
|
||||
let model = "fixture-model";
|
||||
const finish = (answer = "done") => {
|
||||
output({ type: "message_update", assistantMessageEvent: { type: "text_delta", delta: answer } });
|
||||
output({ type: "message_end", message: { role: "assistant", timestamp: Date.now(), stopReason: "stop", usage: { input: 11, output: 3, cacheRead: 2, cacheWrite: 0, cost: { total: 0.01 } } } });
|
||||
output({ type: "agent_end" });
|
||||
// An agent_end alone must not settle the ACP prompt.
|
||||
setTimeout(() => { active = false; output({ type: "agent_settled" }); }, 15);
|
||||
};
|
||||
createInterface({ input: process.stdin }).on("line", (line) => {
|
||||
const request = JSON.parse(line);
|
||||
const response = (data = {}) => output({ type: "response", id: request.id, command: request.type, success: true, data });
|
||||
if (request.type === "extension_ui_response") { finish(JSON.stringify(request)); return; }
|
||||
if (request.type === "get_state") { response({ sessionId: "fixture-session", sessionFile, model: { provider: "openrouter", id: model }, thinkingLevel: "off" }); return; }
|
||||
if (request.type === "get_available_models") { response({ models: [{ provider: "openrouter", id: "fixture-model", name: "Fixture" }] }); return; }
|
||||
if (request.type === "set_model") { model = request.modelId; response({ model: { provider: request.provider, id: model } }); return; }
|
||||
if (request.type === "get_messages") { response({ messages: [] }); return; }
|
||||
if (request.type === "get_commands") { response({ commands: process.env.PI_FIXTURE_EXTENSION_FAIL ? [] : [{ name: "paperclip-runtime-ready-v1", description: "Paperclip runtime gate v1", source: "extension" }] }); return; }
|
||||
if (request.type === "steer") { response(); output({ type: "message_update", assistantMessageEvent: { type: "text_delta", delta: `steered:${request.message}` } }); return; }
|
||||
if (request.type === "follow_up") { response(); finish(`follow-up:${request.message}`); return; }
|
||||
if (request.type === "abort") { response(); if (active) { active = false; output({ type: "agent_settled" }); } return; }
|
||||
if (request.type === "prompt") {
|
||||
response({ disposition: "started" }); active = true; output({ type: "agent_start" });
|
||||
if (request.message === "die") { process.exit(4); }
|
||||
if (request.message === "long") return;
|
||||
if (request.message === "question") { output({ type: "extension_ui_request", id: "question-id", method: "input", title: "Project name", placeholder: "Name" }); return; }
|
||||
if (request.message === "permission") { output({ type: "extension_ui_request", id: "permission-id", method: "select", title: 'paperclip.pi.permission.v1:{"toolCallId":"tool-1","toolName":"bash","input":{"command":"pwd"}}', options: ["Allow once", "Allow for this session", "Deny"] }); return; }
|
||||
if (request.message === "failure") {
|
||||
output({ type: "message_end", message: { role: "assistant", timestamp: 1, stopReason: "error", usage: { input: 1, output: 0 } } });
|
||||
active = false; output({ type: "agent_settled" }); return;
|
||||
}
|
||||
output({ type: "message_update", assistantMessageEvent: { type: "thinking_delta", delta: "fixture reasoning" } });
|
||||
finish("hello🌒\u2028world"); return;
|
||||
}
|
||||
response();
|
||||
}).on("close", () => process.exit(0));
|
||||
@@ -0,0 +1,123 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawn } from "node:child_process";
|
||||
import { once } from "node:events";
|
||||
import { mkdtemp, mkdir, readFile, realpath, rm, writeFile } from "node:fs/promises";
|
||||
import { createRequire, stripTypeScriptTypes } from "node:module";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { StringDecoder } from "node:string_decoder";
|
||||
import test from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
// Optional test-only package location permits the isolated Pi branch to test
|
||||
// before the integration owner updates package.json and the shared lockfile.
|
||||
const packageJson = process.env.PAPERCLIP_TEST_PI_ACP_PACKAGE
|
||||
?? createRequire(import.meta.url).resolve("pi-acp/package.json");
|
||||
const packageRoot = dirname(packageJson);
|
||||
const packageMetadata = JSON.parse(await readFile(packageJson, "utf8"));
|
||||
const packageSource = await readFile(join(packageRoot, "dist/index.js"), "utf8");
|
||||
const helperSource = await readFile(join(packageRoot, "dist/paperclip-runtime.js"), "utf8");
|
||||
const localHelper = await readFile(new URL("../src/drivers/acpx/pi-acp-runtime.ts", import.meta.url), "utf8");
|
||||
const normalize = (source) => source.split("\n").map((line) => line.trimEnd()).join("\n");
|
||||
|
||||
test("Pi patch embeds the reviewed helper and pins exact upstream version", () => {
|
||||
assert.equal(packageMetadata.version, "0.0.33");
|
||||
assert.equal(normalize(helperSource), normalize(stripTypeScriptTypes(localHelper)));
|
||||
assert.match(packageSource, /createPiLaunchSpec\(params, process\.env\)/);
|
||||
assert.match(packageSource, /shell: false/);
|
||||
assert.match(packageSource, /snapshotPiWorkspaceFile\(this\.cwd, abs\)/);
|
||||
});
|
||||
|
||||
async function fixture(t, extra = {}) {
|
||||
const root = await realpath(await mkdtemp(join(tmpdir(), "paperclip-pi-acp-contract-")));
|
||||
await mkdir(join(root, "workspace")); await mkdir(join(root, "agent"));
|
||||
await writeFile(join(root, "extension.js"), "export default function() {}\n");
|
||||
const fakePi = fileURLToPath(new URL("../test-fixtures/pi-acp/fake-pi.mjs", import.meta.url));
|
||||
const child = spawn(process.execPath, [join(packageRoot, "dist/index.js")], {
|
||||
cwd: join(root, "workspace"), stdio: "pipe",
|
||||
env: {
|
||||
PATH: process.env.PATH, HOME: root, PI_CODING_AGENT_DIR: join(root, "agent"),
|
||||
PAPERCLIP_ACPX_ISOLATED_CONTEXT: "1", PAPERCLIP_PI_READ_ONLY: "0",
|
||||
PAPERCLIP_PI_NODE_EXECUTABLE: await realpath(process.execPath),
|
||||
PAPERCLIP_PI_ENTRYPOINT: fakePi, PAPERCLIP_PI_EXTENSION_PATH: join(root, "extension.js"),
|
||||
...extra,
|
||||
},
|
||||
});
|
||||
let stderr = ""; child.stderr.on("data", (chunk) => { stderr += chunk; });
|
||||
const pending = new Map(); const notifications = []; const requests = [];
|
||||
let id = 0; let answer = async (request) => request.method === "session/request_permission"
|
||||
? { outcome: { outcome: "selected", optionId: "allow_once" } }
|
||||
: { action: "accept", content: { answer: "Paperclip" } };
|
||||
const send = (value) => child.stdin.write(JSON.stringify(value) + "\n");
|
||||
const receive = (line) => {
|
||||
const message = JSON.parse(line);
|
||||
if (message.method && Object.hasOwn(message, "id")) {
|
||||
requests.push(message);
|
||||
void answer(message).then((result) => send({ jsonrpc: "2.0", id: message.id, result }));
|
||||
} else if (Object.hasOwn(message, "id")) {
|
||||
const waiter = pending.get(message.id); pending.delete(message.id);
|
||||
if (message.error) waiter?.reject(new Error(JSON.stringify(message.error))); else waiter?.resolve(message.result);
|
||||
} else notifications.push(message);
|
||||
};
|
||||
const decoder = new StringDecoder("utf8"); let buffered = "";
|
||||
child.stdout.on("data", (chunk) => {
|
||||
buffered += decoder.write(chunk);
|
||||
for (;;) { const at = buffered.indexOf("\n"); if (at < 0) break; const line = buffered.slice(0, at); buffered = buffered.slice(at + 1); if (line.trim()) receive(line); }
|
||||
});
|
||||
child.once("exit", () => { for (const waiter of pending.values()) waiter.reject(new Error(`wrapper exited: ${stderr}`)); pending.clear(); });
|
||||
const call = (method, params) => new Promise((resolve_, reject) => {
|
||||
const requestId = ++id;
|
||||
const timer = setTimeout(() => { pending.delete(requestId); reject(new Error(`timeout: ${method}: ${stderr}`)); }, 5000);
|
||||
pending.set(requestId, { resolve: (value) => { clearTimeout(timer); resolve_(value); }, reject: (error) => { clearTimeout(timer); reject(error); } });
|
||||
send({ jsonrpc: "2.0", id: requestId, method, params });
|
||||
});
|
||||
t.after(async () => {
|
||||
child.stdin.end();
|
||||
if (child.exitCode === null) { const timer = setTimeout(() => child.kill("SIGKILL"), 3000); await once(child, "exit"); clearTimeout(timer); }
|
||||
await rm(root, { recursive: true, force: true });
|
||||
});
|
||||
const initialized = await call("initialize", { protocolVersion: 1, clientCapabilities: { elicitation: { form: {} } } });
|
||||
return { call, initialized, root, notifications, requests, setAnswer(value) { answer = value; } };
|
||||
}
|
||||
|
||||
test("actual patched ACP process streams thinking and waits for settlement with usage", async (t) => {
|
||||
const f = await fixture(t);
|
||||
assert.equal(f.initialized.agentCapabilities._meta.paperclipPi.steering, true);
|
||||
const session = await f.call("session/new", { cwd: join(f.root, "workspace"), mcpServers: [] });
|
||||
const result = await f.call("session/prompt", { sessionId: session.sessionId, prompt: [{ type: "text", text: "hello" }] });
|
||||
assert.equal(result.stopReason, "end_turn"); assert.equal(result.usage.inputTokens, 11); assert.equal(result.usage.totalTokens, 16);
|
||||
assert.ok(f.notifications.some((event) => event.params?.update?.sessionUpdate === "agent_thought_chunk"));
|
||||
assert.ok(f.notifications.some((event) => event.params?.update?.content?.text === "hello🌒\u2028world"));
|
||||
});
|
||||
|
||||
test("actual patched ACP process keeps text questions separate from native permissions", async (t) => {
|
||||
const f = await fixture(t); const session = await f.call("session/new", { cwd: join(f.root, "workspace"), mcpServers: [] });
|
||||
await f.call("session/prompt", { sessionId: session.sessionId, prompt: [{ type: "text", text: "question" }] });
|
||||
assert.equal(f.requests.length, 1); assert.notEqual(f.requests[0].method, "session/request_permission");
|
||||
await f.call("session/prompt", { sessionId: session.sessionId, prompt: [{ type: "text", text: "permission" }] });
|
||||
assert.equal(f.requests[1].method, "session/request_permission");
|
||||
assert.equal(f.requests[1].params.options[1].kind, "allow_always");
|
||||
});
|
||||
|
||||
test("native steering is explicit and does not replace the active ACP turn", async (t) => {
|
||||
const f = await fixture(t); const session = await f.call("session/new", { cwd: join(f.root, "workspace"), mcpServers: [] });
|
||||
const prompt = f.call("session/prompt", { sessionId: session.sessionId, prompt: [{ type: "text", text: "long" }] });
|
||||
await f.call("pi/steer", { sessionId: session.sessionId, message: "focus" });
|
||||
await f.call("pi/follow_up", { sessionId: session.sessionId, message: "then finish" });
|
||||
assert.equal((await prompt).stopReason, "end_turn");
|
||||
await assert.rejects(f.call("pi/steer", { sessionId: session.sessionId, message: "too late" }), /active turn/);
|
||||
});
|
||||
|
||||
test("provider exit fails promptly and missing owned extension fails admission", async (t) => {
|
||||
const f = await fixture(t); const session = await f.call("session/new", { cwd: join(f.root, "workspace"), mcpServers: [] });
|
||||
await assert.rejects(f.call("session/prompt", { sessionId: session.sessionId, prompt: [{ type: "text", text: "die" }] }), /exited/);
|
||||
const missing = await fixture(t, { PI_FIXTURE_EXTENSION_FAIL: "1" });
|
||||
await assert.rejects(missing.call("session/new", { cwd: join(missing.root, "workspace"), mcpServers: [] }), /failed admission/);
|
||||
});
|
||||
|
||||
test("provider failure is typed and unadmitted slash commands cannot bypass controls", async (t) => {
|
||||
const f = await fixture(t); const session = await f.call("session/new", { cwd: join(f.root, "workspace"), mcpServers: [] });
|
||||
const result = await f.call("session/prompt", { sessionId: session.sessionId, prompt: [{ type: "text", text: "failure" }] });
|
||||
assert.equal(result._meta.jetbrains.air.sessionFailure.severity, "error");
|
||||
await assert.rejects(f.call("session/prompt", { sessionId: session.sessionId, prompt: [{ type: "text", text: "/export /outside/report.html" }] }), /not admitted/);
|
||||
});
|
||||
@@ -0,0 +1,677 @@
|
||||
diff --git a/dist/index.js b/dist/index.js
|
||||
--- a/dist/index.js
|
||||
+++ b/dist/index.js
|
||||
@@ -53,7 +53,7 @@
|
||||
|
||||
// src/pi-rpc/process.ts
|
||||
import { spawn } from "child_process";
|
||||
-import * as readline from "readline";
|
||||
+import { PI_ACP_FEATURES, PiRpcFrames, PiUiBridge, PiTurnUsage, createPiLaunchSpec, snapshotPiWorkspaceFile } from "./paperclip-runtime.js";
|
||||
|
||||
// src/pi-rpc/command.ts
|
||||
import { platform } from "os";
|
||||
@@ -96,49 +96,38 @@
|
||||
preludeLines = [];
|
||||
constructor(child) {
|
||||
this.child = child;
|
||||
- const rl = readline.createInterface({ input: child.stdout });
|
||||
- rl.on("line", (line) => {
|
||||
- if (!line.trim()) return;
|
||||
- let msg;
|
||||
- try {
|
||||
- msg = JSON.parse(line);
|
||||
- } catch {
|
||||
- const cleaned = stripAnsi(String(line)).trimEnd();
|
||||
- if (cleaned) this.preludeLines.push(cleaned);
|
||||
+ this.exited = null;
|
||||
+ const fail = (error) => {
|
||||
+ if (this.exited) return;
|
||||
+ this.exited = error;
|
||||
+ for (const [, pending] of this.pending) pending.reject(error);
|
||||
+ this.pending.clear();
|
||||
+ for (const handler of this.eventHandlers) handler({ type: "paperclip_process_exit" });
|
||||
+ };
|
||||
+ const frames = new PiRpcFrames((msg) => {
|
||||
+ if (msg.type === "response") {
|
||||
+ if (typeof msg.id === "string") this.pending.get(msg.id)?.resolve(msg);
|
||||
return;
|
||||
}
|
||||
- if (msg?.type === "response") {
|
||||
- const id = typeof msg.id === "string" ? msg.id : void 0;
|
||||
- if (id) {
|
||||
- const pending = this.pending.get(id);
|
||||
- if (pending) {
|
||||
- this.pending.delete(id);
|
||||
- pending.resolve(msg);
|
||||
- return;
|
||||
- }
|
||||
- }
|
||||
- }
|
||||
- for (const h of this.eventHandlers) h(msg);
|
||||
+ for (const handler of this.eventHandlers) handler(msg);
|
||||
});
|
||||
- child.on("exit", (code, signal) => {
|
||||
- const err = new Error(`pi process exited (code=${code}, signal=${signal})`);
|
||||
- for (const [, p] of this.pending) p.reject(err);
|
||||
- this.pending.clear();
|
||||
+ child.stdout.on("data", (chunk) => {
|
||||
+ try { frames.write(chunk); } catch (error) { fail(error); this.dispose("SIGKILL"); }
|
||||
});
|
||||
- child.on("error", (err) => {
|
||||
- for (const [, p] of this.pending) p.reject(err);
|
||||
- this.pending.clear();
|
||||
+ child.stdout.on("end", () => {
|
||||
+ try { frames.end(); } catch (error) { fail(error); }
|
||||
});
|
||||
+ child.on("exit", () => fail(new Error("Pi process exited before its next request")));
|
||||
+ child.on("error", fail);
|
||||
}
|
||||
static async spawn(params) {
|
||||
- const cmd = getPiCommand(params.piCommand);
|
||||
- const args = ["--mode", "rpc", "--no-themes"];
|
||||
- if (params.sessionPath) args.push("--session", params.sessionPath);
|
||||
- const child = spawn(cmd, args, {
|
||||
+ const launch = createPiLaunchSpec(params, process.env);
|
||||
+ const cmd = launch.command;
|
||||
+ const child = spawn(cmd, launch.args, {
|
||||
cwd: params.cwd,
|
||||
stdio: "pipe",
|
||||
- env: process.env,
|
||||
- shell: shouldUseShellForPiCommand(cmd)
|
||||
+ env: launch.env,
|
||||
+ shell: false
|
||||
});
|
||||
try {
|
||||
await new Promise((resolve4, reject) => {
|
||||
@@ -181,21 +170,30 @@
|
||||
const { dirname: dirname3 } = await import("path");
|
||||
mkdirSync2(dirname3(sessionFile), { recursive: true });
|
||||
}
|
||||
- } catch {
|
||||
+ const commands = await proc.getCommands();
|
||||
+ if (!Array.isArray(commands?.commands) || !commands.commands.some((command) => command.name === "paperclip-runtime-ready-v1" && command.description === "Paperclip runtime gate v1")) throw new Error("Pi owned runtime extension did not initialize");
|
||||
+ } catch (error) {
|
||||
+ proc.dispose("SIGKILL");
|
||||
+ throw new PiRpcSpawnError("Pi owned runtime extension failed admission", { cause: error });
|
||||
}
|
||||
return proc;
|
||||
}
|
||||
onEvent(handler) {
|
||||
this.eventHandlers.push(handler);
|
||||
+ if (this.exited) queueMicrotask(() => handler({ type: "paperclip_process_exit" }));
|
||||
return () => {
|
||||
this.eventHandlers = this.eventHandlers.filter((h) => h !== handler);
|
||||
};
|
||||
}
|
||||
dispose(signal = "SIGTERM") {
|
||||
- if (this.child.killed) return;
|
||||
- try {
|
||||
- this.child.kill(signal);
|
||||
- } catch {
|
||||
+ if (this.child.exitCode !== null || this.child.signalCode !== null) return;
|
||||
+ try { this.child.stdin.end(); this.child.kill(signal); } catch {}
|
||||
+ if (signal !== "SIGKILL") {
|
||||
+ const timer = setTimeout(() => {
|
||||
+ if (this.child.exitCode === null && this.child.signalCode === null) this.child.kill("SIGKILL");
|
||||
+ }, 2000);
|
||||
+ timer.unref();
|
||||
+ this.child.once("exit", () => clearTimeout(timer));
|
||||
}
|
||||
}
|
||||
/**
|
||||
@@ -288,12 +286,17 @@
|
||||
const withId = { ...cmd, id };
|
||||
const line = `${JSON.stringify(withId)}
|
||||
`;
|
||||
+ if (this.exited) return Promise.reject(this.exited);
|
||||
return new Promise((resolve4, reject) => {
|
||||
- this.pending.set(id, { resolve: resolve4, reject });
|
||||
- void this.writeLine(line).catch((error) => {
|
||||
- this.pending.delete(id);
|
||||
- reject(error);
|
||||
- });
|
||||
+ const timer = setTimeout(() => finish(new Error("Pi RPC request timed out")), 30000);
|
||||
+ timer.unref();
|
||||
+ const finish = (error, value) => {
|
||||
+ if (!this.pending.delete(id)) return;
|
||||
+ clearTimeout(timer);
|
||||
+ if (error) reject(error); else resolve4(value);
|
||||
+ };
|
||||
+ this.pending.set(id, { resolve: (value) => finish(null, value), reject: (error) => finish(error) });
|
||||
+ void this.writeLine(line).catch((error) => finish(error));
|
||||
});
|
||||
}
|
||||
writeLine(line) {
|
||||
@@ -714,6 +717,7 @@
|
||||
try {
|
||||
proc = await PiRpcProcess.spawn({
|
||||
cwd: params.cwd,
|
||||
+ mcpServers: params.mcpServers,
|
||||
piCommand: params.piCommand
|
||||
});
|
||||
} catch (e) {
|
||||
@@ -808,6 +812,9 @@
|
||||
this.proc = opts.proc;
|
||||
this.conn = opts.conn;
|
||||
this.fileCommands = opts.fileCommands ?? [];
|
||||
+ this.uiBridge = new PiUiBridge(this.sessionId, this.conn, this.proc);
|
||||
+ this.turnUsage = new PiTurnUsage();
|
||||
+ this.turnEpoch = 0;
|
||||
this.proc.onEvent((ev) => this.handlePiEvent(ev));
|
||||
}
|
||||
setStartupInfo(text) {
|
||||
@@ -852,6 +859,7 @@
|
||||
}
|
||||
async cancel() {
|
||||
this.cancelRequested = true;
|
||||
+ this.uiBridge.cancelAll();
|
||||
if (this.turnQueue.length) {
|
||||
const queued = this.turnQueue.splice(0, this.turnQueue.length);
|
||||
for (const t of queued) t.resolve("cancelled");
|
||||
@@ -920,12 +928,16 @@
|
||||
this.cancelRequested = false;
|
||||
this.inAgentLoop = false;
|
||||
this.pendingTurn = { resolve: t.resolve, reject: t.reject };
|
||||
+ const epoch = ++this.turnEpoch;
|
||||
+ this.turnUsage.reset();
|
||||
this.emit({
|
||||
sessionUpdate: "session_info_update",
|
||||
_meta: { piAcp: { queueDepth: this.turnQueue.length, running: true } }
|
||||
});
|
||||
this.proc.prompt(t.message, t.images).catch((err) => {
|
||||
void this.flushEmits().finally(() => {
|
||||
+ if (epoch !== this.turnEpoch || !this.pendingTurn) return;
|
||||
+ this.uiBridge.cancelAll();
|
||||
const authErr = maybeAuthRequiredError(err);
|
||||
if (authErr) {
|
||||
this.pendingTurn?.reject(authErr);
|
||||
@@ -946,6 +958,23 @@
|
||||
handlePiEvent(ev) {
|
||||
const type = String(ev.type ?? "");
|
||||
switch (type) {
|
||||
+ case "paperclip_process_exit": {
|
||||
+ this.uiBridge.cancelAll();
|
||||
+ this.pendingTurn?.reject(new Error("Pi provider process exited"));
|
||||
+ this.pendingTurn = null;
|
||||
+ for (const turn of this.turnQueue.splice(0)) turn.reject(new Error("Pi provider process exited"));
|
||||
+ this.turnEpoch += 1;
|
||||
+ break;
|
||||
+ }
|
||||
+ case "message_end": {
|
||||
+ try { this.turnUsage.accept(ev.message); } catch {
|
||||
+ this.pendingTurn?.reject(new Error("Pi usage receipt is invalid"));
|
||||
+ this.pendingTurn = null;
|
||||
+ this.uiBridge.cancelAll();
|
||||
+ this.proc.dispose("SIGKILL");
|
||||
+ }
|
||||
+ break;
|
||||
+ }
|
||||
case "message_update": {
|
||||
const ame = ev.assistantMessageEvent;
|
||||
if (ame?.type === "text_delta" && typeof ame.delta === "string") {
|
||||
@@ -1047,7 +1076,7 @@
|
||||
if (p) {
|
||||
try {
|
||||
const abs = isAbsolute(p) ? p : resolvePath(this.cwd, p);
|
||||
- snapshotOldText = readFileSync3(abs, "utf8");
|
||||
+ snapshotOldText = snapshotPiWorkspaceFile(this.cwd, abs);
|
||||
this.fileSnapshots.set(toolCallId, { path: p, oldText: snapshotOldText });
|
||||
if (toolName === "edit") {
|
||||
for (const needle of getEditOldTexts(args)) {
|
||||
@@ -1125,7 +1154,7 @@
|
||||
if (!isError && snapshot) {
|
||||
try {
|
||||
const abs = isAbsolute(snapshot.path) ? snapshot.path : resolvePath(this.cwd, snapshot.path);
|
||||
- const newText = readFileSync3(abs, "utf8");
|
||||
+ const newText = snapshotPiWorkspaceFile(this.cwd, abs);
|
||||
if (snapshot.oldText === null || newText !== snapshot.oldText) {
|
||||
hasStructuredDiff = true;
|
||||
content = [
|
||||
@@ -1154,14 +1183,11 @@
|
||||
break;
|
||||
}
|
||||
case "extension_ui_request": {
|
||||
- void this.handleExtensionUiRequest(ev).catch(() => {
|
||||
- const id = stringProp(ev, "id");
|
||||
- if (!id) {
|
||||
- return;
|
||||
- }
|
||||
- void this.proc.sendExtensionUiResponse({ id, cancelled: true }).catch(() => {
|
||||
- });
|
||||
+ if (ev.method === "notify") this.emit({
|
||||
+ sessionUpdate: "session_info_update",
|
||||
+ _meta: { paperclipPi: { notice: { message: typeof ev.message === "string" ? ev.message.slice(0, 4096) : "Pi notification", level: ev.notifyType } } }
|
||||
});
|
||||
+ void this.uiBridge.handle(ev).catch(() => { this.proc.dispose("SIGKILL"); });
|
||||
break;
|
||||
}
|
||||
case "auto_retry_start": {
|
||||
@@ -1210,9 +1236,12 @@
|
||||
break;
|
||||
}
|
||||
case "agent_settled": {
|
||||
+ const epoch = this.turnEpoch;
|
||||
void this.flushEmits().finally(() => {
|
||||
+ if (epoch !== this.turnEpoch || !this.pendingTurn) return;
|
||||
+ this.uiBridge.cancelAll();
|
||||
const reason = this.cancelRequested ? "cancelled" : "end_turn";
|
||||
- this.pendingTurn?.resolve(reason);
|
||||
+ this.pendingTurn?.resolve({ stopReason: reason, ...this.turnUsage.response() });
|
||||
this.pendingTurn = null;
|
||||
this.inAgentLoop = false;
|
||||
const next = this.turnQueue.shift();
|
||||
@@ -1725,6 +1754,7 @@
|
||||
return process.env.PI_CODING_AGENT_DIR ? resolve3(process.env.PI_CODING_AGENT_DIR) : join4(homedir4(), ".pi", "agent");
|
||||
}
|
||||
function getEnableSkillCommands(cwd) {
|
||||
+ if (process.env.PAPERCLIP_ACPX_ISOLATED_CONTEXT === "1") return true;
|
||||
const merged = getMergedSettings(cwd);
|
||||
const direct = merged.enableSkillCommands;
|
||||
if (typeof direct === "boolean") return direct;
|
||||
@@ -1733,6 +1763,7 @@
|
||||
return true;
|
||||
}
|
||||
function getQuietStartup(cwd) {
|
||||
+ if (process.env.PAPERCLIP_ACPX_ISOLATED_CONTEXT === "1") return true;
|
||||
const merged = getMergedSettings(cwd);
|
||||
const direct = merged.quietStartup;
|
||||
if (typeof direct === "boolean") return direct;
|
||||
@@ -1884,11 +1915,13 @@
|
||||
throw RequestError3.invalidParams(`Unknown sessionId: ${sessionId}`);
|
||||
}
|
||||
const cwd = opts?.cwd ?? stored.cwd;
|
||||
+ if (cwd !== stored.cwd || !opts) throw RequestError3.invalidParams("Pi recovery requires session/load in the original workspace");
|
||||
let proc;
|
||||
try {
|
||||
proc = await PiRpcProcess.spawn({
|
||||
cwd,
|
||||
sessionPath: stored.sessionFile,
|
||||
+ mcpServers: opts?.mcpServers ?? [],
|
||||
piCommand: process.env.PI_ACP_PI_COMMAND
|
||||
});
|
||||
} catch (e) {
|
||||
@@ -1897,7 +1930,7 @@
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
- const fileCommands = loadSlashCommands(cwd);
|
||||
+ const fileCommands = [];
|
||||
const session = this.sessions.getOrCreate(sessionId, {
|
||||
cwd,
|
||||
mcpServers: opts?.mcpServers ?? [],
|
||||
@@ -1917,6 +1950,7 @@
|
||||
}
|
||||
}
|
||||
async initialize(params) {
|
||||
+ if (process.env.PAPERCLIP_ACPX_ISOLATED_CONTEXT !== "1") throw RequestError3.invalidParams("Pi requires a qualified Paperclip launch");
|
||||
const supportedVersion = 1;
|
||||
const requested = params.protocolVersion;
|
||||
return {
|
||||
@@ -1933,7 +1967,8 @@
|
||||
}),
|
||||
agentCapabilities: {
|
||||
loadSession: true,
|
||||
- mcpCapabilities: { http: false, sse: false },
|
||||
+ mcpCapabilities: { http: true, sse: false },
|
||||
+ _meta: { paperclipPi: PI_ACP_FEATURES },
|
||||
promptCapabilities: {
|
||||
image: true,
|
||||
audio: false,
|
||||
@@ -1942,8 +1977,7 @@
|
||||
sessionCapabilities: {
|
||||
// **UNSTABLE** ACP capability used by Zed's codex-acp adapter.
|
||||
// Enables a native session picker in clients that support it.
|
||||
- list: {},
|
||||
- delete: {}
|
||||
+ list: {}
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1953,7 +1987,7 @@
|
||||
throw RequestError3.invalidParams(`cwd must be an absolute path: ${params.cwd}`);
|
||||
}
|
||||
this.lastSessionCwd = params.cwd;
|
||||
- const fileCommands = loadSlashCommands(params.cwd);
|
||||
+ const fileCommands = [];
|
||||
const enableSkillCommands = getEnableSkillCommands(params.cwd);
|
||||
const session = await this.sessions.create({
|
||||
cwd: params.cwd,
|
||||
@@ -2066,6 +2100,8 @@
|
||||
const session = await this.restoreSession(params.sessionId);
|
||||
const { message, images } = promptToPiMessage(params.prompt);
|
||||
if (images.length === 0 && message.trimStart().startsWith("/")) {
|
||||
+ const command = message.trim().split(/\s+/)[0];
|
||||
+ if (!["/compact", "/session", "/autocompact"].includes(command)) throw RequestError3.invalidParams("Pi slash command is not admitted by Paperclip Runner");
|
||||
const trimmed = message.trim();
|
||||
const space = trimmed.indexOf(" ");
|
||||
const cmd = space === -1 ? trimmed.slice(1) : trimmed.slice(1, space);
|
||||
@@ -2432,8 +2468,18 @@
|
||||
}
|
||||
}
|
||||
const result = await session.prompt(message, images);
|
||||
- const stopReason = result === "error" ? session.wasCancelRequested() ? "cancelled" : "end_turn" : result;
|
||||
- return { stopReason };
|
||||
+ if (result && typeof result === "object") return result;
|
||||
+ if (result === "error") throw RequestError3.internalError({}, "Pi prompt failed");
|
||||
+ return { stopReason: result };
|
||||
+ }
|
||||
+ async extMethod(method, params) {
|
||||
+ if (method !== "pi/steer" && method !== "pi/follow_up") throw RequestError3.methodNotFound(method);
|
||||
+ if (typeof params.sessionId !== "string" || typeof params.message !== "string" || !params.message.trim() || Buffer.byteLength(params.message) > 65536) throw RequestError3.invalidParams("Invalid Pi continuation");
|
||||
+ const session = this.sessions.maybeGet(params.sessionId);
|
||||
+ if (!session?.pendingTurn || session.cancelRequested) throw RequestError3.invalidParams("Pi continuation requires an active turn");
|
||||
+ const response = await session.proc.request({ type: method === "pi/steer" ? "steer" : "follow_up", message: params.message });
|
||||
+ if (!response.success) throw RequestError3.internalError({}, "Pi continuation was rejected");
|
||||
+ return { accepted: true, sessionId: session.sessionId, kind: method === "pi/steer" ? "steer" : "follow_up" };
|
||||
}
|
||||
async cancel(params) {
|
||||
const session = this.sessions.maybeGet(params.sessionId);
|
||||
@@ -2473,7 +2519,7 @@
|
||||
mcpServers: params.mcpServers
|
||||
});
|
||||
const proc = session.proc;
|
||||
- const fileCommands = loadSlashCommands(params.cwd);
|
||||
+ const fileCommands = [];
|
||||
this.sessions.closeAllExcept?.(session.sessionId);
|
||||
this.store.upsert({
|
||||
sessionId: params.sessionId,
|
||||
@@ -2607,6 +2653,7 @@
|
||||
return response;
|
||||
}
|
||||
async deleteSession(params) {
|
||||
+ throw RequestError3.methodNotFound("session/delete");
|
||||
const stored = this.store.get(params.sessionId);
|
||||
const piSession = findPiSession(params.sessionId);
|
||||
if (!stored && !piSession) {
|
||||
@@ -2828,6 +2875,7 @@
|
||||
return 0;
|
||||
}
|
||||
function buildUpdateNotice() {
|
||||
+ if (process.env.PAPERCLIP_ACPX_ISOLATED_CONTEXT === "1") return null;
|
||||
try {
|
||||
const piVersion = spawnSync("pi", ["--version"], { encoding: "utf-8" });
|
||||
const installed = (String(piVersion.stdout ?? "").trim() || String(piVersion.stderr ?? "").trim()).replace(
|
||||
@@ -2848,6 +2896,7 @@
|
||||
}
|
||||
}
|
||||
function buildStartupInfo(opts) {
|
||||
+ if (process.env.PAPERCLIP_ACPX_ISOLATED_CONTEXT === "1") return "";
|
||||
void opts.fileCommands;
|
||||
const md = [];
|
||||
try {
|
||||
@@ -2980,6 +3029,7 @@
|
||||
|
||||
// src/index.ts
|
||||
if (process.argv.includes("--terminal-login")) {
|
||||
+ throw new Error("Interactive Pi authentication is not available in Paperclip Runner");
|
||||
const { spawnSync: spawnSync2 } = await import("child_process");
|
||||
const cmd = getPiCommand(process.env.PI_ACP_PI_COMMAND);
|
||||
const res = spawnSync2(cmd, [], {
|
||||
@@ -3019,11 +3069,12 @@
|
||||
}
|
||||
});
|
||||
var stream = ndJsonStream(input, output);
|
||||
-var agent = new AgentSideConnection((conn) => new PiAcpAgent(conn), stream);
|
||||
+var ownedPiAgent;
|
||||
+var agent = new AgentSideConnection((conn) => (ownedPiAgent = new PiAcpAgent(conn)), stream);
|
||||
function shutdown() {
|
||||
try {
|
||||
;
|
||||
- agent?.agent?.dispose?.();
|
||||
+ ownedPiAgent?.dispose?.();
|
||||
} catch {
|
||||
}
|
||||
try {
|
||||
diff --git a/dist/paperclip-runtime.js b/dist/paperclip-runtime.js
|
||||
new file mode 100644
|
||||
--- /dev/null
|
||||
+++ b/dist/paperclip-runtime.js
|
||||
@@ -0,0 +1,248 @@
|
||||
+/** Source for the helper embedded in patches/pi-acp@0.0.33.patch. */
|
||||
+import { StringDecoder } from "node:string_decoder";
|
||||
+import { isAbsolute, relative, resolve, sep } from "node:path";
|
||||
+import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync, realpathSync } from "node:fs";
|
||||
+
|
||||
+const PERMISSION_PREFIX = "paperclip.pi.permission.v1:";
|
||||
+const PERMISSION_CHOICES = [
|
||||
+ { optionId: "allow_once", name: "Allow once", kind: "allow_once" },
|
||||
+ { optionId: "allow_always", name: "Allow for this session", kind: "allow_always" },
|
||||
+ { optionId: "reject_once", name: "Deny", kind: "reject_once" },
|
||||
+];
|
||||
+
|
||||
+function record(value ) {
|
||||
+ if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error("Invalid Pi runtime record");
|
||||
+ return value ;
|
||||
+}
|
||||
+function text(value , max = 16_384) {
|
||||
+ if (typeof value !== "string" || Buffer.byteLength(value) > max) throw new Error("Invalid Pi runtime text");
|
||||
+ return value;
|
||||
+}
|
||||
+
|
||||
+export const PI_ACP_FEATURES = Object.freeze({
|
||||
+ version: 1, steering: true, queuedFollowUp: true,
|
||||
+ questions: ["select", "confirm", "input", "editor"],
|
||||
+ nativePermissions: true, promptUsage: true, nativePlan: false,
|
||||
+ pendingRequestRecovery: "live-process-only",
|
||||
+});
|
||||
+
|
||||
+/** Strict LF framing: Unicode line separators inside JSON are ordinary text. */
|
||||
+export class PiRpcFrames {
|
||||
+ decoder = new StringDecoder("utf8");
|
||||
+ pending = "";
|
||||
+ receive ;
|
||||
+ limit ;
|
||||
+ constructor(receive , limit = 4 * 1024 * 1024) { this.receive = receive; this.limit = limit; }
|
||||
+ write(chunk ) {
|
||||
+ this.pending += this.decoder.write(Buffer.from(chunk));
|
||||
+ for (;;) {
|
||||
+ const index = this.pending.indexOf("\n");
|
||||
+ if (index < 0) break;
|
||||
+ const line = this.pending.slice(0, index).replace(/\r$/, "");
|
||||
+ this.pending = this.pending.slice(index + 1);
|
||||
+ if (Buffer.byteLength(line) > this.limit) throw new Error("Pi RPC frame exceeds its bound");
|
||||
+ if (line.trim()) this.receive(record(JSON.parse(line)));
|
||||
+ }
|
||||
+ if (Buffer.byteLength(this.pending) > this.limit) throw new Error("Pi RPC frame exceeds its bound");
|
||||
+ }
|
||||
+ end() {
|
||||
+ this.pending += this.decoder.end();
|
||||
+ if (this.pending.trim()) throw new Error("Pi RPC stream ended inside a frame");
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
+function requiredFile(environment , name ) {
|
||||
+ const path = environment[name];
|
||||
+ if (!path || !isAbsolute(path) || /[\0\r\n]/.test(path)) throw new Error(`Missing verified Pi launch binding: ${name}`);
|
||||
+ // This checks the handoff shape. The runner command lease verifies all bytes,
|
||||
+ // retains their directory identity, and owns the subprocess lifetime.
|
||||
+ if (!lstatSync(path).isFile()) throw new Error(`Invalid verified Pi launch binding: ${name}`);
|
||||
+ return path;
|
||||
+}
|
||||
+function pathArray(raw ) {
|
||||
+ const paths = JSON.parse(raw ?? "[]");
|
||||
+ if (!Array.isArray(paths) || paths.length > 128 || paths.some((p) => typeof p !== "string" || !isAbsolute(p) || /[\0\r\n]/.test(p))) throw new Error("Invalid Pi runtime paths");
|
||||
+ return paths;
|
||||
+}
|
||||
+
|
||||
+/** Provider tool events are untrusted; diff projection cannot read host files. */
|
||||
+export function snapshotPiWorkspaceFile(cwd , path , environment = process.env) {
|
||||
+ const root = realpathSync(cwd);
|
||||
+ const logical = resolve(root, path);
|
||||
+ const physical = realpathSync(logical);
|
||||
+ const within = (base , target ) => {
|
||||
+ const suffix = relative(base, target);
|
||||
+ return !isAbsolute(suffix) && suffix !== ".." && !suffix.startsWith(`..${sep}`);
|
||||
+ };
|
||||
+ if (!within(root, logical) || !within(root, physical)) throw new Error("Pi diff escaped its workspace");
|
||||
+ for (const protectedPath of pathArray(environment.PAPERCLIP_PI_PROTECTED_ROOTS)) {
|
||||
+ if (within(protectedPath, logical)) throw new Error("Pi diff targets protected state");
|
||||
+ let protectedPhysical ;
|
||||
+ try { protectedPhysical = realpathSync(protectedPath); } catch { continue; }
|
||||
+ if (within(protectedPhysical, physical)) throw new Error("Pi diff targets protected state");
|
||||
+ }
|
||||
+ const fd = openSync(physical, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
||||
+ try {
|
||||
+ const before = fstatSync(fd, { bigint: true });
|
||||
+ if (!before.isFile() || before.nlink !== 1n || before.size > 4n * 1024n * 1024n) throw new Error("Pi diff file is unsupported");
|
||||
+ const result = readFileSync(fd, "utf8");
|
||||
+ const after = fstatSync(fd, { bigint: true });
|
||||
+ const named = lstatSync(physical, { bigint: true });
|
||||
+ if (before.ino !== after.ino || before.dev !== after.dev || before.size !== after.size || before.ctimeNs !== after.ctimeNs || named.ino !== before.ino || named.dev !== before.dev || realpathSync(logical) !== physical) throw new Error("Pi diff file changed during capture");
|
||||
+ return result;
|
||||
+ } finally { closeSync(fd); }
|
||||
+}
|
||||
+
|
||||
+export function createPiLaunchSpec(
|
||||
+ params ,
|
||||
+ environment ,
|
||||
+) {
|
||||
+ if (environment.PAPERCLIP_ACPX_ISOLATED_CONTEXT !== "1") throw new Error("Pi requires an isolated runner launch");
|
||||
+ const command = requiredFile(environment, "PAPERCLIP_PI_NODE_EXECUTABLE");
|
||||
+ const entrypoint = requiredFile(environment, "PAPERCLIP_PI_ENTRYPOINT");
|
||||
+ const extension = requiredFile(environment, "PAPERCLIP_PI_EXTENSION_PATH");
|
||||
+ if (environment.PAPERCLIP_PI_READ_ONLY !== "0" && environment.PAPERCLIP_PI_READ_ONLY !== "1") throw new Error("Pi requires an explicit read-only policy");
|
||||
+ const readRoots = pathArray(environment.PAPERCLIP_PI_READ_ROOTS);
|
||||
+ const protectedRoots = pathArray(environment.PAPERCLIP_PI_PROTECTED_ROOTS);
|
||||
+ const workspace = realpathSync(params.cwd);
|
||||
+ const configuration = JSON.stringify({
|
||||
+ workspace, servers: params.mcpServers ?? [],
|
||||
+ readOnly: environment.PAPERCLIP_PI_READ_ONLY === "1",
|
||||
+ readRoots, protectedRoots,
|
||||
+ instructions: environment.PAPERCLIP_PI_SYSTEM_INSTRUCTIONS ?? "",
|
||||
+ });
|
||||
+ if (Buffer.byteLength(configuration) > 64 * 1024) throw new Error("Pi launch configuration exceeds its bound");
|
||||
+ const args = [entrypoint, "--mode", "rpc", "--no-extensions", "--no-skills", "--no-prompt-templates", "--no-themes", "--no-approve", "--offline", "-e", extension];
|
||||
+ for (const root of readRoots) args.push("--skill", root);
|
||||
+ if (params.sessionPath) {
|
||||
+ const home = environment.PI_CODING_AGENT_DIR;
|
||||
+ if (!home) throw new Error("Pi session home is missing");
|
||||
+ const sessionRoot = realpathSync(resolve(home, "sessions"));
|
||||
+ const sessionPath = realpathSync(params.sessionPath);
|
||||
+ const suffix = relative(sessionRoot, sessionPath);
|
||||
+ if (!suffix || isAbsolute(suffix) || suffix === ".." || suffix.startsWith(`..${sep}`) || !lstatSync(params.sessionPath).isFile()) throw new Error("Pi session escaped its private home");
|
||||
+ args.push("--session", sessionPath);
|
||||
+ }
|
||||
+ return { command, args, env: { ...environment, PAPERCLIP_PI_RUNTIME_CONFIGURATION: configuration } };
|
||||
+}
|
||||
+
|
||||
+
|
||||
+
|
||||
+
|
||||
+
|
||||
+
|
||||
+
|
||||
+/** Live promises never become authority to replay an answer after provider death. */
|
||||
+export class PiUiBridge {
|
||||
+ pending = new Map ();
|
||||
+ seen = new Set ();
|
||||
+ sessionId ;
|
||||
+ connection ;
|
||||
+ process ;
|
||||
+ constructor(sessionId , connection , process ) { this.sessionId = sessionId; this.connection = connection; this.process = process; }
|
||||
+
|
||||
+ cancelAll() { for (const value of this.pending.values()) value.cancel(); }
|
||||
+
|
||||
+ async handle(event ) {
|
||||
+ const id = text(event.id, 256);
|
||||
+ const method = text(event.method, 64);
|
||||
+ if (!["select", "confirm", "input", "editor"].includes(method)) return;
|
||||
+ if (this.seen.has(id)) return;
|
||||
+ if (this.seen.size >= 4096) throw new Error("Pi UI request history exceeds its bound");
|
||||
+ this.seen.add(id);
|
||||
+ let done = false;
|
||||
+ let timeout ;
|
||||
+ const finish = async (response ) => {
|
||||
+ if (done) return;
|
||||
+ done = true;
|
||||
+ if (timeout) clearTimeout(timeout);
|
||||
+ this.pending.delete(id);
|
||||
+ await this.process.sendExtensionUiResponse({ id, ...response });
|
||||
+ };
|
||||
+ this.pending.set(id, { cancel: () => { void finish({ cancelled: true }).catch(() => {}); } });
|
||||
+ if (typeof event.timeout === "number" && Number.isFinite(event.timeout)) {
|
||||
+ timeout = setTimeout(() => { void finish({ cancelled: true }).catch(() => {}); }, Math.max(0, Math.min(event.timeout, 2_147_483_647)));
|
||||
+ timeout.unref?.();
|
||||
+ }
|
||||
+ try {
|
||||
+ const title = text(event.title ?? "Additional information needed", 65_536);
|
||||
+ if (method === "select" && title.startsWith(PERMISSION_PREFIX)) {
|
||||
+ const permission = record(JSON.parse(title.slice(PERMISSION_PREFIX.length)));
|
||||
+ const toolCallId = text(permission.toolCallId, 256);
|
||||
+ const toolName = text(permission.toolName, 128);
|
||||
+ const input = record(permission.input);
|
||||
+ const result = record(await this.connection.requestPermission({
|
||||
+ sessionId: this.sessionId,
|
||||
+ toolCall: { toolCallId, title: `Pi ${toolName}`, kind: toolName === "bash" ? "execute" : ["write", "edit"].includes(toolName) ? "edit" : "read", status: "pending", rawInput: input },
|
||||
+ options: PERMISSION_CHOICES,
|
||||
+ }));
|
||||
+ const outcome = record(result.outcome);
|
||||
+ const selected = outcome.outcome === "selected" ? PERMISSION_CHOICES.find((option) => option.optionId === outcome.optionId) : undefined;
|
||||
+ await finish(selected ? { value: selected.name } : { cancelled: true });
|
||||
+ return;
|
||||
+ }
|
||||
+ const property = { type: method === "confirm" ? "boolean" : "string", title };
|
||||
+ if (method === "select") {
|
||||
+ if (!Array.isArray(event.options) || event.options.length < 1 || event.options.length > 128) throw new Error("Invalid Pi question options");
|
||||
+ property.enum = event.options.map((option) => text(option, 4096));
|
||||
+ if (new Set(property.enum ).size !== event.options.length) throw new Error("Ambiguous Pi question options");
|
||||
+ }
|
||||
+ if (method === "input" && typeof event.placeholder === "string") property.description = text(event.placeholder);
|
||||
+ if (method === "editor" && typeof event.prefill === "string") property.default = text(event.prefill);
|
||||
+ const result = record(await this.connection.unstable_createElicitation({
|
||||
+ sessionId: this.sessionId, mode: "form", message: typeof event.message === "string" ? text(event.message) : title,
|
||||
+ requestedSchema: { type: "object", title, properties: { answer: property }, required: ["answer"] },
|
||||
+ _meta: { paperclipPi: { version: 1, requestId: id, method } },
|
||||
+ }));
|
||||
+ if (result.action !== "accept") { await finish({ cancelled: true }); return; }
|
||||
+ const answer = record(result.content).answer;
|
||||
+ if (method === "confirm") {
|
||||
+ if (typeof answer !== "boolean") throw new Error("Invalid Pi confirmation response");
|
||||
+ await finish({ confirmed: answer });
|
||||
+ } else {
|
||||
+ const value = text(answer, 65_536);
|
||||
+ if (method === "select" && !(property.enum ).includes(value)) throw new Error("Invalid Pi question response");
|
||||
+ await finish({ value });
|
||||
+ }
|
||||
+ } catch { await finish({ cancelled: true }).catch(() => {}); }
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
+/** Sum actual assistant receipts, never context occupancy or estimated cost. */
|
||||
+export class PiTurnUsage {
|
||||
+ seen = new Set ();
|
||||
+ total = { inputTokens: 0, outputTokens: 0, cachedReadTokens: 0, cachedWriteTokens: 0, totalTokens: 0 };
|
||||
+ cost = 0;
|
||||
+ failed = false;
|
||||
+ observed = false;
|
||||
+ reset() { this.seen.clear(); this.total = { inputTokens: 0, outputTokens: 0, cachedReadTokens: 0, cachedWriteTokens: 0, totalTokens: 0 }; this.cost = 0; this.failed = false; this.observed = false; }
|
||||
+ accept(value ) {
|
||||
+ const message = record(value);
|
||||
+ if (message.role !== "assistant") return;
|
||||
+ this.failed = message.stopReason === "error";
|
||||
+ if (!message.usage || typeof message.usage !== "object") return;
|
||||
+ const key = JSON.stringify([message.timestamp, message.id, message.usage, message.content]);
|
||||
+ if (this.seen.has(key)) return;
|
||||
+ if (this.seen.size >= 8192) throw new Error("Pi usage receipts exceed their bound");
|
||||
+ this.seen.add(key);
|
||||
+ const usage = record(message.usage);
|
||||
+ const numbers = ["input", "output", "cacheRead", "cacheWrite"].map((name) => usage[name] ?? 0);
|
||||
+ if (numbers.some((value) => typeof value !== "number" || !Number.isSafeInteger(value) || value < 0)) return;
|
||||
+ this.observed = true;
|
||||
+ for (const [index, field] of ["inputTokens", "outputTokens", "cachedReadTokens", "cachedWriteTokens"].entries()) {
|
||||
+ const name = field ;
|
||||
+ this.total[name] += numbers[index] ;
|
||||
+ }
|
||||
+ this.total.totalTokens = this.total.inputTokens + this.total.outputTokens + this.total.cachedReadTokens + this.total.cachedWriteTokens;
|
||||
+ if (usage.cost && typeof usage.cost === "object") {
|
||||
+ const cost = record(usage.cost).total;
|
||||
+ if (typeof cost === "number" && Number.isFinite(cost) && cost >= 0) this.cost += cost;
|
||||
+ }
|
||||
+ }
|
||||
+ response() {
|
||||
+ return {
|
||||
+ ...(this.observed ? { usage: { ...this.total, _meta: { paperclipPi: { provenance: "assistant_message_receipts", costUsd: this.cost } } } } : {}),
|
||||
+ ...(this.failed ? { _meta: { jetbrains: { air: { version: 1, sessionFailure: { severity: "error", category: "service", title: "Pi provider request failed" } } } } } : {}),
|
||||
+ };
|
||||
+ }
|
||||
+}
|
||||
Reference in new issue
Block a user