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:
parent
d1abff2567
commit
3cc9decd9c
|
|
@ -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")}`;
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue