mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-12 18:06:11 +02:00
Bind candidate runtimes to task policy and separate Pi price estimates
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
a5e26ecc04
commit
411df9fa58
7 files changed
+163
-14
No files matched your search
@@ -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 => {
|
||||
|
||||
@@ -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<VerifiedAcpxInstallation> {
|
||||
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<void> {}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<RunnerToolBridgeOptions, "secret">;
|
||||
|
||||
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<string, unknown>;
|
||||
runtimeDirectory: string;
|
||||
@@ -303,10 +307,8 @@ export class AcpxRuntimeHost {
|
||||
dependencies: AcpxRuntimeHostDependencies,
|
||||
): Promise<AcpxRuntimeHost> {
|
||||
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),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { CanonicalProviderEvent } from "../../provider-events.js";
|
||||
import type { QualifiedAcpxAgent } from "./qualified-profiles.js";
|
||||
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
@@ -37,6 +38,7 @@ export function persistedAcpxTurnUsage(
|
||||
before: unknown,
|
||||
after: unknown,
|
||||
requestId: string,
|
||||
agent: QualifiedAcpxAgent | null = null,
|
||||
): Record<string, unknown> | 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<string, unknown>, 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) }],
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user