fix(runtime): bind persistent computer homes into Claude runtime context

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-10 10:16:34 -05:00
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: {