Fix runtime prerequisite home, workspace, and integrity boundaries

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-11 07:49:03 -05:00
1 parent 1525c4ef27
commit 96db062487
9 files changed
+62 -18

No files matched your search

@@ -1,4 +1,4 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { mkdir, mkdtemp, rm, stat, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
@@ -95,7 +95,7 @@ describe("claude remote execution", () => {
}
});
it("prepares the workspace, syncs Claude runtime assets, and restores workspace changes for remote SSH execution", async () => {
it.each([true, false])("prepares and restores a copied SSH workspace (already exists: %s)", async (exists) => {
vi.stubEnv("CLAUDE_CODE_USE_BEDROCK", "1");
vi.stubEnv("ANTHROPIC_MODEL", "host-only-model");
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-claude-remote-"));
@@ -104,7 +104,11 @@ describe("claude remote execution", () => {
const alternateWorkspaceDir = path.join(rootDir, "workspace-other");
const instructionsPath = path.join(rootDir, "instructions.md");
const managedRemoteWorkspace = "/remote/workspace/.paperclip-runtime/runs/run-1/workspace";
await mkdir(workspaceDir, { recursive: true });
if (exists) await mkdir(workspaceDir, { recursive: true });
prepareWorkspaceForSshExecution.mockImplementationOnce(async () => {
expect((await stat(workspaceDir)).isDirectory()).toBe(true);
return { gitBacked: false };
});
await mkdir(alternateWorkspaceDir, { recursive: true });
await writeFile(instructionsPath, "Use the remote workspace.\n", "utf8");
@@ -219,7 +219,7 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise<Cl
executionTargetIsRemote,
executionCwd: effectiveExecutionCwd,
});
if (!executionTargetIsRemote) {
if (executionTarget?.workspaceRealization?.mode !== "in_place") {
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
}
@@ -64,7 +64,7 @@ afterEach(async () => {
describe("ACPX installation integrity", () => {
describe.each(["runtime", "command"] as const)("%s executable hydration", (kind) => {
const verify = kind === "runtime" ? openVerifiedRuntimeExecutable : inspectCommand;
it.each(["once", "every_read", "wrong_digest", "mode", "mtime", "replacement", "symlink", "second_content"] as const)("requires stable full re-verification after a ctime-only transition (%s)", async (transition) => {
it.each(["once", "every_read", "wrong_digest", "mode", "mtime", "replacement", "symlink", "second_content", "uid", "gid", "nlink"] as const)("requires stable full re-verification after a ctime-only transition (%s)", async (transition) => {
const directory = await mkdtemp(join(tmpdir(), "paperclip-runtime-hydration-"));
temporaryDirectories.push(directory);
const executable = join(directory, "runtime");
@@ -74,8 +74,15 @@ describe("ACPX installation integrity", () => {
const probe = await open(executable, "r");
const prototype = Object.getPrototypeOf(probe) as FileHandle;
const originalRead = probe.read;
const originalStat = probe.stat;
await probe.close();
let reads = 0;
// Model ownership changes without requiring privileged chown in the test.
const statSpy = vi.spyOn(prototype, "stat").mockImplementation(async function (this: FileHandle, ...args: Parameters<FileHandle["stat"]>) {
const metadata = await Reflect.apply(originalStat, this, args);
if (reads > 0 && (transition === "uid" || transition === "gid")) metadata[transition] += 1n;
return metadata;
});
const readSpy = vi.spyOn(prototype, "read").mockImplementation(async function (this: FileHandle, ...args: Parameters<FileHandle["read"]>) {
if (reads === 1 && transition === "second_content") {
await writeFile(executable, Buffer.alloc(bytes.length, 120));
@@ -93,6 +100,7 @@ describe("ACPX installation integrity", () => {
const after = await stat(executable, { bigint: true });
expect(after.ctimeNs).not.toBe(before.ctimeNs);
expect([after.dev, after.ino, after.size, after.mode, after.mtimeNs]).toEqual([before.dev, before.ino, before.size, before.mode, before.mtimeNs]);
if (transition === "nlink") await link(executable, `${executable}.link`);
if (transition === "mode") await chmod(executable, 0o700);
if (transition === "mtime") await utimes(executable, new Date(0), new Date(0));
if (transition === "replacement" || transition === "symlink") {
@@ -117,7 +125,7 @@ describe("ACPX installation integrity", () => {
await expect(verify(executable, digest, "fixture")).rejects.toThrow(transition === "wrong_digest" ? "digest mismatch" : "changed while it was verified");
}
expect(reads).toBe(["once", "every_read", "second_content"].includes(transition) ? 2 : 1);
} finally { readSpy.mockRestore(); }
} finally { readSpy.mockRestore(); statSpy.mockRestore(); }
});
});
@@ -1188,7 +1188,10 @@ async function openVerifiedExecutable(
const beforeIdentity = fileIdentity(before);
if (
!sameIdentity(fileIdentity(lexicalBaseline), beforeIdentity) ||
lexicalBaseline.mode !== before.mode
lexicalBaseline.mode !== before.mode ||
lexicalBaseline.uid !== before.uid ||
lexicalBaseline.gid !== before.gid ||
lexicalBaseline.nlink !== before.nlink
) {
throw new Error(
`${label} changed while it was verified`,
@@ -1226,7 +1229,13 @@ async function openVerifiedExecutable(
!lexicalAfter.isFile() ||
!sameIdentity(fileIdentity(lexicalAfter), afterIdentity) ||
lexicalAfter.mode !== after.mode ||
lexicalAfter.uid !== after.uid ||
lexicalAfter.gid !== after.gid ||
lexicalAfter.nlink !== after.nlink ||
before.mode !== after.mode ||
before.uid !== after.uid ||
before.gid !== after.gid ||
before.nlink !== after.nlink ||
!sameIdentity(
{ ...beforeIdentity, changedNanoseconds: afterIdentity.changedNanoseconds },
afterIdentity,
@@ -250,6 +250,8 @@ export function createIsolatedCodexAppServerArgs(
readOnlyRoots: string[] = [],
/** Server-registered run copy, never an environment/config-supplied root. */
instructionWorkingCopyRoot?: string,
/** Separate controller-registered personal directory, never an ambient env grant. */
persistentAgentHome?: string,
): string[] {
const explicitSource = source;
source ??= process.env;
@@ -268,7 +270,9 @@ export function createIsolatedCodexAppServerArgs(
const commandEnvironment = codexCommandEnvironment(source);
// Selected task values are inherited, never serialized into configuration argv.
for (const key of configuredEnvironmentKeys(explicitSource)) delete commandEnvironment[key];
if (instructionWorkingCopyRoot && source.AGENT_HOME === instructionWorkingCopyRoot) commandEnvironment.AGENT_HOME = instructionWorkingCopyRoot;
const registeredAgentHome = persistentAgentHome ?? instructionWorkingCopyRoot;
if (registeredAgentHome && source.AGENT_HOME === registeredAgentHome) commandEnvironment.AGENT_HOME = registeredAgentHome;
const registeredWritableRoots = [...new Set([instructionWorkingCopyRoot, persistentAgentHome].filter((root): root is string => Boolean(root)))];
const shellEnvironmentKeys = [...new Set([...inheritedGitHubKeys, ...Object.keys(commandEnvironment)])].sort();
if (source.PAPERCLIP_GITHUB_LAUNCHER_DIR) readOnlyRoots = [...readOnlyRoots, source.PAPERCLIP_GITHUB_LAUNCHER_DIR];
const deniedHostRoots = [
@@ -289,7 +293,7 @@ export function createIsolatedCodexAppServerArgs(
...readOnlyRoots.map((path) => `${tomlString(resolve(path))}="read"`),
...gitRoots.read.map((path) => `${tomlString(path)}="read"`),
...gitRoots.write.map((path) => `${tomlString(path)}="write"`),
...(instructionWorkingCopyRoot ? [`${tomlString(resolve(instructionWorkingCopyRoot))}="write"`] : []),
...registeredWritableRoots.map((root) => `${tomlString(resolve(root))}="write"`),
...(source.PAPERCLIP_GITHUB_BROKER_TOKEN && source.GH_CONFIG_DIR
? [`${tomlString(resolve(source.GH_CONFIG_DIR))}="write"`] : []),
`":workspace_roots"={"."="write"}`,
@@ -301,7 +305,7 @@ export function createIsolatedCodexAppServerArgs(
...deniedHostRoots.map((path) => `${tomlString(path)}="none"`),
...readOnlyRoots.map((path) => `${tomlString(resolve(path))}="read"`),
...[...gitRoots.read, ...gitRoots.write].map((path) => `${tomlString(path)}="read"`),
...(instructionWorkingCopyRoot ? [`${tomlString(resolve(instructionWorkingCopyRoot))}="read"`] : []),
...registeredWritableRoots.map((root) => `${tomlString(resolve(root))}="read"`),
...(source.PAPERCLIP_GITHUB_BROKER_TOKEN && source.GH_CONFIG_DIR
? [`${tomlString(resolve(source.GH_CONFIG_DIR))}="write"`] : []),
`":workspace_roots"={"."="read"}`,
@@ -527,3 +527,16 @@ it("exposes a trusted persistent personal directory without instruction files",
expect(supplied).not.toContain('AGENT_HOME="/untrusted"');
expect(createRunnerdCodexAppServerArgs(options).join("\n")).not.toContain("AGENT_HOME");
});
it("grants separate registered personal and instruction directories to Codex shells", () => {
const args = createRunnerdCodexAppServerArgs({
environment: { AGENT_HOME: "/untrusted" }, codexHome: "/private-provider",
persistentAgentHome: "/personal/agent", instructionWorkingCopyRoot: "/instructions/run",
}).join("\n");
expect(args).toContain('AGENT_HOME="/personal/agent"');
for (const root of ["/personal/agent", "/instructions/run"]) {
expect(args).toContain(`"${root}"="write"`);
expect(args).toContain(`"${root}"="read"`);
}
expect(args).not.toContain("/untrusted");
});
@@ -3493,7 +3493,8 @@ export function createRunnerdCodexAppServerArgs(input: {
...(input.persistentAgentHome ? { AGENT_HOME: input.persistentAgentHome } : {}),
},
[...(input.readOnlyRoots ?? []), ...codexExecutableReadOnlyRoots(input.environment ?? {}, input.codexCommand)],
input.instructionWorkingCopyRoot ?? input.persistentAgentHome,
input.instructionWorkingCopyRoot,
input.persistentAgentHome,
);
}
@@ -332,7 +332,7 @@ describe("server adapter registry", () => {
expect(config).not.toHaveProperty("engine");
});
it("verifies the Claude harness on a computer after checking its platform", async () => {
it.each([true, false])("retains the unverified ACPX warning after a computer Claude CLI probe (succeeds: %s)", async (succeeds) => {
const calls: string[] = [];
const result = await requireServerAdapter("paperclip_runner").testEnvironment({
companyId: "company-1", adapterType: "paperclip_runner",
@@ -342,13 +342,15 @@ describe("server adapter registry", () => {
calls.push(input.command);
const stdout = input.command === "claude" ? JSON.stringify({ type: "result", result: "hello", usage: { input_tokens: 1, output_tokens: 1 } })
: input.args?.some(arg => arg.includes("uname -s")) ? "Linux\nx86_64\n" : "";
return { exitCode: 0, timedOut: false, stdout, stderr: "", signal: null, pid: null, startedAt: new Date().toISOString() };
return { exitCode: input.command === "claude" && !succeeds ? 1 : 0, timedOut: false, stdout, stderr: "", signal: null, pid: null, startedAt: new Date().toISOString() };
} },
} as never,
});
expect(calls).toContain("claude");
expect(result.checks).toContainEqual(expect.objectContaining({ code: "claude_hello_probe_passed", level: "info" }));
expect(result.checks.some(check => check.level === "error")).toBe(false);
expect(result.status).toBe(succeeds ? "warn" : "fail");
expect(result.checks).toContainEqual(expect.objectContaining({ code: "acpx_remote_runtime_unverified", level: "warn" }));
if (succeeds) expect(result.checks).toContainEqual(expect.objectContaining({ code: "claude_hello_probe_passed", level: "info" }));
expect(result.checks.some(check => check.level === "error")).toBe(!succeeds);
});
it.each([true, false])("checks actual local ACPX installation readiness (%s)", async (ready) => {
+6 -3
View File
@@ -432,16 +432,19 @@ const paperclipRunnerAdapter: ServerAdapterModule = {
if (!((os === "Linux" && arch === "x86_64") || (os === "Darwin" && (arch === "arm64" || (profile.acpxAgent !== "grok" && arch === "x86_64"))))) {
throw new Error(`ACPX ${profile.acpxAgent} requires a qualified Linux x64 or macOS architecture.`);
}
const runtimeUnverified = { code: "acpx_remote_runtime_unverified", level: "warn" as const,
message: "The remote platform is supported. Runtime package integrity and readiness must still be verified by the remote runner before launch." };
if (target.transport === "computer" && profile.acpxAgent === "claude") {
const hello = await claudeTestEnvironment({ ...context, adapterType: "claude_local",
config: { ...context.config, engine: "cli", model: profile.model,
dangerouslySkipPermissions: context.config.acpxPermissionMode === "approve-all" } });
return { ...hello, adapterType: "paperclip_runner" };
return { ...hello, adapterType: "paperclip_runner",
status: hello.status === "fail" ? "fail" as const : "warn" as const,
checks: [...hello.checks, runtimeUnverified] };
}
return {
adapterType: "paperclip_runner", status: "warn" as const, testedAt: new Date().toISOString(),
checks: [{ code: "acpx_remote_runtime_unverified", level: "warn" as const,
message: "The remote platform is supported. Runtime package integrity and readiness must still be verified by the remote runner before launch." }],
checks: [runtimeUnverified],
};
}
const { probeAcpxClaudeInstallation, probeAcpxGrokInstallation, probeAcpxCursorInstallation, probeAcpxPiInstallation } = await import("../vendor/paperclip-runner/live/index.js");