mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-22 16:51:28 +02:00
Merge shared runtime prerequisites into Boat integration
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
commit
8ecad0f359
16 files changed
+532
-12
No files matched your search
@@ -6,4 +6,3 @@ export function adapterExecutionTargetIsCommandBacked(
|
||||
): target is AdapterCommandBackedExecutionTarget {
|
||||
return target?.kind === "remote" && (target.transport === "sandbox" || target.transport === "computer");
|
||||
}
|
||||
|
||||
@@ -5,6 +5,9 @@ import * as ssh from "./ssh.js";
|
||||
import * as serverUtils from "./server-utils.js";
|
||||
import {
|
||||
cleanupGitHubOperationLaunchers,
|
||||
adapterExecutionTargetRuntimeRoot,
|
||||
prepareAdapterExecutionTargetRuntime,
|
||||
type AdapterComputerExecutionTarget,
|
||||
prepareGitHubOperationLaunchers,
|
||||
adapterExecutionTargetUsesManagedHome,
|
||||
ensureAdapterExecutionTargetRuntimeCommandInstalled,
|
||||
@@ -437,3 +440,36 @@ describe("GitHub launcher lifecycle", () => {
|
||||
expect(runner.execute).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("computer legacy runtime ownership", () => {
|
||||
it.each(["claude", "codex"])("isolates %s assets and callback roots for owners sharing a checkout", async adapterKey => {
|
||||
const execute = vi.fn(async () => ({ exitCode: 0, signal: null, timedOut: false, stdout: "", stderr: "", pid: 123, startedAt: "2026-10-10T00:00:00Z" }));
|
||||
const target = (ownerId: string, generation = 1) => ({
|
||||
kind: "remote", transport: "computer", providerKey: "boat", environmentId: "environment",
|
||||
leaseId: ownerId, remoteCwd: "/shared/project", listenerPort: 43127,
|
||||
runner: { execute }, processRunner: { execute },
|
||||
resourceAuthority: { kind: "computer-owner", computerId: "computer", ownerId, generation },
|
||||
fileAuthority: { kind: "remote-persistent", placementId: "shared", root: "/shared/project", agentHome: `/homes/${ownerId}` },
|
||||
launch: vi.fn(), inspectProcess: vi.fn(), retainWarm: vi.fn(), retire: vi.fn(),
|
||||
computerTool: { command: "cua-driver", args: ["mcp"] },
|
||||
} satisfies AdapterComputerExecutionTarget);
|
||||
const prepare = (owner: AdapterComputerExecutionTarget) => prepareAdapterExecutionTargetRuntime({
|
||||
target: owner, runId: owner.leaseId!, adapterKey, workspaceLocalDir: "/unused-local-workspace", syncWorkspace: false,
|
||||
});
|
||||
const first = await prepare(target("agent-one"));
|
||||
const second = await prepare(target("agent-two"));
|
||||
expect(first.workspaceRemoteDir).toBe("/shared/project");
|
||||
expect(second.workspaceRemoteDir).toBe(first.workspaceRemoteDir);
|
||||
expect(first.runtimeRootDir).toBe(adapterExecutionTargetRuntimeRoot(target("agent-one"), adapterKey));
|
||||
expect(second.runtimeRootDir).not.toBe(first.runtimeRootDir);
|
||||
expect(adapterExecutionTargetRuntimeRoot(target("agent-one", 2), adapterKey)).not.toBe(first.runtimeRootDir);
|
||||
expect((await prepare(target("agent-one"))).runtimeRootDir).toBe(first.runtimeRootDir);
|
||||
expect(first.runtimeRootDir).toMatch(new RegExp(`^/shared/project/\\.paperclip-runtime/${adapterKey}/owners/[a-f0-9]{64}$`));
|
||||
});
|
||||
|
||||
it("preserves the existing sandbox runtime root", () => {
|
||||
const target = { kind: "remote", transport: "sandbox", remoteCwd: "/workspace" } as const;
|
||||
expect(adapterExecutionTargetRuntimeRoot(target, "claude")).toBe("/workspace/.paperclip-runtime/claude");
|
||||
});
|
||||
});
|
||||
@@ -210,3 +210,29 @@ describe("paperclip runner transport routing", () => {
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
it("uses each computer owner's allocated port without changing the sandbox default", async () => {
|
||||
const { adapterExecutionTargetIsCommandBacked } = await import("./execution-target.js");
|
||||
const endpoint = ingress();
|
||||
const getRunnerIngressEndpoint = vi.fn(async () => endpoint);
|
||||
const base = {
|
||||
kind: "remote" as const, transport: "computer" as const, providerKey: "boat", remoteCwd: "/home/user/project",
|
||||
leaseId: "attempt-lease", listenerPort: 45101, effectiveCapabilities: capabilities, getRunnerIngressEndpoint,
|
||||
processRunner: { execute: async () => ({ exitCode: 0, signal: null, timedOut: false, stdout: "", stderr: "", pid: null, startedAt: new Date().toISOString() }) },
|
||||
resourceAuthority: { kind: "computer-owner" as const, computerId: "machine", ownerId: "owner-a", generation: 2 },
|
||||
fileAuthority: { kind: "remote-persistent" as const, placementId: "placement", root: "/home/user/project", agentHome: "/home/user/agent-a" },
|
||||
launch: async () => ({}), inspectProcess: async () => ({ running: true, claim: {} }),
|
||||
retainWarm: async () => {}, retire: async () => true, computerTool: { command: "cua-driver", args: ["mcp"] },
|
||||
};
|
||||
expect(adapterExecutionTargetIsCommandBacked(base)).toBe(true);
|
||||
for (const port of [45101, 45102]) {
|
||||
const resolved = await resolvePaperclipRunnerTransport({ target: { ...base, listenerPort: port }, runId: "run-1",
|
||||
localConnectUrl: "ws://localhost/unused", runnerIngressAuthorized: true });
|
||||
expect(resolved).toMatchObject({ mode: "provider_ingress", listenPort: port });
|
||||
expect(getRunnerIngressEndpoint).toHaveBeenLastCalledWith({ leaseId: "attempt-lease", port, path: "/api/runner/v1/connect/run-1" });
|
||||
}
|
||||
const { listenerPort, processRunner, resourceAuthority, fileAuthority, launch, inspectProcess, retainWarm, retire, computerTool, ...common } = base;
|
||||
const resolved = await resolvePaperclipRunnerTransport({ target: { ...common, transport: "sandbox" }, runId: "sandbox-run",
|
||||
localConnectUrl: "ws://localhost/unused", runnerIngressAuthorized: true });
|
||||
expect(resolved).toMatchObject({ mode: "provider_ingress", listenPort: 43127 });
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import path from "node:path";
|
||||
import { afterAll, afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildSshSpawnTarget,
|
||||
createSshCommandManagedRuntimeRunner,
|
||||
buildSshEnvLabFixtureConfig,
|
||||
getSshEnvLabSupport,
|
||||
prepareWorkspaceForSshExecution,
|
||||
@@ -181,6 +182,28 @@ describe("ssh env-lab fixture", () => {
|
||||
// this drains it too instead of stranding a listener until the process exits.
|
||||
afterAll(drainFixtureTeardowns);
|
||||
|
||||
it.each([0, 7])("handles a closed SSH input pipe without losing exit diagnostics (exit %i)", async (exitCode) => {
|
||||
const rootDir = await createFixtureRootDir();
|
||||
const previousPath = process.env.PATH;
|
||||
await writeFile(path.join(rootDir, "ssh"), `#!/bin/sh
|
||||
exec 0<&-
|
||||
printf 'remote rejected upload' >&2
|
||||
sleep 0.05
|
||||
exit ${exitCode}
|
||||
`, { mode: 0o700 });
|
||||
process.env.PATH = `${rootDir}${path.delimiter}${previousPath ?? ""}`;
|
||||
try {
|
||||
const runner = createSshCommandManagedRuntimeRunner({ spec: UNREACHABLE_SSH_SPEC });
|
||||
const result = await runner.execute({ command: "cat", stdin: "x".repeat(8 * 1024 * 1024), timeoutMs: 5000 });
|
||||
expect(result.exitCode).toBe(exitCode === 0 ? null : exitCode);
|
||||
expect(result.stderr).toBe("remote rejected upload");
|
||||
expect(result.timedOut).toBe(false);
|
||||
} finally {
|
||||
if (previousPath === undefined) delete process.env.PATH;
|
||||
else process.env.PATH = previousPath;
|
||||
}
|
||||
});
|
||||
|
||||
it("starts an isolated sshd fixture and executes commands through it", async () => {
|
||||
const rootDir = await createFixtureRootDir();
|
||||
const statePath = path.join(rootDir, "state.json");
|
||||
|
||||
@@ -231,6 +231,31 @@ describe("claude remote execution", () => {
|
||||
}));
|
||||
});
|
||||
|
||||
it("keeps an authoritative remote workspace in place while delivering managed Claude assets", async () => {
|
||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-claude-in-place-"));
|
||||
cleanupDirs.push(rootDir);
|
||||
await execute({
|
||||
runId: "run-in-place",
|
||||
agent: { id: "agent-1", companyId: "company-1", name: "Claude", adapterType: "claude_local", adapterConfig: {} },
|
||||
runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null },
|
||||
config: { engine: "cli", command: "claude", cwd: "/stale-host-cwd" },
|
||||
context: { paperclipWorkspace: { cwd: "/remote-only-agent-home", source: "agent_home" } },
|
||||
executionTarget: {
|
||||
kind: "remote", transport: "ssh", remoteCwd: "/old-copied-workspace",
|
||||
workspaceRealization: { mode: "in_place", authoritativeRoot: "/persistent-project", pathAliases: [], outboundRestorePaths: [] },
|
||||
spec: { host: "127.0.0.1", port: 2222, username: "fixture", remoteWorkspacePath: "/persistent-project", remoteCwd: "/persistent-project",
|
||||
privateKey: "PRIVATE KEY", knownHosts: "[127.0.0.1]:2222 ssh-ed25519 AAAA", strictHostKeyChecking: true },
|
||||
},
|
||||
onLog: async () => {},
|
||||
});
|
||||
expect(prepareWorkspaceForSshExecution).not.toHaveBeenCalled();
|
||||
expect(restoreWorkspaceFromSshExecution).not.toHaveBeenCalled();
|
||||
expect(syncDirectoryToSsh).toHaveBeenCalledTimes(2);
|
||||
expect(syncDirectoryToSsh).toHaveBeenCalledWith(expect.objectContaining({ remoteDir: "/persistent-project/.paperclip-runtime/claude/skills" }));
|
||||
const call = runChildProcess.mock.calls[0] as unknown as [string, string, string[], { remoteExecution: { remoteCwd: string } }];
|
||||
expect(call[3].remoteExecution.remoteCwd).toBe("/persistent-project");
|
||||
});
|
||||
|
||||
it("does not resume saved Claude sessions for remote SSH execution without a matching remote identity", async () => {
|
||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-claude-remote-resume-"));
|
||||
cleanupDirs.push(rootDir);
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -949,6 +949,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-"));
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createRunnerdCodexAppServerArgs } from "../live/runnerd-codex-transport.js";
|
||||
import {
|
||||
chmod,
|
||||
lstat,
|
||||
@@ -27,6 +28,7 @@ import { nativeMcpLaunchBinding } from "./native-mcp.js";
|
||||
import {
|
||||
materializeNativeRuntimeSkills,
|
||||
prepareIsolatedCodexHome,
|
||||
releaseMaterializedNativeRuntimeSkills,
|
||||
} from "./runtime-context-materializer.js";
|
||||
|
||||
const roots: string[] = [];
|
||||
@@ -93,6 +95,19 @@ function context(
|
||||
}
|
||||
|
||||
describe("runtime context materialization", () => {
|
||||
it("installs only the trusted environment computer binding and removes it on refresh without that capability", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "paperclip-computer-context-"));
|
||||
roots.push(root);
|
||||
const codexHome = join(root, "codex-home");
|
||||
await prepareIsolatedCodexHome({ context: null, codexHome,
|
||||
computerTool: { command: "/opt/ascii/cua-driver/cua-driver", args: ["mcp", "--socket", "/run/ascii-cua/driver.sock"] } });
|
||||
const config = await readFile(join(codexHome, "config.toml"), "utf8");
|
||||
expect(config).toContain("[mcp_servers.paperclip_computer]");
|
||||
expect(config).toContain('command = "/opt/ascii/cua-driver/cua-driver"');
|
||||
await releaseMaterializedNativeRuntimeSkills(join(codexHome, "skills"));
|
||||
await prepareIsolatedCodexHome({ context: null, codexHome });
|
||||
expect(await readFile(join(codexHome, "config.toml"), "utf8")).not.toContain("paperclip_computer");
|
||||
});
|
||||
it("restores the legacy working-copy contract without changing its digest or adding new fields", () => {
|
||||
const old = context("/skills", "/instructions");
|
||||
old.instructions.workingCopy = { rootPath: "/old-run/copy", entryPath: "AGENTS.md" };
|
||||
@@ -503,3 +518,12 @@ command = "untrusted-command"
|
||||
await expect(stat(join(symlinkCodexHome, "auth.json"))).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
it("exposes a trusted persistent personal directory without instruction files", () => {
|
||||
const options = { environment: { AGENT_HOME: "/untrusted", HOME: "/ambient" }, codexHome: "/private-provider" };
|
||||
const supplied = createRunnerdCodexAppServerArgs({ ...options, persistentAgentHome: "/personal/agent" }).join("\n");
|
||||
expect(supplied).toContain('AGENT_HOME="/personal/agent"');
|
||||
expect(supplied).toContain('"/private-provider"="none"');
|
||||
expect(supplied).not.toContain('AGENT_HOME="/untrusted"');
|
||||
expect(createRunnerdCodexAppServerArgs(options).join("\n")).not.toContain("AGENT_HOME");
|
||||
});
|
||||
@@ -643,6 +643,46 @@ it("retargets only composed instruction framing and retains legacy custom instru
|
||||
|
||||
});
|
||||
|
||||
it.each([false, true])("preserves ACPX asset framing across cold, warm, and post-idle attachment (guidance=%s)", (guidance) => {
|
||||
const source = assignedRuntimeContext("/controller/skills", "/controller/bundle");
|
||||
const cold = assignedRuntimeContext("/boat/skills", "/boat/context/cold");
|
||||
const resumed = assignedRuntimeContext("/boat/skills", "/boat/context/resumed");
|
||||
const custom = "Managed instructions with literal /controller/bundle example.";
|
||||
const original = composeNativeSystemInstructions(source, custom);
|
||||
const coldText = runnerdRecoveryInternals.retargetComposedInstructions(
|
||||
runnerdRecoveryInternals.withComputerProcessInstructions(original, guidance, source),
|
||||
source,
|
||||
cold,
|
||||
);
|
||||
const suffix = (context: NativeRuntimeContextSnapshot) =>
|
||||
composeNativeSystemInstructions(context, "").slice(context.prompt.text.length);
|
||||
expect(coldText.endsWith(suffix(cold))).toBe(true);
|
||||
const state = { runAttachTemplate: { provider: {
|
||||
kind: "acpx", agent: "claude", runId: "cold-run", runtimeContext: cold, instructions: coldText,
|
||||
} } };
|
||||
const desired = {
|
||||
runnerInstanceId: "runner", environmentLeaseId: "workspace", runId: "warm-run",
|
||||
normalizedSessionId: "same-session", turnId: "turn", itemId: "item",
|
||||
};
|
||||
const warm = runnerdRecoveryInternals.rotatedRunAttachPayload(state, desired, null, undefined);
|
||||
expect((warm.provider as { instructions: string }).instructions).toBe(coldText);
|
||||
const recover = (text: string) => runnerdRecoveryInternals.rotatedRunAttachPayload(
|
||||
{ runAttachTemplate: warm }, { ...desired, runId: "post-idle-run" }, null, undefined, resumed,
|
||||
{ text, context: source },
|
||||
).provider as { instructions: string; runtimeContext: NativeRuntimeContextSnapshot };
|
||||
const recovered = recover(composeNativeSystemInstructions(source, custom));
|
||||
expect(recovered.runtimeContext).toEqual(resumed);
|
||||
expect(recovered.instructions.endsWith(suffix(resumed))).toBe(true);
|
||||
// This is the immutable-prefix equality required by Claude's guest guard.
|
||||
expect(recovered.instructions.slice(0, -suffix(resumed).length))
|
||||
.toBe(coldText.slice(0, -suffix(cold).length));
|
||||
expect(recovered.instructions).toContain(custom);
|
||||
expect(recovered.instructions).not.toContain("Read-only instruction sibling root: /controller/");
|
||||
// Never silently substitute old managed instructions for current grants.
|
||||
expect(recover(composeNativeSystemInstructions(source, "Changed managed instructions")).instructions)
|
||||
.toContain("Changed managed instructions");
|
||||
});
|
||||
|
||||
it("replays the durable run attachment outcome and latest provider identity", () => {
|
||||
expect(
|
||||
runnerdRecoveryInternals.recoveredRunAttachment({
|
||||
@@ -2158,6 +2198,32 @@ it.each(["installed", "explicit"] as const)("records the selected %s Codex comma
|
||||
}
|
||||
});
|
||||
|
||||
it.each([false, true])("binds detached-process guidance only for a trusted persistent computer (%s)", async (computer) => {
|
||||
const root = await mkdtemp(join(tmpdir(), "paperclip-computer-process-guidance-"));
|
||||
const runnerBinary = join(root, "runnerd");
|
||||
await writeFile(runnerBinary, "unexecuted fixture artifact", { mode: 0o700 });
|
||||
let instructions = "";
|
||||
const { transport } = createCapabilityRunnerdCodexTransport({
|
||||
provider: "codex", runnerBinary, stateDirectory: root, sourceCodexHome: "",
|
||||
codexCommand: "/qualified/codex", persistentAgentHome: computer ? "/agent/home" : undefined,
|
||||
controlPlaneRegistration: async (authority) => {
|
||||
instructions = String((authority.store.state.runAttachTemplate!.provider as { instructions: string }).instructions);
|
||||
throw new Error("fixture_stop_before_runner_launch");
|
||||
},
|
||||
runnerProcessLauncher: () => { throw new Error("must not launch"); },
|
||||
});
|
||||
try {
|
||||
await expect(transport.request("thread/start", { cwd: root, model: "gpt-6.1-sol", dynamicTools: [],
|
||||
developerInstructions: "Preserve the task instructions." })).rejects.toThrow("fixture_stop_before_runner_launch");
|
||||
expect(instructions).toContain("Preserve the task instructions.");
|
||||
expect(instructions.includes("nohup setsid")).toBe(computer);
|
||||
if (computer) {
|
||||
expect(instructions).toContain("stop when its warm timeout expires");
|
||||
expect(instructions).toContain("Do not create services or change runner ownership");
|
||||
}
|
||||
} finally { await transport.close(); await rm(root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
it("rejects remote Codex without a guest executable before resolving controller dependencies", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "paperclip-runner-remote-codex-command-"));
|
||||
const runnerBinary = join(root, "runnerd");
|
||||
|
||||
@@ -326,7 +326,7 @@ describe("executeNativeSession recovery", () => {
|
||||
const completeRun = vi.fn(async () => {});
|
||||
try {
|
||||
const execution = executeNativeSession({
|
||||
input, turnTimeoutMs,
|
||||
input, turnTimeoutMs, bootstrapTimeoutMs: 5,
|
||||
backend: {
|
||||
async descriptor() { return { kind: "mock", name: "long-lived", version: "1", capabilities }; },
|
||||
async openSession() { return session; },
|
||||
@@ -2256,15 +2256,18 @@ describe("executeNativeSession recovery", () => {
|
||||
runnerInstanceId: "runner-recovery",
|
||||
controlPlaneInstanceId: "control-recovery",
|
||||
timeoutMs: 5,
|
||||
bootstrapTimeoutMs: 10,
|
||||
});
|
||||
const execution = execute();
|
||||
const rejection = expect(execution).rejects.toThrow(
|
||||
"native session bootstrap timed out after 5ms",
|
||||
"native session bootstrap timed out after 10ms",
|
||||
);
|
||||
|
||||
await bootstrapStarted;
|
||||
expect(bootstrapSignal?.aborted).toBe(false);
|
||||
await vi.advanceTimersByTimeAsync(5);
|
||||
expect(bootstrapSignal?.aborted).toBe(false);
|
||||
await vi.advanceTimersByTimeAsync(5);
|
||||
await rejection;
|
||||
expect(bootstrapSignal?.aborted).toBe(true);
|
||||
expect(openRun).not.toHaveBeenCalled();
|
||||
@@ -2659,6 +2662,7 @@ describe("executeNativeSession recovery", () => {
|
||||
runnerInstanceId: "runner-recovery",
|
||||
controlPlaneInstanceId: "control-recovery",
|
||||
timeoutMs: 10,
|
||||
bootstrapTimeoutMs: 100,
|
||||
keepSessionOpen: true,
|
||||
onSession: (current) => retainedSessions.push(current),
|
||||
});
|
||||
@@ -5513,6 +5517,7 @@ describe("executeNativeSession recovery", () => {
|
||||
controlPlaneInstanceId: "control-recovery",
|
||||
persistedSession: structuredClone(checkpoint),
|
||||
timeoutMs: 5,
|
||||
bootstrapTimeoutMs: 100,
|
||||
onSession,
|
||||
});
|
||||
const rejection = expect(execution).rejects.toThrow(
|
||||
@@ -5648,15 +5653,18 @@ describe("executeNativeSession recovery", () => {
|
||||
controlPlaneInstanceId: "control-recovery",
|
||||
persistedSession: structuredClone(checkpoint),
|
||||
timeoutMs: 5,
|
||||
bootstrapTimeoutMs: 10,
|
||||
onSession,
|
||||
});
|
||||
const rejection = expect(execution).rejects.toThrow(
|
||||
"native session replacement bootstrap timed out after 5ms",
|
||||
"native session replacement bootstrap timed out after 10ms",
|
||||
);
|
||||
|
||||
await replacementStarted;
|
||||
expect(replacementSignal?.aborted).toBe(false);
|
||||
await vi.advanceTimersByTimeAsync(5);
|
||||
expect(replacementSignal?.aborted).toBe(false);
|
||||
await vi.advanceTimersByTimeAsync(5);
|
||||
await rejection;
|
||||
expect(replacementSignal?.aborted).toBe(true);
|
||||
expect(openRun).not.toHaveBeenCalled();
|
||||
|
||||
@@ -983,6 +983,110 @@ describe("claude execute", () => {
|
||||
}
|
||||
}, 10_000);
|
||||
|
||||
it("uses registered persistent instruction paths in computer CLI prompts", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-execute-sandbox-"));
|
||||
const localWorkspace = path.join(root, "workspace");
|
||||
const remoteWorkspace = path.join(root, "sandbox-$HOME");
|
||||
const binDir = path.join(root, "bin");
|
||||
const commandPath = path.join(binDir, "claude");
|
||||
const capturePath1 = path.join(remoteWorkspace, "capture-1.json");
|
||||
const claudeRoot = path.join(root, ".claude");
|
||||
const localInstructions = path.join(localWorkspace, "AGENTS.md");
|
||||
const remoteInstructions = path.join(remoteWorkspace, "nested", "AGENTS.md");
|
||||
const runner = createLocalSandboxRunner();
|
||||
const previousHome = process.env.HOME;
|
||||
const previousPath = process.env.PATH;
|
||||
|
||||
await fs.mkdir(localWorkspace, { recursive: true });
|
||||
await fs.mkdir(remoteWorkspace, { recursive: true });
|
||||
await fs.mkdir(binDir, { recursive: true });
|
||||
await fs.mkdir(claudeRoot, { recursive: true });
|
||||
await fs.writeFile(path.join(claudeRoot, "settings.json"), JSON.stringify({ theme: "test" }), "utf8");
|
||||
await writeFakeClaudeCommand(commandPath);
|
||||
|
||||
await fs.writeFile(localInstructions, "Persistent guidance contents.");
|
||||
process.env.HOME = root;
|
||||
process.env.PATH = `${binDir}${path.delimiter}${process.env.PATH ?? ""}`;
|
||||
|
||||
try {
|
||||
const result = await execute({
|
||||
runId: "run-sandbox-auth",
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
companyId: "company-1",
|
||||
name: "Claude Coder",
|
||||
adapterType: "claude_local",
|
||||
adapterConfig: { engine: "cli" },
|
||||
},
|
||||
runtime: {
|
||||
sessionId: null,
|
||||
sessionParams: null,
|
||||
sessionDisplayId: null,
|
||||
taskKey: null,
|
||||
},
|
||||
config: {
|
||||
engine: "cli",
|
||||
managedAiConnection: true,
|
||||
command: commandPath,
|
||||
cwd: localWorkspace,
|
||||
instructionsFilePath: localInstructions,
|
||||
env: {
|
||||
CLAUDE_CONFIG_DIR: claudeRoot,
|
||||
PAPERCLIP_TEST_CAPTURE_PATH: capturePath1,
|
||||
},
|
||||
promptTemplate: "Follow the paperclip heartbeat.",
|
||||
},
|
||||
context: { paperclipWorkspace: { instructionsFilePath: remoteInstructions, agentHome: remoteWorkspace } },
|
||||
executionTarget: {
|
||||
kind: "remote",
|
||||
transport: "computer",
|
||||
providerKey: "e2b",
|
||||
environmentId: "env-1",
|
||||
leaseId: "lease-1",
|
||||
remoteCwd: remoteWorkspace,
|
||||
timeoutMs: 30_000,
|
||||
runner,
|
||||
processRunner: runner,
|
||||
listenerPort: 12345,
|
||||
resourceAuthority: { kind: "computer-owner", computerId: "computer-1", ownerId: "owner-1", generation: 1 },
|
||||
fileAuthority: { kind: "remote-persistent", placementId: "placement-1", root: remoteWorkspace, agentHome: remoteWorkspace },
|
||||
workspaceRealization: { mode: "in_place", authoritativeRoot: remoteWorkspace },
|
||||
launch: async () => ({}),
|
||||
inspectProcess: async () => ({ running: false, claim: null }),
|
||||
retainWarm: async () => {},
|
||||
retire: async () => true,
|
||||
computerTool: { command: "unused", args: [] },
|
||||
},
|
||||
authToken: "run-jwt-token",
|
||||
onLog: async () => {},
|
||||
});
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
const capture = JSON.parse(await fs.readFile(capturePath1, "utf8")) as CapturePayload;
|
||||
expect(capture.argv).toContain("--dangerously-skip-permissions");
|
||||
expect(capture.argv).not.toContain("--allowedTools");
|
||||
expect(capture.claudeConfigDir).toContain(path.join(remoteWorkspace, ".paperclip-runtime", "claude", "owners"));
|
||||
expect(capture.argv.slice(capture.argv.indexOf("--setting-sources"), capture.argv.indexOf("--setting-sources") + 2)).toEqual(["--setting-sources", "user"]);
|
||||
const userSkills = path.join(capture.claudeConfigDir!, "skills");
|
||||
expect(await fs.realpath(userSkills)).toBe(await fs.realpath(path.join(capture.addDir!, ".claude", "skills")));
|
||||
expect(await fs.readFile(path.join(userSkills, "paperclip", "SKILL.md"), "utf8")).toContain("name: paperclip");
|
||||
expect(capture.instructionsContents).toContain("Persistent guidance contents.");
|
||||
expect(capture.claudeConfigEntries).toContain("settings.json");
|
||||
expect(capture.prompt).toContain(remoteInstructions);
|
||||
expect(capture.prompt).toContain(`${path.dirname(remoteInstructions)}/`);
|
||||
expect(capture.prompt).not.toContain(localInstructions);
|
||||
expect(capture.paperclipApiUrl).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/);
|
||||
expect(capture.paperclipApiKey).not.toBe("run-jwt-token");
|
||||
expect(capture.paperclipApiBridgeMode).toBe("queue_v1");
|
||||
} finally {
|
||||
if (previousHome === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = previousHome;
|
||||
if (previousPath === undefined) delete process.env.PATH;
|
||||
else process.env.PATH = previousPath;
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
}, 10_000);
|
||||
|
||||
it("omits --effort for sandbox-managed runs when the installed Claude CLI does not advertise it", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-execute-sandbox-effort-"));
|
||||
const { workspace, commandPath, capturePath, restore } = await setupExecuteEnv(root, {
|
||||
|
||||
@@ -538,6 +538,101 @@ describe("codex execute", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("uses registered persistent instruction paths in computer CLI prompts", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-execute-sandbox-"));
|
||||
const localWorkspace = path.join(root, "workspace");
|
||||
const remoteWorkspace = path.join(root, "sandbox");
|
||||
const binDir = path.join(root, "bin");
|
||||
const commandPath = path.join(binDir, "codex");
|
||||
const capturePath = path.join(remoteWorkspace, "capture.json");
|
||||
const localInstructions = path.join(localWorkspace, "AGENTS.md");
|
||||
const remoteInstructions = path.join(remoteWorkspace, "nested", "AGENTS.md");
|
||||
const runner = createLocalSandboxRunner();
|
||||
const previousHome = process.env.HOME;
|
||||
const previousPath = process.env.PATH;
|
||||
|
||||
await fs.mkdir(localWorkspace, { recursive: true });
|
||||
await fs.mkdir(remoteWorkspace, { recursive: true });
|
||||
await fs.mkdir(binDir, { recursive: true });
|
||||
await writeFakeCodexCommand(commandPath);
|
||||
|
||||
await fs.writeFile(localInstructions, "Persistent guidance contents.");
|
||||
process.env.HOME = root;
|
||||
process.env.PATH = `${binDir}${path.delimiter}${process.env.PATH ?? ""}`;
|
||||
await seedSharedCodexAuth(root);
|
||||
|
||||
try {
|
||||
const result = await execute({
|
||||
runId: "run-sandbox-auth",
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
companyId: "company-1",
|
||||
name: "Codex Coder",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: { engine: "cli" },
|
||||
},
|
||||
runtime: {
|
||||
sessionId: null,
|
||||
sessionParams: null,
|
||||
sessionDisplayId: null,
|
||||
taskKey: null,
|
||||
},
|
||||
config: {
|
||||
engine: "cli",
|
||||
command: commandPath,
|
||||
cwd: localWorkspace,
|
||||
instructionsFilePath: localInstructions,
|
||||
env: {
|
||||
PAPERCLIP_TEST_CAPTURE_PATH: capturePath,
|
||||
},
|
||||
promptTemplate: "Follow the paperclip heartbeat.",
|
||||
},
|
||||
context: { paperclipWorkspace: { instructionsFilePath: remoteInstructions, agentHome: remoteWorkspace } },
|
||||
executionTarget: {
|
||||
kind: "remote",
|
||||
transport: "computer",
|
||||
providerKey: "e2b",
|
||||
environmentId: "env-1",
|
||||
leaseId: "lease-1",
|
||||
remoteCwd: remoteWorkspace,
|
||||
timeoutMs: 30_000,
|
||||
runner,
|
||||
processRunner: runner,
|
||||
listenerPort: 12345,
|
||||
resourceAuthority: { kind: "computer-owner", computerId: "computer-1", ownerId: "owner-1", generation: 1 },
|
||||
fileAuthority: { kind: "remote-persistent", placementId: "placement-1", root: remoteWorkspace, agentHome: remoteWorkspace },
|
||||
workspaceRealization: { mode: "in_place", authoritativeRoot: remoteWorkspace },
|
||||
launch: async () => ({}),
|
||||
inspectProcess: async () => ({ running: false, claim: null }),
|
||||
retainWarm: async () => {},
|
||||
retire: async () => true,
|
||||
computerTool: { command: "unused", args: [] },
|
||||
},
|
||||
authToken: "run-jwt-token",
|
||||
onLog: async () => {},
|
||||
});
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.errorMessage).toBeNull();
|
||||
|
||||
const capture = JSON.parse(await fs.readFile(capturePath, "utf8")) as CapturePayload;
|
||||
expect(capture.codexHome).toContain(path.join(remoteWorkspace, ".paperclip-runtime", "codex", "owners"));
|
||||
expect(capture.prompt).toContain("Persistent guidance contents.");
|
||||
expect(capture.prompt).toContain(remoteInstructions);
|
||||
expect(capture.prompt).toContain(`${path.dirname(remoteInstructions)}/`);
|
||||
expect(capture.prompt).not.toContain(localInstructions);
|
||||
expect(capture.paperclipApiUrl).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/);
|
||||
expect(capture.paperclipApiKey).not.toBe("run-jwt-token");
|
||||
expect(capture.paperclipApiBridgeMode).toBe("queue_v1");
|
||||
} finally {
|
||||
if (previousHome === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = previousHome;
|
||||
if (previousPath === undefined) delete process.env.PATH;
|
||||
else process.env.PATH = previousPath;
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it.each([false, true])("delivers oversized wake context through stdin (sandbox=%s)", async (sandbox) => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-execute-wake-"));
|
||||
const workspace = path.join(root, "workspace");
|
||||
|
||||
@@ -8,10 +8,11 @@ import type { CommandManagedRuntimeRunner } from "@paperclipai/adapter-utils/com
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { prepareHeartbeatGitHubLaunchers } from "./heartbeat-github-launchers.js";
|
||||
|
||||
const computerTarget = { kind: "remote", transport: "computer", remoteCwd: "/workspace" } as Parameters<typeof prepareHeartbeatGitHubLaunchers>[0]["target"];
|
||||
const target = { kind: "remote" as const, transport: "sandbox" as const, providerKey: "daytona", remoteCwd: "/workspace" };
|
||||
|
||||
describe("heartbeat GitHub launcher lifetime", () => {
|
||||
it.each([target, null])("defers native managed authorization/staging to the session owner (%j)", async (target) => {
|
||||
it.each([target, computerTarget, null])("defers native managed authorization/staging to the session owner (%j)", async (target) => {
|
||||
const prepare = vi.fn();
|
||||
const mint = vi.fn();
|
||||
const result = await prepareHeartbeatGitHubLaunchers({
|
||||
@@ -23,7 +24,7 @@ describe("heartbeat GitHub launcher lifetime", () => {
|
||||
expect(result.cleanupLocation).toBeNull();
|
||||
expect(result.env).toMatchObject({ GH_TOKEN: "", PAPERCLIP_GITHUB_BROKER_TOKEN: "" });
|
||||
});
|
||||
it("keeps anonymous native sandbox launchers stable without issuing a run capability", async () => {
|
||||
it.each([target, computerTarget])("keeps anonymous native launchers stable without issuing a run capability (%j)", async (target) => {
|
||||
const createBrokerToken = vi.fn(() => "run-secret");
|
||||
const prepareLaunchers = vi.fn(async (input) => input.env);
|
||||
const inputs = { native: true, githubConfigured: false, agentId: "agent-a", target,
|
||||
@@ -50,7 +51,7 @@ describe("heartbeat GitHub launcher lifetime", () => {
|
||||
expect(cleanupLaunchers).toHaveBeenCalledExactlyOnceWith({ runId: "failed-run", target });
|
||||
});
|
||||
|
||||
it("does not remove shared anonymous wrappers when staging a later run fails", async () => {
|
||||
it.each([target, computerTarget])("does not remove shared anonymous wrappers when staging a later run fails (%j)", async (target) => {
|
||||
const stagingError = new Error("remote launcher staging failed");
|
||||
const cleanupLaunchers = vi.fn(async () => undefined);
|
||||
await expect(prepareHeartbeatGitHubLaunchers({
|
||||
@@ -64,6 +65,7 @@ describe("heartbeat GitHub launcher lifetime", () => {
|
||||
it.each([
|
||||
{ native: false, githubConfigured: true, target },
|
||||
{ native: false, githubConfigured: false, target },
|
||||
{ native: false, githubConfigured: false, target: computerTarget },
|
||||
{ native: true, githubConfigured: false, target: null },
|
||||
])("preserves run-scoped managed capabilities outside anonymous native sandboxes: %j", async (mode) => {
|
||||
const createBrokerToken = vi.fn(() => "current-run-secret");
|
||||
|
||||
@@ -2,6 +2,7 @@ import { afterEach, expect, it, vi } from "vitest";
|
||||
import { spawn, execFile } from "node:child_process";
|
||||
import { mkdtemp, mkdir, readFile, rm, writeFile, access } from "node:fs/promises";
|
||||
import { promisify } from "node:util";
|
||||
import type { AdapterExecutionTarget } from "@paperclipai/adapter-utils/execution-target";
|
||||
import type { CommandManagedRuntimeRunner } from "@paperclipai/adapter-utils/command-managed-runtime";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
@@ -13,7 +14,7 @@ const cleanups: (() => Promise<unknown>)[] = [];
|
||||
afterEach(async () => { for (const cleanup of cleanups.splice(0).reverse()) await cleanup(); });
|
||||
async function broker(resolveCredentials = vi.fn(async (binding: ReturnType<typeof run>) => ({
|
||||
status: "available", env: { GH_TOKEN: `fixture-${binding.runId}` },
|
||||
})), remote = false, bridgeUnavailable = false, onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise<void>) {
|
||||
})), remote: boolean | "computer" = false, bridgeUnavailable = false, onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise<void>) {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "native-github-reuse-"));
|
||||
cleanups.push(() => rm(root, { recursive: true, force: true }));
|
||||
const bin = path.join(root, "real-bin");
|
||||
@@ -29,20 +30,42 @@ async function broker(resolveCredentials = vi.fn(async (binding: ReturnType<type
|
||||
const result = await child;
|
||||
return { ...result, exitCode: 0, signal: null, timedOut: false, pid: null, startedAt };
|
||||
};
|
||||
const target = remote ? { kind: "remote" as const, transport: "sandbox" as const, providerKey: "test",
|
||||
remoteCwd: root, runner: { execute }, streamRunLogs: false } : null;
|
||||
let generation = 1;
|
||||
let processLive = true;
|
||||
const processRunner = { execute: vi.fn(async (input: Parameters<typeof execute>[0]) => {
|
||||
if (!processLive) throw new Error("computer_process_claim_stale");
|
||||
return execute(input);
|
||||
}) };
|
||||
const target: AdapterExecutionTarget | null = remote === "computer" ? {
|
||||
kind: "remote", transport: "computer", remoteCwd: root,
|
||||
runner: { execute: async (input: Parameters<typeof execute>[0]) => {
|
||||
if (generation !== 1) throw new Error("computer_attempt_superseded");
|
||||
return execute(input);
|
||||
} }, processRunner,
|
||||
listenerPort: 43127,
|
||||
resourceAuthority: { kind: "computer-owner", computerId: "computer-a", ownerId: "owner-a", generation: 1 },
|
||||
fileAuthority: { kind: "remote-persistent", placementId: "placement-a", root, agentHome: root },
|
||||
launch: async () => { throw new Error("Fixture runner is already launched"); },
|
||||
inspectProcess: async () => ({ running: processLive, claim: { nonce: "fixture-process" } }),
|
||||
retainWarm: async () => undefined,
|
||||
retire: async () => { processLive = false; return true; },
|
||||
computerTool: { command: "fixture-computer-tool", args: [] },
|
||||
} : remote ? {
|
||||
kind: "remote" as const, transport: "sandbox" as const, providerKey: "test",
|
||||
remoteCwd: root, runner: { execute }, streamRunLogs: false,
|
||||
} : null;
|
||||
const result = await createNativeGitHubAccess({ scope, target, cwd: root,
|
||||
env: { PATH: `${bin}:${path.dirname(process.execPath)}:/usr/bin:/bin`, PAPERCLIP_API_KEY: "old-run-api-key" }, resolveCredentials, onLog },
|
||||
bridgeUnavailable ? async () => { throw new Error("fixture bridge unavailable"); } : undefined);
|
||||
cleanups.push(result.stop);
|
||||
return { ...result, root, resolveCredentials };
|
||||
return { ...result, root, resolveCredentials, processRunner, advanceGeneration: () => { generation++; }, retireProcess: () => { processLive = false; } };
|
||||
}
|
||||
function request(broker: NativeGitHubAccess, extra: RequestInit = {}, endpoint = "/runtime-tools/github/credentials") {
|
||||
return fetch(broker.env.PAPERCLIP_GITHUB_BROKER_URL + endpoint, {
|
||||
method: "POST", body: "{}", headers: { authorization: `Bearer ${broker.env.PAPERCLIP_GITHUB_BRIDGE_TOKEN}`, "content-type": "application/json" }, ...extra,
|
||||
});
|
||||
}
|
||||
it.each([false, true])("keeps one live parent and its original launcher environment across two authorized runs (callback bridge: %s)", async (remote) => {
|
||||
it.each([false, true, "computer"] as const)("keeps one live parent and its original launcher environment across two authorized runs (callback bridge: %s)", async (remote) => {
|
||||
const b = await broker(undefined, remote);
|
||||
const parent = spawn(process.execPath, ["-e", `
|
||||
const {execFile}=require('node:child_process');
|
||||
@@ -61,6 +84,7 @@ it.each([false, true])("keeps one live parent and its original launcher environm
|
||||
expect(a.value).toBe("fixture-run-a");
|
||||
releaseA();
|
||||
expect((await operation()).value).toBe("anonymous");
|
||||
b.advanceGeneration();
|
||||
const releaseB = b.activate(run("run-b"));
|
||||
releaseA(); // A's delayed cleanup cannot revoke B.
|
||||
const second = await operation();
|
||||
@@ -70,6 +94,11 @@ it.each([false, true])("keeps one live parent and its original launcher environm
|
||||
expect(await readFile(path.join(b.env.PAPERCLIP_GITHUB_LAUNCHER_DIR, "gh"), "utf8")).not.toContain(b.env.PAPERCLIP_GITHUB_BROKER_TOKEN);
|
||||
releaseB(); await b.stop();
|
||||
await expect(access(b.env.PAPERCLIP_GITHUB_LAUNCHER_DIR)).rejects.toThrow();
|
||||
if (remote === "computer") {
|
||||
expect(b.processRunner.execute).toHaveBeenCalled();
|
||||
b.retireProcess();
|
||||
await expect(b.processRunner.execute({ command: "true" })).rejects.toThrow("computer_process_claim_stale");
|
||||
}
|
||||
}, 45_000);
|
||||
it("rejects other scopes, concurrent bindings, browser requests and forged authority", async () => {
|
||||
const b = await broker();
|
||||
|
||||
@@ -8028,6 +8028,41 @@ describe("native warm session supervision", () => {
|
||||
expect(secondCopy.collectStopped).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it.each(["same", "generation", "owner", "computer"])("preserves command-transport directory identity across %s changes", async change => {
|
||||
const name = `managed-computer-identity-${change}`;
|
||||
const current = { ...execution, binding: { ...execution.binding, runId: `${name}-one`, executionWorkspaceId: name },
|
||||
session: { ...execution.session, normalizedSessionId: name, lifecyclePolicy: { mode: "warm", idleTimeoutMs: 60_000 } } } as NativeExecutionInputV1;
|
||||
const close = vi.fn(async () => undefined);
|
||||
const collectStopped = vi.fn(async () => undefined);
|
||||
const target = { kind: "remote", transport: "computer", environmentId: name, remoteCwd: `/tmp/${name}`,
|
||||
resourceAuthority: { kind: "computer-owner", computerId: name, ownerId: name, generation: 1 } };
|
||||
state.execute.mockReset().mockImplementationOnce(async options => { await options.onSession?.({ close }); return result; });
|
||||
await executePaperclipNativeSession({ db: leaseDb(current), execution: current, runnerInstanceId: name,
|
||||
runnerExecutionTarget: target as never,
|
||||
instructionWorkingCopy: { runId: current.binding.runId, root: `/tmp/${name}/home`, collectStopped,
|
||||
checkpointWarm: async () => true, hasChanges: async () => false } });
|
||||
const canReuse = vi.fn(async () => true);
|
||||
const nextAuthority = { ...target.resourceAuthority,
|
||||
...(change === "generation" ? { generation: 2 } : {}),
|
||||
...(change === "owner" ? { ownerId: "different-owner" } : {}),
|
||||
...(change === "computer" ? { computerId: "different-computer" } : {}),
|
||||
};
|
||||
const reservation = await reserveWarmNativeInstructionDirectory({ companyId: current.binding.companyId,
|
||||
agentId: current.binding.agentId, previousRunId: current.binding.runId, runId: `${name}-two`, canReuse,
|
||||
target: { ...target, resourceAuthority: nextAuthority } as never });
|
||||
if (change === "same" || change === "generation") {
|
||||
expect(reservation?.reuseRunId).toBe(current.binding.runId);
|
||||
expect(canReuse).toHaveBeenCalledOnce();
|
||||
expect(close).not.toHaveBeenCalled();
|
||||
await reservation!.release();
|
||||
} else {
|
||||
expect(reservation).toBeNull();
|
||||
expect(canReuse).not.toHaveBeenCalled();
|
||||
expect(close).toHaveBeenCalledOnce();
|
||||
expect(collectStopped).toHaveBeenCalledOnce();
|
||||
}
|
||||
});
|
||||
|
||||
it("stops before collecting when a checkpoint cannot stabilize", async () => {
|
||||
const f = await start("managed-fallback", false);
|
||||
expect(f.close).toHaveBeenCalledOnce();
|
||||
|
||||
Reference in new issue
Block a user