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
This commit is contained in:
Dotta 2026-08-30 15:30:35 -05:00 committed by GitHub
parent d1abff2567
commit 3cc9decd9c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 591 additions and 0 deletions

View File

@ -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<ReturnType<typeof createAcpxIdentityRecord>>;
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")}`;
}

View File

@ -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<AcpxRecoveryBinding> {
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<string, unknown>): 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<string> {
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<string> {
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<string, unknown>,
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<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw new Error("ACPX identity record must be an object");
}
return value as Record<string, unknown>;
}
function digest(value: string): string {
return `sha256:${createHash("sha256").update(value).digest("hex")}`;
}
function canonicalJson(value: Record<string, unknown>): string {
return JSON.stringify(
Object.fromEntries(
Object.entries(value).sort(([left], [right]) =>
left < right ? -1 : left > right ? 1 : 0,
),
),
);
}