mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-18 05:23:39 +02:00
Merge commit '46b759888' into codex/runner-copilot-acp
* commit '46b759888': Bind ACP persistent agent files to registered runtime context
This commit is contained in:
commit
a8a503ee13
7 files changed
+181
-5
No files matched your search
@@ -21,6 +21,18 @@ recommends Cursor and Copilot, followed by Pi, using the existing qualified ACPX
|
||||
path. Codex app-server is the richness benchmark. The legacy Cursor and Pi
|
||||
adapters are outside this change.
|
||||
|
||||
## Registered persistent agent files
|
||||
|
||||
Candidate ACP processes receive `AGENT_HOME` only from the authenticated
|
||||
runtime context's server-registered `agent_files` working copy. Ambient
|
||||
environment values cannot grant a directory. Admission rejects symlink roots,
|
||||
filesystem roots and overlap with provider runtime state; directory identity is
|
||||
rechecked at launch and before each turn. Each provider reopen receives the
|
||||
current run's registered copy. The native executor requires provider shutdown
|
||||
before collecting and synchronizing the copy. Pi additionally receives a
|
||||
runner-owned native-tool root binding; its task read-only policy still applies.
|
||||
These checks do not by themselves qualify provider-specific native file tools.
|
||||
|
||||
## Branches and evidence ownership
|
||||
|
||||
| Branch | Deliverable |
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
|
||||
export interface CodexAcpxNativeSessionBackendOptions extends Omit<
|
||||
CodexAcpxDriverOptions,
|
||||
"model" | "permissionMode" | "systemInstructions" | "providerPolicy"
|
||||
"model" | "permissionMode" | "systemInstructions" | "providerPolicy" | "runtimeContext"
|
||||
> {}
|
||||
|
||||
export type AcpxNativeSessionBackendOptions =
|
||||
@@ -73,6 +73,7 @@ export function createAcpxNativeSessionBackend(
|
||||
model: input.provider.model,
|
||||
permissionMode: input.provider.permissionMode ?? "approve-reads",
|
||||
systemInstructions,
|
||||
runtimeContext: "runtimeContext" in input ? input.runtimeContext : null,
|
||||
providerPolicy: { readOnly: "executionMode" in input && input.executionMode === "plan" },
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { mkdtempSync, mkdirSync, renameSync, rmSync, symlinkSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { NativeRuntimeContextSnapshot } from "../../contracts/runtime-context.js";
|
||||
import { bindAcpxAgentFiles } from "./agent-files-binding.js";
|
||||
|
||||
const roots: string[] = [];
|
||||
afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); });
|
||||
function fixture() {
|
||||
const parent = mkdtempSync(join(tmpdir(), "acpx-agent-files-"));
|
||||
roots.push(parent);
|
||||
const root = join(parent, "registered-run-copy");
|
||||
const protectedRoot = join(parent, "provider-state");
|
||||
mkdirSync(root); mkdirSync(protectedRoot);
|
||||
const context = { instructions: { workingCopy: { kind: "agent_files", rootPath: root, entryPath: "AGENTS.md" } } } as NativeRuntimeContextSnapshot;
|
||||
return { parent, root, protectedRoot, context };
|
||||
}
|
||||
|
||||
describe("registered ACP agent files", () => {
|
||||
it("does not grant absent or legacy instruction copies", () => {
|
||||
expect(bindAcpxAgentFiles(null, [])).toBeNull();
|
||||
const { context } = fixture();
|
||||
delete context.instructions.workingCopy!.kind;
|
||||
expect(bindAcpxAgentFiles(context, [])).toBeNull();
|
||||
});
|
||||
it("binds only the registered copy and detects replaced roots", () => {
|
||||
const { root, context, protectedRoot } = fixture();
|
||||
const binding = bindAcpxAgentFiles(context, [protectedRoot])!;
|
||||
expect(binding.root.endsWith("/registered-run-copy")).toBe(true);
|
||||
expect(() => binding.assertHeld()).not.toThrow();
|
||||
renameSync(root, `${root}-old`); mkdirSync(root);
|
||||
expect(() => binding.assertHeld()).toThrow("changed");
|
||||
});
|
||||
it("rejects symlink roots and symlink replacement", () => {
|
||||
const { root, context, protectedRoot } = fixture();
|
||||
const binding = bindAcpxAgentFiles(context, [])!;
|
||||
renameSync(root, `${root}-old`); symlinkSync(protectedRoot, root);
|
||||
expect(() => binding.assertHeld()).toThrow("changed");
|
||||
expect(() => bindAcpxAgentFiles(context, [])).toThrow("real directory");
|
||||
});
|
||||
it("rejects both directions of protected-root overlap", () => {
|
||||
const { parent, root, context } = fixture();
|
||||
const child = join(root, "nested-runtime"); mkdirSync(child);
|
||||
for (const protectedRoot of [parent, root, child]) {
|
||||
expect(() => bindAcpxAgentFiles(context, [protectedRoot])).toThrow("overlaps");
|
||||
}
|
||||
});
|
||||
it("rejects relative, non-normalized and filesystem-root grants", () => {
|
||||
const { context } = fixture();
|
||||
for (const rootPath of ["relative", "/tmp/../tmp", "/", "/tmp\0invalid"]) {
|
||||
context.instructions.workingCopy!.rootPath = rootPath;
|
||||
expect(() => bindAcpxAgentFiles(context, [])).toThrow();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { lstatSync, realpathSync } from "node:fs";
|
||||
import { isAbsolute, relative, resolve } from "node:path";
|
||||
import type { NativeRuntimeContextSnapshot } from "../../contracts/runtime-context.js";
|
||||
|
||||
export interface AcpxAgentFilesBinding {
|
||||
readonly root: string;
|
||||
assertHeld(): void;
|
||||
}
|
||||
|
||||
/** The authenticated runtime context, never an ambient environment value, grants this run copy. */
|
||||
export function bindAcpxAgentFiles(
|
||||
context: NativeRuntimeContextSnapshot | null | undefined,
|
||||
protectedRoots: readonly string[],
|
||||
): AcpxAgentFilesBinding | null {
|
||||
const copy = context?.instructions.workingCopy;
|
||||
if (copy?.kind !== "agent_files") return null;
|
||||
const declared = copy.rootPath;
|
||||
if (!isAbsolute(declared) || declared.includes("\0") || resolve(declared) !== declared) {
|
||||
throw new Error("ACP agent directory must be an absolute normalized registered path");
|
||||
}
|
||||
const metadata = lstatSync(declared);
|
||||
if (!metadata.isDirectory() || metadata.isSymbolicLink()) {
|
||||
throw new Error("ACP agent directory must be a real directory");
|
||||
}
|
||||
const root = realpathSync(declared);
|
||||
if (resolve(root, "..") === root) throw new Error("ACP agent directory cannot be a filesystem root");
|
||||
for (const protectedRoot of protectedRoots) {
|
||||
// These roots belong to the verified runtime and sandbox, not user input.
|
||||
const canonical = realpathSync(protectedRoot);
|
||||
if (contains(root, canonical) || contains(canonical, root)) {
|
||||
throw new Error("ACP agent directory overlaps protected runtime state");
|
||||
}
|
||||
}
|
||||
const assertHeld = () => {
|
||||
const current = lstatSync(declared);
|
||||
if (!current.isDirectory() || current.isSymbolicLink()
|
||||
|| current.dev !== metadata.dev || current.ino !== metadata.ino
|
||||
|| realpathSync(declared) !== root) {
|
||||
throw new Error("ACP registered agent directory changed during provider lifetime");
|
||||
}
|
||||
};
|
||||
return Object.freeze({ root, assertHeld });
|
||||
}
|
||||
|
||||
function contains(root: string, path: string): boolean {
|
||||
const rel = relative(root, path);
|
||||
return rel === "" || (!rel.startsWith("../") && rel !== ".." && !isAbsolute(rel));
|
||||
}
|
||||
@@ -141,6 +141,7 @@ export interface CodexAcpxDriverOptions {
|
||||
model: string;
|
||||
permissionMode?: NativeAcpxPermissionMode;
|
||||
providerPolicy?: { readOnly: boolean };
|
||||
runtimeContext?: OpenAcpxRuntimeHostOptions["runtimeContext"];
|
||||
systemInstructions?: string;
|
||||
environment?: NodeJS.ProcessEnv;
|
||||
managedCodexCredentialSourcePath?: string;
|
||||
@@ -453,6 +454,7 @@ export class CodexAcpxDriver implements HarnessDriver {
|
||||
model: this.#options.model,
|
||||
permissionMode: this.#options.permissionMode ?? "approve-all",
|
||||
providerPolicy: this.#options.providerPolicy,
|
||||
runtimeContext: this.#options.runtimeContext,
|
||||
systemInstructions: this.#options.systemInstructions,
|
||||
environment: this.#options.environment,
|
||||
managedCodexCredentialSourcePath:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
|
||||
import { mkdir, mkdtemp, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
@@ -832,6 +832,43 @@ describe("ACPX runtime host", () => {
|
||||
expect(openRuntime).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it.each(["cursor", "copilot", "pi"] as const)("binds %s agent files from each registered run copy, never ambient roots", async (agent) => {
|
||||
const fixture = await hostFixture();
|
||||
const copies = await mkdtemp(join(tmpdir(), "paperclip-agent-copies-"));
|
||||
temporaryDirectories.push(copies);
|
||||
const model = agent === "pi" ? "openrouter/deepseek/deepseek-v4-flash-0731" : "explicit-test-model";
|
||||
const options = { ...fixture.options, agent, model, permissionMode: "approve-all" as const,
|
||||
providerPolicy: { readOnly: false }, environment: { AGENT_HOME: "/ambient/other-agent", PAPERCLIP_PI_AGENT_HOME: "/ambient/other-agent",
|
||||
...(agent === "pi" ? { OPENROUTER_API_KEY: "test" } : agent === "cursor" ? { CURSOR_API_KEY: "test" } : { COPILOT_GITHUB_TOKEN: "test" }),
|
||||
},
|
||||
};
|
||||
const opened: AcpxRuntimePortOpenOptions[] = [];
|
||||
const dependencies = fixture.dependencies({ openRuntime: async launch => {
|
||||
opened.push(launch);
|
||||
return runtimePort({ getStatus: async () => ({ models: { currentModelId: model } }) });
|
||||
} });
|
||||
// Missing trusted context must not turn ambient values into authority.
|
||||
const withoutCopy = await AcpxRuntimeHost.open(options, dependencies);
|
||||
expect(opened.at(-1)!.launchEnvironment.AGENT_HOME).toBeUndefined();
|
||||
expect(opened.at(-1)!.launchEnvironment.PAPERCLIP_PI_AGENT_HOME).toBeUndefined();
|
||||
await withoutCopy.close({ reason: "no registered copy" });
|
||||
for (const run of ["first", "second"]) {
|
||||
const rootPath = join(copies, run); await mkdir(rootPath);
|
||||
const runtimeContext = { instructions: { workingCopy: { kind: "agent_files", rootPath, entryPath: "AGENTS.md" } }, skills: [], mcp: { bindingId: null } } as unknown as NativeRuntimeContextSnapshot;
|
||||
const host = await AcpxRuntimeHost.open({ ...options, runtimeContext }, dependencies);
|
||||
const launch = opened.at(-1)!;
|
||||
expect(launch.launchEnvironment.AGENT_HOME?.endsWith(`/${run}`)).toBe(true);
|
||||
expect(launch.launchEnvironment.PAPERCLIP_PI_AGENT_HOME).toBe(agent === "pi" ? launch.launchEnvironment.AGENT_HOME : undefined);
|
||||
expect(launch.launchEnvironment.HOME).not.toBe(launch.launchEnvironment.AGENT_HOME);
|
||||
if (run === "second") {
|
||||
await rename(rootPath, `${rootPath}-replaced`); await mkdir(rootPath);
|
||||
expect(() => launch.assertWorkspaceHeld?.()).toThrow("changed");
|
||||
expect(() => host.startTurn({ text: "test", requestId: "stale-directory" })).toThrow("changed");
|
||||
}
|
||||
await host.close({ reason: "registered copy test complete" });
|
||||
}
|
||||
});
|
||||
|
||||
it("selects and verifies Claude's qualified reported model", async () => {
|
||||
const fixture = await hostFixture();
|
||||
let selected = false;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { dirname, join } from "node:path";
|
||||
import { bindAcpxAgentFiles } from "./agent-files-binding.js";
|
||||
import { assertAcpxProfileEnvironment, assertAcpxProfileWorkspace, classifyAcpxProfileError, verifyAcpxProfileInstallation } from "./profile-installation.js";
|
||||
import { createAcpxRuntimeSkillLease } from "./runtime-skill-lease.js";
|
||||
import { stageManagedGrokCredential } from "./grok-credentials.js";
|
||||
@@ -278,6 +279,7 @@ export class AcpxRuntimeHost {
|
||||
readonly #command: VerifiedAcpxCommandLease;
|
||||
readonly #toolBridge: RunnerToolBridge | null;
|
||||
readonly #claudeSkillNames: readonly string[];
|
||||
readonly #assertAgentFilesHeld: (() => void) | undefined;
|
||||
#activeTurn: AcpxRuntimeTurn | null = null;
|
||||
#closingStarted = false;
|
||||
#closePromise: Promise<void> | null = null;
|
||||
@@ -292,6 +294,7 @@ export class AcpxRuntimeHost {
|
||||
command: VerifiedAcpxCommandLease;
|
||||
toolBridge: RunnerToolBridge | null;
|
||||
claudeSkillNames: readonly string[];
|
||||
assertAgentFilesHeld?: () => void;
|
||||
}) {
|
||||
this.#runtime = input.runtime;
|
||||
this.#binding = input.binding;
|
||||
@@ -301,6 +304,7 @@ export class AcpxRuntimeHost {
|
||||
this.#command = input.command;
|
||||
this.#toolBridge = input.toolBridge;
|
||||
this.#claudeSkillNames = [...input.claudeSkillNames];
|
||||
this.#assertAgentFilesHeld = input.assertAgentFilesHeld;
|
||||
}
|
||||
|
||||
static async open(
|
||||
@@ -458,6 +462,19 @@ export class AcpxRuntimeHost {
|
||||
}
|
||||
assertAcpxProfileEnvironment(options.agent, sandbox.launchEnvironment);
|
||||
let launchEnvironment = sandbox.launchEnvironment;
|
||||
// This copy is registered by the authenticated controller for its company,
|
||||
// agent and run. Environment/config values cannot widen the grant. Each
|
||||
// resumed process receives the newly registered copy; collection already
|
||||
// requires verified provider shutdown in the native executor.
|
||||
const agentFiles = ["cursor", "copilot", "pi"].includes(options.agent)
|
||||
? bindAcpxAgentFiles(options.runtimeContext, [sandbox.root,
|
||||
...(installation.agentServerPackageJsonPath === null ? [] : [dirname(installation.agentServerPackageJsonPath)]),
|
||||
]) : null;
|
||||
if (agentFiles) {
|
||||
launchEnvironment = Object.freeze({ ...launchEnvironment, AGENT_HOME: agentFiles.root,
|
||||
...(options.agent === "pi" ? { PAPERCLIP_PI_AGENT_HOME: agentFiles.root } : {}),
|
||||
});
|
||||
}
|
||||
if (options.agent === "pi") {
|
||||
const skills = await acquireAbortableAdmissionResource({
|
||||
signal: options.signal,
|
||||
@@ -476,7 +493,7 @@ export class AcpxRuntimeHost {
|
||||
await skills.close();
|
||||
},
|
||||
};
|
||||
launchEnvironment = Object.freeze({ ...sandbox.launchEnvironment,
|
||||
launchEnvironment = Object.freeze({ ...launchEnvironment,
|
||||
PAPERCLIP_PI_READ_ROOTS: JSON.stringify([...(options.providerPolicy?.readRoots ?? []), ...skills.readRoots]),
|
||||
});
|
||||
}
|
||||
@@ -523,6 +540,7 @@ export class AcpxRuntimeHost {
|
||||
signal: options.signal,
|
||||
acquire: async () => {
|
||||
options.assertWorkspaceHeld?.();
|
||||
agentFiles?.assertHeld();
|
||||
await assertAcpxProfileWorkspace(options.agent, binding.workspacePath);
|
||||
return dependencies.openRuntime({
|
||||
...(options.clientCapabilities === undefined ? {} : {
|
||||
@@ -543,9 +561,9 @@ export class AcpxRuntimeHost {
|
||||
activateCredentialFenceOwner:
|
||||
admittedLifetime.activateLifetimeOwner.bind(admittedLifetime),
|
||||
systemInstructions: boundedInstructions(options.systemInstructions),
|
||||
...(options.assertWorkspaceHeld === undefined
|
||||
...(options.assertWorkspaceHeld === undefined && !agentFiles
|
||||
? {}
|
||||
: { assertWorkspaceHeld: options.assertWorkspaceHeld }),
|
||||
: { assertWorkspaceHeld: () => { options.assertWorkspaceHeld?.(); agentFiles?.assertHeld(); } }),
|
||||
...(options.signal === undefined ? {} : { signal: options.signal }),
|
||||
mcpServers: [
|
||||
...(toolBridge ? [{ name: "paperclip", url: toolBridge.url,
|
||||
@@ -618,6 +636,7 @@ export class AcpxRuntimeHost {
|
||||
credential,
|
||||
command,
|
||||
toolBridge,
|
||||
assertAgentFilesHeld: agentFiles?.assertHeld,
|
||||
claudeSkillNames: options.agent === "claude"
|
||||
? (options.runtimeContext?.skills.map((skill) => skill.runtimeName) ?? [])
|
||||
: [],
|
||||
@@ -741,6 +760,7 @@ export class AcpxRuntimeHost {
|
||||
}
|
||||
|
||||
startTurn(input: AcpxRuntimeTurnInput): AcpxRuntimeTurn {
|
||||
this.#assertAgentFilesHeld?.();
|
||||
if (this.#closed || this.#closingStarted) {
|
||||
throw new Error("ACPX runtime host is closing");
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user