mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 08:22:20 +02:00
Verify Pi native extension admission against pinned runtime
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
927efd0699
commit
ea9eb3f85e
2 files changed
+89
-4
No files matched your search
@@ -79,7 +79,8 @@ skill paths. Project trust is denied; user-bash RPC and terminal login are disab
|
||||
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
|
||||
The wrapper must not infer complete extension registration from process startup.
|
||||
It therefore 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
|
||||
@@ -114,9 +115,10 @@ 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
|
||||
`test/pi-native-package-contract.test.mjs` runs the real Pi 0.84.2 CLI without
|
||||
credentials. It loads the compiled owned extension, initializes a loopback MCP
|
||||
catalog, and returns the exact readiness command. An invalid catalog exits Pi
|
||||
before RPC admission. 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.
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawn } from "node:child_process";
|
||||
import { once } from "node:events";
|
||||
import { createServer } from "node:http";
|
||||
import { mkdtemp, mkdir, readFile, realpath, rm, writeFile } from "node:fs/promises";
|
||||
import { stripTypeScriptTypes } from "node:module";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { StringDecoder } from "node:string_decoder";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import test from "node:test";
|
||||
|
||||
// This test runs real pinned Pi RPC without issuing a prompt or loading keys.
|
||||
const packageRoot = process.env.PAPERCLIP_TEST_PI_RUNTIME_ROOT
|
||||
?? dirname(dirname(fileURLToPath(import.meta.resolve("@earendil-works/pi-coding-agent"))));
|
||||
const metadata = JSON.parse(await readFile(join(packageRoot, "package.json"), "utf8"));
|
||||
const extensionSource = await readFile(new URL("../src/drivers/acpx/pi-runtime-extension.ts", import.meta.url), "utf8");
|
||||
|
||||
for (const brokenCatalog of [false, true]) {
|
||||
test(`real pinned Pi ${brokenCatalog ? "withholds" : "registers"} readiness after MCP initialization`, { timeout: 20_000 }, async (t) => {
|
||||
assert.equal(metadata.version, "0.84.2");
|
||||
const root = await realpath(await mkdtemp(join(tmpdir(), "paperclip-pi-native-contract-")));
|
||||
await mkdir(join(root, "workspace")); await mkdir(join(root, "agent"));
|
||||
const extension = join(root, "extension.mjs");
|
||||
await writeFile(extension, stripTypeScriptTypes(extensionSource));
|
||||
const calls = [];
|
||||
const server = createServer(async (request, response) => {
|
||||
assert.equal(request.headers.authorization, "Bearer 0123456789abcdef0123456789abcdef");
|
||||
let body = ""; for await (const chunk of request) body += chunk;
|
||||
const rpc = JSON.parse(body); calls.push(rpc.method);
|
||||
response.setHeader("Content-Type", "application/json");
|
||||
const result = rpc.method === "initialize"
|
||||
? { protocolVersion: "2025-03-26", capabilities: { tools: {} }, serverInfo: { name: "fixture", version: "1" } }
|
||||
: { tools: [{ name: brokenCatalog ? "invalid/name" : "report_progress", description: "Report progress", inputSchema: { type: "object", properties: { text: { type: "string" } }, required: ["text"] } }] };
|
||||
response.end(JSON.stringify({ jsonrpc: "2.0", id: rpc.id, result }));
|
||||
});
|
||||
server.listen(0, "127.0.0.1"); await once(server, "listening");
|
||||
const port = server.address().port;
|
||||
const child = spawn(await realpath(process.execPath), [join(packageRoot, "dist/cli.js"), "--mode", "rpc", "--no-extensions", "--no-skills", "--no-prompt-templates", "--no-themes", "--no-approve", "--offline", "-e", extension], {
|
||||
cwd: join(root, "workspace"), stdio: "pipe",
|
||||
env: {
|
||||
PATH: process.env.PATH, HOME: root, PI_CODING_AGENT_DIR: join(root, "agent"), PI_SKIP_VERSION_CHECK: "1", PI_TELEMETRY: "0",
|
||||
PAPERCLIP_PI_RUNTIME_CONFIGURATION: JSON.stringify({
|
||||
workspace: join(root, "workspace"), readOnly: true, readRoots: [], protectedRoots: [join(root, "agent")], instructions: "Use the assigned tools",
|
||||
servers: [{ type: "http", name: "paperclip", url: `http://127.0.0.1:${port}/mcp`, headers: [{ name: "Authorization", value: "Bearer 0123456789abcdef0123456789abcdef" }] }],
|
||||
}),
|
||||
},
|
||||
});
|
||||
let stderr = ""; child.stderr.on("data", (chunk) => { stderr += chunk; });
|
||||
t.after(async () => {
|
||||
child.stdin.end();
|
||||
if (child.exitCode === null) { const timer = setTimeout(() => child.kill("SIGKILL"), 2000); await once(child, "exit"); clearTimeout(timer); }
|
||||
server.closeAllConnections(); await new Promise((resolve) => server.close(resolve));
|
||||
await rm(root, { recursive: true, force: true });
|
||||
});
|
||||
const admission = new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error(`Pi RPC admission timed out: ${stderr}`)), 15_000);
|
||||
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()) continue;
|
||||
const value = JSON.parse(line);
|
||||
if (value.type === "response" && value.id === "admission") { clearTimeout(timer); resolve(value); }
|
||||
}
|
||||
});
|
||||
child.once("error", (error) => { clearTimeout(timer); reject(error); });
|
||||
child.once("exit", () => { clearTimeout(timer); reject(new Error(`Pi RPC exited before admission: ${stderr}`)); });
|
||||
child.stdin.write(JSON.stringify({ type: "get_commands", id: "admission" }) + "\n");
|
||||
});
|
||||
if (brokenCatalog) {
|
||||
await assert.rejects(admission, /Pi RPC exited before admission: Error: Failed to load extension/);
|
||||
assert.deepEqual(calls, ["initialize", "tools/list"]);
|
||||
return;
|
||||
}
|
||||
const result = await admission;
|
||||
assert.equal(result.success, true);
|
||||
assert.deepEqual(calls, ["initialize", "tools/list"]);
|
||||
const readiness = result.data.commands.find((command) => command.name === "paperclip-runtime-ready-v1");
|
||||
assert.equal(readiness?.description, "Paperclip runtime gate v1");
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user