mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 10:13:11 +02:00
Bind Pi launches to verified immutable provider assets
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
9aa68a41c2
commit
742f9bef15
7 files changed
+307
-43
No files matched your search
@@ -190,3 +190,27 @@ archive hashes were checked against the [official Node release checksums](https:
|
||||
The x64 closure pins combine the identical locked package/resource graph with each
|
||||
verified official x64 interpreter. They are candidate artifact identity, not proof
|
||||
that those targets have executed successfully.
|
||||
|
||||
### Installation authority and token semantics
|
||||
|
||||
`verifyPiInstallation(profile)` admits only profile version 2 and the source-owned
|
||||
Pi identity. It resolves `provider-assets/pi/<platform>-<arch>` inside the verified
|
||||
Runner package, checks the complete runtime against source-pinned closure hashes,
|
||||
and opens a guarded immutable native snapshot. The snapshot bootstrap binds Node,
|
||||
Pi, and the extension relative to itself, overriding inherited launch paths.
|
||||
Unconfigured installations return an explicit bound-credential error and expose
|
||||
no terminal login option. The materializer returns the runtime `version`,
|
||||
`profileDigest`, and complete `closureDigest` for the provider-pack manifest.
|
||||
|
||||
For the exact OpenRouter qualification model, Pi AI 0.84.2's
|
||||
`dist/providers/data/openrouter.json` selects `openai-completions`. Its
|
||||
`dist/api/openai-completions.js` assigns `usage.output` from `completion_tokens`,
|
||||
which already includes reasoning tokens; `usage.reasoning` is a subset. PRP must
|
||||
therefore use zero additional thought tokens for this pinned profile, rather than
|
||||
counting that subset twice. Missing cache categories remain unknown at the wrapper
|
||||
receipt boundary. `usage.cost` is a Pi catalog pricing estimate, never an invoice.
|
||||
|
||||
The closed snapshot has been exercised against actual pinned Pi through ACP
|
||||
initialization and the missing-credential admission path without a model call.
|
||||
The exact reported `currentModelId` and billable receipt still need credentialed
|
||||
local and Linux/Daytona qualification.
|
||||
@@ -7,6 +7,7 @@ import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
import { PI_DISTRIBUTION_CLOSURE_SHA256 } from "../src/drivers/acpx/pi-closure-pins.ts";
|
||||
import { PI_NODE_DISTRIBUTIONS, PI_NODE_VERSION } from "../src/drivers/acpx/pi-node-pins.ts";
|
||||
import { QUALIFIED_ACPX_PROFILES } from "../src/drivers/acpx/qualified-profiles.ts";
|
||||
import { inventoryPiRuntimeFiles, verifyPiRuntimeManifest } from "../src/drivers/acpx/pi-verified-runtime.ts";
|
||||
|
||||
const run = promisify(execFile);
|
||||
@@ -17,7 +18,7 @@ const patchPath = join(workspaceRoot, "patches/pi-acp@0.0.33.patch");
|
||||
const supportedTargets = new Set(["darwin-arm64", "darwin-x64", "linux-x64"]);
|
||||
export const PI_DISTRIBUTION_PINS = Object.freeze({
|
||||
wrapper: "0.0.33", runtime: "0.84.2", sdk: "0.26.0", zod: "3.25.76", nodeVersion: PI_NODE_VERSION,
|
||||
wrapperSha256: "d2a2cf67bebedb7cff46b2cebdc98dda8e91f3e64b15f26c660715e5145c7cc6",
|
||||
wrapperSha256: "18375d30e7eca9fddf6c024f49899d6da38c064d8587aa1678fd4c2d70308fbc",
|
||||
helperSha256: "57dd03e3d1e59d0191f0dd66f5a809e6c825de3c4b6660bff697686c160a659f",
|
||||
});
|
||||
const hash = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
||||
@@ -93,6 +94,20 @@ export async function writePiDistributionManifest(runtimeRoot) {
|
||||
return { manifest, ...verified };
|
||||
}
|
||||
|
||||
export function piDistributionBootstrapSource() {
|
||||
return [
|
||||
'const path=require("node:path");',
|
||||
'process.env.PAPERCLIP_PI_NODE_EXECUTABLE=path.join(__dirname,"node/bin/node");',
|
||||
'process.env.PAPERCLIP_PI_ENTRYPOINT=path.join(__dirname,"node_modules/@earendil-works/pi-coding-agent/dist/cli.js");',
|
||||
'process.env.PAPERCLIP_PI_EXTENSION_PATH=path.join(__dirname,"extensions/paperclip.js");',
|
||||
'process.env.PAPERCLIP_PI_MODULE_GUARD_PATH=path.join(__dirname,".paperclip-native-module-guard.cjs");',
|
||||
'const protectedRoots=JSON.parse(process.env.PAPERCLIP_PI_PROTECTED_ROOTS??"[]");if(!Array.isArray(protectedRoots))throw new Error("Invalid Pi protected roots");',
|
||||
'process.env.PAPERCLIP_PI_PROTECTED_ROOTS=JSON.stringify([...protectedRoots,__dirname]);',
|
||||
'import(require("node:url").pathToFileURL(path.join(__dirname,"node_modules/pi-acp/dist/index.js")).href).catch(()=>{process.exitCode=1;});',
|
||||
'',
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
/** Build on the target platform. No lifecycle scripts, model requests, or auth. */
|
||||
export async function materializePiDistribution({ outputRoot, nodeExecutable, npmExecutable = "npm" }) {
|
||||
if (typeof outputRoot !== "string" || !outputRoot || !isAbsolute(outputRoot)) throw new Error("Pi distribution output must be absolute");
|
||||
@@ -170,15 +185,7 @@ export async function materializePiDistribution({ outputRoot, nodeExecutable, np
|
||||
}
|
||||
}
|
||||
await rm(join(runtimeRoot, "node_modules/.package-lock.json"), { force: true });
|
||||
await writeFile(join(runtimeRoot, "pi-entry.cjs"), [
|
||||
'const path=require("node:path");',
|
||||
'process.env.PAPERCLIP_PI_NODE_EXECUTABLE=path.join(__dirname,"node/bin/node");',
|
||||
'process.env.PAPERCLIP_PI_ENTRYPOINT=path.join(__dirname,"node_modules/@earendil-works/pi-coding-agent/dist/cli.js");',
|
||||
'process.env.PAPERCLIP_PI_EXTENSION_PATH=path.join(__dirname,"extensions/paperclip.js");',
|
||||
'process.env.PAPERCLIP_PI_MODULE_GUARD_PATH=path.join(__dirname,".paperclip-native-module-guard.cjs");',
|
||||
'import(require("node:url").pathToFileURL(path.join(__dirname,"node_modules/pi-acp/dist/index.js")).href).catch(()=>{process.exitCode=1;});',
|
||||
'',
|
||||
].join("\n"));
|
||||
await writeFile(join(runtimeRoot, "pi-entry.cjs"), piDistributionBootstrapSource());
|
||||
for (const item of await readdir(staging)) if (item !== "runtime") await rm(join(staging, item), { recursive: true, force: true });
|
||||
const { manifest, manifestDigest } = await writePiDistributionManifest(runtimeRoot);
|
||||
const entries = await Promise.all(manifest.files.map(async (file) => {
|
||||
@@ -200,7 +207,12 @@ export async function materializePiDistribution({ outputRoot, nodeExecutable, np
|
||||
await rename(staging, output);
|
||||
const finalRoot = join(output, "runtime");
|
||||
const binding = await verifyPiRuntimeManifest(finalRoot, manifest);
|
||||
return { outputRoot: output, runtimeRoot: finalRoot, manifestPath: join(output, "pi-distribution.json"), metadata, ...binding };
|
||||
return {
|
||||
version: PI_DISTRIBUTION_PINS.runtime,
|
||||
profileDigest: QUALIFIED_ACPX_PROFILES.pi.commandDigest,
|
||||
closureDigest: `sha256:${nativeClosureSha256}`,
|
||||
outputRoot: output, runtimeRoot: finalRoot, manifestPath: join(output, "pi-distribution.json"), metadata, ...binding,
|
||||
};
|
||||
} catch (error) { await rm(staging, { recursive: true, force: true }); throw error; }
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* three pins. Never accept a digest supplied only by an installed manifest.
|
||||
*/
|
||||
export const PI_DISTRIBUTION_CLOSURE_SHA256 = Object.freeze({
|
||||
"darwin-arm64": "277568e3adb63faefafb1aedd8e8d33441608e94d9b764fbfd980e390ff07c8c",
|
||||
"darwin-x64": "f8f271ce09284ef0bf52a99adaa280798f2135b8f8a6ee628394324b923b5d7f",
|
||||
"linux-x64": "b0d71abf0cfe464c147fcc5bfd875e349f94773d84e46a1bdcbadfc0184f8690",
|
||||
"darwin-arm64": "f2c95f532e4b56a57e938ceb62b5ea1a86bc721cba29b41de6270afe3fe0041f",
|
||||
"darwin-x64": "93f3c06c91fc9e06084dff083f3b43ac100dee3ac2c5f5981e3d281f1e561a20",
|
||||
"linux-x64": "99707d2c78656bebdf5df4b85e4674a72d5d30a3a0c730536246251a2889d0a8",
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import { cp, mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
|
||||
import { once } from "node:events";
|
||||
import { StringDecoder } from "node:string_decoder";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { assertPiInstallationProfile, verifyPiInstallation } from "./pi-installation.js";
|
||||
import { QUALIFIED_ACPX_PROFILES, type QualifiedAcpxProfile } from "./qualified-profiles.js";
|
||||
|
||||
const roots: string[] = [];
|
||||
afterEach(async () => { vi.unstubAllEnvs(); await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))); });
|
||||
const candidate = (): QualifiedAcpxProfile => ({ ...QUALIFIED_ACPX_PROFILES.pi, agentProfileVersion: 2 });
|
||||
|
||||
async function fixture() {
|
||||
const root = await mkdtemp(join(tmpdir(), "pi-installation-test-")); roots.push(root);
|
||||
await writeFile(join(root, "package.json"), JSON.stringify({ name: "@paperclipai/paperclip-runner" }));
|
||||
vi.stubEnv("PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT", root);
|
||||
vi.stubEnv("PAPERCLIP_ACPX_PROVIDER_PACKAGE_MANIFEST", join(root, "package.json"));
|
||||
const assets = join(root, "provider-assets/pi", `${process.platform}-${process.arch}`);
|
||||
await mkdir(join(assets, "runtime"), { recursive: true });
|
||||
return { root, assets };
|
||||
}
|
||||
|
||||
describe("Pi installation factory", () => {
|
||||
it("rejects legacy profiles and caller-selected identities", () => {
|
||||
expect(() => assertPiInstallationProfile(candidate())).not.toThrow();
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 1 })).toThrow("version 2");
|
||||
for (const changed of [{ agentServerVersion: "latest" }, { commandDigest: `sha256:${"0".repeat(64)}` }, { reportedModelId: "different" }, { agentRuntimePackage: "ambient-pi" }]) {
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), ...changed })).toThrow("trusted declaration");
|
||||
}
|
||||
});
|
||||
it("never takes closure authority from installed metadata", async () => {
|
||||
const { assets } = await fixture();
|
||||
await writeFile(join(assets, "pi-distribution.json"), JSON.stringify({
|
||||
schema: "paperclip.pi-distribution.v1", runtimeRoot: "runtime", nativeClosureSha256: "0".repeat(64),
|
||||
target: { platform: process.platform, architecture: process.arch, nodeVersion: "24.19.0" },
|
||||
pins: { nodeVersion: "24.19.0", wrapper: "0.0.33", runtime: "0.84.2", sdk: "0.26.0", zod: "3.25.76" },
|
||||
}));
|
||||
await expect(verifyPiInstallation(candidate())).rejects.toThrow("trusted target pin");
|
||||
});
|
||||
it.runIf(process.env.PAPERCLIP_TEST_PI_DISTRIBUTION_ROOT)("launches the actual closed Pi snapshot with bound subprocess paths", async () => {
|
||||
const { root, assets } = await fixture();
|
||||
await cp(process.env.PAPERCLIP_TEST_PI_DISTRIBUTION_ROOT!, assets, { recursive: true });
|
||||
const workspace = join(root, "workspace"); const agentHome = join(root, "agent");
|
||||
await mkdir(workspace); await mkdir(agentHome);
|
||||
const installation = await verifyPiInstallation(candidate());
|
||||
const command = await installation.openCommand();
|
||||
const child = command.spawn([], { cwd: workspace, env: {
|
||||
PATH: "/usr/bin:/bin", HOME: agentHome, PI_CODING_AGENT_DIR: agentHome,
|
||||
PAPERCLIP_ACPX_ISOLATED_CONTEXT: "1", PAPERCLIP_PI_READ_ONLY: "1",
|
||||
PAPERCLIP_PI_READ_ROOTS: "[]", PAPERCLIP_PI_PROTECTED_ROOTS: JSON.stringify([agentHome]),
|
||||
PAPERCLIP_PI_ENTRYPOINT: "/unverified/ignored.js", PI_TELEMETRY: "0",
|
||||
} });
|
||||
let stderr = ""; child.stderr!.on("data", chunk => { stderr += String(chunk); });
|
||||
const pending = new Map<number, { resolve(value: any): void; reject(error: Error): void }>();
|
||||
const decoder = new StringDecoder("utf8"); let buffer = ""; let id = 0;
|
||||
child.stdout!.on("data", chunk => {
|
||||
buffer += decoder.write(chunk);
|
||||
for (;;) {
|
||||
const at = buffer.indexOf("\n"); if (at < 0) break;
|
||||
const line = buffer.slice(0, at); buffer = buffer.slice(at + 1); if (!line.trim()) continue;
|
||||
const value = JSON.parse(line); const waiter = pending.get(value.id);
|
||||
if (waiter) { pending.delete(value.id); if (value.error) waiter.reject(new Error(JSON.stringify(value.error))); else waiter.resolve(value.result); }
|
||||
}
|
||||
});
|
||||
child.once("exit", () => { for (const waiter of pending.values()) waiter.reject(new Error(`Pi exited: ${stderr}`)); pending.clear(); });
|
||||
const call = (method: string, params: unknown) => new Promise<any>((resolve, reject) => {
|
||||
const requestId = ++id;
|
||||
const timer = setTimeout(() => reject(new Error(`Pi snapshot admission timed out: ${stderr}`)), 15_000);
|
||||
pending.set(requestId, { resolve: value => { clearTimeout(timer); resolve(value); }, reject: error => { clearTimeout(timer); reject(error); } });
|
||||
child.stdin!.write(`${JSON.stringify({ jsonrpc: "2.0", id: requestId, method, params })}\n`);
|
||||
});
|
||||
try {
|
||||
const initialized = await call("initialize", { protocolVersion: 1, clientCapabilities: { elicitation: { form: {} } } });
|
||||
expect(initialized.agentCapabilities._meta.paperclipPi.steering).toBe(true);
|
||||
expect(initialized.authMethods).toEqual([]);
|
||||
await expect(call("session/new", { cwd: workspace, mcpServers: [] })).rejects.toThrow("Bind the configured OpenRouter API credential");
|
||||
} finally {
|
||||
child.stdin!.end();
|
||||
if (child.exitCode === null) { const timer = setTimeout(() => child.kill("SIGKILL"), 3000); await once(child, "close"); clearTimeout(timer); }
|
||||
await command.close();
|
||||
}
|
||||
}, 90_000);
|
||||
|
||||
it("refuses symlinked target assets and metadata", async () => {
|
||||
const { root, assets } = await fixture();
|
||||
await rm(assets, { recursive: true });
|
||||
await symlink(root, assets);
|
||||
await expect(verifyPiInstallation(candidate())).rejects.toThrow("fixed asset directory");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { constants } from "node:fs";
|
||||
import { lstat, open, realpath } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { verifyNativeAcpxInstallation, type VerifiedAcpxInstallation } from "./installation-integrity.js";
|
||||
import { PI_DISTRIBUTION_CLOSURE_SHA256 } from "./pi-closure-pins.js";
|
||||
import { PI_NODE_VERSION } from "./pi-node-pins.js";
|
||||
import { verifyPiRuntimeManifest, type PiRuntimeManifest } from "./pi-verified-runtime.js";
|
||||
import { resolveRunnerProviderAssetsRoot } from "./provider-assets-root.js";
|
||||
import { QUALIFIED_ACPX_PROFILES, type QualifiedAcpxProfile } from "./qualified-profiles.js";
|
||||
|
||||
const MAX_DISTRIBUTION_METADATA_BYTES = 4 * 1024 * 1024;
|
||||
const PI_MODEL = "openrouter/deepseek/deepseek-v4-flash-0731";
|
||||
const FIXED_PATHS = Object.freeze({
|
||||
node: "node/bin/node",
|
||||
piEntrypoint: "node_modules/@earendil-works/pi-coding-agent/dist/cli.js",
|
||||
extension: "extensions/paperclip.js",
|
||||
wrapperEntrypoint: "node_modules/pi-acp/dist/index.js",
|
||||
});
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error("Pi distribution metadata is invalid");
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Candidate identity is build-owned; model selection cannot substitute a CLI. */
|
||||
export function assertPiInstallationProfile(profile: QualifiedAcpxProfile): void {
|
||||
const trusted = QUALIFIED_ACPX_PROFILES.pi;
|
||||
if (profile.agentProfileVersion !== 2) throw new Error("Pi rich ACP requires profile version 2; reopen the previous session");
|
||||
if (profile.agent !== "pi" || profile.driverKind !== trusted.driverKind || profile.protocolVersion !== trusted.protocolVersion || profile.acpxVersion !== trusted.acpxVersion || profile.agentServerPackage !== "pi-acp" || profile.agentServerVersion !== "0.0.33" || profile.agentRuntimePackage !== "@earendil-works/pi-coding-agent" || profile.agentRuntimeVersion !== "0.84.2" || profile.commandDigest !== trusted.commandDigest || profile.permissionPolicy !== "interactive" || profile.qualificationModel !== PI_MODEL || profile.reportedModelId !== PI_MODEL) throw new Error("Pi distribution profile differs from its trusted declaration");
|
||||
}
|
||||
|
||||
async function readDistributionMetadata(path: string): Promise<Record<string, unknown>> {
|
||||
const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
||||
try {
|
||||
const before = await file.stat({ bigint: true });
|
||||
if (!before.isFile() || before.nlink !== 1n || before.size < 1n || before.size > BigInt(MAX_DISTRIBUTION_METADATA_BYTES)) throw new Error("Pi distribution metadata is not a bounded private file");
|
||||
const bytes = await file.readFile();
|
||||
const after = await file.stat({ bigint: true });
|
||||
const named = await lstat(path, { bigint: true });
|
||||
if (bytes.length !== Number(before.size) || before.dev !== after.dev || before.ino !== after.ino || before.size !== after.size || before.mtimeNs !== after.mtimeNs || before.ctimeNs !== after.ctimeNs || named.dev !== before.dev || named.ino !== before.ino || named.isSymbolicLink()) throw new Error("Pi distribution metadata changed while read");
|
||||
return record(JSON.parse(bytes.toString("utf8")));
|
||||
} finally { await file.close(); }
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve only fixed runner package assets. The independently source-pinned
|
||||
* native closure supplies the snapshot and provider-group lifetime guardian.
|
||||
* Qualification status remains the caller's production admission gate.
|
||||
*/
|
||||
export async function verifyPiInstallation(profile: QualifiedAcpxProfile): Promise<VerifiedAcpxInstallation> {
|
||||
assertPiInstallationProfile(profile);
|
||||
const target = `${process.platform}-${process.arch}`;
|
||||
if (!Object.hasOwn(PI_DISTRIBUTION_CLOSURE_SHA256, target)) throw new Error("Pi distribution target is unsupported");
|
||||
const expectedClosure = PI_DISTRIBUTION_CLOSURE_SHA256[target as keyof typeof PI_DISTRIBUTION_CLOSURE_SHA256];
|
||||
const assets = join(resolveRunnerProviderAssetsRoot(import.meta.url, "pi"), target);
|
||||
const runtimeRoot = join(assets, "runtime");
|
||||
const assertDirectories = async (): Promise<void> => {
|
||||
for (const path of [assets, runtimeRoot]) {
|
||||
const stat = await lstat(path);
|
||||
if (!stat.isDirectory() || stat.isSymbolicLink() || await realpath(path) !== path) throw new Error("Pi distribution escaped its fixed asset directory");
|
||||
}
|
||||
};
|
||||
await assertDirectories();
|
||||
const metadataPath = join(assets, "pi-distribution.json");
|
||||
const metadata = await readDistributionMetadata(metadataPath);
|
||||
const targetMetadata = record(metadata.target);
|
||||
const pins = record(metadata.pins);
|
||||
if (metadata.schema !== "paperclip.pi-distribution.v1" || metadata.runtimeRoot !== "runtime" || metadata.nativeClosureSha256 !== expectedClosure || targetMetadata.platform !== process.platform || targetMetadata.architecture !== process.arch || targetMetadata.nodeVersion !== PI_NODE_VERSION || pins.nodeVersion !== PI_NODE_VERSION || pins.wrapper !== "0.0.33" || pins.runtime !== "0.84.2" || pins.sdk !== "0.26.0" || pins.zod !== "3.25.76") throw new Error("Pi distribution metadata does not match its trusted target pin");
|
||||
const manifest = record(metadata.manifest) as unknown as PiRuntimeManifest;
|
||||
for (const [key, value] of Object.entries(FIXED_PATHS)) {
|
||||
if (manifest[key as keyof typeof FIXED_PATHS] !== value) throw new Error("Pi distribution changed a fixed launch path");
|
||||
}
|
||||
const verified = await verifyPiRuntimeManifest(runtimeRoot, manifest);
|
||||
if (verified.manifestDigest !== metadata.manifestDigest) throw new Error("Pi runtime manifest digest does not match its distribution");
|
||||
const native = await verifyNativeAcpxInstallation({
|
||||
distributionRoot: runtimeRoot,
|
||||
manifestPath: join(assets, "native-closure.json"),
|
||||
expectedClosureSha256: expectedClosure,
|
||||
executable: FIXED_PATHS.node,
|
||||
entrypoint: "pi-entry.cjs",
|
||||
fixedArguments: [],
|
||||
});
|
||||
return Object.freeze({
|
||||
commandDigest: profile.commandDigest,
|
||||
agentServerPackageJsonPath: join(runtimeRoot, "node_modules/pi-acp/package.json"),
|
||||
agentRuntimePackageJsonPath: join(runtimeRoot, "node_modules/@earendil-works/pi-coding-agent/package.json"),
|
||||
async openCommand() {
|
||||
await assertDirectories();
|
||||
// The native primitive reads every admitted file through held descriptors
|
||||
// into a new immutable snapshot. The bootstrap derives its own launch
|
||||
// environment from that snapshot, never these mutable installation paths.
|
||||
return native.openCommand();
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -83,6 +83,7 @@ async function fixture(t, extra = {}) {
|
||||
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);
|
||||
assert.deepEqual(f.initialized.authMethods, []);
|
||||
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);
|
||||
|
||||
+71
-29
@@ -1,7 +1,15 @@
|
||||
diff --git a/dist/index.js b/dist/index.js
|
||||
--- a/dist/index.js
|
||||
+++ b/dist/index.js
|
||||
@@ -53,7 +53,7 @@
|
||||
@@ -11,6 +11,7 @@
|
||||
// src/acp/auth.ts
|
||||
var PI_SETUP_METHOD_ID = "pi_terminal_login";
|
||||
function getAuthMethods(opts) {
|
||||
+ if (process.env.PAPERCLIP_ACPX_ISOLATED_CONTEXT === "1") return [];
|
||||
const supportsTerminalAuthMeta = opts?.supportsTerminalAuthMeta ?? true;
|
||||
const method = {
|
||||
id: PI_SETUP_METHOD_ID,
|
||||
@@ -53,7 +54,7 @@
|
||||
|
||||
// src/pi-rpc/process.ts
|
||||
import { spawn } from "child_process";
|
||||
@@ -10,7 +18,7 @@ diff --git a/dist/index.js b/dist/index.js
|
||||
|
||||
// src/pi-rpc/command.ts
|
||||
import { platform } from "os";
|
||||
@@ -96,49 +96,38 @@
|
||||
@@ -96,49 +97,38 @@
|
||||
preludeLines = [];
|
||||
constructor(child) {
|
||||
this.child = child;
|
||||
@@ -83,7 +91,7 @@ diff --git a/dist/index.js b/dist/index.js
|
||||
});
|
||||
try {
|
||||
await new Promise((resolve4, reject) => {
|
||||
@@ -181,21 +170,30 @@
|
||||
@@ -181,21 +171,30 @@
|
||||
const { dirname: dirname3 } = await import("path");
|
||||
mkdirSync2(dirname3(sessionFile), { recursive: true });
|
||||
}
|
||||
@@ -119,7 +127,7 @@ diff --git a/dist/index.js b/dist/index.js
|
||||
}
|
||||
}
|
||||
/**
|
||||
@@ -288,12 +286,17 @@
|
||||
@@ -288,12 +287,17 @@
|
||||
const withId = { ...cmd, id };
|
||||
const line = `${JSON.stringify(withId)}
|
||||
`;
|
||||
@@ -142,7 +150,16 @@ diff --git a/dist/index.js b/dist/index.js
|
||||
});
|
||||
}
|
||||
writeLine(line) {
|
||||
@@ -714,6 +717,7 @@
|
||||
@@ -337,7 +341,7 @@
|
||||
{
|
||||
authMethods: getAuthMethods()
|
||||
},
|
||||
- "Configure an API key or log in with an OAuth provider."
|
||||
+ "Bind the configured OpenRouter API credential to this Paperclip runtime."
|
||||
);
|
||||
}
|
||||
|
||||
@@ -714,6 +718,7 @@
|
||||
try {
|
||||
proc = await PiRpcProcess.spawn({
|
||||
cwd: params.cwd,
|
||||
@@ -150,7 +167,7 @@ diff --git a/dist/index.js b/dist/index.js
|
||||
piCommand: params.piCommand
|
||||
});
|
||||
} catch (e) {
|
||||
@@ -808,6 +812,9 @@
|
||||
@@ -808,6 +813,9 @@
|
||||
this.proc = opts.proc;
|
||||
this.conn = opts.conn;
|
||||
this.fileCommands = opts.fileCommands ?? [];
|
||||
@@ -160,7 +177,7 @@ diff --git a/dist/index.js b/dist/index.js
|
||||
this.proc.onEvent((ev) => this.handlePiEvent(ev));
|
||||
}
|
||||
setStartupInfo(text) {
|
||||
@@ -852,6 +859,7 @@
|
||||
@@ -852,6 +860,7 @@
|
||||
}
|
||||
async cancel() {
|
||||
this.cancelRequested = true;
|
||||
@@ -168,7 +185,7 @@ diff --git a/dist/index.js b/dist/index.js
|
||||
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 @@
|
||||
@@ -920,12 +929,16 @@
|
||||
this.cancelRequested = false;
|
||||
this.inAgentLoop = false;
|
||||
this.pendingTurn = { resolve: t.resolve, reject: t.reject };
|
||||
@@ -185,7 +202,7 @@ diff --git a/dist/index.js b/dist/index.js
|
||||
const authErr = maybeAuthRequiredError(err);
|
||||
if (authErr) {
|
||||
this.pendingTurn?.reject(authErr);
|
||||
@@ -946,6 +958,23 @@
|
||||
@@ -946,6 +959,23 @@
|
||||
handlePiEvent(ev) {
|
||||
const type = String(ev.type ?? "");
|
||||
switch (type) {
|
||||
@@ -209,7 +226,7 @@ diff --git a/dist/index.js b/dist/index.js
|
||||
case "message_update": {
|
||||
const ame = ev.assistantMessageEvent;
|
||||
if (ame?.type === "text_delta" && typeof ame.delta === "string") {
|
||||
@@ -1047,7 +1076,7 @@
|
||||
@@ -1047,7 +1077,7 @@
|
||||
if (p) {
|
||||
try {
|
||||
const abs = isAbsolute(p) ? p : resolvePath(this.cwd, p);
|
||||
@@ -218,7 +235,7 @@ diff --git a/dist/index.js b/dist/index.js
|
||||
this.fileSnapshots.set(toolCallId, { path: p, oldText: snapshotOldText });
|
||||
if (toolName === "edit") {
|
||||
for (const needle of getEditOldTexts(args)) {
|
||||
@@ -1125,7 +1154,7 @@
|
||||
@@ -1125,7 +1155,7 @@
|
||||
if (!isError && snapshot) {
|
||||
try {
|
||||
const abs = isAbsolute(snapshot.path) ? snapshot.path : resolvePath(this.cwd, snapshot.path);
|
||||
@@ -227,7 +244,7 @@ diff --git a/dist/index.js b/dist/index.js
|
||||
if (snapshot.oldText === null || newText !== snapshot.oldText) {
|
||||
hasStructuredDiff = true;
|
||||
content = [
|
||||
@@ -1154,14 +1183,11 @@
|
||||
@@ -1154,14 +1184,11 @@
|
||||
break;
|
||||
}
|
||||
case "extension_ui_request": {
|
||||
@@ -246,7 +263,7 @@ diff --git a/dist/index.js b/dist/index.js
|
||||
break;
|
||||
}
|
||||
case "auto_retry_start": {
|
||||
@@ -1210,9 +1236,12 @@
|
||||
@@ -1210,9 +1237,12 @@
|
||||
break;
|
||||
}
|
||||
case "agent_settled": {
|
||||
@@ -260,7 +277,7 @@ diff --git a/dist/index.js b/dist/index.js
|
||||
this.pendingTurn = null;
|
||||
this.inAgentLoop = false;
|
||||
const next = this.turnQueue.shift();
|
||||
@@ -1725,6 +1754,7 @@
|
||||
@@ -1725,6 +1755,7 @@
|
||||
return process.env.PI_CODING_AGENT_DIR ? resolve3(process.env.PI_CODING_AGENT_DIR) : join4(homedir4(), ".pi", "agent");
|
||||
}
|
||||
function getEnableSkillCommands(cwd) {
|
||||
@@ -268,7 +285,7 @@ diff --git a/dist/index.js b/dist/index.js
|
||||
const merged = getMergedSettings(cwd);
|
||||
const direct = merged.enableSkillCommands;
|
||||
if (typeof direct === "boolean") return direct;
|
||||
@@ -1733,6 +1763,7 @@
|
||||
@@ -1733,6 +1764,7 @@
|
||||
return true;
|
||||
}
|
||||
function getQuietStartup(cwd) {
|
||||
@@ -276,7 +293,15 @@ diff --git a/dist/index.js b/dist/index.js
|
||||
const merged = getMergedSettings(cwd);
|
||||
const direct = merged.quietStartup;
|
||||
if (typeof direct === "boolean") return direct;
|
||||
@@ -1884,11 +1915,13 @@
|
||||
@@ -1824,6 +1856,7 @@
|
||||
const out = [];
|
||||
const seen = /* @__PURE__ */ new Set();
|
||||
for (const c of [...a, ...b]) {
|
||||
+ if (process.env.PAPERCLIP_ACPX_ISOLATED_CONTEXT === "1" && !["compact", "session", "autocompact"].includes(c.name)) continue;
|
||||
if (seen.has(c.name)) continue;
|
||||
seen.add(c.name);
|
||||
out.push(c);
|
||||
@@ -1884,11 +1917,13 @@
|
||||
throw RequestError3.invalidParams(`Unknown sessionId: ${sessionId}`);
|
||||
}
|
||||
const cwd = opts?.cwd ?? stored.cwd;
|
||||
@@ -290,7 +315,7 @@ diff --git a/dist/index.js b/dist/index.js
|
||||
piCommand: process.env.PI_ACP_PI_COMMAND
|
||||
});
|
||||
} catch (e) {
|
||||
@@ -1897,7 +1930,7 @@
|
||||
@@ -1897,7 +1932,7 @@
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
@@ -299,7 +324,7 @@ diff --git a/dist/index.js b/dist/index.js
|
||||
const session = this.sessions.getOrCreate(sessionId, {
|
||||
cwd,
|
||||
mcpServers: opts?.mcpServers ?? [],
|
||||
@@ -1917,6 +1950,7 @@
|
||||
@@ -1917,6 +1952,7 @@
|
||||
}
|
||||
}
|
||||
async initialize(params) {
|
||||
@@ -307,7 +332,7 @@ diff --git a/dist/index.js b/dist/index.js
|
||||
const supportedVersion = 1;
|
||||
const requested = params.protocolVersion;
|
||||
return {
|
||||
@@ -1933,7 +1967,8 @@
|
||||
@@ -1933,7 +1969,8 @@
|
||||
}),
|
||||
agentCapabilities: {
|
||||
loadSession: true,
|
||||
@@ -317,7 +342,7 @@ diff --git a/dist/index.js b/dist/index.js
|
||||
promptCapabilities: {
|
||||
image: true,
|
||||
audio: false,
|
||||
@@ -1942,8 +1977,7 @@
|
||||
@@ -1942,8 +1979,7 @@
|
||||
sessionCapabilities: {
|
||||
// **UNSTABLE** ACP capability used by Zed's codex-acp adapter.
|
||||
// Enables a native session picker in clients that support it.
|
||||
@@ -327,7 +352,7 @@ diff --git a/dist/index.js b/dist/index.js
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1953,7 +1987,7 @@
|
||||
@@ -1953,7 +1989,7 @@
|
||||
throw RequestError3.invalidParams(`cwd must be an absolute path: ${params.cwd}`);
|
||||
}
|
||||
this.lastSessionCwd = params.cwd;
|
||||
@@ -336,7 +361,24 @@ diff --git a/dist/index.js b/dist/index.js
|
||||
const enableSkillCommands = getEnableSkillCommands(params.cwd);
|
||||
const session = await this.sessions.create({
|
||||
cwd: params.cwd,
|
||||
@@ -2066,6 +2100,8 @@
|
||||
@@ -1994,14 +2030,14 @@
|
||||
this.cleanupFailedNewSession(session.sessionId, state);
|
||||
throw RequestError3.authRequired(
|
||||
{ authMethods: getAuthMethods() },
|
||||
- "Configure an API key or log in with an OAuth provider."
|
||||
+ "Bind the configured OpenRouter API credential to this Paperclip runtime."
|
||||
);
|
||||
}
|
||||
if (stateErr && maybeAuthRequiredError(stateErr)) {
|
||||
this.cleanupFailedNewSession(session.sessionId, state);
|
||||
throw RequestError3.authRequired(
|
||||
{ authMethods: getAuthMethods() },
|
||||
- "Configure an API key or log in with an OAuth provider."
|
||||
+ "Bind the configured OpenRouter API credential to this Paperclip runtime."
|
||||
);
|
||||
}
|
||||
const { configOptions, models, modes } = await getSessionConfiguration(session.proc, {
|
||||
@@ -2066,6 +2102,8 @@
|
||||
const session = await this.restoreSession(params.sessionId);
|
||||
const { message, images } = promptToPiMessage(params.prompt);
|
||||
if (images.length === 0 && message.trimStart().startsWith("/")) {
|
||||
@@ -345,7 +387,7 @@ diff --git a/dist/index.js b/dist/index.js
|
||||
const trimmed = message.trim();
|
||||
const space = trimmed.indexOf(" ");
|
||||
const cmd = space === -1 ? trimmed.slice(1) : trimmed.slice(1, space);
|
||||
@@ -2432,8 +2468,18 @@
|
||||
@@ -2432,8 +2470,18 @@
|
||||
}
|
||||
}
|
||||
const result = await session.prompt(message, images);
|
||||
@@ -366,7 +408,7 @@ diff --git a/dist/index.js b/dist/index.js
|
||||
}
|
||||
async cancel(params) {
|
||||
const session = this.sessions.maybeGet(params.sessionId);
|
||||
@@ -2473,7 +2519,7 @@
|
||||
@@ -2473,7 +2521,7 @@
|
||||
mcpServers: params.mcpServers
|
||||
});
|
||||
const proc = session.proc;
|
||||
@@ -375,7 +417,7 @@ diff --git a/dist/index.js b/dist/index.js
|
||||
this.sessions.closeAllExcept?.(session.sessionId);
|
||||
this.store.upsert({
|
||||
sessionId: params.sessionId,
|
||||
@@ -2607,6 +2653,7 @@
|
||||
@@ -2607,6 +2655,7 @@
|
||||
return response;
|
||||
}
|
||||
async deleteSession(params) {
|
||||
@@ -383,7 +425,7 @@ diff --git a/dist/index.js b/dist/index.js
|
||||
const stored = this.store.get(params.sessionId);
|
||||
const piSession = findPiSession(params.sessionId);
|
||||
if (!stored && !piSession) {
|
||||
@@ -2828,6 +2875,7 @@
|
||||
@@ -2828,6 +2877,7 @@
|
||||
return 0;
|
||||
}
|
||||
function buildUpdateNotice() {
|
||||
@@ -391,7 +433,7 @@ diff --git a/dist/index.js b/dist/index.js
|
||||
try {
|
||||
const piVersion = spawnSync("pi", ["--version"], { encoding: "utf-8" });
|
||||
const installed = (String(piVersion.stdout ?? "").trim() || String(piVersion.stderr ?? "").trim()).replace(
|
||||
@@ -2848,6 +2896,7 @@
|
||||
@@ -2848,6 +2898,7 @@
|
||||
}
|
||||
}
|
||||
function buildStartupInfo(opts) {
|
||||
@@ -399,7 +441,7 @@ diff --git a/dist/index.js b/dist/index.js
|
||||
void opts.fileCommands;
|
||||
const md = [];
|
||||
try {
|
||||
@@ -2980,6 +3029,7 @@
|
||||
@@ -2980,6 +3031,7 @@
|
||||
|
||||
// src/index.ts
|
||||
if (process.argv.includes("--terminal-login")) {
|
||||
@@ -407,7 +449,7 @@ diff --git a/dist/index.js b/dist/index.js
|
||||
const { spawnSync: spawnSync2 } = await import("child_process");
|
||||
const cmd = getPiCommand(process.env.PI_ACP_PI_COMMAND);
|
||||
const res = spawnSync2(cmd, [], {
|
||||
@@ -3019,11 +3069,12 @@
|
||||
@@ -3019,11 +3071,12 @@
|
||||
}
|
||||
});
|
||||
var stream = ndJsonStream(input, output);
|
||||
|
||||
Reference in new issue
Block a user