mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-14 01:50:39 +09:00
## Thinking Path
> - Paperclip orchestrates AI agents for zero-human companies
> - Agents on remote sandboxes call back into the Paperclip control
plane via a
> callback bridge — the host process running the bridge proxies HTTP
requests
> from the sandbox to the Paperclip API
> - When something goes wrong end-to-end (sandbox can't reach Paperclip,
requests
> timing out, malformed responses), it's hard to tell whether the bridge
> processed the request, what URL/method it used, and what the upstream
> responded with
> - There was no built-in way to log bridge proxy traffic without
modifying
> adapter code or attaching a debugger
> - This PR adds opt-in stdout logging of every bridge proxy request and
response
> (method, path, query, status), gated behind `PAPERCLIP_BRIDGE_DEBUG`
so it
> stays off by default
> - The benefit is that operators can flip a single env var to get full
visibility
> into bridge traffic when debugging remote runs, without changing code
## What Changed
- `packages/adapter-utils/src/execution-target.ts`:
`startAdapterExecutionTargetPaperclipBridge`'s `handleRequest` now logs
each
proxied request and response when `PAPERCLIP_BRIDGE_DEBUG` is truthy:
- `[paperclip] Bridge proxy <METHOD> <path>?<query>` before fetch
- `[paperclip] Bridge proxy response <status> for <METHOD>
<path>?<query>` after
- Logging is no-op when the env var is unset/`"0"`/`"false"`.
## Verification
- Set `PAPERCLIP_BRIDGE_DEBUG=1` in the host process env, run an agent
against
a sandbox-backed environment, confirm the bridge log lines appear in
stdout.
- Unset the env var and confirm no extra log lines appear during normal
runs.
## Risks
- Off-by-default, no observable change for shipping users.
- When enabled, the logging is verbose — every API call from the sandbox
produces 2 stdout lines. Operators should only enable it during active
debugging.
## Model Used
- OpenAI GPT-5.4 (reasoning effort: high) via Codex CLI
- Provider: OpenAI
- Used to author the code changes in this PR
## 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 run tests locally and they pass
- [ ] I have added or updated tests where applicable — covered by
exercising the
flag in dev; the underlying handleRequest behavior is unchanged
- [ ] If this change affects the UI, I have included before/after
screenshots — N/A
- [ ] I have updated relevant documentation to reflect my changes — N/A
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge
832 lines
27 KiB
TypeScript
832 lines
27 KiB
TypeScript
import path from "node:path";
|
|
import type { SshRemoteExecutionSpec } from "./ssh.js";
|
|
import {
|
|
prepareCommandManagedRuntime,
|
|
type CommandManagedRuntimeRunner,
|
|
} from "./command-managed-runtime.js";
|
|
import {
|
|
buildRemoteExecutionSessionIdentity,
|
|
prepareRemoteManagedRuntime,
|
|
remoteExecutionSessionMatches,
|
|
type RemoteManagedRuntimeAsset,
|
|
} from "./remote-managed-runtime.js";
|
|
import {
|
|
createCommandManagedSandboxCallbackBridgeQueueClient,
|
|
createSandboxCallbackBridgeAsset,
|
|
createSandboxCallbackBridgeToken,
|
|
DEFAULT_SANDBOX_CALLBACK_BRIDGE_MAX_BODY_BYTES,
|
|
startSandboxCallbackBridgeServer,
|
|
startSandboxCallbackBridgeWorker,
|
|
} from "./sandbox-callback-bridge.js";
|
|
import { createSshCommandManagedRuntimeRunner, parseSshRemoteExecutionSpec, runSshCommand, shellQuote } from "./ssh.js";
|
|
import {
|
|
ensureCommandResolvable,
|
|
resolveCommandForLogs,
|
|
runChildProcess,
|
|
type RunProcessResult,
|
|
type TerminalResultCleanupOptions,
|
|
} from "./server-utils.js";
|
|
import { preferredShellForSandbox } from "./sandbox-shell.js";
|
|
|
|
export interface AdapterLocalExecutionTarget {
|
|
kind: "local";
|
|
environmentId?: string | null;
|
|
leaseId?: string | null;
|
|
}
|
|
|
|
export interface AdapterSshExecutionTarget {
|
|
kind: "remote";
|
|
transport: "ssh";
|
|
environmentId?: string | null;
|
|
leaseId?: string | null;
|
|
remoteCwd: string;
|
|
spec: SshRemoteExecutionSpec;
|
|
}
|
|
|
|
export interface AdapterSandboxExecutionTarget {
|
|
kind: "remote";
|
|
transport: "sandbox";
|
|
providerKey?: string | null;
|
|
shellCommand?: "bash" | "sh" | null;
|
|
environmentId?: string | null;
|
|
leaseId?: string | null;
|
|
remoteCwd: string;
|
|
timeoutMs?: number | null;
|
|
runner?: CommandManagedRuntimeRunner;
|
|
}
|
|
|
|
export type AdapterExecutionTarget =
|
|
| AdapterLocalExecutionTarget
|
|
| AdapterSshExecutionTarget
|
|
| AdapterSandboxExecutionTarget;
|
|
|
|
export type AdapterRemoteExecutionSpec = SshRemoteExecutionSpec;
|
|
|
|
export type AdapterManagedRuntimeAsset = RemoteManagedRuntimeAsset;
|
|
|
|
export interface PreparedAdapterExecutionTargetRuntime {
|
|
target: AdapterExecutionTarget;
|
|
runtimeRootDir: string | null;
|
|
assetDirs: Record<string, string>;
|
|
restoreWorkspace(): Promise<void>;
|
|
}
|
|
|
|
export interface AdapterExecutionTargetProcessOptions {
|
|
cwd: string;
|
|
env: Record<string, string>;
|
|
stdin?: string;
|
|
timeoutSec: number;
|
|
graceSec: number;
|
|
onLog: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
|
|
onSpawn?: (meta: { pid: number; processGroupId: number | null; startedAt: string }) => Promise<void>;
|
|
terminalResultCleanup?: TerminalResultCleanupOptions;
|
|
}
|
|
|
|
export interface AdapterExecutionTargetShellOptions {
|
|
cwd: string;
|
|
env: Record<string, string>;
|
|
timeoutSec?: number;
|
|
graceSec?: number;
|
|
onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
|
|
}
|
|
|
|
export interface AdapterExecutionTargetPaperclipBridgeHandle {
|
|
env: Record<string, string>;
|
|
stop(): Promise<void>;
|
|
}
|
|
|
|
function parseObject(value: unknown): Record<string, unknown> {
|
|
return value && typeof value === "object" && !Array.isArray(value)
|
|
? (value as Record<string, unknown>)
|
|
: {};
|
|
}
|
|
|
|
function readString(value: unknown): string | null {
|
|
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
|
}
|
|
|
|
function readStringMeta(parsed: Record<string, unknown>, key: string): string | null {
|
|
return readString(parsed[key]);
|
|
}
|
|
|
|
function resolveHostForUrl(rawHost: string): string {
|
|
const host = rawHost.trim();
|
|
if (!host || host === "0.0.0.0" || host === "::") return "localhost";
|
|
if (host.includes(":") && !host.startsWith("[") && !host.endsWith("]")) return `[${host}]`;
|
|
return host;
|
|
}
|
|
|
|
function resolveDefaultPaperclipApiUrl(): string {
|
|
const runtimeHost = resolveHostForUrl(
|
|
process.env.PAPERCLIP_LISTEN_HOST ?? process.env.HOST ?? "localhost",
|
|
);
|
|
// 3100 matches the default Paperclip dev server port when the runtime does not provide one.
|
|
const runtimePort = process.env.PAPERCLIP_LISTEN_PORT ?? process.env.PORT ?? "3100";
|
|
return `http://${runtimeHost}:${runtimePort}`;
|
|
}
|
|
|
|
function isBridgeDebugEnabled(env: NodeJS.ProcessEnv): boolean {
|
|
const value = env.PAPERCLIP_BRIDGE_DEBUG?.trim().toLowerCase();
|
|
return value === "1" || value === "true" || value === "yes";
|
|
}
|
|
|
|
function isAdapterExecutionTargetInstance(value: unknown): value is AdapterExecutionTarget {
|
|
const parsed = parseObject(value);
|
|
if (parsed.kind === "local") return true;
|
|
if (parsed.kind !== "remote") return false;
|
|
if (parsed.transport === "ssh") return parseSshRemoteExecutionSpec(parseObject(parsed.spec)) !== null;
|
|
if (parsed.transport !== "sandbox") return false;
|
|
return readStringMeta(parsed, "remoteCwd") !== null;
|
|
}
|
|
|
|
export function adapterExecutionTargetToRemoteSpec(
|
|
target: AdapterExecutionTarget | null | undefined,
|
|
): AdapterRemoteExecutionSpec | null {
|
|
return target?.kind === "remote" && target.transport === "ssh" ? target.spec : null;
|
|
}
|
|
|
|
export function adapterExecutionTargetIsRemote(
|
|
target: AdapterExecutionTarget | null | undefined,
|
|
): boolean {
|
|
return target?.kind === "remote";
|
|
}
|
|
|
|
export function adapterExecutionTargetUsesManagedHome(
|
|
target: AdapterExecutionTarget | null | undefined,
|
|
): boolean {
|
|
return target?.kind === "remote" && target.transport === "sandbox";
|
|
}
|
|
|
|
export function adapterExecutionTargetRemoteCwd(
|
|
target: AdapterExecutionTarget | null | undefined,
|
|
localCwd: string,
|
|
): string {
|
|
return target?.kind === "remote" ? target.remoteCwd : localCwd;
|
|
}
|
|
|
|
export function resolveAdapterExecutionTargetCwd(
|
|
target: AdapterExecutionTarget | null | undefined,
|
|
configuredCwd: string | null | undefined,
|
|
localFallbackCwd: string,
|
|
): string {
|
|
if (typeof configuredCwd === "string" && configuredCwd.trim().length > 0) {
|
|
return configuredCwd;
|
|
}
|
|
return adapterExecutionTargetRemoteCwd(target, localFallbackCwd);
|
|
}
|
|
|
|
export function adapterExecutionTargetUsesPaperclipBridge(
|
|
target: AdapterExecutionTarget | null | undefined,
|
|
): boolean {
|
|
return target?.kind === "remote";
|
|
}
|
|
|
|
export function describeAdapterExecutionTarget(
|
|
target: AdapterExecutionTarget | null | undefined,
|
|
): string {
|
|
if (!target || target.kind === "local") return "local environment";
|
|
if (target.transport === "ssh") {
|
|
return `SSH environment ${target.spec.username}@${target.spec.host}:${target.spec.port}`;
|
|
}
|
|
return `sandbox environment${target.providerKey ? ` (${target.providerKey})` : ""}`;
|
|
}
|
|
|
|
function requireSandboxRunner(target: AdapterSandboxExecutionTarget): CommandManagedRuntimeRunner {
|
|
if (target.runner) return target.runner;
|
|
throw new Error(
|
|
"Sandbox execution target is missing its provider runtime runner. Sandbox commands must execute through the environment runtime.",
|
|
);
|
|
}
|
|
|
|
function preferredSandboxShell(target: AdapterSandboxExecutionTarget): "bash" | "sh" {
|
|
return preferredShellForSandbox(target.shellCommand);
|
|
}
|
|
|
|
type AdapterCommandCapableExecutionTarget = AdapterSshExecutionTarget | AdapterSandboxExecutionTarget;
|
|
|
|
function adapterExecutionTargetCommandRunner(target: AdapterCommandCapableExecutionTarget): CommandManagedRuntimeRunner {
|
|
if (target.transport === "ssh") {
|
|
return createSshCommandManagedRuntimeRunner({
|
|
spec: target.spec,
|
|
defaultCwd: target.remoteCwd,
|
|
maxBufferBytes: DEFAULT_SANDBOX_CALLBACK_BRIDGE_MAX_BODY_BYTES * 4,
|
|
});
|
|
}
|
|
return requireSandboxRunner(target);
|
|
}
|
|
|
|
function adapterExecutionTargetShellCommand(target: AdapterCommandCapableExecutionTarget): "bash" | "sh" {
|
|
return target.transport === "ssh" ? "sh" : preferredSandboxShell(target);
|
|
}
|
|
|
|
function adapterExecutionTargetTimeoutMs(
|
|
target: AdapterCommandCapableExecutionTarget,
|
|
): number | null | undefined {
|
|
return target.transport === "sandbox" ? target.timeoutMs : undefined;
|
|
}
|
|
|
|
export async function ensureAdapterExecutionTargetCommandResolvable(
|
|
command: string,
|
|
target: AdapterExecutionTarget | null | undefined,
|
|
cwd: string,
|
|
env: NodeJS.ProcessEnv,
|
|
) {
|
|
if (target?.kind === "remote" && target.transport === "sandbox") {
|
|
return;
|
|
}
|
|
await ensureCommandResolvable(command, cwd, env, {
|
|
remoteExecution: adapterExecutionTargetToRemoteSpec(target),
|
|
});
|
|
}
|
|
|
|
export async function resolveAdapterExecutionTargetCommandForLogs(
|
|
command: string,
|
|
target: AdapterExecutionTarget | null | undefined,
|
|
cwd: string,
|
|
env: NodeJS.ProcessEnv,
|
|
): Promise<string> {
|
|
if (target?.kind === "remote" && target.transport === "sandbox") {
|
|
return `sandbox://${target.providerKey ?? "provider"}/${target.leaseId ?? "lease"}/${target.remoteCwd} :: ${command}`;
|
|
}
|
|
return await resolveCommandForLogs(command, cwd, env, {
|
|
remoteExecution: adapterExecutionTargetToRemoteSpec(target),
|
|
});
|
|
}
|
|
|
|
export async function runAdapterExecutionTargetProcess(
|
|
runId: string,
|
|
target: AdapterExecutionTarget | null | undefined,
|
|
command: string,
|
|
args: string[],
|
|
options: AdapterExecutionTargetProcessOptions,
|
|
): Promise<RunProcessResult> {
|
|
if (target?.kind === "remote" && target.transport === "sandbox") {
|
|
const runner = requireSandboxRunner(target);
|
|
return await runner.execute({
|
|
command,
|
|
args,
|
|
cwd: target.remoteCwd,
|
|
env: options.env,
|
|
stdin: options.stdin,
|
|
timeoutMs: options.timeoutSec > 0 ? options.timeoutSec * 1000 : target.timeoutMs ?? undefined,
|
|
onLog: options.onLog,
|
|
onSpawn: options.onSpawn
|
|
? async (meta) => options.onSpawn?.({ ...meta, processGroupId: null })
|
|
: undefined,
|
|
});
|
|
}
|
|
|
|
return await runChildProcess(runId, command, args, {
|
|
cwd: options.cwd,
|
|
env: options.env,
|
|
stdin: options.stdin,
|
|
timeoutSec: options.timeoutSec,
|
|
graceSec: options.graceSec,
|
|
onLog: options.onLog,
|
|
onSpawn: options.onSpawn,
|
|
terminalResultCleanup: options.terminalResultCleanup,
|
|
remoteExecution: adapterExecutionTargetToRemoteSpec(target),
|
|
});
|
|
}
|
|
|
|
export async function runAdapterExecutionTargetShellCommand(
|
|
runId: string,
|
|
target: AdapterExecutionTarget | null | undefined,
|
|
command: string,
|
|
options: AdapterExecutionTargetShellOptions,
|
|
): Promise<RunProcessResult> {
|
|
const onLog = options.onLog ?? (async () => {});
|
|
if (target?.kind === "remote") {
|
|
const startedAt = new Date().toISOString();
|
|
if (target.transport === "ssh") {
|
|
try {
|
|
const result = await runSshCommand(target.spec, `sh -lc ${shellQuote(command)}`, {
|
|
timeoutMs: (options.timeoutSec ?? 15) * 1000,
|
|
});
|
|
if (result.stdout) await onLog("stdout", result.stdout);
|
|
if (result.stderr) await onLog("stderr", result.stderr);
|
|
return {
|
|
exitCode: 0,
|
|
signal: null,
|
|
timedOut: false,
|
|
stdout: result.stdout,
|
|
stderr: result.stderr,
|
|
pid: null,
|
|
startedAt,
|
|
};
|
|
} catch (error) {
|
|
const timedOutError = error as NodeJS.ErrnoException & {
|
|
stdout?: string;
|
|
stderr?: string;
|
|
signal?: string | null;
|
|
};
|
|
const stdout = timedOutError.stdout ?? "";
|
|
const stderr = timedOutError.stderr ?? "";
|
|
if (typeof timedOutError.code === "number") {
|
|
if (stdout) await onLog("stdout", stdout);
|
|
if (stderr) await onLog("stderr", stderr);
|
|
return {
|
|
exitCode: timedOutError.code,
|
|
signal: timedOutError.signal ?? null,
|
|
timedOut: false,
|
|
stdout,
|
|
stderr,
|
|
pid: null,
|
|
startedAt,
|
|
};
|
|
}
|
|
if (timedOutError.code !== "ETIMEDOUT") {
|
|
throw error;
|
|
}
|
|
if (stdout) await onLog("stdout", stdout);
|
|
if (stderr) await onLog("stderr", stderr);
|
|
return {
|
|
exitCode: null,
|
|
signal: timedOutError.signal ?? null,
|
|
timedOut: true,
|
|
stdout,
|
|
stderr,
|
|
pid: null,
|
|
startedAt,
|
|
};
|
|
}
|
|
}
|
|
|
|
const shellCommand = preferredSandboxShell(target);
|
|
return await requireSandboxRunner(target).execute({
|
|
command: shellCommand,
|
|
args: ["-lc", command],
|
|
cwd: target.remoteCwd,
|
|
env: options.env,
|
|
timeoutMs: (options.timeoutSec ?? 15) * 1000,
|
|
onLog,
|
|
});
|
|
}
|
|
|
|
return await runAdapterExecutionTargetProcess(
|
|
runId,
|
|
target,
|
|
"sh",
|
|
["-lc", command],
|
|
{
|
|
cwd: options.cwd,
|
|
env: options.env,
|
|
timeoutSec: options.timeoutSec ?? 15,
|
|
graceSec: options.graceSec ?? 5,
|
|
onLog,
|
|
},
|
|
);
|
|
}
|
|
|
|
export async function readAdapterExecutionTargetHomeDir(
|
|
runId: string,
|
|
target: AdapterExecutionTarget | null | undefined,
|
|
options: AdapterExecutionTargetShellOptions,
|
|
): Promise<string | null> {
|
|
const result = await runAdapterExecutionTargetShellCommand(
|
|
runId,
|
|
target,
|
|
'printf %s "$HOME"',
|
|
options,
|
|
);
|
|
const homeDir = result.stdout.trim();
|
|
return homeDir.length > 0 ? homeDir : null;
|
|
}
|
|
|
|
export async function ensureAdapterExecutionTargetFile(
|
|
runId: string,
|
|
target: AdapterExecutionTarget | null | undefined,
|
|
filePath: string,
|
|
options: AdapterExecutionTargetShellOptions,
|
|
): Promise<void> {
|
|
await runAdapterExecutionTargetShellCommand(
|
|
runId,
|
|
target,
|
|
`mkdir -p ${shellQuote(path.posix.dirname(filePath))} && : > ${shellQuote(filePath)}`,
|
|
options,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Ensure a working directory exists (and is a directory) on the execution target.
|
|
*
|
|
* For local targets this delegates to the local `ensureAbsoluteDirectory` helper
|
|
* (Node fs). For remote (SSH/sandbox) targets it shells out and runs
|
|
* `mkdir -p` (when allowed) followed by a `[ -d ]` check so the result reflects
|
|
* the directory state inside the environment, not on the Paperclip host.
|
|
*
|
|
* Throws an Error with a human-readable message on failure.
|
|
*/
|
|
export async function ensureAdapterExecutionTargetDirectory(
|
|
runId: string,
|
|
target: AdapterExecutionTarget | null | undefined,
|
|
cwd: string,
|
|
options: AdapterExecutionTargetShellOptions & { createIfMissing?: boolean },
|
|
): Promise<void> {
|
|
const createIfMissing = options.createIfMissing ?? false;
|
|
|
|
if (!target || target.kind === "local") {
|
|
const { ensureAbsoluteDirectory } = await import("./server-utils.js");
|
|
await ensureAbsoluteDirectory(cwd, { createIfMissing });
|
|
return;
|
|
}
|
|
|
|
// Remote (SSH or sandbox): both expect POSIX absolute paths inside the env.
|
|
if (!cwd.startsWith("/")) {
|
|
throw new Error(`Working directory must be an absolute POSIX path on the remote target: "${cwd}"`);
|
|
}
|
|
|
|
const quoted = shellQuote(cwd);
|
|
const script = createIfMissing
|
|
? `mkdir -p ${quoted} && [ -d ${quoted} ]`
|
|
: `[ -d ${quoted} ]`;
|
|
|
|
const result = await runAdapterExecutionTargetShellCommand(runId, target, script, {
|
|
cwd: target.kind === "remote" ? target.remoteCwd : cwd,
|
|
env: options.env,
|
|
timeoutSec: options.timeoutSec ?? 15,
|
|
graceSec: options.graceSec ?? 5,
|
|
onLog: options.onLog,
|
|
});
|
|
|
|
if (result.timedOut) {
|
|
throw new Error(`Timed out checking working directory on remote target: "${cwd}"`);
|
|
}
|
|
if ((result.exitCode ?? 1) !== 0) {
|
|
const detail = (result.stderr || result.stdout || "").trim();
|
|
if (createIfMissing) {
|
|
throw new Error(
|
|
`Could not create working directory "${cwd}" on remote target${detail ? `: ${detail}` : "."}`,
|
|
);
|
|
}
|
|
throw new Error(
|
|
`Working directory does not exist on remote target: "${cwd}"${detail ? ` (${detail})` : ""}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
export function adapterExecutionTargetSessionIdentity(
|
|
target: AdapterExecutionTarget | null | undefined,
|
|
): Record<string, unknown> | null {
|
|
if (!target || target.kind === "local") return null;
|
|
if (target.transport === "ssh") return buildRemoteExecutionSessionIdentity(target.spec);
|
|
return {
|
|
transport: "sandbox",
|
|
providerKey: target.providerKey ?? null,
|
|
environmentId: target.environmentId ?? null,
|
|
leaseId: target.leaseId ?? null,
|
|
remoteCwd: target.remoteCwd,
|
|
};
|
|
}
|
|
|
|
export function adapterExecutionTargetSessionMatches(
|
|
saved: unknown,
|
|
target: AdapterExecutionTarget | null | undefined,
|
|
): boolean {
|
|
if (!target || target.kind === "local") {
|
|
return Object.keys(parseObject(saved)).length === 0;
|
|
}
|
|
if (target.transport === "ssh") return remoteExecutionSessionMatches(saved, target.spec);
|
|
const current = adapterExecutionTargetSessionIdentity(target);
|
|
const parsedSaved = parseObject(saved);
|
|
return (
|
|
readStringMeta(parsedSaved, "transport") === current?.transport &&
|
|
readStringMeta(parsedSaved, "providerKey") === current?.providerKey &&
|
|
readStringMeta(parsedSaved, "environmentId") === current?.environmentId &&
|
|
readStringMeta(parsedSaved, "leaseId") === current?.leaseId &&
|
|
readStringMeta(parsedSaved, "remoteCwd") === current?.remoteCwd
|
|
);
|
|
}
|
|
|
|
export function parseAdapterExecutionTarget(value: unknown): AdapterExecutionTarget | null {
|
|
const parsed = parseObject(value);
|
|
const kind = readStringMeta(parsed, "kind");
|
|
|
|
if (kind === "local") {
|
|
return {
|
|
kind: "local",
|
|
environmentId: readStringMeta(parsed, "environmentId"),
|
|
leaseId: readStringMeta(parsed, "leaseId"),
|
|
};
|
|
}
|
|
|
|
if (kind === "remote" && readStringMeta(parsed, "transport") === "ssh") {
|
|
const spec = parseSshRemoteExecutionSpec(parseObject(parsed.spec));
|
|
if (!spec) return null;
|
|
return {
|
|
kind: "remote",
|
|
transport: "ssh",
|
|
environmentId: readStringMeta(parsed, "environmentId"),
|
|
leaseId: readStringMeta(parsed, "leaseId"),
|
|
remoteCwd: spec.remoteCwd,
|
|
spec,
|
|
};
|
|
}
|
|
|
|
if (kind === "remote" && readStringMeta(parsed, "transport") === "sandbox") {
|
|
const remoteCwd = readStringMeta(parsed, "remoteCwd");
|
|
if (!remoteCwd) return null;
|
|
return {
|
|
kind: "remote",
|
|
transport: "sandbox",
|
|
providerKey: readStringMeta(parsed, "providerKey"),
|
|
environmentId: readStringMeta(parsed, "environmentId"),
|
|
leaseId: readStringMeta(parsed, "leaseId"),
|
|
remoteCwd,
|
|
timeoutMs: typeof parsed.timeoutMs === "number" ? parsed.timeoutMs : null,
|
|
};
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
export function adapterExecutionTargetFromRemoteExecution(
|
|
remoteExecution: unknown,
|
|
metadata: Pick<AdapterLocalExecutionTarget, "environmentId" | "leaseId"> = {},
|
|
): AdapterExecutionTarget | null {
|
|
const parsed = parseObject(remoteExecution);
|
|
const ssh = parseSshRemoteExecutionSpec(parsed);
|
|
if (ssh) {
|
|
return {
|
|
kind: "remote",
|
|
transport: "ssh",
|
|
environmentId: metadata.environmentId ?? null,
|
|
leaseId: metadata.leaseId ?? null,
|
|
remoteCwd: ssh.remoteCwd,
|
|
spec: ssh,
|
|
};
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
export function readAdapterExecutionTarget(input: {
|
|
executionTarget?: unknown;
|
|
legacyRemoteExecution?: unknown;
|
|
}): AdapterExecutionTarget | null {
|
|
if (isAdapterExecutionTargetInstance(input.executionTarget)) {
|
|
return input.executionTarget;
|
|
}
|
|
return (
|
|
parseAdapterExecutionTarget(input.executionTarget) ??
|
|
adapterExecutionTargetFromRemoteExecution(input.legacyRemoteExecution)
|
|
);
|
|
}
|
|
|
|
export async function prepareAdapterExecutionTargetRuntime(input: {
|
|
target: AdapterExecutionTarget | null | undefined;
|
|
adapterKey: string;
|
|
workspaceLocalDir: string;
|
|
workspaceExclude?: string[];
|
|
preserveAbsentOnRestore?: string[];
|
|
assets?: AdapterManagedRuntimeAsset[];
|
|
installCommand?: string | null;
|
|
}): Promise<PreparedAdapterExecutionTargetRuntime> {
|
|
const target = input.target ?? { kind: "local" as const };
|
|
if (target.kind === "local") {
|
|
return {
|
|
target,
|
|
runtimeRootDir: null,
|
|
assetDirs: {},
|
|
restoreWorkspace: async () => {},
|
|
};
|
|
}
|
|
|
|
if (target.transport === "ssh") {
|
|
const prepared = await prepareRemoteManagedRuntime({
|
|
spec: target.spec,
|
|
adapterKey: input.adapterKey,
|
|
workspaceLocalDir: input.workspaceLocalDir,
|
|
assets: input.assets,
|
|
});
|
|
return {
|
|
target,
|
|
runtimeRootDir: prepared.runtimeRootDir,
|
|
assetDirs: prepared.assetDirs,
|
|
restoreWorkspace: prepared.restoreWorkspace,
|
|
};
|
|
}
|
|
|
|
const prepared = await prepareCommandManagedRuntime({
|
|
runner: requireSandboxRunner(target),
|
|
spec: {
|
|
providerKey: target.providerKey,
|
|
shellCommand: target.shellCommand,
|
|
leaseId: target.leaseId,
|
|
remoteCwd: target.remoteCwd,
|
|
timeoutMs: target.timeoutMs,
|
|
},
|
|
adapterKey: input.adapterKey,
|
|
workspaceLocalDir: input.workspaceLocalDir,
|
|
workspaceExclude: input.workspaceExclude,
|
|
preserveAbsentOnRestore: input.preserveAbsentOnRestore,
|
|
assets: input.assets,
|
|
installCommand: input.installCommand,
|
|
});
|
|
return {
|
|
target,
|
|
runtimeRootDir: prepared.runtimeRootDir,
|
|
assetDirs: prepared.assetDirs,
|
|
restoreWorkspace: prepared.restoreWorkspace,
|
|
};
|
|
}
|
|
|
|
export function runtimeAssetDir(
|
|
prepared: Pick<PreparedAdapterExecutionTargetRuntime, "assetDirs">,
|
|
key: string,
|
|
fallbackRemoteCwd: string,
|
|
): string {
|
|
return prepared.assetDirs[key] ?? path.posix.join(fallbackRemoteCwd, ".paperclip-runtime", key);
|
|
}
|
|
|
|
function buildBridgeResponseHeaders(response: Response): Record<string, string> {
|
|
const out: Record<string, string> = {};
|
|
for (const key of ["content-type", "etag", "last-modified"]) {
|
|
const value = response.headers.get(key);
|
|
if (value && value.trim().length > 0) out[key] = value.trim();
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function buildBridgeForwardUrl(baseUrl: string, request: { path: string; query: string }): URL {
|
|
const url = new URL(request.path, baseUrl);
|
|
const query = request.query.trim();
|
|
url.search = query.startsWith("?") ? query.slice(1) : query;
|
|
return url;
|
|
}
|
|
|
|
function bridgeResponseBodyLimitError(maxBodyBytes: number): Error {
|
|
return new Error(`Bridge response body exceeded the configured size limit of ${maxBodyBytes} bytes.`);
|
|
}
|
|
|
|
async function readBridgeForwardResponseBody(response: Response, maxBodyBytes: number): Promise<string> {
|
|
const rawContentLength = response.headers.get("content-length");
|
|
if (rawContentLength) {
|
|
const contentLength = Number.parseInt(rawContentLength, 10);
|
|
if (Number.isFinite(contentLength) && contentLength > maxBodyBytes) {
|
|
throw bridgeResponseBodyLimitError(maxBodyBytes);
|
|
}
|
|
}
|
|
|
|
if (!response.body) {
|
|
return "";
|
|
}
|
|
|
|
const reader = response.body.getReader();
|
|
const chunks: Buffer[] = [];
|
|
let totalBytes = 0;
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
if (!value) continue;
|
|
totalBytes += value.byteLength;
|
|
if (totalBytes > maxBodyBytes) {
|
|
await reader.cancel().catch(() => undefined);
|
|
throw bridgeResponseBodyLimitError(maxBodyBytes);
|
|
}
|
|
chunks.push(Buffer.from(value));
|
|
}
|
|
return Buffer.concat(chunks, totalBytes).toString("utf8");
|
|
}
|
|
|
|
export async function startAdapterExecutionTargetPaperclipBridge(input: {
|
|
runId: string;
|
|
target: AdapterExecutionTarget | null | undefined;
|
|
runtimeRootDir: string | null | undefined;
|
|
adapterKey: string;
|
|
hostApiToken: string | null | undefined;
|
|
hostApiUrl?: string | null;
|
|
onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
|
|
maxBodyBytes?: number | null;
|
|
}): Promise<AdapterExecutionTargetPaperclipBridgeHandle | null> {
|
|
if (!adapterExecutionTargetUsesPaperclipBridge(input.target)) {
|
|
return null;
|
|
}
|
|
if (!input.target || input.target.kind !== "remote") {
|
|
return null;
|
|
}
|
|
|
|
const target = input.target;
|
|
const onLog = input.onLog ?? (async () => {});
|
|
const hostApiToken = input.hostApiToken?.trim() ?? "";
|
|
if (hostApiToken.length === 0) {
|
|
throw new Error("Sandbox bridge mode requires a host-side Paperclip API token.");
|
|
}
|
|
|
|
const runtimeRootDir =
|
|
input.runtimeRootDir?.trim().length
|
|
? input.runtimeRootDir.trim()
|
|
: path.posix.join(target.remoteCwd, ".paperclip-runtime", input.adapterKey);
|
|
const bridgeRuntimeDir = path.posix.join(runtimeRootDir, "paperclip-bridge");
|
|
const queueDir = path.posix.join(bridgeRuntimeDir, "queue");
|
|
const assetRemoteDir = path.posix.join(bridgeRuntimeDir, "server");
|
|
const bridgeToken = createSandboxCallbackBridgeToken();
|
|
const maxBodyBytes =
|
|
typeof input.maxBodyBytes === "number" && Number.isFinite(input.maxBodyBytes) && input.maxBodyBytes > 0
|
|
? Math.trunc(input.maxBodyBytes)
|
|
: DEFAULT_SANDBOX_CALLBACK_BRIDGE_MAX_BODY_BYTES;
|
|
const hostApiUrl =
|
|
input.hostApiUrl?.trim() ||
|
|
process.env.PAPERCLIP_RUNTIME_API_URL?.trim() ||
|
|
process.env.PAPERCLIP_API_URL?.trim() ||
|
|
resolveDefaultPaperclipApiUrl();
|
|
const shellCommand = adapterExecutionTargetShellCommand(target);
|
|
const runner = adapterExecutionTargetCommandRunner(target);
|
|
|
|
await onLog(
|
|
"stdout",
|
|
`[paperclip] Starting sandbox callback bridge for ${input.adapterKey} in ${bridgeRuntimeDir}.\n`,
|
|
);
|
|
|
|
const bridgeAsset = await createSandboxCallbackBridgeAsset();
|
|
let server: Awaited<ReturnType<typeof startSandboxCallbackBridgeServer>> | null = null;
|
|
let worker: Awaited<ReturnType<typeof startSandboxCallbackBridgeWorker>> | null = null;
|
|
try {
|
|
const client = createCommandManagedSandboxCallbackBridgeQueueClient({
|
|
runner,
|
|
remoteCwd: target.remoteCwd,
|
|
timeoutMs: adapterExecutionTargetTimeoutMs(target),
|
|
shellCommand,
|
|
});
|
|
// PAPERCLIP_BRIDGE_DEBUG opts into verbose stdout logs of every bridge
|
|
// proxy request/response. The query string is logged verbatim, so callers
|
|
// who pass auth tokens or other sensitive values as query parameters
|
|
// should be aware those values appear in the host process's stdout when
|
|
// this flag is enabled. Only intended for active debugging in trusted
|
|
// environments.
|
|
const bridgeDebugEnabled = isBridgeDebugEnabled(process.env);
|
|
worker = await startSandboxCallbackBridgeWorker({
|
|
client,
|
|
queueDir,
|
|
maxBodyBytes,
|
|
handleRequest: async (request) => {
|
|
const method = request.method.trim().toUpperCase() || "GET";
|
|
if (bridgeDebugEnabled) {
|
|
await onLog(
|
|
"stdout",
|
|
`[paperclip] Bridge proxy ${method} ${request.path}${request.query ? `?${request.query}` : ""}\n`,
|
|
);
|
|
}
|
|
const headers = new Headers();
|
|
for (const [key, value] of Object.entries(request.headers)) {
|
|
if (value.trim().length === 0) continue;
|
|
headers.set(key, value);
|
|
}
|
|
headers.set("authorization", `Bearer ${hostApiToken}`);
|
|
headers.set("x-paperclip-run-id", input.runId);
|
|
const response = await fetch(buildBridgeForwardUrl(hostApiUrl, request), {
|
|
method,
|
|
headers,
|
|
...(method === "GET" || method === "HEAD" ? {} : { body: request.body }),
|
|
signal: AbortSignal.timeout(30_000),
|
|
});
|
|
if (bridgeDebugEnabled) {
|
|
await onLog(
|
|
"stdout",
|
|
`[paperclip] Bridge proxy response ${response.status} for ${method} ${request.path}${request.query ? `?${request.query}` : ""}\n`,
|
|
);
|
|
}
|
|
return {
|
|
status: response.status,
|
|
headers: buildBridgeResponseHeaders(response),
|
|
body: await readBridgeForwardResponseBody(response, maxBodyBytes),
|
|
};
|
|
},
|
|
});
|
|
server = await startSandboxCallbackBridgeServer({
|
|
runner,
|
|
remoteCwd: target.remoteCwd,
|
|
assetRemoteDir,
|
|
queueDir,
|
|
bridgeToken,
|
|
bridgeAsset,
|
|
timeoutMs: adapterExecutionTargetTimeoutMs(target),
|
|
maxBodyBytes,
|
|
shellCommand,
|
|
});
|
|
} catch (error) {
|
|
await Promise.allSettled([
|
|
server?.stop(),
|
|
worker?.stop(),
|
|
bridgeAsset.cleanup(),
|
|
]);
|
|
throw error;
|
|
}
|
|
|
|
return {
|
|
env: {
|
|
PAPERCLIP_API_URL: server.baseUrl,
|
|
PAPERCLIP_API_KEY: bridgeToken,
|
|
PAPERCLIP_API_BRIDGE_MODE: "queue_v1",
|
|
},
|
|
stop: async () => {
|
|
await Promise.allSettled([
|
|
server?.stop(),
|
|
]);
|
|
await Promise.allSettled([
|
|
worker?.stop(),
|
|
bridgeAsset.cleanup(),
|
|
]);
|
|
},
|
|
};
|
|
}
|