mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-18 05:23:39 +02:00
fix(runtime): bind persistent computer homes into Claude runtime context
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
b52863e616
commit
51e52d5849
7 files changed
+70
-5
No files matched your search
@@ -37,6 +37,13 @@ function savedContext(revision: string, text: string) {
|
||||
}
|
||||
|
||||
describe("persisted native execution prompts", () => {
|
||||
it("preserves an authenticated persistent home independently of instruction copies", () => {
|
||||
const context = { ...savedContext("saved", "Instructions"), persistentAgentHome: { rootPath: "/computer/agent" } };
|
||||
expect(parseNativeRuntimeContext(context).persistentAgentHome).toEqual({ rootPath: "/computer/agent" });
|
||||
expect(parseNativeRuntimeContext(context).instructions.workingCopy).toBeUndefined();
|
||||
expect(() => parseNativeRuntimeContext({ ...context, persistentAgentHome: { rootPath: "" } })).toThrow();
|
||||
expect(() => parseNativeRuntimeContext({ ...context, persistentAgentHome: { rootPath: "/computer/agent", untrusted: true } })).toThrow();
|
||||
});
|
||||
it.each([
|
||||
["snapshot-before-upgrade", "Saved instructions from before the upgrade."],
|
||||
["future-prompt-fixture", " Saved instructions absent from this release.\n"],
|
||||
|
||||
@@ -21,6 +21,8 @@ export interface NativeRuntimeContextSnapshot {
|
||||
/** Server-registered writable copy; excluded from the pinned prompt digest. */
|
||||
workingCopy?: { rootPath: string; entryPath: string; kind?: "agent_files" };
|
||||
};
|
||||
/** Authenticated controller grant for an attached computer; independent of instruction-copy ownership. */
|
||||
persistentAgentHome?: { rootPath: string };
|
||||
skills: Array<{ key: string; runtimeName: string; versionId: string | null; bundle: NativeRuntimeAssetReference }>;
|
||||
mcp: { assignmentSetId: string; digest: string; bindingId: string | null };
|
||||
connectionInstructions?: { text: string; digest: string };
|
||||
@@ -80,6 +82,8 @@ function parseAsset(value: unknown, path: string): NativeRuntimeAssetReference {
|
||||
function aggregatePayload(value: Omit<NativeRuntimeContextSnapshot, "aggregateDigest">) {
|
||||
return {
|
||||
prompt: value.prompt,
|
||||
// Registered filesystem locators (workingCopy and persistentAgentHome)
|
||||
// are authenticated run authority, not pinned instruction content.
|
||||
instructions: { entryPath: value.instructions.entryPath, bundleDigest: value.instructions.bundle.digest },
|
||||
skills: [...value.skills].sort((a, b) => a.key.localeCompare(b.key)).map((skill) => ({
|
||||
key: skill.key, runtimeName: skill.runtimeName, versionId: skill.versionId, bundleDigest: skill.bundle.digest,
|
||||
@@ -100,7 +104,7 @@ export function nativeRuntimePromptDigest(): string { return sha256(PAPERCLIP_EX
|
||||
|
||||
export function parseNativeRuntimeContext(value: unknown): NativeRuntimeContextSnapshot {
|
||||
const context = object(value, "input.runtimeContext");
|
||||
exact(context, ["prompt", "instructions", "skills", "mcp", "connectionInstructions", "aggregateDigest"], "input.runtimeContext");
|
||||
exact(context, ["prompt", "instructions", "persistentAgentHome", "skills", "mcp", "connectionInstructions", "aggregateDigest"], "input.runtimeContext");
|
||||
const prompt = object(context.prompt, "input.runtimeContext.prompt");
|
||||
exact(prompt, ["revision", "text", "digest"], "input.runtimeContext.prompt");
|
||||
// New runs use the current constants. Recovery uses the immutable saved
|
||||
@@ -141,7 +145,13 @@ export function parseNativeRuntimeContext(value: unknown): NativeRuntimeContextS
|
||||
if (sha256(content) !== contentDigest) throw new NativeRuntimeContextError("Connection instruction digest does not match text");
|
||||
connectionInstructions = { text: content, digest: contentDigest };
|
||||
}
|
||||
const persistentAgentHome = context.persistentAgentHome === undefined ? undefined
|
||||
: object(context.persistentAgentHome, "input.runtimeContext.persistentAgentHome");
|
||||
if (persistentAgentHome) exact(persistentAgentHome, ["rootPath"], "input.runtimeContext.persistentAgentHome");
|
||||
const parsed = {
|
||||
...(persistentAgentHome ? { persistentAgentHome: {
|
||||
rootPath: text(persistentAgentHome.rootPath, "input.runtimeContext.persistentAgentHome.rootPath"),
|
||||
} } : {}),
|
||||
prompt: { revision: promptRevision, text: promptText, digest: promptDigest },
|
||||
instructions: {
|
||||
entryPath: safeRelativePath(instructions.entryPath, "input.runtimeContext.instructions.entryPath"),
|
||||
|
||||
@@ -18,6 +18,19 @@ function fixture() {
|
||||
}
|
||||
|
||||
describe("registered ACP agent files", () => {
|
||||
it("binds a persistent computer home without inventing an instruction copy", () => {
|
||||
const { root, parent, context } = fixture();
|
||||
delete context.instructions.workingCopy;
|
||||
context.persistentAgentHome = { rootPath: root };
|
||||
const nestedRuntime = join(root, ".paperclip-runtime"); mkdirSync(nestedRuntime);
|
||||
const binding = bindAcpxAgentFiles(context, [nestedRuntime])!;
|
||||
expect(binding.root.endsWith("registered-run-copy")).toBe(true);
|
||||
for (const protectedRoot of [parent, root]) {
|
||||
expect(() => bindAcpxAgentFiles(context, [protectedRoot])).toThrow("overlaps");
|
||||
}
|
||||
renameSync(root, `${root}-old`); mkdirSync(root);
|
||||
expect(() => binding.assertHeld()).toThrow("changed");
|
||||
});
|
||||
it("does not grant absent or legacy instruction copies", () => {
|
||||
expect(bindAcpxAgentFiles(null, [])).toBeNull();
|
||||
const { context } = fixture();
|
||||
|
||||
@@ -13,8 +13,9 @@ export function bindAcpxAgentFiles(
|
||||
protectedRoots: readonly string[],
|
||||
): AcpxAgentFilesBinding | null {
|
||||
const copy = context?.instructions.workingCopy;
|
||||
if (copy?.kind !== "agent_files") return null;
|
||||
const declared = copy.rootPath;
|
||||
const persistentHome = context?.persistentAgentHome;
|
||||
if (!persistentHome && copy?.kind !== "agent_files") return null;
|
||||
const declared = persistentHome?.rootPath ?? copy!.rootPath;
|
||||
if (!isAbsolute(declared) || declared.includes("\0") || resolve(declared) !== declared) {
|
||||
throw new Error("ACP agent directory must be an absolute normalized registered path");
|
||||
}
|
||||
@@ -27,7 +28,10 @@ export function bindAcpxAgentFiles(
|
||||
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)) {
|
||||
// Attached-computer homes contain the runner's isolated state beneath
|
||||
// .paperclip-runtime. This env pointer does not widen the provider policy;
|
||||
// retain its protected roots, and never alias the home into one of them.
|
||||
if (contains(canonical, root) || (!persistentHome && contains(root, canonical))) {
|
||||
throw new Error("ACP agent directory overlaps protected runtime state");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -901,6 +901,34 @@ describe("ACPX runtime host", () => {
|
||||
expect(fixture.commandClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("gives Claude shells only the registered persistent home while keeping provider HOME isolated", async () => {
|
||||
const fixture = await hostFixture();
|
||||
const home = await realpath(await mkdtemp(join(tmpdir(), "paperclip-computer-agent-home-")));
|
||||
temporaryDirectories.push(home);
|
||||
const context: NativeRuntimeContextSnapshot = {
|
||||
prompt: { revision: PAPERCLIP_EXECUTION_PROMPT_REVISION, text: PAPERCLIP_EXECUTION_PROMPT, digest: nativeRuntimePromptDigest() },
|
||||
instructions: { entryPath: "AGENTS.md", bundle: { schema: NATIVE_RUNTIME_ASSET_SCHEMA, digest: "0".repeat(64), manifestDigest: "0".repeat(64), rootPath: home, fileCount: 0, totalBytes: 0 } },
|
||||
persistentAgentHome: { rootPath: home }, skills: [],
|
||||
mcp: { assignmentSetId: "none", digest: "0".repeat(64), bindingId: null }, aggregateDigest: "",
|
||||
};
|
||||
context.aggregateDigest = canonicalNativeRuntimeContextDigest(context);
|
||||
let observed: { home: string; agentHome: string } | undefined;
|
||||
const host = await AcpxRuntimeHost.open({ ...fixture.options, agent: "claude", model: "claude-sonnet-5",
|
||||
environment: { AGENT_HOME: "/ambient/untrusted" }, runtimeContext: context,
|
||||
}, fixture.dependencies({ openRuntime: async launch => {
|
||||
observed = JSON.parse(execFileSync(process.execPath, ["-e", 'console.log(JSON.stringify({home:process.env.HOME,agentHome:process.env.AGENT_HOME}))'], {
|
||||
env: launch.launchEnvironment, encoding: "utf8", timeout: 5_000,
|
||||
}));
|
||||
return runtimePort({ getStatus: async () => ({ models: { currentModelId: "claude-sonnet-5" } }) });
|
||||
} }));
|
||||
try {
|
||||
expect(observed?.agentHome).toBe(home);
|
||||
expect(observed?.home).not.toBe(home);
|
||||
expect(observed?.home).toContain("home");
|
||||
expect(context.instructions.workingCopy).toBeUndefined();
|
||||
} finally { await host.close({ reason: "persistent home verified" }); }
|
||||
});
|
||||
|
||||
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-"));
|
||||
|
||||
@@ -483,7 +483,7 @@ export class AcpxRuntimeHost {
|
||||
// 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)
|
||||
const agentFiles = options.runtimeContext?.persistentAgentHome || ["cursor", "copilot", "pi"].includes(options.agent)
|
||||
? bindAcpxAgentFiles(options.runtimeContext, [sandbox.root,
|
||||
...(installation.agentServerPackageJsonPath === null ? [] : [dirname(installation.agentServerPackageJsonPath)]),
|
||||
]) : null;
|
||||
|
||||
@@ -11797,6 +11797,9 @@ async function createRunnerdBackendWithinSessionClaim(
|
||||
remoteRunnerFilesystemRoot && sourceRuntimeContext
|
||||
? {
|
||||
...sourceRuntimeContext,
|
||||
...(remoteTarget?.transport === "computer" ? {
|
||||
persistentAgentHome: { rootPath: remoteTarget.fileAuthority.agentHome },
|
||||
} : {}),
|
||||
instructions: {
|
||||
...sourceRuntimeContext.instructions,
|
||||
bundle: {
|
||||
|
||||
Reference in new issue
Block a user