mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-11 05:31:46 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Agent runs execute through adapters (e.g. `acpx_local`), which can run locally, over SSH, or inside sandbox execution targets, each with a wall-clock execution timeout > - Sandbox-backed runs defaulted to a 30-minute wall-clock backstop (`DEFAULT_REMOTE_SANDBOX_ADAPTER_TIMEOUT_SEC = 1800`), which kills healthy long agent runs that are still making progress — long before the recovery watchdog's 4h critical threshold would even consider them stuck > - On top of that, `acpx_local` resolved its timeout directly from `adapterConfig.timeoutSec` instead of the shared execution-target resolver, and its timeout failures surfaced as a bare `Timed out after Ns` — giving operators no clue which timer fired or which knob raises it > - This pull request raises the sandbox backstop to 4h (aligned with the recovery watchdog), routes `acpx_local` through the shared timeout resolver, logs the effective timeout and its source at run start, and makes every timeout error message self-describing > - The benefit is that long-running sandbox agent runs no longer die at 30 minutes, and when a wall-clock timeout does fire, the run log states exactly which timer fired and how to configure it ## Linked Issues or Issue Description Refs #4535 (related: wall-clock execution timeouts killing agent runs that are still making progress — that issue covers a different hardcoded 600s timer, but the operator pain is the same). No exact public issue exists for this one, so describing it in-PR: **Bug:** A long sandbox-backed `acpx_local` agent run was killed with a bare `Timed out after 1800s` even though the agent was actively working. - **What happened:** The run hit the 30-minute `DEFAULT_REMOTE_SANDBOX_ADAPTER_TIMEOUT_SEC` backstop. `acpx_local` never consulted the shared execution-target timeout resolution (it read `adapterConfig.timeoutSec` directly, default 0), so on sandbox targets the sandbox-provider default applied with no adapter-level say. The resulting error named neither the timer that fired nor the knob that controls it. - **Expected:** Healthy long runs should not be killed by a 30-minute wall-clock backstop when the recovery watchdog only treats runs as critically stuck after 4h of output silence; and any timeout error should say which timeout fired and how to raise it. - **Impact:** Long, legitimate agent runs in sandboxes fail mid-work; operators waste time reverse-engineering which of several timers produced "Timed out after Ns". ## What Changed - `packages/adapter-utils/src/execution-target.ts` - `DEFAULT_REMOTE_SANDBOX_ADAPTER_TIMEOUT_SEC` raised from `1_800` to `14_400` (4h), with a comment explaining it intentionally matches the recovery watchdog's `ACTIVE_RUN_OUTPUT_CRITICAL_THRESHOLD_MS` (4h) so the adapter backstop never fires before the watchdog path. Output-inactivity monitors remain the primary hang detectors. - New `resolveAdapterExecutionTargetTimeout(target, configuredTimeoutSec)` returns `{ timeoutSec, source }` where `source` is `configured` / `sandbox_default` / `unlimited`. The existing `resolveAdapterExecutionTargetTimeoutSec` is preserved as a thin wrapper, so current callers are unaffected. - New `formatAdapterExecutionTimeoutErrorMessage(resolution)` and `formatAdapterExecutionTimeoutStartLogLine(resolution)` produce self-describing messages that name the timer that fired and the `adapterConfig.timeoutSec` knob that controls it. - `packages/adapters/acpx-local/src/server/execute.ts` - `buildRuntime` now resolves the wall-clock timeout through the shared resolver: sandbox targets default to the 4h backstop, local/SSH keep the historical "0 = no adapter timeout", and a configured `adapterConfig.timeoutSec` always wins. - The executor logs the effective timeout and its source at run start (`[paperclip] Adapter execution timeout: …`), so a later timeout is diagnosable from the run log alone. - All three bare timeout messages (timer cancel reason, turn result `errorMessage`, catch-path `messageOverride`) now use the self-describing format. - `packages/adapters/acpx-local/src/index.ts` — the adapter configuration doc for `timeoutSec` states the sandbox default and that the output-inactivity monitor remains the primary hang detector. - Tests: `packages/adapter-utils/src/execution-target-sandbox.test.ts` and `packages/adapters/acpx-local/src/server/execute.test.ts` (see Verification). ## Verification - `pnpm --filter @paperclipai/adapter-utils typecheck` — passes - `pnpm --filter @paperclipai/adapter-acpx-local typecheck` — passes - `npx vitest run packages/adapter-utils/src/execution-target-sandbox.test.ts packages/adapters/acpx-local/src/server/execute.test.ts` — 2 files, 40 tests, all pass - New/updated test coverage: - sandbox default resolves to 4h (and the constant is asserted to be `4 * 60 * 60`) - `resolveAdapterExecutionTargetTimeout` reports `configured` / `sandbox_default` / `unlimited` sources with the correct precedence (configured > sandbox default; local/SSH stay unlimited) - exact wording of the self-describing error message and the start-of-run log line - `acpx_local` runtime picks up the sandbox default into `timeoutMs`, keeps the unlimited local default, honors configured-over-default precedence, emits the start-of-run log line, and surfaces the self-describing `errorMessage`/cancel reason when the wall-clock timer kills a turn ## Risks - **Behavioral shift:** sandbox-backed adapter runs that previously hit the 30-minute backstop now run up to 4h before the adapter kills them. Genuinely hung runs are still caught much earlier by the adapters' output-inactivity monitors and by the recovery watchdog; the wall-clock timer is a last-resort kill switch. Operators who relied on the 30-minute default can restore it explicitly via `adapterConfig.timeoutSec`. - **Error-message consumers:** any tooling that pattern-matched the exact `Timed out after Ns` string from `acpx_local` will see the new self-describing message instead. - No API or schema changes; `resolveAdapterExecutionTargetTimeoutSec` keeps its exact signature and behavior (modulo the raised sandbox default). > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - Claude (Anthropic) via Claude Code CLI — model ID `claude-fable-5`, extended thinking enabled, agentic tool use (file edits, shell, test execution) ## 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 - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] 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: Paperclip <noreply@paperclip.ing>
1101 lines
37 KiB
TypeScript
1101 lines
37 KiB
TypeScript
import { createServer } from "node:http";
|
|
import { mkdir, mkdtemp, readdir, readFile, rm } from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
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,
|
|
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";
|
|
|
|
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): Promise<void> {
|
|
const deadline = Date.now() + 1000;
|
|
while (Date.now() < deadline) {
|
|
if (predicate()) return;
|
|
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
}
|
|
throw new Error(message);
|
|
}
|
|
|
|
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("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()));
|
|
}
|
|
});
|
|
});
|