From 411df9fa58a64a764dff8cf070024414cff068e6 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 28 Sep 2026 11:35:14 -0500 Subject: [PATCH] Bind candidate runtimes to task policy and separate Pi price estimates Co-Authored-By: Paperclip --- .../acpx/native-distribution-integrity.ts | 2 +- .../src/drivers/acpx/profile-installation.ts | 13 +++++ .../src/drivers/acpx/runtime-host.test.ts | 30 ++++++++++- .../src/drivers/acpx/runtime-host.ts | 51 +++++++++++++++---- .../src/drivers/acpx/runtime-skill-lease.ts | 22 ++++++++ .../src/drivers/acpx/usage-accounting.test.ts | 28 ++++++++++ .../src/drivers/acpx/usage-accounting.ts | 31 ++++++++++- 7 files changed, 163 insertions(+), 14 deletions(-) create mode 100644 packages/paperclip-runner/src/drivers/acpx/profile-installation.ts create mode 100644 packages/paperclip-runner/src/drivers/acpx/runtime-skill-lease.ts diff --git a/packages/paperclip-runner/src/drivers/acpx/native-distribution-integrity.ts b/packages/paperclip-runner/src/drivers/acpx/native-distribution-integrity.ts index efdbc52bbd..0c1d366273 100644 --- a/packages/paperclip-runner/src/drivers/acpx/native-distribution-integrity.ts +++ b/packages/paperclip-runner/src/drivers/acpx/native-distribution-integrity.ts @@ -36,7 +36,7 @@ const validPath = (value: unknown): value is string => typeof value === "string" export function parseNativeAcpxDistributionEntries(value: unknown, expectedSha256: string): NativeAcpxDistributionEntry[] { if (!/^[a-f0-9]{64}$/.test(expectedSha256)) throw new Error("Native ACPX closure pin is invalid"); const entries = value !== null && typeof value === "object" ? (value as { entries?: unknown }).entries : undefined; - if (!Array.isArray(entries) || entries.length === 0 || entries.length > 10_000) throw new Error("Native ACPX closure inventory is invalid"); + if (!Array.isArray(entries) || entries.length === 0 || entries.length > 30_000) throw new Error("Native ACPX closure inventory is invalid"); let total = 0; let previous = ""; const parsed = entries.map(raw => { diff --git a/packages/paperclip-runner/src/drivers/acpx/profile-installation.ts b/packages/paperclip-runner/src/drivers/acpx/profile-installation.ts new file mode 100644 index 0000000000..c7adaaa9d7 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/profile-installation.ts @@ -0,0 +1,13 @@ +import type { QualifiedAcpxAgent, QualifiedAcpxProfile } from "./qualified-profiles.js"; +import { verifyQualifiedAcpxInstallation, type VerifiedAcpxInstallation } from "./installation-integrity.js"; + +/** Closed build-owned registry. Provider branches add their pinned installations here. */ +export async function verifyAcpxProfileInstallation(profile: QualifiedAcpxProfile): Promise { + if (profile.agent !== "claude" && profile.agent !== "codex") { + throw new Error(`ACPX ${profile.agent} verified candidate distribution is not installed in this build`); + } + return verifyQualifiedAcpxInstallation(profile); +} + +/** Provider policy admission is repeated immediately before each process launch. */ +export async function assertAcpxProfileWorkspace(_agent: QualifiedAcpxAgent, _workspace: string): Promise {} diff --git a/packages/paperclip-runner/src/drivers/acpx/runtime-host.test.ts b/packages/paperclip-runner/src/drivers/acpx/runtime-host.test.ts index b0ed749af0..3d75a2eb42 100644 --- a/packages/paperclip-runner/src/drivers/acpx/runtime-host.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/runtime-host.test.ts @@ -713,7 +713,7 @@ describe("ACPX runtime host", () => { ).rejects.toThrow(); }); - it("rejects Pi before installation or runtime launch", async () => { + it("rejects Pi without an explicit task policy before installation or runtime launch", async () => { const fixture = await hostFixture(); const verifyInstallation = vi.fn(); const openRuntime = vi.fn(); @@ -731,11 +731,37 @@ describe("ACPX runtime host", () => { reportRetainedCleanupFailure: vi.fn(), }, ), - ).rejects.toThrow("descriptor-confined verified launch"); + ).rejects.toThrow("explicit task execution policy"); expect(verifyInstallation).not.toHaveBeenCalled(); expect(openRuntime).not.toHaveBeenCalled(); }); + it("binds Pi task policy independently of permissions and rejects an uninstalled candidate", async () => { + const fixture = await hostFixture(); + const model = "openrouter/deepseek/deepseek-v4-flash-0731"; + const profile = resolveQualifiedAcpxProfile("pi", model); + const openRuntime = vi.fn(async (options: AcpxRuntimePortOpenOptions) => { + expect(options.launchEnvironment.PAPERCLIP_PI_READ_ONLY).toBe("1"); + expect(options.launchEnvironment.PAPERCLIP_PI_SYSTEM_INSTRUCTIONS).toBe("Bound instructions"); + expect(JSON.parse(options.launchEnvironment.PAPERCLIP_PI_READ_ROOTS!)).toEqual([]); + expect(options.permissionMode).toBe("approve-all"); + return runtimePort({ getStatus: async () => ({ models: { currentModelId: model } }) }); + }); + const options = { ...fixture.options, agent: "pi" as const, model, + permissionMode: "approve-all" as const, providerPolicy: { readOnly: true }, systemInstructions: "Bound instructions" }; + await expect(AcpxRuntimeHost.open(options, { openRuntime, reportRetainedCleanupFailure: vi.fn() })) + .rejects.toThrow("verified candidate distribution is not installed"); + expect(openRuntime).not.toHaveBeenCalled(); + const host = await AcpxRuntimeHost.open(options, fixture.dependencies({ + verifyInstallation: async () => ({ commandDigest: profile.commandDigest, + agentServerPackageJsonPath: join(fixture.root, "package.json"), agentRuntimePackageJsonPath: null, + openCommand: async () => ({ spawn: () => { throw new Error("not used"); }, close: async () => {} }), + }), openRuntime, + })); + await host.close({ reason: "policy verified" }); + expect(openRuntime).toHaveBeenCalledOnce(); + }); + it("selects and verifies Claude's qualified reported model", async () => { const fixture = await hostFixture(); let selected = false; diff --git a/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts b/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts index 37cbc74e84..803343a947 100644 --- a/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts +++ b/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts @@ -1,4 +1,6 @@ -import { join } from "node:path"; +import { dirname, join } from "node:path"; +import { assertAcpxProfileWorkspace, verifyAcpxProfileInstallation } from "./profile-installation.js"; +import { createAcpxRuntimeSkillLease } from "./runtime-skill-lease.js"; import { claudeNativeSkillPrompt } from "./native-skill-prompt.js"; import { nativeMcpLaunchBinding } from "../native-mcp.js"; @@ -26,7 +28,6 @@ import { type AcpxProviderLifetimeLease, } from "./codex-credentials.js"; import { - verifyQualifiedAcpxInstallation, type VerifiedAcpxCommandLease, type VerifiedAcpxInstallation, } from "./installation-integrity.js"; @@ -51,6 +52,7 @@ import { import { prepareAcpxRuntimeSandbox, type AcpxRuntimeSandbox, + type AcpxProviderRuntimePolicy, } from "./runtime-sandbox.js"; import type { AcpxExpectedSessionIdentity } from "./sidecar-protocol.js"; @@ -179,7 +181,7 @@ export type AcpxSemanticToolSession = Omit; export interface AcpxRetainedCleanupFailure { resource: - "credential" | "provider_lifetime" | "command" | "runtime" | "tool_bridge"; + "credential" | "provider_lifetime" | "command" | "runtime" | "tool_bridge" | "skills"; attempt: number; error: unknown; } @@ -211,6 +213,8 @@ export interface AcpxRuntimeHostDependencies { } export interface OpenAcpxRuntimeHostOptions { + /** Explicit task execution policy; never inferred from auto-approval. Required for Pi. */ + providerPolicy?: AcpxProviderRuntimePolicy; /** Never persisted as session options or recovery identity. */ clientCapabilities?: Record; runtimeDirectory: string; @@ -303,10 +307,8 @@ export class AcpxRuntimeHost { dependencies: AcpxRuntimeHostDependencies, ): Promise { options.signal?.throwIfAborted(); - if (options.agent === "pi") { - throw new Error( - "ACPX pi is unavailable until its runtime has descriptor-confined verified launch", - ); + if (options.agent === "pi" && typeof options.providerPolicy?.readOnly !== "boolean") { + throw new Error("Pi admission requires an explicit task execution policy"); } const nativeMcp = nativeMcpLaunchBinding(options.environment); if (nativeMcp?.name === "paperclip") { @@ -345,7 +347,7 @@ export class AcpxRuntimeHost { const installation = await runAbortableAdmissionStage( options.signal, () => - (dependencies.verifyInstallation ?? verifyQualifiedAcpxInstallation)( + (dependencies.verifyInstallation ?? verifyAcpxProfileInstallation)( profile, ), dependencies.retainAdmissionCleanup, @@ -403,6 +405,11 @@ export class AcpxRuntimeHost { agent: options.agent, environment: options.environment, tools: options.semanticTools?.tools, + ...(options.providerPolicy === undefined ? {} : { providerPolicy: { + ...options.providerPolicy, + systemInstructions: boundedInstructions(options.systemInstructions), + protectedPaths: [...(options.providerPolicy.protectedPaths ?? []), dirname(installation.agentServerPackageJsonPath)], + } }), }), dependencies.retainAdmissionCleanup, ); @@ -433,6 +440,29 @@ export class AcpxRuntimeHost { dependencies.reportRetainedCleanupFailure(failure), }); } + let launchEnvironment = sandbox.launchEnvironment; + if (options.agent === "pi") { + const skills = await acquireAbortableAdmissionResource({ + signal: options.signal, + acquire: () => createAcpxRuntimeSkillLease(options.runtimeContext ?? null), + resource: "skills", releaseLate: value => value.close(), + reportFailure: dependencies.reportRetainedCleanupFailure, + }); + const providerLifetime = credential; + credential = { + lifetimeFenceCandidates: providerLifetime.lifetimeFenceCandidates, + lifetimeFenceFds: providerLifetime.lifetimeFenceFds, + activateLifetimeOwner: pid => providerLifetime.activateLifetimeOwner(pid), + async close() { + // Only release assigned files after the provider lifetime quorum closes. + await providerLifetime.close(); + await skills.close(); + }, + }; + launchEnvironment = Object.freeze({ ...sandbox.launchEnvironment, + PAPERCLIP_PI_READ_ROOTS: JSON.stringify([...(options.providerPolicy?.readRoots ?? []), ...skills.readRoots]), + }); + } if (options.agent === "claude") { // The lifetime lease proves the previous provider has stopped. Refresh // the assigned snapshot before every launch, including durable resume; @@ -474,8 +504,9 @@ export class AcpxRuntimeHost { } runtime = await acquireAbortableAdmissionResource({ signal: options.signal, - acquire: () => { + acquire: async () => { options.assertWorkspaceHeld?.(); + await assertAcpxProfileWorkspace(options.agent, binding.workspacePath); return dependencies.openRuntime({ ...(options.clientCapabilities === undefined ? {} : { clientCapabilities: structuredClone(options.clientCapabilities), @@ -490,7 +521,7 @@ export class AcpxRuntimeHost { permissionPolicy: acpxRuntimePermissionPolicy( binding.permissionMode, ), - launchEnvironment: sandbox.launchEnvironment, + launchEnvironment, credentialFenceFds: admittedLifetime.lifetimeFenceFds, activateCredentialFenceOwner: admittedLifetime.activateLifetimeOwner.bind(admittedLifetime), diff --git a/packages/paperclip-runner/src/drivers/acpx/runtime-skill-lease.ts b/packages/paperclip-runner/src/drivers/acpx/runtime-skill-lease.ts new file mode 100644 index 0000000000..aec91cc1ae --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/runtime-skill-lease.ts @@ -0,0 +1,22 @@ +import { mkdtemp, realpath, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { NativeRuntimeContextSnapshot } from "../../contracts/runtime-context.js"; +import { materializeNativeRuntimeSkills, releaseMaterializedNativeRuntimeSkills } from "../runtime-context-materializer.js"; + +/** Assigned assets live outside the protected credential/runtime tree. */ +export async function createAcpxRuntimeSkillLease(context: NativeRuntimeContextSnapshot | null) { + const root = await realpath(await mkdtemp(join(tmpdir(), "paperclip-acpx-skills-"))); + const skills = join(root, "skills"); + const close = async () => { + await releaseMaterializedNativeRuntimeSkills(skills); + await rm(root, { recursive: true, force: true }); + }; + try { + await materializeNativeRuntimeSkills(context, skills); + return { readRoots: context?.skills.length ? [await realpath(skills)] : [], close }; + } catch (error) { + await close(); + throw error; + } +} diff --git a/packages/paperclip-runner/src/drivers/acpx/usage-accounting.test.ts b/packages/paperclip-runner/src/drivers/acpx/usage-accounting.test.ts index 168e81cc8c..b7a0301876 100644 --- a/packages/paperclip-runner/src/drivers/acpx/usage-accounting.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/usage-accounting.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { persistedAcpxTurnUsage, + acpxUsageEstimateNotice, qualifiedAcpxUsageBreakdown, } from "./usage-accounting.js"; @@ -130,3 +131,30 @@ describe("persisted terminal ACPX usage", () => { }); }); }); + +describe("Pi prompt accounting authority", () => { + const after = { lastRequestId: "turn", usageCost: { amount: 999, currency: "USD" }, requestTokenUsage: { + message: { input_tokens: 12, paperclip_pi: { provenance: "assistant_message_receipts", cost_usd: 0.123 } }, + } }; + it("preserves an exact new Pi receipt estimate without charging it as billed spend", () => { + const usage = persistedAcpxTurnUsage({}, after, "turn", "pi")!; + expect(usage.cost).toBeUndefined(); + expect(usage.pricingEstimateUsd).toBe(0.123); + expect(acpxUsageEstimateNotice(usage, "turn:pricing")?.payload).toMatchObject({ + category: "pi_usage_pricing_estimate", summary: expect.stringContaining("Billing cost is unverified"), + }); + expect(persistedAcpxTurnUsage(after, after, "turn", "pi")).toBeNull(); + }); + it("ignores foreign, missing, invalid and unproven estimates", () => { + expect(persistedAcpxTurnUsage({}, after, "turn", "copilot")?.pricingEstimateUsd).toBeUndefined(); + for (const receipt of [{}, { provenance: "other", cost_usd: 2 }, + { provenance: "assistant_message_receipts", cost_usd: -1 }, + { provenance: "assistant_message_receipts", cost_usd: Infinity }]) { + const usage = persistedAcpxTurnUsage({}, { ...after, requestTokenUsage: { + message: { input_tokens: 12, paperclip_pi: receipt }, + } }, "turn", "pi")!; + expect(usage.cost).toBeUndefined(); + expect(acpxUsageEstimateNotice(usage, "turn:pricing")).toBeNull(); + } + }); +}); diff --git a/packages/paperclip-runner/src/drivers/acpx/usage-accounting.ts b/packages/paperclip-runner/src/drivers/acpx/usage-accounting.ts index f6c2ad50d6..f22bee520e 100644 --- a/packages/paperclip-runner/src/drivers/acpx/usage-accounting.ts +++ b/packages/paperclip-runner/src/drivers/acpx/usage-accounting.ts @@ -1,3 +1,4 @@ +import type { CanonicalProviderEvent } from "../../provider-events.js"; import type { QualifiedAcpxAgent } from "./qualified-profiles.js"; function record(value: unknown): Record { @@ -37,6 +38,7 @@ export function persistedAcpxTurnUsage( before: unknown, after: unknown, requestId: string, + agent: QualifiedAcpxAgent | null = null, ): Record | null { const current = record(after); if (current.lastRequestId !== requestId) return null; @@ -47,11 +49,19 @@ export function persistedAcpxTurnUsage( ); if (added.length !== 1) return null; const usage = record(receipts[added[0]!]); + const piReceipt = agent === "pi" ? record(usage.paperclip_pi) : {}; + const piReceiptVerified = piReceipt.provenance === "assistant_message_receipts"; + const estimate = piReceiptVerified && typeof piReceipt.cost_usd === "number" + && Number.isFinite(piReceipt.cost_usd) && piReceipt.cost_usd >= 0 ? piReceipt.cost_usd : undefined; return { type: "status", tag: "usage_update", text: "terminal prompt usage", - cost: current.usageCost, + // Pi calculates cost from catalog prices, not billing receipts. Never feed + // this estimate into the authoritative/cumulative provider spend channel. + cost: agent === "pi" ? undefined : current.usageCost, + ...(piReceiptVerified ? { usageProvenance: "pi_assistant_message_receipts" } : {}), + ...(estimate === undefined ? {} : { pricingEstimateUsd: estimate }), breakdown: { inputTokens: usage.input_tokens, outputTokens: usage.output_tokens, @@ -62,3 +72,22 @@ export function persistedAcpxTurnUsage( }, }; } + +/** Preserve the estimate for inspection while keeping billing authority separate. */ +export function acpxUsageEstimateNotice(usage: Record, itemId: string): CanonicalProviderEvent | null { + if (usage.usageProvenance !== "pi_assistant_message_receipts" + || typeof usage.pricingEstimateUsd !== "number" || !Number.isFinite(usage.pricingEstimateUsd) + || usage.pricingEstimateUsd < 0) return null; + return { + eventType: "provider.notice.recorded", itemId, + payload: { + schema: "paperclip.provider.notice.v1", noticeId: itemId, + severity: "info", category: "pi_usage_pricing_estimate", scope: "turn", + recoverable: true, userActionable: false, + summary: `Pi estimates this turn at $${usage.pricingEstimateUsd.toFixed(6)} from its model prices. Billing cost is unverified.`, + details: [{ name: "Cost source", value: "Pi model catalog pricing estimate" }, + { name: "Usage source", value: "Assistant message receipts for this prompt" }, + { name: "Estimated USD", value: String(usage.pricingEstimateUsd) }], + }, + }; +}