mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-11 23:36:51 +02:00
Fix runtime prerequisite home, workspace, and integrity boundaries
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
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) => {
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in new issue
Block a user