mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 00:05:49 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Agent adapters (Claude, Codex, Gemini) default to the ACP engine lane, which needs a live bidirectional stdio session with the agent process > - Sandbox execution targets only exposed one-shot command execution, so every ACP-capable adapter refused remote targets and fell back to the CLI lane with a "supports only the local Paperclip host" warning > - Running agents in sandboxes is a core deployment mode, and losing ACP there means losing streaming updates, structured events, and default-lane parity with local runs > - This pull request adds a provider-agnostic process-session bridge that relays the ACP stdio session into the sandbox over the existing sandbox runner contract, and updates the adapters to use it > - The benefit is that the default ACP lane now behaves the same on the local host and in any sandbox provider, with CLI fallback reserved for targets that genuinely cannot host a bidirectional session ## Linked Issues or Issue Description No existing public issue covers this; inline description following the feature request template: **Problem or motivation** Configuring an ACP-capable adapter (e.g. Claude) with a sandbox environment made every run fall back to the CLI lane with the warning "Claude ACP currently supports only the local Paperclip host, but this run targets a remote environment." The ACP engine only knew how to spawn a local subprocess, while sandbox providers only expose one-shot command execution — so there was no way to hold the bidirectional stdio session ACP requires. **Proposed solution** Add a process-session bridge in `adapter-utils`: a local ACPX-spawnable proxy script connects to a token-authenticated loopback TCP server, which relays JSON-framed stdin/stdout/stderr events to and from a small relay script executed inside the sandbox via the provider's ordinary runner. Claude/Codex/Gemini adapters now treat sandbox targets with a runner as ACP-capable, resolve agent commands against the remote target, and fall back to CLI only when the sandbox exposes no bidirectional path. The sandbox callback bridge injects a run-scoped API endpoint and bridge token so the agent inside the sandbox can reach Paperclip (including work-product handoffs) without ever receiving the host run JWT. **Alternatives considered** A provider-specific lane was prototyped first: Daytona minting SSH access metadata at lease time, converted into an SSH execution target. It was dropped because it only worked for providers able to advertise SSH, added per-provider surface area, and left every other sandbox provider on the CLI fallback. The merged design rides the one-shot runner contract all providers already implement; a regression test pins that sandbox targets stay on the bridge lane even when lease metadata advertises SSH access. **Roadmap alignment** Directly advances the "Cloud / Sandbox agents" roadmap item — agents running in remote and sandboxed environments keep the same control-plane behavior as local ones. No overlap with other planned core work. ## What Changed - `packages/adapter-utils/src/execution-target.ts`: new `startAdapterExecutionTargetProcessSessionBridge()` plus helpers — writes a token-authenticated local proxy script (spawnable by ACPX) and a remote relay script synced into the sandbox, with a loopback TCP server streaming JSON-framed stdio between them; events emitted before the ACP client attaches are buffered so none are lost. - `packages/adapter-utils/src/acpx-engine/execute.ts`: the ACP engine can execute against remote sandbox targets through the bridge instead of requiring a local subprocess, including remote cwd/env shaping. - `packages/adapter-utils/src/sandbox-callback-bridge.ts`: sandbox-scoped API bridging extended to allow work-product handoffs; the sandbox payload env carries a bridge token, never the host run JWT. - `packages/adapters/claude-local`, `codex-local`, `gemini-local` (`src/server/acp.ts`): default-lane selection no longer rejects all remote targets; command resolution is remote-aware (`ensureAdapterExecutionTargetCommandResolvable`, `resolveAdapterExecutionTargetCwd`); the fallback reason is now scoped to sandboxes that expose only one-shot execution. - `server/src/__tests__/environment-execution-target.test.ts`: pins that sandbox targets resolve to the bridge lane, including when lease metadata advertises SSH access. - Non-sandbox remote targets (e.g. SSH) keep the CLI lane: the ACP engine's remote transport is sandbox-only, so default-lane selection falls back for those targets across all three adapters, and tests covering CLI-specific remote behavior pin `engine: "cli"` explicitly. - The bridge authenticates loopback connections before they can own the session or receive buffered output (token required, idle unauthenticated peers dropped), and remote event writes are serialized so the exit event always lands after stdout/stderr have drained. - Daytona plugin: formatting-only residue from the earlier iteration; no functional change. ## Verification - `vitest run` over the touched suites — `packages/adapter-utils/src/acpx-engine/execute.test.ts`, `packages/adapter-utils/src/execution-target-sandbox.test.ts`, `packages/adapter-utils/src/sandbox-callback-bridge.test.ts`, the three adapter `acp.test.ts` files, and `server/src/__tests__/environment-execution-target.test.ts` — 102 tests pass. - End to end: with a Claude agent configured on a Daytona sandbox environment, the primary-model test now selects the default ACP lane (no fallback warning), and the full round trip (wake → sandbox execution → API bridge → comment post) was exercised twice from inside a live sandbox. ## Risks - Behavioral shift: adapters that previously always fell back to CLI on sandbox targets now default to ACP there; `engine=cli` still pins the CLI lane explicitly. - The bridge relays stdio as JSON lines over loopback TCP guarded by a per-session random token; the remote relay runs inside the sandbox under the provider's runner. Providers with slow one-shot execution will see higher session startup latency — the CLI fallback remains for genuinely incapable targets. - No schema or migration changes. ## Model Used - Claude Fable 5 (`claude-fable-5`, Anthropic) — extended thinking enabled, agentic tool use via the Claude Agent SDK harness; implementation iterated with local Vitest verification. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes (no shipped docs describe the old local-only ACP limitation) - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Cody <noreply@paperclip.ing> Co-authored-by: Cody <cody@paperclip.local>
1432 lines
48 KiB
TypeScript
1432 lines
48 KiB
TypeScript
import { createServer } from "node:http";
|
|
import net from "node:net";
|
|
import { execFile, spawn } from "node:child_process";
|
|
import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { promisify } from "node:util";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
|
|
import {
|
|
DEFAULT_REMOTE_SANDBOX_ADAPTER_TIMEOUT_SEC,
|
|
adapterExecutionTargetSessionIdentity,
|
|
adapterExecutionTargetToRemoteSpec,
|
|
adapterExecutionTargetUsesPaperclipBridge,
|
|
ensureAdapterExecutionTargetCommandResolvable,
|
|
formatAdapterExecutionTimeoutErrorMessage,
|
|
formatAdapterExecutionTimeoutStartLogLine,
|
|
resolveAdapterExecutionTargetTimeout,
|
|
resolveAdapterExecutionTargetTimeoutSec,
|
|
runAdapterExecutionTargetProcess,
|
|
runAdapterExecutionTargetShellCommand,
|
|
startAdapterExecutionTargetProcessSessionBridge,
|
|
startAdapterExecutionTargetPaperclipBridge,
|
|
type AdapterSandboxExecutionTarget,
|
|
} from "./execution-target.js";
|
|
import { createSandboxRunLogTailFactory } from "./sandbox-run-log-stream.js";
|
|
import { runChildProcess } from "./server-utils.js";
|
|
import { shellQuote } from "./ssh.js";
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
|
|
describe("sandbox adapter execution targets", () => {
|
|
const cleanupDirs: string[] = [];
|
|
|
|
afterEach(async () => {
|
|
vi.unstubAllEnvs();
|
|
while (cleanupDirs.length > 0) {
|
|
const dir = cleanupDirs.pop();
|
|
if (!dir) continue;
|
|
await rm(dir, { recursive: true, force: true }).catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
function createLocalSandboxRunner() {
|
|
let counter = 0;
|
|
return {
|
|
execute: async (input: {
|
|
command: string;
|
|
args?: string[];
|
|
cwd?: string;
|
|
env?: Record<string, string>;
|
|
stdin?: string;
|
|
timeoutMs?: number;
|
|
onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
|
|
onSpawn?: (meta: { pid: number; startedAt: string }) => Promise<void>;
|
|
}) => {
|
|
counter += 1;
|
|
const command = input.command === "bash" ? "/bin/bash" : input.command;
|
|
return runChildProcess(`sandbox-run-${counter}`, command, input.args ?? [], {
|
|
cwd: input.cwd ?? process.cwd(),
|
|
env: input.env ?? {},
|
|
stdin: input.stdin,
|
|
timeoutSec: Math.max(1, Math.ceil((input.timeoutMs ?? 30_000) / 1000)),
|
|
graceSec: 5,
|
|
onLog: input.onLog ?? (async () => {}),
|
|
onSpawn: input.onSpawn
|
|
? async (meta) => input.onSpawn?.({ pid: meta.pid, startedAt: meta.startedAt })
|
|
: undefined,
|
|
});
|
|
},
|
|
};
|
|
}
|
|
|
|
async function readRuntimeTextFiles(rootDir: string): Promise<string[]> {
|
|
const entries = await readdir(rootDir, { withFileTypes: true }).catch(() => []);
|
|
const contents: string[] = [];
|
|
for (const entry of entries) {
|
|
const entryPath = path.join(rootDir, entry.name);
|
|
if (entry.isDirectory()) {
|
|
contents.push(...await readRuntimeTextFiles(entryPath));
|
|
} else if (entry.isFile()) {
|
|
contents.push(await readFile(entryPath, "utf8").catch(() => ""));
|
|
}
|
|
}
|
|
return contents;
|
|
}
|
|
|
|
function encodeTailTick(stdout: Buffer, stderr: Buffer): string {
|
|
return [
|
|
"__PAPERCLIP_RUN_LOG_STDOUT__",
|
|
stdout.toString("base64"),
|
|
"__PAPERCLIP_RUN_LOG_STDERR__",
|
|
stderr.toString("base64"),
|
|
"__PAPERCLIP_RUN_LOG_END__",
|
|
"",
|
|
].join("\n");
|
|
}
|
|
|
|
async function waitForCondition(predicate: () => boolean, message: string, timeoutMs = 1000): Promise<void> {
|
|
const deadline = Date.now() + timeoutMs;
|
|
while (Date.now() < deadline) {
|
|
if (predicate()) return;
|
|
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
}
|
|
throw new Error(message);
|
|
}
|
|
|
|
async function runProxyWithInput(command: string, input: string): Promise<{ stdout: string; stderr: string; code: number | null }> {
|
|
const child = spawn(command, [], { stdio: ["pipe", "pipe", "pipe"] });
|
|
let stdout = "";
|
|
let stderr = "";
|
|
child.stdout.setEncoding("utf8");
|
|
child.stderr.setEncoding("utf8");
|
|
child.stdout.on("data", (chunk) => {
|
|
stdout += chunk;
|
|
});
|
|
child.stderr.on("data", (chunk) => {
|
|
stderr += chunk;
|
|
});
|
|
child.stdin.end(input);
|
|
const code = await new Promise<number | null>((resolve, reject) => {
|
|
const timeout = setTimeout(() => {
|
|
child.kill("SIGKILL");
|
|
reject(new Error("Timed out waiting for process session proxy."));
|
|
}, 5000);
|
|
child.on("error", (error) => {
|
|
clearTimeout(timeout);
|
|
reject(error);
|
|
});
|
|
child.on("exit", (exitCode) => {
|
|
clearTimeout(timeout);
|
|
resolve(exitCode);
|
|
});
|
|
});
|
|
return { stdout, stderr, code };
|
|
}
|
|
|
|
function combinedStream(
|
|
events: Array<{ stream: "stdout" | "stderr"; chunk: string }>,
|
|
stream: "stdout" | "stderr",
|
|
): string {
|
|
return events.filter((event) => event.stream === stream).map((event) => event.chunk).join("");
|
|
}
|
|
|
|
it("executes through the provider-neutral runner without a remote spec", async () => {
|
|
const runner = {
|
|
execute: vi.fn(async () => ({
|
|
exitCode: 0,
|
|
signal: null,
|
|
timedOut: false,
|
|
stdout: "ok\n",
|
|
stderr: "",
|
|
pid: null,
|
|
startedAt: new Date().toISOString(),
|
|
})),
|
|
};
|
|
const target: AdapterSandboxExecutionTarget = {
|
|
kind: "remote",
|
|
transport: "sandbox",
|
|
providerKey: "acme-sandbox",
|
|
environmentId: "env-1",
|
|
leaseId: "lease-1",
|
|
remoteCwd: "/workspace",
|
|
timeoutMs: 30_000,
|
|
runner,
|
|
};
|
|
|
|
expect(adapterExecutionTargetToRemoteSpec(target)).toBeNull();
|
|
|
|
const result = await runAdapterExecutionTargetProcess("run-1", target, "agent-cli", ["--json"], {
|
|
cwd: "/local/workspace",
|
|
env: { TOKEN: "token" },
|
|
stdin: "prompt",
|
|
timeoutSec: 5,
|
|
graceSec: 1,
|
|
onLog: async () => {},
|
|
});
|
|
|
|
expect(result.stdout).toBe("ok\n");
|
|
expect(runner.execute).toHaveBeenCalledWith(expect.objectContaining({
|
|
command: "agent-cli",
|
|
args: ["--json"],
|
|
cwd: "/workspace",
|
|
env: { TOKEN: "token" },
|
|
stdin: "prompt",
|
|
timeoutMs: 5000,
|
|
}));
|
|
expect(adapterExecutionTargetSessionIdentity(target)).toEqual({
|
|
transport: "sandbox",
|
|
providerKey: "acme-sandbox",
|
|
environmentId: "env-1",
|
|
leaseId: "lease-1",
|
|
remoteCwd: "/workspace",
|
|
});
|
|
});
|
|
|
|
it("bridges bidirectional sandbox process sessions through a local ACPX-spawnable proxy", async () => {
|
|
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-process-session-"));
|
|
cleanupDirs.push(rootDir);
|
|
const childPath = path.join(rootDir, "fake-acp-child.mjs");
|
|
await writeFile(
|
|
childPath,
|
|
[
|
|
"process.stdin.on('data', (chunk) => {",
|
|
" process.stdout.write('out:' + chunk.toString());",
|
|
" process.stderr.write('err:' + chunk.toString());",
|
|
"});",
|
|
].join("\n"),
|
|
"utf8",
|
|
);
|
|
const target: AdapterSandboxExecutionTarget = {
|
|
kind: "remote",
|
|
transport: "sandbox",
|
|
providerKey: "local-test",
|
|
remoteCwd: rootDir,
|
|
timeoutMs: 30_000,
|
|
runner: createLocalSandboxRunner(),
|
|
};
|
|
|
|
const bridge = await startAdapterExecutionTargetProcessSessionBridge({
|
|
runId: "run-process-session",
|
|
target,
|
|
runtimeRootDir: path.posix.join(rootDir, ".paperclip-runtime", "acpx"),
|
|
adapterKey: "acpx",
|
|
command: process.execPath,
|
|
args: [childPath],
|
|
cwd: rootDir,
|
|
env: {},
|
|
timeoutSec: 5,
|
|
onLog: async () => {},
|
|
});
|
|
expect(bridge).not.toBeNull();
|
|
|
|
try {
|
|
const result = await runProxyWithInput(bridge!.agentCommand, "hello\n");
|
|
expect(result.code).toBe(0);
|
|
expect(result.stdout).toBe("out:hello\n");
|
|
expect(result.stderr).toBe("err:hello\n");
|
|
} finally {
|
|
await bridge?.stop();
|
|
}
|
|
});
|
|
|
|
it("buffers sandbox process session output until the local proxy connects", async () => {
|
|
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-process-session-buffer-"));
|
|
cleanupDirs.push(rootDir);
|
|
const childPath = path.join(rootDir, "fast-acp-child.mjs");
|
|
await writeFile(
|
|
childPath,
|
|
[
|
|
"process.stdout.write('early-out\\n');",
|
|
"process.stderr.write('early-err\\n');",
|
|
"setTimeout(() => process.exit(0), 20);",
|
|
].join("\n"),
|
|
"utf8",
|
|
);
|
|
const target: AdapterSandboxExecutionTarget = {
|
|
kind: "remote",
|
|
transport: "sandbox",
|
|
providerKey: "local-test",
|
|
remoteCwd: rootDir,
|
|
timeoutMs: 30_000,
|
|
runner: createLocalSandboxRunner(),
|
|
};
|
|
|
|
const bridge = await startAdapterExecutionTargetProcessSessionBridge({
|
|
runId: "run-process-session-buffer",
|
|
target,
|
|
runtimeRootDir: path.posix.join(rootDir, ".paperclip-runtime", "acpx"),
|
|
adapterKey: "acpx",
|
|
command: process.execPath,
|
|
args: [childPath],
|
|
cwd: rootDir,
|
|
env: {},
|
|
timeoutSec: 5,
|
|
onLog: async () => {},
|
|
});
|
|
expect(bridge).not.toBeNull();
|
|
|
|
try {
|
|
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
const result = await runProxyWithInput(bridge!.agentCommand, "");
|
|
expect(result.code).toBe(0);
|
|
expect(result.stdout).toBe("early-out\n");
|
|
expect(result.stderr).toBe("early-err\n");
|
|
} finally {
|
|
await bridge?.stop();
|
|
}
|
|
});
|
|
|
|
it("delivers full output when the sandbox child exits immediately after writing", async () => {
|
|
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-process-session-fast-exit-"));
|
|
cleanupDirs.push(rootDir);
|
|
const childPath = path.join(rootDir, "instant-exit-acp-child.mjs");
|
|
await writeFile(
|
|
childPath,
|
|
[
|
|
"process.stdout.write('final-out\\n');",
|
|
"process.stderr.write('final-err\\n');",
|
|
].join("\n"),
|
|
"utf8",
|
|
);
|
|
const target: AdapterSandboxExecutionTarget = {
|
|
kind: "remote",
|
|
transport: "sandbox",
|
|
providerKey: "local-test",
|
|
remoteCwd: rootDir,
|
|
timeoutMs: 30_000,
|
|
runner: createLocalSandboxRunner(),
|
|
};
|
|
|
|
const bridge = await startAdapterExecutionTargetProcessSessionBridge({
|
|
runId: "run-process-session-fast-exit",
|
|
target,
|
|
runtimeRootDir: path.posix.join(rootDir, ".paperclip-runtime", "acpx"),
|
|
adapterKey: "acpx",
|
|
command: process.execPath,
|
|
args: [childPath],
|
|
cwd: rootDir,
|
|
env: {},
|
|
timeoutSec: 5,
|
|
onLog: async () => {},
|
|
});
|
|
expect(bridge).not.toBeNull();
|
|
|
|
try {
|
|
const result = await runProxyWithInput(bridge!.agentCommand, "");
|
|
expect(result.code).toBe(0);
|
|
expect(result.stdout).toBe("final-out\n");
|
|
expect(result.stderr).toBe("final-err\n");
|
|
} finally {
|
|
await bridge?.stop();
|
|
}
|
|
});
|
|
|
|
it("ignores unauthenticated connections to the process session bridge", async () => {
|
|
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-process-session-auth-"));
|
|
cleanupDirs.push(rootDir);
|
|
const childPath = path.join(rootDir, "guarded-acp-child.mjs");
|
|
await writeFile(childPath, "process.stdout.write('guarded-out\\n');", "utf8");
|
|
const target: AdapterSandboxExecutionTarget = {
|
|
kind: "remote",
|
|
transport: "sandbox",
|
|
providerKey: "local-test",
|
|
remoteCwd: rootDir,
|
|
timeoutMs: 30_000,
|
|
runner: createLocalSandboxRunner(),
|
|
};
|
|
|
|
const bridge = await startAdapterExecutionTargetProcessSessionBridge({
|
|
runId: "run-process-session-auth",
|
|
target,
|
|
runtimeRootDir: path.posix.join(rootDir, ".paperclip-runtime", "acpx"),
|
|
adapterKey: "acpx",
|
|
command: process.execPath,
|
|
args: [childPath],
|
|
cwd: rootDir,
|
|
env: {},
|
|
timeoutSec: 5,
|
|
onLog: async () => {},
|
|
});
|
|
expect(bridge).not.toBeNull();
|
|
|
|
let squatter: net.Socket | null = null;
|
|
try {
|
|
const proxySource = await readFile(bridge!.agentCommand, "utf8");
|
|
const port = Number(/port: (\d+)/.exec(proxySource)?.[1] ?? Number.NaN);
|
|
expect(Number.isFinite(port)).toBe(true);
|
|
|
|
// An idle local connection must not claim the session or see buffered output.
|
|
const squatterSocket = net.createConnection({ host: "127.0.0.1", port });
|
|
squatter = squatterSocket;
|
|
let squatterReceived = "";
|
|
squatterSocket.setEncoding("utf8");
|
|
squatterSocket.on("data", (chunk: string) => {
|
|
squatterReceived += chunk;
|
|
});
|
|
squatterSocket.on("error", () => undefined);
|
|
await new Promise<void>((resolve, reject) => {
|
|
squatterSocket.once("connect", () => resolve());
|
|
squatterSocket.once("error", reject);
|
|
});
|
|
|
|
// A peer presenting the wrong token is disconnected outright.
|
|
const badPeer = net.createConnection({ host: "127.0.0.1", port });
|
|
badPeer.on("error", () => undefined);
|
|
const badPeerClosed = new Promise<void>((resolve) => badPeer.once("close", () => resolve()));
|
|
badPeer.once("connect", () => badPeer.write(`${JSON.stringify({ token: "wrong-token", type: "stdinEnd" })}\n`));
|
|
await badPeerClosed;
|
|
|
|
// The authenticated proxy still attaches and receives the buffered output.
|
|
const result = await runProxyWithInput(bridge!.agentCommand, "");
|
|
expect(result.code).toBe(0);
|
|
expect(result.stdout).toBe("guarded-out\n");
|
|
expect(squatterReceived).toBe("");
|
|
} finally {
|
|
squatter?.destroy();
|
|
await bridge?.stop();
|
|
}
|
|
});
|
|
|
|
it("streams sandbox process session output before the remote child exits", async () => {
|
|
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-process-session-stream-"));
|
|
cleanupDirs.push(rootDir);
|
|
const childPath = path.join(rootDir, "streaming-acp-child.mjs");
|
|
await writeFile(
|
|
childPath,
|
|
[
|
|
"process.stdin.setEncoding('utf8');",
|
|
"process.stdin.on('data', (chunk) => {",
|
|
" if (chunk.includes('ping')) {",
|
|
" process.stdout.write('delta:ping\\n');",
|
|
" process.stderr.write('trace:ping\\n');",
|
|
" }",
|
|
" if (chunk.includes('finish')) process.exit(0);",
|
|
"});",
|
|
"process.stdin.resume();",
|
|
].join("\n"),
|
|
"utf8",
|
|
);
|
|
const target: AdapterSandboxExecutionTarget = {
|
|
kind: "remote",
|
|
transport: "sandbox",
|
|
providerKey: "local-test",
|
|
remoteCwd: rootDir,
|
|
timeoutMs: 30_000,
|
|
runner: createLocalSandboxRunner(),
|
|
};
|
|
|
|
const bridge = await startAdapterExecutionTargetProcessSessionBridge({
|
|
runId: "run-process-session-stream",
|
|
target,
|
|
runtimeRootDir: path.posix.join(rootDir, ".paperclip-runtime", "acpx"),
|
|
adapterKey: "acpx",
|
|
command: process.execPath,
|
|
args: [childPath],
|
|
cwd: rootDir,
|
|
env: {},
|
|
timeoutSec: 5,
|
|
onLog: async () => {},
|
|
});
|
|
expect(bridge).not.toBeNull();
|
|
|
|
const child = spawn(bridge!.agentCommand, [], { stdio: ["pipe", "pipe", "pipe"] });
|
|
let stdout = "";
|
|
let stderr = "";
|
|
let exited = false;
|
|
child.stdout.setEncoding("utf8");
|
|
child.stderr.setEncoding("utf8");
|
|
child.stdout.on("data", (chunk) => {
|
|
stdout += chunk;
|
|
});
|
|
child.stderr.on("data", (chunk) => {
|
|
stderr += chunk;
|
|
});
|
|
const exitPromise = new Promise<number | null>((resolve, reject) => {
|
|
const timeout = setTimeout(() => {
|
|
child.kill("SIGKILL");
|
|
reject(new Error("Timed out waiting for streaming process session proxy."));
|
|
}, 5000);
|
|
child.on("error", (error) => {
|
|
clearTimeout(timeout);
|
|
reject(error);
|
|
});
|
|
child.on("exit", (exitCode) => {
|
|
exited = true;
|
|
clearTimeout(timeout);
|
|
resolve(exitCode);
|
|
});
|
|
});
|
|
|
|
try {
|
|
child.stdin.write("ping\n");
|
|
await waitForCondition(
|
|
() => stdout.includes("delta:ping\n") && stderr.includes("trace:ping\n"),
|
|
"Timed out waiting for live process session output.",
|
|
3000,
|
|
);
|
|
expect(exited).toBe(false);
|
|
|
|
child.stdin.end("finish\n");
|
|
await expect(exitPromise).resolves.toBe(0);
|
|
} finally {
|
|
if (!exited) {
|
|
child.kill("SIGKILL");
|
|
await exitPromise.catch(() => undefined);
|
|
}
|
|
await bridge?.stop();
|
|
}
|
|
});
|
|
|
|
it("applies the remote sandbox fallback when adapter timeoutSec is unset", () => {
|
|
const sandboxTarget: AdapterSandboxExecutionTarget = {
|
|
kind: "remote",
|
|
transport: "sandbox",
|
|
remoteCwd: "/workspace",
|
|
runner: createLocalSandboxRunner(),
|
|
};
|
|
|
|
// The sandbox default is a 4h wall-clock backstop matching the recovery
|
|
// watchdog critical threshold (ACTIVE_RUN_OUTPUT_CRITICAL_THRESHOLD_MS);
|
|
// the output-inactivity monitor remains the primary hang detector.
|
|
expect(DEFAULT_REMOTE_SANDBOX_ADAPTER_TIMEOUT_SEC).toBe(4 * 60 * 60);
|
|
expect(resolveAdapterExecutionTargetTimeoutSec(sandboxTarget, 0)).toBe(
|
|
DEFAULT_REMOTE_SANDBOX_ADAPTER_TIMEOUT_SEC,
|
|
);
|
|
expect(resolveAdapterExecutionTargetTimeoutSec(sandboxTarget, 90)).toBe(90);
|
|
expect(resolveAdapterExecutionTargetTimeoutSec({
|
|
kind: "remote",
|
|
transport: "ssh",
|
|
remoteCwd: "/workspace",
|
|
spec: {
|
|
host: "127.0.0.1",
|
|
port: 22,
|
|
username: "fixture",
|
|
remoteWorkspacePath: "/workspace",
|
|
remoteCwd: "/workspace",
|
|
privateKey: "KEY",
|
|
knownHosts: "host key",
|
|
strictHostKeyChecking: true,
|
|
},
|
|
}, 0)).toBe(0);
|
|
expect(resolveAdapterExecutionTargetTimeoutSec({ kind: "local" }, 0)).toBe(0);
|
|
});
|
|
|
|
it("reports which knob produced the resolved timeout", () => {
|
|
const sandboxTarget: AdapterSandboxExecutionTarget = {
|
|
kind: "remote",
|
|
transport: "sandbox",
|
|
remoteCwd: "/workspace",
|
|
runner: createLocalSandboxRunner(),
|
|
};
|
|
|
|
expect(resolveAdapterExecutionTargetTimeout(sandboxTarget, 0)).toEqual({
|
|
timeoutSec: DEFAULT_REMOTE_SANDBOX_ADAPTER_TIMEOUT_SEC,
|
|
source: "sandbox_default",
|
|
});
|
|
expect(resolveAdapterExecutionTargetTimeout(sandboxTarget, 90)).toEqual({
|
|
timeoutSec: 90,
|
|
source: "configured",
|
|
});
|
|
expect(resolveAdapterExecutionTargetTimeout({ kind: "local" }, 0)).toEqual({
|
|
timeoutSec: 0,
|
|
source: "unlimited",
|
|
});
|
|
// Fractional (sub-second) configured timeouts are preserved rather than
|
|
// floored to 0, which would silently mean "no timeout".
|
|
expect(resolveAdapterExecutionTargetTimeout({ kind: "local" }, 0.01)).toEqual({
|
|
timeoutSec: 0.01,
|
|
source: "configured",
|
|
});
|
|
expect(resolveAdapterExecutionTargetTimeout(sandboxTarget, 0.5)).toEqual({
|
|
timeoutSec: 0.5,
|
|
source: "configured",
|
|
});
|
|
});
|
|
|
|
it("treats a negative timeoutSec as the explicit no-timeout opt-out, even on sandbox targets", () => {
|
|
const sandboxTarget: AdapterSandboxExecutionTarget = {
|
|
kind: "remote",
|
|
transport: "sandbox",
|
|
remoteCwd: "/workspace",
|
|
runner: createLocalSandboxRunner(),
|
|
};
|
|
|
|
expect(resolveAdapterExecutionTargetTimeout(sandboxTarget, -1)).toEqual({
|
|
timeoutSec: 0,
|
|
source: "configured",
|
|
});
|
|
expect(resolveAdapterExecutionTargetTimeout({ kind: "local" }, -1)).toEqual({
|
|
timeoutSec: 0,
|
|
source: "configured",
|
|
});
|
|
expect(resolveAdapterExecutionTargetTimeoutSec(sandboxTarget, -1)).toBe(0);
|
|
|
|
// Explicit zero intentionally does NOT opt out: the adapter config UI
|
|
// persists the schema default of 0 for untouched fields, so a stored
|
|
// timeoutSec=0 cannot be read as operator intent. It keeps the sandbox
|
|
// backstop; the documented opt-out is a negative value.
|
|
expect(resolveAdapterExecutionTargetTimeout(sandboxTarget, 0)).toEqual({
|
|
timeoutSec: DEFAULT_REMOTE_SANDBOX_ADAPTER_TIMEOUT_SEC,
|
|
source: "sandbox_default",
|
|
});
|
|
// Unset behaves like zero.
|
|
expect(resolveAdapterExecutionTargetTimeout(sandboxTarget, undefined)).toEqual({
|
|
timeoutSec: DEFAULT_REMOTE_SANDBOX_ADAPTER_TIMEOUT_SEC,
|
|
source: "sandbox_default",
|
|
});
|
|
expect(resolveAdapterExecutionTargetTimeout({ kind: "local" }, undefined)).toEqual({
|
|
timeoutSec: 0,
|
|
source: "unlimited",
|
|
});
|
|
});
|
|
|
|
it("formats self-describing timeout errors naming the timer and knob", () => {
|
|
expect(
|
|
formatAdapterExecutionTimeoutErrorMessage({
|
|
timeoutSec: DEFAULT_REMOTE_SANDBOX_ADAPTER_TIMEOUT_SEC,
|
|
source: "sandbox_default",
|
|
}),
|
|
).toBe(
|
|
"Run exceeded the adapter execution timeout (timeoutSec=14400, sandbox default). " +
|
|
"Set adapterConfig.timeoutSec to raise it.",
|
|
);
|
|
expect(
|
|
formatAdapterExecutionTimeoutErrorMessage({ timeoutSec: 1800, source: "configured" }),
|
|
).toBe(
|
|
"Run exceeded the adapter execution timeout (timeoutSec=1800, configured via adapterConfig.timeoutSec). " +
|
|
"Set adapterConfig.timeoutSec to raise it.",
|
|
);
|
|
});
|
|
|
|
it("formats the start-of-run timeout log line with the resolved value and source", () => {
|
|
expect(
|
|
formatAdapterExecutionTimeoutStartLogLine({
|
|
timeoutSec: DEFAULT_REMOTE_SANDBOX_ADAPTER_TIMEOUT_SEC,
|
|
source: "sandbox_default",
|
|
}),
|
|
).toBe(
|
|
"Adapter execution timeout: timeoutSec=14400 (sandbox default; set adapterConfig.timeoutSec to override).",
|
|
);
|
|
expect(
|
|
formatAdapterExecutionTimeoutStartLogLine({ timeoutSec: 900, source: "configured" }),
|
|
).toBe(
|
|
"Adapter execution timeout: timeoutSec=900 (configured via adapterConfig.timeoutSec; set adapterConfig.timeoutSec to override).",
|
|
);
|
|
expect(
|
|
formatAdapterExecutionTimeoutStartLogLine({ timeoutSec: 0, source: "unlimited" }),
|
|
).toBe(
|
|
"Adapter execution timeout: none (no adapter wall-clock timeout for this target; set adapterConfig.timeoutSec to add one).",
|
|
);
|
|
// Negative opt-out resolves to { timeoutSec: 0, source: "configured" }.
|
|
expect(
|
|
formatAdapterExecutionTimeoutStartLogLine({ timeoutSec: 0, source: "configured" }),
|
|
).toBe(
|
|
"Adapter execution timeout: none (explicitly disabled via adapterConfig.timeoutSec; set it to a positive value to add one).",
|
|
);
|
|
});
|
|
|
|
it("uses the caller timeout override when installing a missing sandbox command", async () => {
|
|
const runner = {
|
|
execute: vi.fn()
|
|
.mockResolvedValueOnce({
|
|
exitCode: 1,
|
|
signal: null,
|
|
timedOut: false,
|
|
stdout: "",
|
|
stderr: "",
|
|
pid: null,
|
|
startedAt: new Date().toISOString(),
|
|
})
|
|
.mockResolvedValueOnce({
|
|
exitCode: 0,
|
|
signal: null,
|
|
timedOut: false,
|
|
stdout: "",
|
|
stderr: "",
|
|
pid: null,
|
|
startedAt: new Date().toISOString(),
|
|
})
|
|
.mockResolvedValueOnce({
|
|
exitCode: 0,
|
|
signal: null,
|
|
timedOut: false,
|
|
stdout: "/usr/bin/opencode\n",
|
|
stderr: "",
|
|
pid: null,
|
|
startedAt: new Date().toISOString(),
|
|
}),
|
|
};
|
|
const target: AdapterSandboxExecutionTarget = {
|
|
kind: "remote",
|
|
transport: "sandbox",
|
|
remoteCwd: "/workspace",
|
|
timeoutMs: 300_000,
|
|
runner,
|
|
};
|
|
|
|
await ensureAdapterExecutionTargetCommandResolvable(
|
|
"opencode",
|
|
target,
|
|
"/local/workspace",
|
|
{},
|
|
{ installCommand: "npm install -g opencode", timeoutSec: 1800 },
|
|
);
|
|
|
|
expect(runner.execute).toHaveBeenNthCalledWith(2, expect.objectContaining({
|
|
command: "sh",
|
|
args: ["-c", "npm install -g opencode"],
|
|
timeoutMs: 1_800_000,
|
|
}));
|
|
});
|
|
|
|
it("runs shell commands through the same runner", async () => {
|
|
const runner = {
|
|
execute: vi.fn(async () => ({
|
|
exitCode: 0,
|
|
signal: null,
|
|
timedOut: false,
|
|
stdout: "/home/sandbox",
|
|
stderr: "",
|
|
pid: null,
|
|
startedAt: new Date().toISOString(),
|
|
})),
|
|
};
|
|
const target: AdapterSandboxExecutionTarget = {
|
|
kind: "remote",
|
|
transport: "sandbox",
|
|
remoteCwd: "/workspace",
|
|
runner,
|
|
};
|
|
|
|
await runAdapterExecutionTargetShellCommand("run-2", target, 'printf %s "$HOME"', {
|
|
cwd: "/local/workspace",
|
|
env: {},
|
|
timeoutSec: 7,
|
|
});
|
|
|
|
expect(runner.execute).toHaveBeenCalledWith(expect.objectContaining({
|
|
command: "sh",
|
|
args: ["-c", 'printf %s "$HOME"'],
|
|
cwd: "/workspace",
|
|
timeoutMs: 7000,
|
|
}));
|
|
});
|
|
|
|
it("strips inherited host identity env before sandbox execution", async () => {
|
|
vi.stubEnv("PATH", "/host/bin:/usr/bin");
|
|
vi.stubEnv("HOME", "/Users/local");
|
|
vi.stubEnv("TMPDIR", "/var/folders/local/T");
|
|
|
|
const runner = {
|
|
execute: vi.fn(async () => ({
|
|
exitCode: 0,
|
|
signal: null,
|
|
timedOut: false,
|
|
stdout: "ok\n",
|
|
stderr: "",
|
|
pid: null,
|
|
startedAt: new Date().toISOString(),
|
|
})),
|
|
};
|
|
const target: AdapterSandboxExecutionTarget = {
|
|
kind: "remote",
|
|
transport: "sandbox",
|
|
remoteCwd: "/workspace",
|
|
runner,
|
|
};
|
|
|
|
await runAdapterExecutionTargetProcess("run-1b", target, "agent-cli", ["--json"], {
|
|
cwd: "/local/workspace",
|
|
env: {
|
|
PATH: "/host/bin:/usr/bin",
|
|
HOME: "/Users/local",
|
|
TMPDIR: "/var/folders/local/T",
|
|
SAFE_VALUE: "visible",
|
|
},
|
|
timeoutSec: 5,
|
|
graceSec: 1,
|
|
onLog: async () => {},
|
|
});
|
|
|
|
expect(runner.execute).toHaveBeenCalledWith(expect.objectContaining({
|
|
env: {
|
|
SAFE_VALUE: "visible",
|
|
},
|
|
}));
|
|
});
|
|
|
|
it("preserves explicit remote identity env overrides for sandbox execution", async () => {
|
|
vi.stubEnv("PATH", "/host/bin:/usr/bin");
|
|
vi.stubEnv("HOME", "/Users/local");
|
|
|
|
const runner = {
|
|
execute: vi.fn(async () => ({
|
|
exitCode: 0,
|
|
signal: null,
|
|
timedOut: false,
|
|
stdout: "ok\n",
|
|
stderr: "",
|
|
pid: null,
|
|
startedAt: new Date().toISOString(),
|
|
})),
|
|
};
|
|
const target: AdapterSandboxExecutionTarget = {
|
|
kind: "remote",
|
|
transport: "sandbox",
|
|
remoteCwd: "/workspace",
|
|
runner,
|
|
};
|
|
|
|
await runAdapterExecutionTargetProcess("run-1c", target, "agent-cli", ["--json"], {
|
|
cwd: "/local/workspace",
|
|
env: {
|
|
PATH: "/custom/remote/bin:/usr/bin",
|
|
HOME: "/home/sandbox",
|
|
SAFE_VALUE: "visible",
|
|
},
|
|
timeoutSec: 5,
|
|
graceSec: 1,
|
|
onLog: async () => {},
|
|
});
|
|
|
|
expect(runner.execute).toHaveBeenCalledWith(expect.objectContaining({
|
|
env: {
|
|
PATH: "/custom/remote/bin:/usr/bin",
|
|
HOME: "/home/sandbox",
|
|
SAFE_VALUE: "visible",
|
|
},
|
|
}));
|
|
});
|
|
|
|
it("treats SSH targets as bridge-only", () => {
|
|
const target = {
|
|
kind: "remote" as const,
|
|
transport: "ssh" as const,
|
|
remoteCwd: "/workspace",
|
|
spec: {
|
|
host: "ssh.example.test",
|
|
port: 22,
|
|
username: "paperclip",
|
|
remoteWorkspacePath: "/workspace",
|
|
remoteCwd: "/workspace",
|
|
privateKey: null,
|
|
knownHosts: null,
|
|
strictHostKeyChecking: true,
|
|
},
|
|
};
|
|
|
|
expect(adapterExecutionTargetUsesPaperclipBridge(target)).toBe(true);
|
|
expect(adapterExecutionTargetSessionIdentity(target)).toEqual({
|
|
transport: "ssh",
|
|
host: "ssh.example.test",
|
|
port: 22,
|
|
username: "paperclip",
|
|
remoteCwd: "/workspace",
|
|
});
|
|
});
|
|
|
|
it("uses the provider-declared shell for sandbox helper commands", async () => {
|
|
const runner = {
|
|
execute: vi.fn(async () => ({
|
|
exitCode: 0,
|
|
signal: null,
|
|
timedOut: false,
|
|
stdout: "/home/sandbox",
|
|
stderr: "",
|
|
pid: null,
|
|
startedAt: new Date().toISOString(),
|
|
})),
|
|
};
|
|
const target: AdapterSandboxExecutionTarget = {
|
|
kind: "remote",
|
|
transport: "sandbox",
|
|
providerKey: "custom-provider",
|
|
shellCommand: "bash",
|
|
remoteCwd: "/workspace",
|
|
runner,
|
|
};
|
|
|
|
await runAdapterExecutionTargetShellCommand("run-2b", target, 'printf %s "$HOME"', {
|
|
cwd: "/local/workspace",
|
|
env: {},
|
|
timeoutSec: 7,
|
|
});
|
|
|
|
expect(runner.execute).toHaveBeenCalledWith(expect.objectContaining({
|
|
command: "bash",
|
|
args: ["-c", 'printf %s "$HOME"'],
|
|
cwd: "/workspace",
|
|
timeoutMs: 7000,
|
|
}));
|
|
});
|
|
|
|
it("starts a localhost Paperclip bridge for sandbox targets in bridge mode", async () => {
|
|
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-execution-target-bridge-"));
|
|
cleanupDirs.push(rootDir);
|
|
const remoteCwd = path.join(rootDir, "workspace");
|
|
const runtimeRootDir = path.join(remoteCwd, ".paperclip-runtime", "codex");
|
|
await mkdir(runtimeRootDir, { recursive: true });
|
|
|
|
const requests: Array<{ method: string; url: string; auth: string | null; runId: string | null }> = [];
|
|
const apiServer = createServer((req, res) => {
|
|
requests.push({
|
|
method: req.method ?? "GET",
|
|
url: req.url ?? "/",
|
|
auth: req.headers.authorization ?? null,
|
|
runId: typeof req.headers["x-paperclip-run-id"] === "string" ? req.headers["x-paperclip-run-id"] : null,
|
|
});
|
|
res.writeHead(200, { "content-type": "application/json" });
|
|
res.end(JSON.stringify({ ok: true }));
|
|
});
|
|
await new Promise<void>((resolve, reject) => {
|
|
apiServer.once("error", reject);
|
|
apiServer.listen(0, "127.0.0.1", () => resolve());
|
|
});
|
|
const address = apiServer.address();
|
|
if (!address || typeof address === "string") {
|
|
throw new Error("Expected the bridge test API server to listen on a TCP port.");
|
|
}
|
|
|
|
const target: AdapterSandboxExecutionTarget = {
|
|
kind: "remote",
|
|
transport: "sandbox",
|
|
providerKey: "e2b",
|
|
environmentId: "env-1",
|
|
leaseId: "lease-1",
|
|
remoteCwd,
|
|
runner: createLocalSandboxRunner(),
|
|
timeoutMs: 30_000,
|
|
};
|
|
|
|
const bridge = await startAdapterExecutionTargetPaperclipBridge({
|
|
runId: "run-bridge",
|
|
target,
|
|
runtimeRootDir,
|
|
adapterKey: "codex",
|
|
hostApiToken: "real-run-jwt",
|
|
hostApiUrl: `http://127.0.0.1:${address.port}`,
|
|
});
|
|
try {
|
|
expect(bridge).not.toBeNull();
|
|
expect(bridge?.env.PAPERCLIP_API_URL).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/);
|
|
expect(bridge?.env.PAPERCLIP_API_KEY).not.toBe("real-run-jwt");
|
|
expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("queue_v1");
|
|
|
|
const response = await fetch(`${bridge!.env.PAPERCLIP_API_URL}/api/agents/me`, {
|
|
headers: {
|
|
authorization: `Bearer ${bridge!.env.PAPERCLIP_API_KEY}`,
|
|
accept: "application/json",
|
|
},
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(await response.json()).toEqual({ ok: true });
|
|
expect(requests).toEqual([{
|
|
method: "GET",
|
|
url: "/api/agents/me",
|
|
auth: "Bearer real-run-jwt",
|
|
runId: "run-bridge",
|
|
}]);
|
|
} finally {
|
|
await bridge?.stop();
|
|
await new Promise<void>((resolve) => apiServer.close(() => resolve()));
|
|
}
|
|
});
|
|
|
|
it("creates a sandbox run log tail factory when bridge streaming is enabled", async () => {
|
|
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-execution-target-bridge-stream-"));
|
|
cleanupDirs.push(rootDir);
|
|
const remoteCwd = path.join(rootDir, "workspace");
|
|
const runtimeRootDir = path.join(remoteCwd, ".paperclip-runtime", "codex");
|
|
await mkdir(runtimeRootDir, { recursive: true });
|
|
|
|
const logs: Array<{ stream: "stdout" | "stderr"; chunk: string }> = [];
|
|
const target: AdapterSandboxExecutionTarget = {
|
|
kind: "remote",
|
|
transport: "sandbox",
|
|
providerKey: "e2b",
|
|
environmentId: "env-1",
|
|
leaseId: "lease-1",
|
|
remoteCwd,
|
|
runner: createLocalSandboxRunner(),
|
|
streamRunLogs: true,
|
|
timeoutMs: 30_000,
|
|
};
|
|
|
|
const bridge = await startAdapterExecutionTargetPaperclipBridge({
|
|
runId: "run-bridge-stream",
|
|
target,
|
|
runtimeRootDir,
|
|
adapterKey: "codex",
|
|
hostApiToken: "real-run-jwt",
|
|
hostApiUrl: "http://127.0.0.1:9",
|
|
onLog: async (stream, chunk) => {
|
|
logs.push({ stream, chunk });
|
|
},
|
|
});
|
|
try {
|
|
expect(bridge?.runLogTail).toBeTruthy();
|
|
expect(combinedStream(logs, "stdout")).toContain("Sandbox run log streaming enabled");
|
|
|
|
const wrapped = bridge!.runLogTail!.create().wrapCommand("agent-cli", ["--message", "hello world"]);
|
|
expect(wrapped.command).toBe("sh");
|
|
expect(wrapped.args.join("\n")).toContain("tee -a");
|
|
expect(wrapped.args.join("\n")).toContain("agent-cli");
|
|
} finally {
|
|
await bridge?.stop();
|
|
}
|
|
});
|
|
|
|
it("defaults sandbox run log streaming on and honors the explicit opt-out", async () => {
|
|
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-execution-target-bridge-stream-default-"));
|
|
cleanupDirs.push(rootDir);
|
|
const remoteCwd = path.join(rootDir, "workspace");
|
|
const runtimeRootDir = path.join(remoteCwd, ".paperclip-runtime", "codex");
|
|
await mkdir(runtimeRootDir, { recursive: true });
|
|
|
|
const baseTarget: AdapterSandboxExecutionTarget = {
|
|
kind: "remote",
|
|
transport: "sandbox",
|
|
providerKey: "e2b",
|
|
environmentId: "env-1",
|
|
leaseId: "lease-1",
|
|
remoteCwd,
|
|
runner: createLocalSandboxRunner(),
|
|
timeoutMs: 30_000,
|
|
};
|
|
|
|
const defaultBridge = await startAdapterExecutionTargetPaperclipBridge({
|
|
runId: "run-bridge-stream-default",
|
|
target: baseTarget,
|
|
runtimeRootDir,
|
|
adapterKey: "codex",
|
|
hostApiToken: "real-run-jwt",
|
|
hostApiUrl: "http://127.0.0.1:9",
|
|
});
|
|
try {
|
|
expect(defaultBridge?.runLogTail).toBeTruthy();
|
|
} finally {
|
|
await defaultBridge?.stop();
|
|
}
|
|
|
|
const optOutBridge = await startAdapterExecutionTargetPaperclipBridge({
|
|
runId: "run-bridge-stream-opt-out",
|
|
target: { ...baseTarget, streamRunLogs: false },
|
|
runtimeRootDir,
|
|
adapterKey: "codex",
|
|
hostApiToken: "real-run-jwt",
|
|
hostApiUrl: "http://127.0.0.1:9",
|
|
});
|
|
try {
|
|
expect(optOutBridge?.runLogTail ?? null).toBeNull();
|
|
} finally {
|
|
await optOutBridge?.stop();
|
|
}
|
|
});
|
|
|
|
it("tails sandbox run log chunks with byte offsets and dedupes the final batch", async () => {
|
|
const stdoutText = "stdout-abc\n";
|
|
const stderrText = "stderr-xyz\n";
|
|
const stdoutBytes = Buffer.from(stdoutText, "utf8");
|
|
const stderrBytes = Buffer.from(stderrText, "utf8");
|
|
const stdoutOffsets: number[] = [];
|
|
const stderrOffsets: number[] = [];
|
|
const events: Array<{ stream: "stdout" | "stderr"; chunk: string }> = [];
|
|
|
|
const runner = {
|
|
execute: vi.fn(async (input: {
|
|
command: string;
|
|
args?: string[];
|
|
cwd?: string;
|
|
env?: Record<string, string>;
|
|
timeoutMs?: number;
|
|
}) => {
|
|
const script = input.args?.[1] ?? "";
|
|
const offsets = [...script.matchAll(/tail -c \+(\d+) /g)].map((match) => Number(match[1]));
|
|
const stdoutStart = Math.max(0, (offsets[0] ?? 1) - 1);
|
|
const stderrStart = Math.max(0, (offsets[1] ?? 1) - 1);
|
|
stdoutOffsets.push(stdoutStart + 1);
|
|
stderrOffsets.push(stderrStart + 1);
|
|
return {
|
|
exitCode: 0,
|
|
signal: null,
|
|
timedOut: false,
|
|
stdout: encodeTailTick(
|
|
stdoutBytes.subarray(stdoutStart, stdoutStart + 4),
|
|
stderrBytes.subarray(stderrStart, stderrStart + 4),
|
|
),
|
|
stderr: "",
|
|
pid: null,
|
|
startedAt: new Date().toISOString(),
|
|
};
|
|
}),
|
|
};
|
|
|
|
const tail = createSandboxRunLogTailFactory({
|
|
runner,
|
|
remoteCwd: "/workspace",
|
|
logsDir: "/workspace/.paperclip-runtime/codex/paperclip-bridge/queue/logs",
|
|
pollIntervalMs: 1,
|
|
maxChunkBytesPerTick: 4,
|
|
tickTimeoutMs: 50,
|
|
}).create();
|
|
|
|
tail.start(async (stream, chunk) => {
|
|
events.push({ stream, chunk });
|
|
});
|
|
|
|
await waitForCondition(
|
|
() => combinedStream(events, "stdout") === stdoutText && combinedStream(events, "stderr") === stderrText,
|
|
"run log tail did not stream expected stdout/stderr chunks",
|
|
);
|
|
|
|
await tail.finish({ stdout: stdoutText, stderr: stderrText });
|
|
|
|
expect(combinedStream(events, "stdout")).toBe(stdoutText);
|
|
expect(combinedStream(events, "stderr")).toBe(stderrText);
|
|
expect(stdoutOffsets.slice(0, 3)).toEqual([1, 5, 9]);
|
|
expect(stderrOffsets.slice(0, 3)).toEqual([1, 5, 9]);
|
|
expect(runner.execute).toHaveBeenCalledWith(expect.objectContaining({
|
|
command: "sh",
|
|
cwd: "/workspace",
|
|
env: { PAPERCLIP_SANDBOX_EXEC_CHANNEL: "bridge" },
|
|
timeoutMs: 50,
|
|
}));
|
|
});
|
|
|
|
it("emits only the unstreamed final suffix when the tail loop stops early", async () => {
|
|
const finalStdout = "prefix suffix\n";
|
|
const finalBytes = Buffer.from(finalStdout, "utf8");
|
|
const events: Array<{ stream: "stdout" | "stderr"; chunk: string }> = [];
|
|
|
|
const runner = {
|
|
execute: vi.fn(async (input: { args?: string[] }) => {
|
|
const script = input.args?.[1] ?? "";
|
|
const offsets = [...script.matchAll(/tail -c \+(\d+) /g)].map((match) => Number(match[1]));
|
|
const stdoutStart = Math.max(0, (offsets[0] ?? 1) - 1);
|
|
return {
|
|
exitCode: 0,
|
|
signal: null,
|
|
timedOut: false,
|
|
stdout: encodeTailTick(finalBytes.subarray(stdoutStart, stdoutStart + 7), Buffer.alloc(0)),
|
|
stderr: "",
|
|
pid: null,
|
|
startedAt: new Date().toISOString(),
|
|
};
|
|
}),
|
|
};
|
|
|
|
const tail = createSandboxRunLogTailFactory({
|
|
runner,
|
|
remoteCwd: "/workspace",
|
|
logsDir: "/workspace/.paperclip-runtime/codex/paperclip-bridge/queue/logs",
|
|
pollIntervalMs: 1,
|
|
maxChunkBytesPerTick: 7,
|
|
tickTimeoutMs: 50,
|
|
}).create();
|
|
|
|
tail.start(async (stream, chunk) => {
|
|
events.push({ stream, chunk });
|
|
});
|
|
await waitForCondition(() => combinedStream(events, "stdout").length >= 7, "run log tail did not emit prefix");
|
|
await tail.finish({ stdout: finalStdout, stderr: "" });
|
|
|
|
expect(combinedStream(events, "stdout")).toBe(finalStdout);
|
|
expect(events.filter((event) => event.stream === "stdout").map((event) => event.chunk).join("|"))
|
|
.toBe("prefix |suffix\n");
|
|
});
|
|
|
|
it("delivers the final batch and a warning when run log polling degrades", async () => {
|
|
const events: Array<{ stream: "stdout" | "stderr"; chunk: string }> = [];
|
|
const runner = {
|
|
execute: vi.fn(async () => ({
|
|
exitCode: 1,
|
|
signal: null,
|
|
timedOut: false,
|
|
stdout: "",
|
|
stderr: "tail failed",
|
|
pid: null,
|
|
startedAt: new Date().toISOString(),
|
|
})),
|
|
};
|
|
|
|
const tail = createSandboxRunLogTailFactory({
|
|
runner,
|
|
remoteCwd: "/workspace",
|
|
logsDir: "/workspace/.paperclip-runtime/codex/paperclip-bridge/queue/logs",
|
|
pollIntervalMs: 1,
|
|
tickTimeoutMs: 50,
|
|
maxConsecutiveFailures: 1,
|
|
}).create();
|
|
|
|
tail.start(async (stream, chunk) => {
|
|
events.push({ stream, chunk });
|
|
});
|
|
await waitForCondition(() => runner.execute.mock.calls.length >= 1, "run log tail did not poll before finish");
|
|
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
await tail.finish({ stdout: "final out\n", stderr: "final err\n" });
|
|
|
|
expect(combinedStream(events, "stdout")).toBe("final out\n");
|
|
expect(combinedStream(events, "stderr")).toBe(
|
|
"final err\n[paperclip] Run log streaming degraded during the run; remaining output was delivered at completion.\n",
|
|
);
|
|
});
|
|
|
|
it("exposes the Paperclip bridge to the sandbox shell surface", async () => {
|
|
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-execution-target-bridge-shell-"));
|
|
cleanupDirs.push(rootDir);
|
|
const remoteCwd = path.join(rootDir, "workspace");
|
|
const runtimeRootDir = path.join(remoteCwd, ".paperclip-runtime", "claude");
|
|
await mkdir(runtimeRootDir, { recursive: true });
|
|
|
|
const requests: Array<{ method: string; url: string; auth: string | null; runId: string | null }> = [];
|
|
const apiServer = createServer((req, res) => {
|
|
requests.push({
|
|
method: req.method ?? "GET",
|
|
url: req.url ?? "/",
|
|
auth: req.headers.authorization ?? null,
|
|
runId: typeof req.headers["x-paperclip-run-id"] === "string" ? req.headers["x-paperclip-run-id"] : null,
|
|
});
|
|
res.writeHead(200, { "content-type": "application/json" });
|
|
res.end(JSON.stringify({ ok: true }));
|
|
});
|
|
await new Promise<void>((resolve, reject) => {
|
|
apiServer.once("error", reject);
|
|
apiServer.listen(0, "127.0.0.1", () => resolve());
|
|
});
|
|
const address = apiServer.address();
|
|
if (!address || typeof address === "string") {
|
|
throw new Error("Expected the bridge shell test API server to listen on a TCP port.");
|
|
}
|
|
|
|
const delegateRunner = createLocalSandboxRunner();
|
|
const runner = {
|
|
execute: vi.fn(async (input: Parameters<typeof delegateRunner.execute>[0]) => delegateRunner.execute(input)),
|
|
};
|
|
const target: AdapterSandboxExecutionTarget = {
|
|
kind: "remote",
|
|
transport: "sandbox",
|
|
providerKey: "daytona",
|
|
environmentId: "env-1",
|
|
leaseId: "lease-1",
|
|
remoteCwd,
|
|
runner,
|
|
timeoutMs: 30_000,
|
|
};
|
|
|
|
const bridge = await startAdapterExecutionTargetPaperclipBridge({
|
|
runId: "run-bridge-shell",
|
|
target,
|
|
runtimeRootDir,
|
|
adapterKey: "claude",
|
|
hostApiToken: "real-run-jwt",
|
|
hostApiUrl: `http://127.0.0.1:${address.port}`,
|
|
});
|
|
try {
|
|
expect(bridge).not.toBeNull();
|
|
const shellProbe = [
|
|
"const url = `${process.env.PAPERCLIP_API_URL}/api/agents/me`;",
|
|
"fetch(url, { headers: { authorization: `Bearer ${process.env.PAPERCLIP_API_KEY}`, accept: 'application/json' } })",
|
|
" .then(async (response) => {",
|
|
" const body = await response.json();",
|
|
" process.stdout.write(JSON.stringify({",
|
|
" status: response.status,",
|
|
" body,",
|
|
" bridgeMode: process.env.PAPERCLIP_API_BRIDGE_MODE,",
|
|
" }));",
|
|
" })",
|
|
" .catch((error) => {",
|
|
" console.error(error instanceof Error ? error.stack : String(error));",
|
|
" process.exit(1);",
|
|
" });",
|
|
].join("\n");
|
|
|
|
const result = await runAdapterExecutionTargetShellCommand(
|
|
"run-bridge-shell",
|
|
target,
|
|
`${shellQuote(process.execPath)} -e ${shellQuote(shellProbe)}`,
|
|
{
|
|
cwd: remoteCwd,
|
|
env: bridge!.env,
|
|
timeoutSec: 15,
|
|
graceSec: 5,
|
|
onLog: async () => {},
|
|
},
|
|
);
|
|
|
|
expect(result.exitCode).toBe(0);
|
|
expect(result.stderr).toBe("");
|
|
expect(JSON.parse(result.stdout)).toEqual({
|
|
status: 200,
|
|
body: { ok: true },
|
|
bridgeMode: "queue_v1",
|
|
});
|
|
expect(`${result.stdout}\n${result.stderr}`).not.toContain("real-run-jwt");
|
|
expect(`${result.stdout}\n${result.stderr}`).not.toContain(bridge!.env.PAPERCLIP_API_KEY);
|
|
const runnerCommandText = JSON.stringify(
|
|
runner.execute.mock.calls.map(([call]) => ({
|
|
command: call.command,
|
|
args: call.args,
|
|
})),
|
|
);
|
|
expect(runnerCommandText).not.toContain("real-run-jwt");
|
|
expect(runnerCommandText).not.toContain(bridge!.env.PAPERCLIP_API_KEY);
|
|
const runtimeFiles = (await readRuntimeTextFiles(runtimeRootDir)).join("\n");
|
|
expect(runtimeFiles).not.toContain("real-run-jwt");
|
|
expect(runtimeFiles).not.toContain(bridge!.env.PAPERCLIP_API_KEY);
|
|
expect(requests).toEqual([{
|
|
method: "GET",
|
|
url: "/api/agents/me",
|
|
auth: "Bearer real-run-jwt",
|
|
runId: "run-bridge-shell",
|
|
}]);
|
|
} finally {
|
|
await bridge?.stop();
|
|
await new Promise<void>((resolve) => apiServer.close(() => resolve()));
|
|
}
|
|
});
|
|
|
|
it("uses the effective adapter timeout when starting the sandbox callback bridge", async () => {
|
|
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-execution-target-bridge-timeout-"));
|
|
cleanupDirs.push(rootDir);
|
|
const remoteCwd = path.join(rootDir, "workspace");
|
|
const runtimeRootDir = path.join(remoteCwd, ".paperclip-runtime", "codex");
|
|
await mkdir(runtimeRootDir, { recursive: true });
|
|
|
|
const delegateRunner = createLocalSandboxRunner();
|
|
const runner = {
|
|
execute: vi.fn(async (input: Parameters<typeof delegateRunner.execute>[0]) => delegateRunner.execute(input)),
|
|
};
|
|
const apiServer = createServer((req, res) => {
|
|
res.writeHead(200, { "content-type": "application/json" });
|
|
res.end(JSON.stringify({ ok: true }));
|
|
});
|
|
await new Promise<void>((resolve, reject) => {
|
|
apiServer.once("error", reject);
|
|
apiServer.listen(0, "127.0.0.1", () => resolve());
|
|
});
|
|
const address = apiServer.address();
|
|
if (!address || typeof address === "string") {
|
|
throw new Error("Expected the bridge timeout test API server to listen on a TCP port.");
|
|
}
|
|
|
|
const target: AdapterSandboxExecutionTarget = {
|
|
kind: "remote",
|
|
transport: "sandbox",
|
|
providerKey: "cloudflare",
|
|
environmentId: "env-1",
|
|
leaseId: "lease-1",
|
|
remoteCwd,
|
|
runner,
|
|
timeoutMs: 30_000,
|
|
};
|
|
|
|
const bridge = await startAdapterExecutionTargetPaperclipBridge({
|
|
runId: "run-bridge-timeout",
|
|
target,
|
|
runtimeRootDir,
|
|
adapterKey: "codex",
|
|
timeoutSec: DEFAULT_REMOTE_SANDBOX_ADAPTER_TIMEOUT_SEC,
|
|
hostApiToken: "real-run-jwt",
|
|
hostApiUrl: `http://127.0.0.1:${address.port}`,
|
|
});
|
|
try {
|
|
expect(bridge).not.toBeNull();
|
|
expect(runner.execute).toHaveBeenCalled();
|
|
expect(
|
|
runner.execute.mock.calls.some(([input]) => input.timeoutMs === DEFAULT_REMOTE_SANDBOX_ADAPTER_TIMEOUT_SEC * 1000),
|
|
).toBe(true);
|
|
} finally {
|
|
await bridge?.stop();
|
|
await new Promise<void>((resolve) => apiServer.close(() => resolve()));
|
|
}
|
|
});
|
|
|
|
it("fails oversized host responses with a 502 before returning them to the sandbox client", async () => {
|
|
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-execution-target-bridge-limit-"));
|
|
cleanupDirs.push(rootDir);
|
|
const remoteCwd = path.join(rootDir, "workspace");
|
|
const runtimeRootDir = path.join(remoteCwd, ".paperclip-runtime", "codex");
|
|
await mkdir(runtimeRootDir, { recursive: true });
|
|
|
|
const requests: Array<{ method: string; url: string; auth: string | null; runId: string | null }> = [];
|
|
const largeBody = "x".repeat(64);
|
|
const apiServer = createServer((req, res) => {
|
|
requests.push({
|
|
method: req.method ?? "GET",
|
|
url: req.url ?? "/",
|
|
auth: req.headers.authorization ?? null,
|
|
runId: typeof req.headers["x-paperclip-run-id"] === "string" ? req.headers["x-paperclip-run-id"] : null,
|
|
});
|
|
res.writeHead(200, {
|
|
"content-type": "application/json",
|
|
"content-length": String(Buffer.byteLength(largeBody, "utf8")),
|
|
});
|
|
res.end(largeBody);
|
|
});
|
|
await new Promise<void>((resolve, reject) => {
|
|
apiServer.once("error", reject);
|
|
apiServer.listen(0, "127.0.0.1", () => resolve());
|
|
});
|
|
const address = apiServer.address();
|
|
if (!address || typeof address === "string") {
|
|
throw new Error("Expected the bridge test API server to listen on a TCP port.");
|
|
}
|
|
|
|
const target: AdapterSandboxExecutionTarget = {
|
|
kind: "remote",
|
|
transport: "sandbox",
|
|
providerKey: "e2b",
|
|
environmentId: "env-1",
|
|
leaseId: "lease-1",
|
|
remoteCwd,
|
|
runner: createLocalSandboxRunner(),
|
|
timeoutMs: 30_000,
|
|
};
|
|
|
|
const bridge = await startAdapterExecutionTargetPaperclipBridge({
|
|
runId: "run-bridge-limit",
|
|
target,
|
|
runtimeRootDir,
|
|
adapterKey: "codex",
|
|
hostApiToken: "real-run-jwt",
|
|
hostApiUrl: `http://127.0.0.1:${address.port}`,
|
|
maxBodyBytes: 32,
|
|
});
|
|
try {
|
|
const response = await fetch(`${bridge!.env.PAPERCLIP_API_URL}/api/agents/me`, {
|
|
headers: {
|
|
authorization: `Bearer ${bridge!.env.PAPERCLIP_API_KEY}`,
|
|
accept: "application/json",
|
|
},
|
|
});
|
|
|
|
expect(response.status).toBe(502);
|
|
await expect(response.json()).resolves.toEqual({
|
|
error: "Bridge response body exceeded the configured size limit of 32 bytes.",
|
|
});
|
|
expect(requests).toEqual([{
|
|
method: "GET",
|
|
url: "/api/agents/me",
|
|
auth: "Bearer real-run-jwt",
|
|
runId: "run-bridge-limit",
|
|
}]);
|
|
} finally {
|
|
await bridge?.stop();
|
|
await new Promise<void>((resolve) => apiServer.close(() => resolve()));
|
|
}
|
|
});
|
|
});
|