From 3cc9decd9c160226acd17056063140ffb006f024 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Sun, 30 Aug 2026 15:30:35 -0500 Subject: [PATCH] feat(runner): bind ACPX recovery identity (#12395) ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - A durable ACPX session must resume only the workspace, profile, model, policy, and provider session that created it. > - Sanitized directory names can collide, and lexical workspace paths can change meaning through symbolic links. > - Schema-less draft records cannot prove workspace or normalized-session provenance. > - This pull request establishes one closed v1 identity format and rejects records that cannot prove every immutable binding. > - The benefit is fail-closed recovery without enabling or selecting the ACPX runtime. ## Linked Issues or Issue Description **Agent or provider** Durable sessions for qualified Pi, Claude, and Codex ACP servers through the internal ACPX driver. **Why this adapter is useful** The runner must prevent a persisted provider session from being reused with another workspace, model, permission policy, profile, or normalized session. It must also distinguish normalized session names that sanitize to the same pathname. **How the agent is invoked** A later pull request will use this binding when the private ACPX runtime opens or recovers a session. This pull request does not launch a process, add a dependency, register an adapter, or change runtime selection. **Compatibility boundary** No ACPX identity writer exists on master or in a shipped runtime. This pull request establishes the first accepted persisted format. Draft schema-less records and early-v1 command-digest records cannot prove every immutable binding and are intentionally rejected; an affected experimental session must start fresh. ## What Changed - Resolve real workspace and runtime-directory paths and reject filesystem roots or non-directories. - Derive collision-resistant runtime roots and provider session keys. - Bind the session key to workspace, complete qualified profile, model, protocol, agent, and permission mode. - Add a closed v1 ACPX identity record. - Verify controller identity and persisted identity against every immutable binding. - Reject schema-less, early-digest, unknown-version, unknown-field, and malformed records. - Add tests for canonical paths, collision resistance, drift, workspace/session replay, missing policy, malformed records, and unsafe roots. ## Verification - GitHub Actions are the authoritative typecheck, test, build, and integration gate for the final head. - Greptile, Superagent, and Snyk are required to pass on the final head. - `git diff --check` passes for the two-file delta. - The diff does not change `pnpm-lock.yaml`, a workflow, a dependency, a public export, server selection, migration, or UI behavior. ## Risks The main risk is accepting an identity under a different immutable session configuration. Controller and v1 record fields are compared exactly, including permission mode and the complete qualified-profile digest. Records that lack workspace/session provenance or use an obsolete partial digest fail closed. Because no writer for those draft formats has shipped, requiring a fresh experimental session is safer than synthesizing missing authority from the current request. ## Model Used OpenAI Codex with GPT-5 and repository tool use. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either linked an existing public item or described the issue in this PR - [x] I have not referenced internal or instance-local Paperclip issues or links - [x] My branch name describes the change and contains no internal task identifier - [x] I have added or updated tests where applicable - [x] I have documented the recovery and compatibility boundary - [ ] All applicable GitHub Actions are green on the final head - [ ] Greptile is 5/5 with every actionable comment resolved - [x] I will address all review findings before requesting merge --- .../drivers/acpx/recovery-identity.test.ts | 276 +++++++++++++++ .../src/drivers/acpx/recovery-identity.ts | 315 ++++++++++++++++++ 2 files changed, 591 insertions(+) create mode 100644 packages/paperclip-runner/src/drivers/acpx/recovery-identity.test.ts create mode 100644 packages/paperclip-runner/src/drivers/acpx/recovery-identity.ts diff --git a/packages/paperclip-runner/src/drivers/acpx/recovery-identity.test.ts b/packages/paperclip-runner/src/drivers/acpx/recovery-identity.test.ts new file mode 100644 index 0000000000..e14b02a7cd --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/recovery-identity.test.ts @@ -0,0 +1,276 @@ +import { createHash } from "node:crypto"; +import { mkdir, mkdtemp, realpath, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, parse } from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { resolveQualifiedAcpxProfile } from "./qualified-profiles.js"; +import { + ACPX_IDENTITY_RECORD_SCHEMA, + createAcpxIdentityRecord, + createAcpxRecoveryBinding, + verifyExpectedAcpxIdentity, +} from "./recovery-identity.js"; + +const temporaryDirectories: string[] = []; + +afterEach(async () => { + await Promise.all( + temporaryDirectories + .splice(0) + .map((directory) => rm(directory, { force: true, recursive: true })), + ); +}); + +describe("ACPX recovery identity", () => { + it("binds the canonical workspace, profile, model, policy, and session", async () => { + const fixture = await recoveryFixture(); + expect(fixture.binding.runtimeRoot).toContain("session-1-"); + expect(fixture.binding.workspaceDigest).toMatch(/^sha256:[0-9a-f]{64}$/); + expect(fixture.binding.profileSessionKey).toMatch( + /^paperclip-[0-9a-f]{64}$/, + ); + expect(fixture.binding.workspacePath).toBe( + await realpath(fixture.workspace), + ); + + const record = createAcpxIdentityRecord(fixture.expected, fixture.binding); + expect(record).toMatchObject({ + schema: ACPX_IDENTITY_RECORD_SCHEMA, + normalizedSessionId: "session-1", + permissionMode: "approve-reads", + }); + expect(() => + verifyExpectedAcpxIdentity(fixture.expected, fixture.binding, record), + ).not.toThrow(); + }); + + it("uses collision-resistant roots and policy-bound provider keys", async () => { + const fixture = await recoveryFixture(); + const otherSession = await createAcpxRecoveryBinding({ + ...fixture.input, + normalizedSessionId: "session/1", + }); + const otherPolicy = await createAcpxRecoveryBinding({ + ...fixture.input, + permissionMode: "deny-all", + }); + const otherRuntimePackage = await createAcpxRecoveryBinding({ + ...fixture.input, + profile: { + ...fixture.input.profile, + agentRuntimePackage: "@paperclip/test-runtime", + }, + }); + const otherRuntimeVersion = await createAcpxRecoveryBinding({ + ...fixture.input, + profile: { + ...fixture.input.profile, + agentRuntimeVersion: "99.0.0", + }, + }); + + expect(otherSession.runtimeRoot).not.toBe(fixture.binding.runtimeRoot); + expect(otherPolicy.profileSessionKey).not.toBe( + fixture.binding.profileSessionKey, + ); + expect(otherRuntimePackage.profileDigest).not.toBe( + fixture.binding.profileDigest, + ); + expect(otherRuntimePackage.profileSessionKey).not.toBe( + fixture.binding.profileSessionKey, + ); + expect(otherRuntimeVersion.profileDigest).not.toBe( + fixture.binding.profileDigest, + ); + expect(otherRuntimeVersion.profileSessionKey).not.toBe( + fixture.binding.profileSessionKey, + ); + }); + + it("rejects immutable workspace, profile, model, and policy drift", async () => { + const fixture = await recoveryFixture(); + for (const changed of [ + { ...fixture.expected, workspaceDigest: digest("different") }, + { ...fixture.expected, profileDigest: digest("different") }, + { ...fixture.expected, requestedModel: "other" }, + { ...fixture.expected, permissionMode: "deny-all" as const }, + { ...fixture.expected, permissionMode: undefined }, + ]) { + expect(() => + verifyExpectedAcpxIdentity(changed, fixture.binding, null), + ).toThrow(/immutable session configuration/); + } + }); + + it("rejects schema-less records that cannot prove session provenance", async () => { + const fixture = await recoveryFixture(); + const legacy = { + acpxRecordId: fixture.expected.acpxRecordId, + backendSessionId: fixture.expected.backendSessionId, + agentSessionId: fixture.expected.agentSessionId, + requestedModel: fixture.expected.requestedModel, + effectiveModel: fixture.expected.effectiveModel, + profileDigest: fixture.input.profile.commandDigest, + }; + expect(() => + verifyExpectedAcpxIdentity(fixture.expected, fixture.binding, legacy), + ).toThrow(/Unsupported ACPX identity record schema/); + + const otherBinding = await createAcpxRecoveryBinding({ + ...fixture.input, + normalizedSessionId: "other-session", + }); + expect(() => + verifyExpectedAcpxIdentity( + { + ...fixture.expected, + normalizedSessionId: otherBinding.normalizedSessionId, + profileDigest: otherBinding.profileDigest, + workspaceDigest: otherBinding.workspaceDigest, + }, + otherBinding, + legacy, + ), + ).toThrow(/Unsupported ACPX identity record schema/); + + const otherWorkspace = join(fixture.root, "other-workspace"); + await mkdir(otherWorkspace); + const otherWorkspaceBinding = await createAcpxRecoveryBinding({ + ...fixture.input, + workingDirectory: otherWorkspace, + }); + expect(() => + verifyExpectedAcpxIdentity( + { + ...fixture.expected, + workspaceDigest: otherWorkspaceBinding.workspaceDigest, + }, + otherWorkspaceBinding, + legacy, + ), + ).toThrow(/Unsupported ACPX identity record schema/); + }); + + it("rejects early v1 command digests across qualified-profile drift", async () => { + const fixture = await recoveryFixture(); + const earlyV1 = { + ...createAcpxIdentityRecord(fixture.expected, fixture.binding), + profileDigest: fixture.input.profile.commandDigest, + }; + + expect(() => + verifyExpectedAcpxIdentity(fixture.expected, fixture.binding, earlyV1), + ).toThrow(/persisted runtime record/); + expect(() => + verifyExpectedAcpxIdentity( + { + ...fixture.expected, + profileDigest: fixture.input.profile.commandDigest, + }, + fixture.binding, + earlyV1, + ), + ).toThrow(/immutable session configuration/); + + const changedBinding = await createAcpxRecoveryBinding({ + ...fixture.input, + profile: { + ...fixture.input.profile, + agentRuntimeVersion: "99.0.0", + }, + }); + expect(changedBinding.profileDigest).not.toBe( + fixture.binding.profileDigest, + ); + expect(() => + verifyExpectedAcpxIdentity( + { + ...fixture.expected, + profileDigest: changedBinding.profileDigest, + }, + changedBinding, + earlyV1, + ), + ).toThrow(/persisted runtime record/); + }); + + it("rejects malformed records and unsafe workspace roots", async () => { + const fixture = await recoveryFixture(); + expect(() => + verifyExpectedAcpxIdentity(fixture.expected, fixture.binding, { + ...createAcpxIdentityRecord(fixture.expected, fixture.binding), + unexpected: true, + }), + ).toThrow(/unknown field/); + expect(() => + verifyExpectedAcpxIdentity(fixture.expected, fixture.binding, { + ...createAcpxIdentityRecord(fixture.expected, fixture.binding), + schema: "paperclip.runner.acpx-identity.v2", + }), + ).toThrow(/Unsupported ACPX identity record schema/); + const missingPermissionMode = createAcpxIdentityRecord( + fixture.expected, + fixture.binding, + ) as Partial>; + delete missingPermissionMode.permissionMode; + expect(() => + verifyExpectedAcpxIdentity( + fixture.expected, + fixture.binding, + missingPermissionMode, + ), + ).toThrow(/permission mode is invalid/); + + await expect( + createAcpxRecoveryBinding({ + ...fixture.input, + workingDirectory: parse(fixture.workspace).root, + }), + ).rejects.toThrow(/non-root directory/); + const file = join(fixture.root, "file"); + await writeFile(file, "not a directory"); + await expect( + createAcpxRecoveryBinding({ + ...fixture.input, + workingDirectory: file, + }), + ).rejects.toThrow(/non-root directory/); + }); +}); + +async function recoveryFixture() { + const root = await mkdtemp(join(tmpdir(), "paperclip-acpx-recovery-")); + temporaryDirectories.push(root); + const workspace = join(root, "workspace"); + const runtimeDirectory = join(root, "runtime"); + await Promise.all([mkdir(workspace), mkdir(runtimeDirectory)]); + const profile = resolveQualifiedAcpxProfile("claude", "claude-sonnet-5"); + const input = { + runtimeDirectory, + normalizedSessionId: "session-1", + workingDirectory: workspace, + profile, + requestedModel: "claude-sonnet-5", + permissionMode: "approve-reads" as const, + }; + const binding = await createAcpxRecoveryBinding(input); + const expected = { + kind: "acpx" as const, + normalizedSessionId: input.normalizedSessionId, + acpxRecordId: "record-1", + backendSessionId: "backend-1", + agentSessionId: "agent-1", + profileDigest: binding.profileDigest, + workspaceDigest: binding.workspaceDigest, + requestedModel: binding.requestedModel, + effectiveModel: binding.effectiveModel, + permissionMode: binding.permissionMode, + }; + return { root, workspace, input, binding, expected }; +} + +function digest(value: string): string { + return `sha256:${createHash("sha256").update(value).digest("hex")}`; +} diff --git a/packages/paperclip-runner/src/drivers/acpx/recovery-identity.ts b/packages/paperclip-runner/src/drivers/acpx/recovery-identity.ts new file mode 100644 index 0000000000..b5cca23154 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/recovery-identity.ts @@ -0,0 +1,315 @@ +import { createHash } from "node:crypto"; +import { realpath, stat } from "node:fs/promises"; +import { dirname, join, resolve } from "node:path"; + +import type { NativeAcpxPermissionMode } from "../../contracts/native-execution.js"; +import type { AcpxExpectedSessionIdentity } from "./sidecar-protocol.js"; +import type { QualifiedAcpxProfile } from "./qualified-profiles.js"; + +export const ACPX_IDENTITY_RECORD_SCHEMA = + "paperclip.runner.acpx-identity.v1" as const; + +export interface AcpxRecoveryBinding { + normalizedSessionId: string; + workspacePath: string; + workspaceDigest: string; + runtimeRoot: string; + profileDigest: string; + requestedModel: string; + effectiveModel: string; + permissionMode: NativeAcpxPermissionMode; + profileSessionKey: string; +} + +export interface AcpxIdentityRecord { + schema: typeof ACPX_IDENTITY_RECORD_SCHEMA; + normalizedSessionId: string; + acpxRecordId: string; + backendSessionId: string; + agentSessionId: string; + profileDigest: string; + workspaceDigest: string; + requestedModel: string; + effectiveModel: string; + permissionMode: NativeAcpxPermissionMode; +} + +export async function createAcpxRecoveryBinding(input: { + runtimeDirectory: string; + normalizedSessionId: string; + workingDirectory: string; + profile: QualifiedAcpxProfile; + requestedModel: string; + permissionMode: NativeAcpxPermissionMode; +}): Promise { + validateIdentity(input.normalizedSessionId, "normalized session"); + if (input.requestedModel !== input.profile.qualificationModel) { + throw new Error("ACPX recovery requested an unqualified model"); + } + if (!isDigest(input.profile.commandDigest)) { + throw new Error("ACPX recovery profile command digest is invalid"); + } + const workspacePath = await resolveWorkspace(input.workingDirectory); + const workspaceDigest = digest(workspacePath); + const runtimeRoot = await acpxRuntimeRoot( + input.runtimeDirectory, + input.normalizedSessionId, + ); + const profileDigest = digest( + canonicalJson({ + driverKind: input.profile.driverKind, + protocolVersion: input.profile.protocolVersion, + acpxVersion: input.profile.acpxVersion, + agent: input.profile.agent, + agentProfileVersion: input.profile.agentProfileVersion, + agentServerPackage: input.profile.agentServerPackage, + agentServerVersion: input.profile.agentServerVersion, + agentRuntimePackage: input.profile.agentRuntimePackage, + agentRuntimeVersion: input.profile.agentRuntimeVersion, + commandDigest: input.profile.commandDigest, + qualificationModel: input.profile.qualificationModel, + reportedModelId: input.profile.reportedModelId, + permissionPolicy: input.profile.permissionPolicy, + }), + ); + const profileSessionKey = digest( + canonicalJson({ + normalizedSessionId: input.normalizedSessionId, + workspacePath, + workspaceDigest, + requestedModel: input.requestedModel, + profileDigest, + permissionMode: input.permissionMode, + }), + ).replace("sha256:", "paperclip-"); + return { + normalizedSessionId: input.normalizedSessionId, + workspacePath, + workspaceDigest, + runtimeRoot, + profileDigest, + requestedModel: input.requestedModel, + effectiveModel: input.requestedModel, + permissionMode: input.permissionMode, + profileSessionKey, + }; +} + +export function createAcpxIdentityRecord( + expected: AcpxExpectedSessionIdentity, + binding: AcpxRecoveryBinding, +): AcpxIdentityRecord { + verifyExpectedAcpxIdentity(expected, binding, null); + return { + schema: ACPX_IDENTITY_RECORD_SCHEMA, + normalizedSessionId: binding.normalizedSessionId, + acpxRecordId: expected.acpxRecordId, + backendSessionId: expected.backendSessionId, + agentSessionId: expected.agentSessionId, + profileDigest: binding.profileDigest, + workspaceDigest: binding.workspaceDigest, + requestedModel: binding.requestedModel, + effectiveModel: binding.effectiveModel, + permissionMode: binding.permissionMode, + }; +} + +/** + * Verify both the controller-provided identity and a persisted runtime record. + * Only the complete v1 record is recoverable. Draft schema-less and + * command-digest records cannot prove every immutable session binding, so + * callers must fail closed and start a fresh provider session for them. + */ +export function verifyExpectedAcpxIdentity( + expected: AcpxExpectedSessionIdentity, + binding: AcpxRecoveryBinding, + persisted: unknown, +): void { + validateExpected(expected); + if ( + expected.normalizedSessionId !== binding.normalizedSessionId || + expected.profileDigest !== binding.profileDigest || + expected.workspaceDigest !== binding.workspaceDigest || + expected.requestedModel !== binding.requestedModel || + expected.effectiveModel !== binding.effectiveModel || + expected.permissionMode !== binding.permissionMode + ) { + throw new Error( + "ACPX recovery identity conflicts with the immutable session configuration", + ); + } + if (persisted === null) return; + + const record = parsePersistedRecord(persisted); + if ( + record.acpxRecordId !== expected.acpxRecordId || + record.backendSessionId !== expected.backendSessionId || + record.agentSessionId !== expected.agentSessionId || + record.normalizedSessionId !== binding.normalizedSessionId || + record.profileDigest !== binding.profileDigest || + record.workspaceDigest !== binding.workspaceDigest || + record.requestedModel !== binding.requestedModel || + record.effectiveModel !== binding.effectiveModel || + record.permissionMode !== binding.permissionMode + ) { + throw new Error( + "ACPX recovery identity does not match the persisted runtime record", + ); + } +} + +function parsePersistedRecord(value: unknown): AcpxIdentityRecord { + const record = object(value); + rejectUnknownKeys(record, [ + "schema", + "normalizedSessionId", + "acpxRecordId", + "backendSessionId", + "agentSessionId", + "profileDigest", + "workspaceDigest", + "requestedModel", + "effectiveModel", + "permissionMode", + ]); + return validatedRecord(record); +} + +function validatedRecord(value: Record): AcpxIdentityRecord { + if (value.schema !== ACPX_IDENTITY_RECORD_SCHEMA) { + throw new Error("Unsupported ACPX identity record schema"); + } + for (const field of [ + "normalizedSessionId", + "acpxRecordId", + "backendSessionId", + "agentSessionId", + "requestedModel", + "effectiveModel", + ] as const) { + validateIdentity(value[field], field); + } + for (const field of ["profileDigest", "workspaceDigest"] as const) { + if (!isDigest(value[field])) + throw new Error(`ACPX identity ${field} is invalid`); + } + if (!isPermissionMode(value.permissionMode)) { + throw new Error("ACPX identity permission mode is invalid"); + } + return value as unknown as AcpxIdentityRecord; +} + +function validateExpected(expected: AcpxExpectedSessionIdentity): void { + if (expected.kind !== "acpx") throw new Error("Expected ACPX identity kind"); + for (const value of [ + expected.normalizedSessionId, + expected.acpxRecordId, + expected.backendSessionId, + expected.agentSessionId, + expected.requestedModel, + expected.effectiveModel, + ]) { + validateIdentity(value, "expected ACPX"); + } + if ( + !isDigest(expected.profileDigest) || + !isDigest(expected.workspaceDigest) + ) { + throw new Error("Expected ACPX identity digest is invalid"); + } + if ( + expected.permissionMode !== undefined && + !isPermissionMode(expected.permissionMode) + ) { + throw new Error("Expected ACPX permission mode is invalid"); + } +} + +async function resolveWorkspace(value: string): Promise { + if (!value.trim()) throw new Error("ACPX working directory is required"); + const workspacePath = await realpath(value); + const metadata = await stat(workspacePath); + if (!metadata.isDirectory() || workspacePath === dirname(workspacePath)) { + throw new Error("ACPX working directory must be a non-root directory"); + } + return workspacePath; +} + +async function acpxRuntimeRoot( + runtimeDirectory: string, + sessionId: string, +): Promise { + if (!runtimeDirectory.trim()) + throw new Error("ACPX runtime directory is required"); + const root = await realpath(runtimeDirectory); + const metadata = await stat(root); + if (!metadata.isDirectory()) + throw new Error("ACPX runtime directory must be a directory"); + if (root === dirname(root)) + throw new Error("ACPX runtime directory must not be a filesystem root"); + const readable = sessionId + .replace(/[^a-zA-Z0-9._-]/g, "_") + .replace(/^\.+$/, "session") + .slice(0, 80); + const suffix = createHash("sha256") + .update(sessionId) + .digest("hex") + .slice(0, 16); + return join(resolve(root), "acpx", `${readable || "session"}-${suffix}`); +} + +function validateIdentity( + value: unknown, + label: string, +): asserts value is string { + if ( + typeof value !== "string" || + value.length < 1 || + value.length > 240 || + /[\u0000-\u001f\u007f]/.test(value) + ) { + throw new Error(`${label} identity is missing or invalid`); + } +} + +function isDigest(value: unknown): value is string { + return typeof value === "string" && /^sha256:[0-9a-f]{64}$/.test(value); +} + +function isPermissionMode(value: unknown): value is NativeAcpxPermissionMode { + return ( + typeof value === "string" && + ["approve-all", "approve-reads", "deny-all"].includes(value) + ); +} + +function rejectUnknownKeys( + value: Record, + allowed: readonly string[], +): void { + const names = new Set(allowed); + if (Object.keys(value).some((key) => !names.has(key))) { + throw new Error("ACPX identity record contains an unknown field"); + } +} + +function object(value: unknown): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error("ACPX identity record must be an object"); + } + return value as Record; +} + +function digest(value: string): string { + return `sha256:${createHash("sha256").update(value).digest("hex")}`; +} + +function canonicalJson(value: Record): string { + return JSON.stringify( + Object.fromEntries( + Object.entries(value).sort(([left], [right]) => + left < right ? -1 : left > right ? 1 : 0, + ), + ), + ); +}