mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-01 15:35:12 +01:00
## Thinking Path > - Paperclip is the open source app that manages AI agents for work > - Sandbox providers let agents run in remote and isolated environments > - Daytona session commands need a path that sends agent output to the host without host polling > - Host polling adds delay and repeats provider output work > - This pull request adds typed execute.log notifications and a log sink for incremental output > - This pull request adds an optional ACP session stream with final-result replay protection > - The benefit is lower output delay while the default flags keep current behavior unchanged ## Linked Issues or Issue Description **Subsystem affected** Cross-cutting. The change spans the plugin SDK, Daytona provider, adapter utilities, and server execution services. **Problem or motivation** The Daytona ACP bridge polls a host output file while an agent command runs. This adds delay and can repeat work. The host also needs a safe route for provider output chunks. **Proposed solution** Add a typed `execute.log` notification with host-issued invocation correlation. Add an ordered log sink to the environment execute path. Add an optional ACP session-log path that parses newline-delimited JSON frames and removes the host output poll for that path. **Alternatives considered** Keep the output-file poll as the only path. This keeps the current behavior but does not provide timely output. The new path stays behind flags, so the existing path remains the default fallback. **Roadmap alignment** This change supports the shipped Cloud / Sandbox agents milestone in `ROADMAP.md`, including Daytona support. ## What Changed - Add the typed `execute.log` worker-to-host notification and company-scoped host route. - Add ordered `stdout` and `stderr` chunk delivery before the final execute result. - Add the Daytona session log sink and the optional ACP streamed session path. - Add monotonic frame handling so live and final output reach the host once. - Keep `useLogStream` and `streamAgentSessionOutput` off by default. - Add unit and integration coverage for the notification, execution target, runtime, and Daytona paths. ## Verification - Run adapter-utils tests: 445 tests pass locally. - Run server environment tests: 73 tests pass locally. - Run Daytona plugin tests: 131 tests pass locally. - Run TypeScript checks for shared, adapter-utils, and server. - Review the pull request checks after GitHub completes them. - All required GitHub checks pass on the current head. ## Risks The new paths change output delivery only when a feature flag enables them. The final execute result remains available for parsing and fallback. The main risk is a provider stream or frame-order error; the final-result parser limits that risk. ## Model Used OpenAI Codex, GPT-5, tool use and code execution. The runtime did not supply a context-window value. ## 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 operator documentation change applies because both new flags remain disabled by default. - [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: Paperclip <noreply@paperclip.ing>
1311 lines
50 KiB
TypeScript
1311 lines
50 KiB
TypeScript
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
import { promises as fs } from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
|
|
import {
|
|
runWithoutActiveStep,
|
|
runWithRuntimeParent,
|
|
type RuntimeSpanRunner,
|
|
type StartupSpanContext,
|
|
} from "./acpx-engine/startup-timing.js";
|
|
import type { CommandManagedRuntimeRunner } from "./command-managed-runtime.js";
|
|
import { preferredShellForSandbox, shellCommandArgs } from "./sandbox-shell.js";
|
|
import type { RunProcessResult } from "./server-utils.js";
|
|
|
|
const DEFAULT_BRIDGE_TOKEN_BYTES = 24;
|
|
const DEFAULT_BRIDGE_POLL_INTERVAL_MS = 100;
|
|
const DEFAULT_BRIDGE_RESPONSE_TIMEOUT_MS = 30_000;
|
|
const DEFAULT_BRIDGE_STOP_TIMEOUT_MS = 2_000;
|
|
const DEFAULT_BRIDGE_MAX_QUEUE_DEPTH = 64;
|
|
const DEFAULT_BRIDGE_MAX_BODY_BYTES = 256 * 1024;
|
|
const REMOTE_WRITE_BASE64_CHUNK_SIZE = 32 * 1024;
|
|
const SANDBOX_CALLBACK_BRIDGE_ENTRYPOINT = "paperclip-bridge-server.mjs";
|
|
const SANDBOX_EXEC_CHANNEL_ENV = "PAPERCLIP_SANDBOX_EXEC_CHANNEL";
|
|
const SANDBOX_EXEC_CHANNEL_BRIDGE = "bridge";
|
|
|
|
/** Span name that wraps one Paperclip-API callback request — read the request,
|
|
* write the response, and remove the request file. */
|
|
const CALLBACK_BRIDGE_RELAY_REQUEST_SPAN = "sandbox.callbackBridge.relayRequest";
|
|
|
|
export const DEFAULT_SANDBOX_CALLBACK_BRIDGE_MAX_BODY_BYTES = DEFAULT_BRIDGE_MAX_BODY_BYTES;
|
|
|
|
export interface SandboxCallbackBridgeRouteRule {
|
|
method: string;
|
|
path: RegExp;
|
|
}
|
|
|
|
// Routes the in-sandbox heartbeat skill is documented to call. The server
|
|
// still enforces actor-level permissions on top of this allowlist; the list
|
|
// exists to bound the surface area a compromised CLI could reach via the
|
|
// reverse bridge. Keep this in sync with the Paperclip skill in
|
|
// `skills/paperclip/SKILL.md` and `references/api-reference.md`.
|
|
export const DEFAULT_SANDBOX_CALLBACK_BRIDGE_ROUTE_ALLOWLIST: readonly SandboxCallbackBridgeRouteRule[] = [
|
|
// Identity, inbox, agent self-management
|
|
{ method: "GET", path: /^\/api\/agents\/me$/ },
|
|
{ method: "GET", path: /^\/api\/agents\/me\/inbox-lite$/ },
|
|
{ method: "GET", path: /^\/api\/agents\/me\/inbox\/mine$/ },
|
|
{ method: "GET", path: /^\/api\/agents\/[^/]+$/ },
|
|
{ method: "GET", path: /^\/api\/agents\/[^/]+\/skills$/ },
|
|
{ method: "POST", path: /^\/api\/agents\/[^/]+\/skills\/sync$/ },
|
|
{ method: "PATCH", path: /^\/api\/agents\/[^/]+\/instructions-path$/ },
|
|
|
|
// Company-level reads used to discover work and context
|
|
{ method: "GET", path: /^\/api\/companies\/[^/]+$/ },
|
|
{ method: "GET", path: /^\/api\/companies\/[^/]+\/dashboard$/ },
|
|
{ method: "GET", path: /^\/api\/companies\/[^/]+\/agents$/ },
|
|
{ method: "GET", path: /^\/api\/companies\/[^/]+\/issues$/ },
|
|
{ method: "GET", path: /^\/api\/companies\/[^/]+\/projects$/ },
|
|
{ method: "GET", path: /^\/api\/companies\/[^/]+\/goals$/ },
|
|
{ method: "GET", path: /^\/api\/companies\/[^/]+\/org$/ },
|
|
{ method: "GET", path: /^\/api\/companies\/[^/]+\/approvals$/ },
|
|
{ method: "GET", path: /^\/api\/companies\/[^/]+\/routines$/ },
|
|
{ method: "GET", path: /^\/api\/companies\/[^/]+\/skills$/ },
|
|
{ method: "GET", path: /^\/api\/projects\/[^/]+$/ },
|
|
{ method: "GET", path: /^\/api\/goals\/[^/]+$/ },
|
|
|
|
// Issue lifecycle: read context, checkout, update, comment, document, release
|
|
{ method: "GET", path: /^\/api\/issues\/[^/]+$/ },
|
|
{ method: "GET", path: /^\/api\/issues\/[^/]+\/heartbeat-context$/ },
|
|
{ method: "GET", path: /^\/api\/issues\/[^/]+\/comments(?:\/[^/]+)?$/ },
|
|
{ method: "POST", path: /^\/api\/issues\/[^/]+\/comments$/ },
|
|
{ method: "GET", path: /^\/api\/issues\/[^/]+\/documents(?:\/[^/]+)?$/ },
|
|
{ method: "GET", path: /^\/api\/issues\/[^/]+\/documents\/[^/]+\/revisions$/ },
|
|
{ method: "PUT", path: /^\/api\/issues\/[^/]+\/documents\/[^/]+$/ },
|
|
{ method: "POST", path: /^\/api\/issues\/[^/]+\/checkout$/ },
|
|
{ method: "POST", path: /^\/api\/issues\/[^/]+\/release$/ },
|
|
{ method: "PATCH", path: /^\/api\/issues\/[^/]+$/ },
|
|
{ method: "GET", path: /^\/api\/issues\/[^/]+\/approvals$/ },
|
|
|
|
// Work products: publish branch/commit/artifact metadata for completed work.
|
|
{ method: "GET", path: /^\/api\/issues\/[^/]+\/work-products$/ },
|
|
{ method: "POST", path: /^\/api\/issues\/[^/]+\/work-products$/ },
|
|
{ method: "PATCH", path: /^\/api\/work-products\/[^/]+$/ },
|
|
|
|
// Issue-thread interactions (create, resolve, verdict, and withdraw)
|
|
{ method: "GET", path: /^\/api\/issues\/[^/]+\/interactions(?:\/[^/]+)?$/ },
|
|
{ method: "POST", path: /^\/api\/issues\/[^/]+\/interactions$/ },
|
|
{ method: "POST", path: /^\/api\/issues\/[^/]+\/interactions\/[^/]+\/(?:accept|reject|respond|verdicts|withdraw)$/ },
|
|
|
|
// Subtasks / delegation
|
|
{ method: "POST", path: /^\/api\/companies\/[^/]+\/issues$/ },
|
|
|
|
// Approvals (request, read, comment)
|
|
{ method: "GET", path: /^\/api\/approvals\/[^/]+$/ },
|
|
{ method: "GET", path: /^\/api\/approvals\/[^/]+\/issues$/ },
|
|
{ method: "GET", path: /^\/api\/approvals\/[^/]+\/comments$/ },
|
|
{ method: "POST", path: /^\/api\/approvals\/[^/]+\/comments$/ },
|
|
{ method: "POST", path: /^\/api\/companies\/[^/]+\/approvals$/ },
|
|
|
|
// Execution workspaces and runtime services (start/stop/restart dev servers)
|
|
{ method: "GET", path: /^\/api\/execution-workspaces\/[^/]+$/ },
|
|
{ method: "POST", path: /^\/api\/execution-workspaces\/[^/]+\/runtime-services\/(?:start|stop|restart)$/ },
|
|
|
|
// Routines (agents manage their own routines and triggers)
|
|
{ method: "GET", path: /^\/api\/routines\/[^/]+$/ },
|
|
{ method: "GET", path: /^\/api\/routines\/[^/]+\/runs$/ },
|
|
{ method: "POST", path: /^\/api\/companies\/[^/]+\/routines$/ },
|
|
{ method: "PATCH", path: /^\/api\/routines\/[^/]+$/ },
|
|
{ method: "POST", path: /^\/api\/routines\/[^/]+\/run$/ },
|
|
{ method: "POST", path: /^\/api\/routines\/[^/]+\/triggers$/ },
|
|
{ method: "PATCH", path: /^\/api\/routine-triggers\/[^/]+$/ },
|
|
{ method: "DELETE", path: /^\/api\/routine-triggers\/[^/]+$/ },
|
|
] as const;
|
|
|
|
export const DEFAULT_SANDBOX_CALLBACK_BRIDGE_HEADER_ALLOWLIST = [
|
|
"accept",
|
|
"content-type",
|
|
"if-match",
|
|
"if-none-match",
|
|
] as const;
|
|
|
|
export interface SandboxCallbackBridgeRequest {
|
|
id: string;
|
|
method: string;
|
|
path: string;
|
|
query: string;
|
|
headers: Record<string, string>;
|
|
/**
|
|
* UTF-8 body contents. The bridge rejects non-JSON request bodies; binary
|
|
* payloads are intentionally out of scope for this queue protocol.
|
|
*/
|
|
body: string;
|
|
createdAt: string;
|
|
}
|
|
|
|
export interface SandboxCallbackBridgeResponse {
|
|
id: string;
|
|
status: number;
|
|
headers: Record<string, string>;
|
|
body: string;
|
|
completedAt: string;
|
|
}
|
|
|
|
export interface SandboxCallbackBridgeAsset {
|
|
localDir: string;
|
|
entrypoint: string;
|
|
cleanup(): Promise<void>;
|
|
}
|
|
|
|
export interface SandboxCallbackBridgeDirectories {
|
|
rootDir: string;
|
|
requestsDir: string;
|
|
responsesDir: string;
|
|
logsDir: string;
|
|
readyFile: string;
|
|
pidFile: string;
|
|
logFile: string;
|
|
}
|
|
|
|
export interface SandboxCallbackBridgeQueueClient {
|
|
makeDir(remotePath: string): Promise<void>;
|
|
// Optional batched directory create. The built-in clients create every
|
|
// queue directory in one remote exec. A client that predates this method
|
|
// omits it; the worker falls back to sequential `makeDir` calls, so an
|
|
// external implementation stays compatible without a change.
|
|
makeDirs?(remotePaths: string[]): Promise<void>;
|
|
listJsonFiles(remotePath: string): Promise<string[]>;
|
|
readTextFile(remotePath: string): Promise<string>;
|
|
writeTextFile(remotePath: string, body: string): Promise<void>;
|
|
writeResponseFile?(
|
|
responsePath: string,
|
|
body: string,
|
|
options?: {
|
|
requestPath?: string | null;
|
|
},
|
|
): Promise<{ wrote: boolean }>;
|
|
rename(fromPath: string, toPath: string): Promise<void>;
|
|
remove(remotePath: string): Promise<void>;
|
|
}
|
|
|
|
export interface SandboxCallbackBridgeWorkerHandle {
|
|
stop(options?: { drainTimeoutMs?: number }): Promise<void>;
|
|
}
|
|
|
|
export interface StartedSandboxCallbackBridgeServer {
|
|
baseUrl: string;
|
|
host: string;
|
|
port: number;
|
|
pid: number;
|
|
directories: SandboxCallbackBridgeDirectories;
|
|
stop(): Promise<void>;
|
|
}
|
|
|
|
function shellQuote(value: string) {
|
|
return `'${value.replace(/'/g, `'"'"'`)}'`;
|
|
}
|
|
|
|
function normalizeMethod(value: string | null | undefined): string {
|
|
return typeof value === "string" && value.trim().length > 0 ? value.trim().toUpperCase() : "GET";
|
|
}
|
|
|
|
function normalizeTimeoutMs(value: number | null | undefined, fallback: number): number {
|
|
return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.trunc(value) : fallback;
|
|
}
|
|
|
|
function toBuffer(bytes: Buffer | Uint8Array | ArrayBuffer): Buffer {
|
|
if (Buffer.isBuffer(bytes)) return bytes;
|
|
if (bytes instanceof ArrayBuffer) return Buffer.from(bytes);
|
|
return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
}
|
|
|
|
function buildRunnerFailureMessage(action: string, result: RunProcessResult): string {
|
|
const stderr = result.stderr.trim();
|
|
const stdout = result.stdout.trim();
|
|
const detail = stderr || stdout;
|
|
if (result.timedOut) {
|
|
return `${action} timed out${detail ? `: ${detail}` : ""}`;
|
|
}
|
|
return `${action} failed with exit code ${result.exitCode ?? "null"}${detail ? `: ${detail}` : ""}`;
|
|
}
|
|
|
|
async function runShell(
|
|
runner: CommandManagedRuntimeRunner,
|
|
cwd: string,
|
|
script: string,
|
|
timeoutMs: number,
|
|
shellCommand: "bash" | "sh" = "sh",
|
|
stdin?: string,
|
|
): Promise<RunProcessResult> {
|
|
return await runner.execute({
|
|
command: shellCommand,
|
|
args: shellCommandArgs(script),
|
|
cwd,
|
|
env: {
|
|
[SANDBOX_EXEC_CHANNEL_ENV]: SANDBOX_EXEC_CHANNEL_BRIDGE,
|
|
},
|
|
timeoutMs,
|
|
stdin,
|
|
// Every command that rides this helper is bridge control-plane plumbing:
|
|
// input delivery, output read, callback relay, and queue/setup bookkeeping.
|
|
// It must run concurrently with the agent, so force it off the persistent
|
|
// session. In streamed mode the agent holds that single serialized session
|
|
// for the whole run; a control write on the same session queues behind the
|
|
// agent command that never returns — a permanent deadlock.
|
|
bypassSession: true,
|
|
});
|
|
}
|
|
|
|
function requireSuccessfulResult(action: string, result: RunProcessResult): RunProcessResult {
|
|
if (!result.timedOut && result.exitCode === 0) return result;
|
|
throw new Error(buildRunnerFailureMessage(action, result));
|
|
}
|
|
|
|
function base64Chunks(body: string): string[] {
|
|
const out: string[] = [];
|
|
for (let offset = 0; offset < body.length; offset += REMOTE_WRITE_BASE64_CHUNK_SIZE) {
|
|
out.push(body.slice(offset, offset + REMOTE_WRITE_BASE64_CHUNK_SIZE));
|
|
}
|
|
return out;
|
|
}
|
|
|
|
async function pathExists(filePath: string): Promise<boolean> {
|
|
return await fs.stat(filePath).then(() => true).catch(() => false);
|
|
}
|
|
|
|
function buildRemotePidLockAcquireScript(lockDirExpr: string, timeoutMessage: string): string[] {
|
|
return [
|
|
"attempts=0",
|
|
`while ! mkdir ${lockDirExpr} 2>/dev/null; do`,
|
|
" holder_pid=\"\"",
|
|
` if [ -s ${lockDirExpr}/pid ]; then`,
|
|
` holder_pid="$(cat ${lockDirExpr}/pid 2>/dev/null || true)"`,
|
|
" fi",
|
|
" if [ -n \"$holder_pid\" ] && ! kill -0 \"$holder_pid\" 2>/dev/null; then",
|
|
` rm -rf ${lockDirExpr}`,
|
|
" continue",
|
|
" fi",
|
|
" attempts=$((attempts + 1))",
|
|
" if [ \"$attempts\" -ge 600 ]; then",
|
|
` echo ${shellQuote(timeoutMessage)} >&2`,
|
|
" exit 1",
|
|
" fi",
|
|
" sleep 0.05",
|
|
"done",
|
|
`printf '%s\\n' "$$" > ${lockDirExpr}/pid`,
|
|
];
|
|
}
|
|
|
|
function buildRemotePidLockCleanupScript(lockDirExpr: string, cleanupLines: string[]): string[] {
|
|
return [
|
|
"cleanup() {",
|
|
...cleanupLines.map((line) => ` ${line}`),
|
|
` rm -rf ${lockDirExpr}`,
|
|
"}",
|
|
"trap cleanup EXIT INT TERM",
|
|
];
|
|
}
|
|
|
|
export function createSandboxCallbackBridgeToken(bytes = DEFAULT_BRIDGE_TOKEN_BYTES): string {
|
|
return randomBytes(bytes).toString("base64url");
|
|
}
|
|
|
|
export function authorizeSandboxCallbackBridgeRequestWithRoutes(
|
|
request: Pick<SandboxCallbackBridgeRequest, "method" | "path">,
|
|
routes: readonly SandboxCallbackBridgeRouteRule[] = DEFAULT_SANDBOX_CALLBACK_BRIDGE_ROUTE_ALLOWLIST,
|
|
): string | null {
|
|
const method = normalizeMethod(request.method);
|
|
return routes.some((route) => route.method === method && route.path.test(request.path))
|
|
? null
|
|
: `Route not allowed: ${method} ${request.path}`;
|
|
}
|
|
|
|
export function sanitizeSandboxCallbackBridgeHeaders(
|
|
headers: Record<string, string>,
|
|
allowlist: readonly string[] = DEFAULT_SANDBOX_CALLBACK_BRIDGE_HEADER_ALLOWLIST,
|
|
): Record<string, string> {
|
|
const allowed = new Set(allowlist.map((header) => header.toLowerCase()));
|
|
return Object.fromEntries(
|
|
Object.entries(headers).filter(([key]) => allowed.has(key.toLowerCase())),
|
|
);
|
|
}
|
|
|
|
export function sandboxCallbackBridgeDirectories(rootDir: string): SandboxCallbackBridgeDirectories {
|
|
return {
|
|
rootDir,
|
|
requestsDir: path.posix.join(rootDir, "requests"),
|
|
responsesDir: path.posix.join(rootDir, "responses"),
|
|
logsDir: path.posix.join(rootDir, "logs"),
|
|
readyFile: path.posix.join(rootDir, "ready.json"),
|
|
pidFile: path.posix.join(rootDir, "server.pid"),
|
|
logFile: path.posix.join(rootDir, "logs", "bridge.log"),
|
|
};
|
|
}
|
|
|
|
export function buildSandboxCallbackBridgeEnv(input: {
|
|
queueDir: string;
|
|
bridgeToken: string;
|
|
host?: string;
|
|
port?: number | null;
|
|
pollIntervalMs?: number | null;
|
|
responseTimeoutMs?: number | null;
|
|
maxQueueDepth?: number | null;
|
|
maxBodyBytes?: number | null;
|
|
}): Record<string, string> {
|
|
return {
|
|
PAPERCLIP_API_BRIDGE_MODE: "queue_v1",
|
|
PAPERCLIP_BRIDGE_QUEUE_DIR: input.queueDir,
|
|
PAPERCLIP_BRIDGE_TOKEN: input.bridgeToken,
|
|
PAPERCLIP_BRIDGE_HOST: input.host?.trim() || "127.0.0.1",
|
|
PAPERCLIP_BRIDGE_PORT: String(input.port && input.port > 0 ? Math.trunc(input.port) : 0),
|
|
PAPERCLIP_BRIDGE_POLL_INTERVAL_MS: String(
|
|
normalizeTimeoutMs(input.pollIntervalMs, DEFAULT_BRIDGE_POLL_INTERVAL_MS),
|
|
),
|
|
PAPERCLIP_BRIDGE_RESPONSE_TIMEOUT_MS: String(
|
|
normalizeTimeoutMs(input.responseTimeoutMs, DEFAULT_BRIDGE_RESPONSE_TIMEOUT_MS),
|
|
),
|
|
PAPERCLIP_BRIDGE_MAX_QUEUE_DEPTH: String(
|
|
normalizeTimeoutMs(input.maxQueueDepth, DEFAULT_BRIDGE_MAX_QUEUE_DEPTH),
|
|
),
|
|
PAPERCLIP_BRIDGE_MAX_BODY_BYTES: String(
|
|
normalizeTimeoutMs(input.maxBodyBytes, DEFAULT_BRIDGE_MAX_BODY_BYTES),
|
|
),
|
|
};
|
|
}
|
|
|
|
export async function createSandboxCallbackBridgeAsset(): Promise<SandboxCallbackBridgeAsset> {
|
|
const localDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-bridge-asset-"));
|
|
const entrypoint = path.join(localDir, SANDBOX_CALLBACK_BRIDGE_ENTRYPOINT);
|
|
await fs.writeFile(entrypoint, getSandboxCallbackBridgeServerSource(), "utf8");
|
|
return {
|
|
localDir,
|
|
entrypoint,
|
|
cleanup: async () => {
|
|
await fs.rm(localDir, { recursive: true, force: true }).catch(() => undefined);
|
|
},
|
|
};
|
|
}
|
|
|
|
export function createFileSystemSandboxCallbackBridgeQueueClient(): SandboxCallbackBridgeQueueClient {
|
|
return {
|
|
makeDir: async (remotePath) => {
|
|
await fs.mkdir(remotePath, { recursive: true });
|
|
},
|
|
makeDirs: async (remotePaths) => {
|
|
for (const remotePath of remotePaths) {
|
|
await fs.mkdir(remotePath, { recursive: true });
|
|
}
|
|
},
|
|
listJsonFiles: async (remotePath) => {
|
|
const entries = await fs.readdir(remotePath, { withFileTypes: true }).catch(() => []);
|
|
return entries
|
|
.filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
|
|
.map((entry) => entry.name)
|
|
.sort((left, right) => left.localeCompare(right));
|
|
},
|
|
readTextFile: async (remotePath) => await fs.readFile(remotePath, "utf8"),
|
|
writeTextFile: async (remotePath, body) => {
|
|
await fs.mkdir(path.posix.dirname(remotePath), { recursive: true });
|
|
await fs.writeFile(remotePath, body, "utf8");
|
|
},
|
|
writeResponseFile: async (responsePath, body, options = {}) => {
|
|
const responseDir = path.posix.dirname(responsePath);
|
|
const tempPath = `${responsePath}.tmp`;
|
|
const lockDir = `${responsePath}.paperclip-write.lock`;
|
|
const lockPidFile = `${lockDir}/pid`;
|
|
if (options.requestPath) {
|
|
const requestExists = await pathExists(options.requestPath);
|
|
if (!requestExists) {
|
|
return { wrote: false };
|
|
}
|
|
}
|
|
await fs.mkdir(responseDir, { recursive: true });
|
|
// PID-liveness mkdir-mutex: mirrors the shell-based bridge mutex so a
|
|
// crashed holder (SIGKILL / OOM) doesn't deadlock subsequent writers
|
|
// for the full timeout window.
|
|
let attempts = 0;
|
|
while (true) {
|
|
try {
|
|
await fs.mkdir(lockDir);
|
|
await fs.writeFile(lockPidFile, `${process.pid}\n`, "utf8");
|
|
break;
|
|
} catch (error) {
|
|
const code = (error as NodeJS.ErrnoException)?.code;
|
|
if (code !== "EEXIST") {
|
|
throw error;
|
|
}
|
|
let holderPid: number | null = null;
|
|
try {
|
|
const raw = await fs.readFile(lockPidFile, "utf8");
|
|
const parsed = Number.parseInt(raw.trim(), 10);
|
|
if (Number.isFinite(parsed) && parsed > 0) holderPid = parsed;
|
|
} catch {
|
|
// pid file missing or unreadable — treat as stale lock
|
|
}
|
|
let holderAlive = false;
|
|
if (holderPid !== null) {
|
|
try {
|
|
process.kill(holderPid, 0);
|
|
holderAlive = true;
|
|
} catch {
|
|
holderAlive = false;
|
|
}
|
|
}
|
|
if (!holderAlive) {
|
|
await fs.rm(lockDir, { recursive: true, force: true }).catch(() => undefined);
|
|
continue;
|
|
}
|
|
attempts += 1;
|
|
if (attempts >= 600) {
|
|
throw new Error("Timed out acquiring sandbox callback bridge response lock.");
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
}
|
|
}
|
|
|
|
try {
|
|
if (options.requestPath) {
|
|
const requestExists = await pathExists(options.requestPath);
|
|
if (!requestExists) {
|
|
return { wrote: false };
|
|
}
|
|
}
|
|
const responseExists = await pathExists(responsePath);
|
|
if (responseExists) {
|
|
return { wrote: false };
|
|
}
|
|
await fs.writeFile(tempPath, body, "utf8");
|
|
await fs.rename(tempPath, responsePath);
|
|
return { wrote: true };
|
|
} finally {
|
|
await fs.rm(tempPath, { force: true }).catch(() => undefined);
|
|
await fs.rm(lockDir, { recursive: true, force: true }).catch(() => undefined);
|
|
}
|
|
},
|
|
rename: async (fromPath, toPath) => {
|
|
await fs.mkdir(path.posix.dirname(toPath), { recursive: true });
|
|
await fs.rename(fromPath, toPath);
|
|
},
|
|
remove: async (remotePath) => {
|
|
await fs.rm(remotePath, { recursive: true, force: true }).catch(() => undefined);
|
|
},
|
|
};
|
|
}
|
|
|
|
export function createCommandManagedSandboxCallbackBridgeQueueClient(input: {
|
|
runner: CommandManagedRuntimeRunner;
|
|
remoteCwd: string;
|
|
timeoutMs?: number | null;
|
|
shellCommand?: "bash" | "sh" | null;
|
|
}): SandboxCallbackBridgeQueueClient {
|
|
const timeoutMs = normalizeTimeoutMs(input.timeoutMs, DEFAULT_BRIDGE_RESPONSE_TIMEOUT_MS);
|
|
const shellCommand = preferredShellForSandbox(input.shellCommand);
|
|
const runChecked = async (action: string, script: string) =>
|
|
requireSuccessfulResult(action, await runShell(input.runner, input.remoteCwd, script, timeoutMs, shellCommand));
|
|
|
|
return {
|
|
makeDir: async (remotePath) => {
|
|
await runChecked(`mkdir ${remotePath}`, `mkdir -p ${shellQuote(remotePath)}`);
|
|
},
|
|
makeDirs: async (remotePaths) => {
|
|
if (remotePaths.length === 0) {
|
|
return;
|
|
}
|
|
const quoted = remotePaths.map((remotePath) => shellQuote(remotePath));
|
|
await runChecked(`mkdir ${remotePaths.join(" ")}`, `mkdir -p ${quoted.join(" ")}`);
|
|
},
|
|
listJsonFiles: async (remotePath) => {
|
|
const result = await runShell(
|
|
input.runner,
|
|
input.remoteCwd,
|
|
[
|
|
`if [ -d ${shellQuote(remotePath)} ]; then`,
|
|
` for file in ${shellQuote(remotePath)}/*.json; do`,
|
|
` [ -f "$file" ] || continue`,
|
|
" basename \"$file\"",
|
|
" done",
|
|
"fi",
|
|
].join("\n"),
|
|
timeoutMs,
|
|
shellCommand,
|
|
);
|
|
requireSuccessfulResult(`list ${remotePath}`, result);
|
|
return result.stdout
|
|
.split(/\r?\n/)
|
|
.map((line) => line.trim())
|
|
.filter((line) => line.length > 0)
|
|
.sort((left, right) => left.localeCompare(right));
|
|
},
|
|
readTextFile: async (remotePath) => {
|
|
const result = await runChecked(`read ${remotePath}`, `base64 < ${shellQuote(remotePath)}`);
|
|
return Buffer.from(result.stdout.replace(/\s+/g, ""), "base64").toString("utf8");
|
|
},
|
|
writeTextFile: async (remotePath, body) => {
|
|
const remoteDir = path.posix.dirname(remotePath);
|
|
const tempPath = `${remotePath}.paperclip-upload.b64`;
|
|
await runChecked(
|
|
`prepare upload ${remotePath}`,
|
|
`mkdir -p ${shellQuote(remoteDir)} && rm -f ${shellQuote(tempPath)} && : > ${shellQuote(tempPath)}`,
|
|
);
|
|
const base64Body = toBuffer(Buffer.from(body, "utf8")).toString("base64");
|
|
for (const chunk of base64Chunks(base64Body)) {
|
|
await runChecked(
|
|
`append upload chunk ${remotePath}`,
|
|
`printf '%s' ${shellQuote(chunk)} >> ${shellQuote(tempPath)}`,
|
|
);
|
|
}
|
|
await runChecked(
|
|
`finalize upload ${remotePath}`,
|
|
`base64 -d < ${shellQuote(tempPath)} > ${shellQuote(remotePath)} && rm -f ${shellQuote(tempPath)}`,
|
|
);
|
|
},
|
|
writeResponseFile: async (responsePath, body, options = {}) => {
|
|
const responseDir = path.posix.dirname(responsePath);
|
|
const tempPath = `${responsePath}.tmp`;
|
|
const lockDir = `${responsePath}.paperclip-write.lock`;
|
|
const requestPath = options.requestPath?.trim() || "";
|
|
const result = await runShell(
|
|
input.runner,
|
|
input.remoteCwd,
|
|
[
|
|
"set -eu",
|
|
`response_dir=${shellQuote(responseDir)}`,
|
|
`response_path=${shellQuote(responsePath)}`,
|
|
`temp_path=${shellQuote(tempPath)}`,
|
|
`lock_dir=${shellQuote(lockDir)}`,
|
|
`request_path=${shellQuote(requestPath)}`,
|
|
"mkdir -p \"$response_dir\"",
|
|
...buildRemotePidLockAcquireScript("\"$lock_dir\"", "Timed out acquiring sandbox callback bridge response lock."),
|
|
...buildRemotePidLockCleanupScript("\"$lock_dir\"", [
|
|
"rm -f \"$temp_path\"",
|
|
]),
|
|
"if [ -n \"$request_path\" ] && [ ! -f \"$request_path\" ]; then",
|
|
" printf '{\"wrote\":false}\\n'",
|
|
" exit 0",
|
|
"fi",
|
|
"if [ -f \"$response_path\" ]; then",
|
|
" printf '{\"wrote\":false}\\n'",
|
|
" exit 0",
|
|
"fi",
|
|
"cat > \"$temp_path\"",
|
|
"mv \"$temp_path\" \"$response_path\"",
|
|
"printf '{\"wrote\":true}\\n'",
|
|
].join("\n"),
|
|
timeoutMs,
|
|
shellCommand,
|
|
body,
|
|
);
|
|
requireSuccessfulResult(`write bridge response ${responsePath}`, result);
|
|
try {
|
|
return {
|
|
wrote: JSON.parse(result.stdout.trim())?.wrote === true,
|
|
};
|
|
} catch (error) {
|
|
throw new Error(
|
|
`Sandbox callback bridge response write wrote invalid result JSON: ${error instanceof Error ? error.message : String(error)}`,
|
|
);
|
|
}
|
|
},
|
|
rename: async (fromPath, toPath) => {
|
|
await runChecked(
|
|
`rename ${fromPath}`,
|
|
`mkdir -p ${shellQuote(path.posix.dirname(toPath))} && mv ${shellQuote(fromPath)} ${shellQuote(toPath)}`,
|
|
);
|
|
},
|
|
remove: async (remotePath) => {
|
|
await runChecked(`remove ${remotePath}`, `rm -rf ${shellQuote(remotePath)}`);
|
|
},
|
|
};
|
|
}
|
|
|
|
async function writeBridgeResponse(
|
|
client: SandboxCallbackBridgeQueueClient,
|
|
requestPath: string,
|
|
responsePath: string,
|
|
response: SandboxCallbackBridgeResponse,
|
|
options: { requireRequestPath?: boolean } = {},
|
|
) {
|
|
const body = `${JSON.stringify(response)}\n`;
|
|
if (client.writeResponseFile) {
|
|
await client.writeResponseFile(responsePath, body, options.requireRequestPath === false ? {} : { requestPath });
|
|
return;
|
|
}
|
|
const tempPath = `${responsePath}.tmp`;
|
|
await client.writeTextFile(tempPath, body);
|
|
await client.rename(tempPath, responsePath);
|
|
}
|
|
|
|
export async function startSandboxCallbackBridgeWorker(input: {
|
|
client: SandboxCallbackBridgeQueueClient;
|
|
queueDir: string;
|
|
pollIntervalMs?: number | null;
|
|
authorizeRequest?: (request: SandboxCallbackBridgeRequest) => string | null | Promise<string | null>;
|
|
handleRequest: (request: SandboxCallbackBridgeRequest) => Promise<{
|
|
status: number;
|
|
headers?: Record<string, string>;
|
|
body?: string;
|
|
}>;
|
|
maxBodyBytes?: number | null;
|
|
// Return the current-run parent-context token. The worker reads it per request
|
|
// and runs the request work under it, so the request `sandbox.exec` span
|
|
// parents to the live run span (`agent.turn` during the turn, `task.run`
|
|
// otherwise). When it is absent, the request work runs with an empty store,
|
|
// exactly like the earlier `runWithoutActiveStep` behavior.
|
|
getRuntimeParentContext?: () => StartupSpanContext | undefined;
|
|
// Wrap each Paperclip-API callback request in a
|
|
// `sandbox.callbackBridge.relayRequest` span, so the request's read, write, and
|
|
// remove execs group under one named span. When it is absent, the request work
|
|
// runs under the run parent with no wrapper span, exactly like the earlier
|
|
// behavior.
|
|
runtimeSpan?: RuntimeSpanRunner;
|
|
}): Promise<SandboxCallbackBridgeWorkerHandle> {
|
|
const pollIntervalMs = normalizeTimeoutMs(input.pollIntervalMs, DEFAULT_BRIDGE_POLL_INTERVAL_MS);
|
|
const maxBodyBytes = normalizeTimeoutMs(input.maxBodyBytes, DEFAULT_BRIDGE_MAX_BODY_BYTES);
|
|
const directories = sandboxCallbackBridgeDirectories(input.queueDir);
|
|
const queueDirectories = [
|
|
directories.rootDir,
|
|
directories.requestsDir,
|
|
directories.responsesDir,
|
|
directories.logsDir,
|
|
];
|
|
if (input.client.makeDirs) {
|
|
await input.client.makeDirs(queueDirectories);
|
|
} else {
|
|
// Backward-compatible fallback for a queue client that omits the batched
|
|
// makeDirs method. Create each queue directory with a single makeDir.
|
|
for (const directory of queueDirectories) {
|
|
await input.client.makeDir(directory);
|
|
}
|
|
}
|
|
|
|
let stopping = false;
|
|
let inFlight = 0;
|
|
let settled = false;
|
|
let stopDeadline = Number.POSITIVE_INFINITY;
|
|
let settleResolve: (() => void) | null = null;
|
|
const settledPromise = new Promise<void>((resolve) => {
|
|
settleResolve = resolve;
|
|
});
|
|
const authorizeRequest = input.authorizeRequest ??
|
|
((request: SandboxCallbackBridgeRequest) => authorizeSandboxCallbackBridgeRequestWithRoutes(request));
|
|
const buildWorkerFailureMessage = (error: unknown) =>
|
|
`Sandbox callback bridge worker failed: ${error instanceof Error ? error.message : String(error)}`;
|
|
|
|
const processRequestFile = async (fileName: string) => {
|
|
const requestPath = path.posix.join(directories.requestsDir, fileName);
|
|
const responsePath = path.posix.join(directories.responsesDir, fileName);
|
|
const raw = await input.client.readTextFile(requestPath);
|
|
let request: SandboxCallbackBridgeRequest;
|
|
try {
|
|
request = JSON.parse(raw) as SandboxCallbackBridgeRequest;
|
|
} catch {
|
|
const requestId = fileName.replace(/\.json$/i, "") || randomUUID();
|
|
await writeBridgeResponse(input.client, requestPath, responsePath, {
|
|
id: requestId,
|
|
status: 400,
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ error: "Invalid bridge request payload." }),
|
|
completedAt: new Date().toISOString(),
|
|
});
|
|
await input.client.remove(requestPath);
|
|
return;
|
|
}
|
|
|
|
const denialReason = await authorizeRequest(request);
|
|
if (denialReason) {
|
|
await writeBridgeResponse(input.client, requestPath, responsePath, {
|
|
id: request.id,
|
|
status: 403,
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ error: denialReason }),
|
|
completedAt: new Date().toISOString(),
|
|
});
|
|
await input.client.remove(requestPath);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const result = await input.handleRequest(request);
|
|
const responseBody = result.body ?? "";
|
|
if (Buffer.byteLength(responseBody, "utf8") > maxBodyBytes) {
|
|
throw new Error(`Bridge response body exceeded the configured size limit of ${maxBodyBytes} bytes.`);
|
|
}
|
|
await writeBridgeResponse(input.client, requestPath, responsePath, {
|
|
id: request.id,
|
|
status: result.status,
|
|
headers: result.headers ?? {},
|
|
body: responseBody,
|
|
completedAt: new Date().toISOString(),
|
|
});
|
|
} catch (error) {
|
|
console.warn(
|
|
`[paperclip] sandbox callback bridge handler failed for ${request.id}: ${error instanceof Error ? error.message : String(error)}`,
|
|
);
|
|
await writeBridgeResponse(input.client, requestPath, responsePath, {
|
|
id: request.id,
|
|
status: 502,
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({
|
|
error: error instanceof Error ? error.message : String(error),
|
|
}),
|
|
completedAt: new Date().toISOString(),
|
|
});
|
|
} finally {
|
|
await input.client.remove(requestPath);
|
|
}
|
|
};
|
|
|
|
const failPendingRequests = async (message: string) => {
|
|
const fileNames = await input.client.listJsonFiles(directories.requestsDir).catch(() => []);
|
|
for (const fileName of fileNames) {
|
|
const requestPath = path.posix.join(directories.requestsDir, fileName);
|
|
const responsePath = path.posix.join(directories.responsesDir, fileName);
|
|
const requestId = fileName.replace(/\.json$/i, "") || randomUUID();
|
|
try {
|
|
const raw = await input.client.readTextFile(requestPath);
|
|
const parsed = JSON.parse(raw) as Partial<SandboxCallbackBridgeRequest>;
|
|
await input.client.remove(requestPath).catch(() => undefined);
|
|
await writeBridgeResponse(input.client, requestPath, responsePath, {
|
|
id: typeof parsed.id === "string" && parsed.id.length > 0 ? parsed.id : requestId,
|
|
status: 503,
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ error: message }),
|
|
completedAt: new Date().toISOString(),
|
|
}, {
|
|
requireRequestPath: false,
|
|
});
|
|
} catch (error) {
|
|
console.warn(
|
|
`[paperclip] sandbox callback bridge failed to abort pending request ${requestId}: ${error instanceof Error ? error.message : String(error)}`,
|
|
);
|
|
} finally {
|
|
await input.client.remove(requestPath).catch(() => undefined);
|
|
}
|
|
}
|
|
};
|
|
|
|
// Start the long-lived poll loop outside the measured startup-step store.
|
|
// The `makeDir` calls above are startup work and must keep the active
|
|
// `bridge.paperclip` step. The loop runs run-time execs for the whole run,
|
|
// so each loop `sandbox.exec` span must not parent to the ended step or copy
|
|
// its `criticalPath` flag. `runWithoutActiveStep` empties the store for the
|
|
// loop only; Node keeps the empty store on every later poll continuation.
|
|
const loop = runWithoutActiveStep(() => (async () => {
|
|
try {
|
|
while (true) {
|
|
const fileNames = await input.client.listJsonFiles(directories.requestsDir);
|
|
if (fileNames.length === 0) {
|
|
if (stopping) {
|
|
break;
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
|
|
continue;
|
|
}
|
|
for (const fileName of fileNames) {
|
|
if (stopping && Date.now() >= stopDeadline) break;
|
|
inFlight += 1;
|
|
try {
|
|
// A request is run-time work, not startup work. Wrap it in a
|
|
// `sandbox.callbackBridge.relayRequest` span, so its read, write, and
|
|
// remove execs group under one named span that parents to the live
|
|
// run span. The span runner reads the run parent per request: the
|
|
// live parent switches to `agent.turn` during the turn and back to
|
|
// `task.run` after it. Without a runner, the request runs under the
|
|
// run parent with no wrapper span, exactly like the earlier behavior.
|
|
await (input.runtimeSpan
|
|
? input.runtimeSpan(CALLBACK_BRIDGE_RELAY_REQUEST_SPAN, () =>
|
|
processRequestFile(fileName),
|
|
)
|
|
: runWithRuntimeParent(input.getRuntimeParentContext?.(), () =>
|
|
processRequestFile(fileName),
|
|
));
|
|
} finally {
|
|
inFlight -= 1;
|
|
}
|
|
}
|
|
if (stopping && Date.now() >= stopDeadline) {
|
|
break;
|
|
}
|
|
}
|
|
} catch (error) {
|
|
const message = buildWorkerFailureMessage(error);
|
|
console.warn(`[paperclip] ${message}`);
|
|
try {
|
|
await failPendingRequests(message);
|
|
} catch (failPendingError) {
|
|
console.warn(
|
|
`[paperclip] sandbox callback bridge failed to abort queued requests after worker failure: ${failPendingError instanceof Error ? failPendingError.message : String(failPendingError)}`,
|
|
);
|
|
}
|
|
} finally {
|
|
settled = true;
|
|
if (settleResolve) {
|
|
settleResolve();
|
|
}
|
|
}
|
|
})());
|
|
|
|
void loop;
|
|
|
|
return {
|
|
stop: async (options = {}) => {
|
|
stopping = true;
|
|
const drainMs = normalizeTimeoutMs(options.drainTimeoutMs, DEFAULT_BRIDGE_STOP_TIMEOUT_MS);
|
|
stopDeadline = Date.now() + drainMs;
|
|
if (!settled) {
|
|
await Promise.race([
|
|
settledPromise,
|
|
new Promise<void>((resolve) => setTimeout(resolve, drainMs)),
|
|
]);
|
|
}
|
|
await failPendingRequests("Bridge worker stopped before request could be handled.");
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Content-hash-skip write of a Paperclip-authored text file into the sandbox, in
|
|
* a SINGLE remote exec. The body's sha256 is computed on the host; the one shell
|
|
* round-trip skips the write entirely when the remote file already hashes to the
|
|
* same value (warm start — 0 write execs), otherwise it uploads (base64 over
|
|
* stdin), verifies the decoded bytes, and atomically renames into place. A
|
|
* PID-liveness lock serializes concurrent writers to the same path and the
|
|
* verify step guards against a torn upload.
|
|
*
|
|
* Fail loudly: a non-zero remote exit (surfaced by `requireSuccessfulResult`) or
|
|
* malformed result JSON throws rather than silently re-uploading and masking a
|
|
* failed check. The only intentional degradation is when the remote has neither
|
|
* `sha256sum` nor `shasum` — then the skip cannot be proven and we conservatively
|
|
* re-upload (and the post-upload verify is best-effort, as noted inline).
|
|
*/
|
|
export async function syncRemoteTextFileWithHashSkip(input: {
|
|
runner: CommandManagedRuntimeRunner;
|
|
remoteCwd: string;
|
|
remoteDir: string;
|
|
remotePath: string;
|
|
body: string;
|
|
// Human-readable noun phrase used in fail-loud messages, e.g.
|
|
// "Sandbox callback bridge entrypoint" / "Process session remote script".
|
|
label: string;
|
|
// Short action label for `requireSuccessfulResult`, e.g.
|
|
// "sync sandbox callback bridge entrypoint".
|
|
action: string;
|
|
lockDir: string;
|
|
timeoutMs?: number | null;
|
|
shellCommand?: "bash" | "sh" | null;
|
|
}): Promise<{ uploaded: boolean; sha256: string }> {
|
|
const timeoutMs = normalizeTimeoutMs(input.timeoutMs, DEFAULT_BRIDGE_RESPONSE_TIMEOUT_MS);
|
|
const shellCommand = preferredShellForSandbox(input.shellCommand);
|
|
const remotePartial = `${input.remotePath}.partial`;
|
|
const remoteUploadPath = `${input.remotePath}.paperclip-upload.b64`;
|
|
const base64Body = toBuffer(Buffer.from(input.body, "utf8")).toString("base64");
|
|
const sha256 = createHash("sha256").update(input.body, "utf8").digest("hex");
|
|
|
|
const syncResult = await runShell(
|
|
input.runner,
|
|
input.remoteCwd,
|
|
[
|
|
"set -eu",
|
|
`remote_dir=${shellQuote(input.remoteDir)}`,
|
|
`remote_path=${shellQuote(input.remotePath)}`,
|
|
`remote_partial=${shellQuote(remotePartial)}`,
|
|
`remote_upload=${shellQuote(remoteUploadPath)}`,
|
|
`lock_dir=${shellQuote(input.lockDir)}`,
|
|
`expected_sha=${shellQuote(sha256)}`,
|
|
"hash_file() {",
|
|
" if command -v sha256sum >/dev/null 2>&1; then",
|
|
" sha256sum \"$1\" | awk '{print $1}'",
|
|
" return 0",
|
|
" fi",
|
|
" if command -v shasum >/dev/null 2>&1; then",
|
|
" shasum -a 256 \"$1\" | awk '{print $1}'",
|
|
" return 0",
|
|
" fi",
|
|
" return 127",
|
|
"}",
|
|
"mkdir -p \"$remote_dir\"",
|
|
...buildRemotePidLockAcquireScript("\"$lock_dir\"", `Timed out acquiring ${input.label} upload lock.`),
|
|
...buildRemotePidLockCleanupScript("\"$lock_dir\"", [
|
|
"rm -f \"$remote_upload\" \"$remote_partial\"",
|
|
]),
|
|
"current_sha=\"\"",
|
|
"if [ -f \"$remote_path\" ]; then",
|
|
" current_sha=\"$(hash_file \"$remote_path\" 2>/dev/null)\" || current_sha=\"\"",
|
|
"fi",
|
|
"if [ -n \"$current_sha\" ] && [ \"$current_sha\" = \"$expected_sha\" ]; then",
|
|
" printf '{\"uploaded\":false}\\n'",
|
|
" exit 0",
|
|
"fi",
|
|
"rm -f \"$remote_upload\" \"$remote_partial\"",
|
|
"cat > \"$remote_upload\"",
|
|
"base64 -d < \"$remote_upload\" > \"$remote_partial\"",
|
|
// Verify upload integrity. If neither sha256sum nor shasum is on PATH
|
|
// (minimal Alpine/scratch images), surface the missing-tool error
|
|
// instead of a misleading "sha mismatch" — the verify step is then
|
|
// best-effort and we trust base64-decode + atomic rename below.
|
|
"if partial_sha=\"$(hash_file \"$remote_partial\" 2>/dev/null)\"; then",
|
|
" if [ \"$partial_sha\" != \"$expected_sha\" ]; then",
|
|
` echo ${shellQuote(`${input.label} upload sha mismatch.`)} >&2`,
|
|
" exit 1",
|
|
" fi",
|
|
"else",
|
|
` echo ${shellQuote(`${input.label} sha verify skipped: no sha256sum/shasum on remote.`)} >&2`,
|
|
"fi",
|
|
"mv \"$remote_partial\" \"$remote_path\"",
|
|
"printf '{\"uploaded\":true}\\n'",
|
|
].join("\n"),
|
|
timeoutMs,
|
|
shellCommand,
|
|
base64Body,
|
|
);
|
|
requireSuccessfulResult(input.action, syncResult);
|
|
|
|
let uploaded = false;
|
|
try {
|
|
uploaded = JSON.parse(syncResult.stdout.trim())?.uploaded === true;
|
|
} catch (error) {
|
|
throw new Error(
|
|
`${input.label} sync wrote invalid result JSON: ${error instanceof Error ? error.message : String(error)}`,
|
|
);
|
|
}
|
|
|
|
return { uploaded, sha256 };
|
|
}
|
|
|
|
export async function syncSandboxCallbackBridgeEntrypoint(input: {
|
|
runner: CommandManagedRuntimeRunner;
|
|
remoteCwd: string;
|
|
assetRemoteDir: string;
|
|
bridgeAsset: SandboxCallbackBridgeAsset;
|
|
timeoutMs?: number | null;
|
|
shellCommand?: "bash" | "sh" | null;
|
|
}): Promise<{ remoteEntrypoint: string; sha256: string; uploaded: boolean }> {
|
|
const remoteEntrypoint = path.posix.join(input.assetRemoteDir, SANDBOX_CALLBACK_BRIDGE_ENTRYPOINT);
|
|
const entrypointSource = await fs.readFile(input.bridgeAsset.entrypoint, "utf8");
|
|
const { uploaded, sha256 } = await syncRemoteTextFileWithHashSkip({
|
|
runner: input.runner,
|
|
remoteCwd: input.remoteCwd,
|
|
remoteDir: input.assetRemoteDir,
|
|
remotePath: remoteEntrypoint,
|
|
body: entrypointSource,
|
|
label: "Sandbox callback bridge entrypoint",
|
|
action: "sync sandbox callback bridge entrypoint",
|
|
lockDir: path.posix.join(input.assetRemoteDir, ".paperclip-bridge-upload.lock"),
|
|
timeoutMs: input.timeoutMs,
|
|
shellCommand: input.shellCommand,
|
|
});
|
|
|
|
return {
|
|
remoteEntrypoint,
|
|
sha256,
|
|
uploaded,
|
|
};
|
|
}
|
|
|
|
export async function startSandboxCallbackBridgeServer(input: {
|
|
runner: CommandManagedRuntimeRunner;
|
|
remoteCwd: string;
|
|
assetRemoteDir: string;
|
|
queueDir: string;
|
|
bridgeToken: string;
|
|
bridgeAsset?: SandboxCallbackBridgeAsset | null;
|
|
host?: string;
|
|
port?: number | null;
|
|
pollIntervalMs?: number | null;
|
|
responseTimeoutMs?: number | null;
|
|
timeoutMs?: number | null;
|
|
nodeCommand?: string;
|
|
shellCommand?: "bash" | "sh" | null;
|
|
maxQueueDepth?: number | null;
|
|
maxBodyBytes?: number | null;
|
|
}): Promise<StartedSandboxCallbackBridgeServer> {
|
|
const timeoutMs = normalizeTimeoutMs(input.timeoutMs, DEFAULT_BRIDGE_RESPONSE_TIMEOUT_MS);
|
|
const shellCommand = preferredShellForSandbox(input.shellCommand);
|
|
const directories = sandboxCallbackBridgeDirectories(input.queueDir);
|
|
let remoteEntrypoint = path.posix.join(input.assetRemoteDir, SANDBOX_CALLBACK_BRIDGE_ENTRYPOINT);
|
|
if (input.bridgeAsset) {
|
|
const assetSync = await syncSandboxCallbackBridgeEntrypoint({
|
|
runner: input.runner,
|
|
remoteCwd: input.remoteCwd,
|
|
assetRemoteDir: input.assetRemoteDir,
|
|
bridgeAsset: input.bridgeAsset,
|
|
timeoutMs,
|
|
shellCommand,
|
|
});
|
|
remoteEntrypoint = assetSync.remoteEntrypoint;
|
|
}
|
|
const env = buildSandboxCallbackBridgeEnv({
|
|
queueDir: input.queueDir,
|
|
bridgeToken: input.bridgeToken,
|
|
host: input.host,
|
|
port: input.port,
|
|
pollIntervalMs: input.pollIntervalMs,
|
|
responseTimeoutMs: input.responseTimeoutMs,
|
|
maxQueueDepth: input.maxQueueDepth,
|
|
maxBodyBytes: input.maxBodyBytes,
|
|
});
|
|
const nodeCommand = input.nodeCommand?.trim() || "node";
|
|
const startResult = await input.runner.execute({
|
|
command: shellCommand,
|
|
args: shellCommandArgs(
|
|
[
|
|
`mkdir -p ${shellQuote(directories.requestsDir)} ${shellQuote(directories.responsesDir)} ${shellQuote(directories.logsDir)}`,
|
|
`rm -f ${shellQuote(directories.readyFile)} ${shellQuote(directories.pidFile)}`,
|
|
`nohup ${shellQuote(nodeCommand)} ${shellQuote(remoteEntrypoint)} ` +
|
|
`>> ${shellQuote(directories.logFile)} 2>&1 < /dev/null &`,
|
|
"pid=$!",
|
|
`printf '%s\\n' \"$pid\" > ${shellQuote(directories.pidFile)}`,
|
|
"printf '{\"pid\":%s}\\n' \"$pid\"",
|
|
].join("\n"),
|
|
),
|
|
cwd: input.remoteCwd,
|
|
env: {
|
|
[SANDBOX_EXEC_CHANNEL_ENV]: SANDBOX_EXEC_CHANNEL_BRIDGE,
|
|
...env,
|
|
},
|
|
timeoutMs,
|
|
});
|
|
requireSuccessfulResult("start sandbox callback bridge", startResult);
|
|
|
|
const readyResult = await runShell(
|
|
input.runner,
|
|
input.remoteCwd,
|
|
[
|
|
"i=0",
|
|
`while [ \"$i\" -lt 200 ]; do`,
|
|
` if [ -s ${shellQuote(directories.readyFile)} ]; then`,
|
|
` cat ${shellQuote(directories.readyFile)}`,
|
|
" exit 0",
|
|
" fi",
|
|
` if [ -s ${shellQuote(directories.logFile)} ] && ! kill -0 \"$(cat ${shellQuote(directories.pidFile)} 2>/dev/null)\" 2>/dev/null; then`,
|
|
` cat ${shellQuote(directories.logFile)} >&2`,
|
|
" exit 1",
|
|
" fi",
|
|
" i=$((i + 1))",
|
|
" sleep 0.05",
|
|
"done",
|
|
`echo "Timed out waiting for bridge readiness." >&2`,
|
|
`if [ -s ${shellQuote(directories.logFile)} ]; then cat ${shellQuote(directories.logFile)} >&2; fi`,
|
|
"exit 1",
|
|
].join("\n"),
|
|
timeoutMs,
|
|
shellCommand,
|
|
);
|
|
requireSuccessfulResult("wait for sandbox callback bridge readiness", readyResult);
|
|
|
|
let readyData: { host?: string; port?: number; baseUrl?: string; pid?: number };
|
|
try {
|
|
readyData = JSON.parse(readyResult.stdout.trim()) as { host?: string; port?: number; baseUrl?: string; pid?: number };
|
|
} catch (error) {
|
|
throw new Error(
|
|
`Sandbox callback bridge wrote invalid readiness JSON: ${error instanceof Error ? error.message : String(error)}`,
|
|
);
|
|
}
|
|
|
|
const host = typeof readyData.host === "string" && readyData.host.trim().length > 0
|
|
? readyData.host.trim()
|
|
: "127.0.0.1";
|
|
const port = typeof readyData.port === "number" && Number.isFinite(readyData.port) ? readyData.port : 0;
|
|
if (!port) {
|
|
throw new Error("Sandbox callback bridge did not report a listening port.");
|
|
}
|
|
const baseUrl =
|
|
typeof readyData.baseUrl === "string" && readyData.baseUrl.trim().length > 0
|
|
? readyData.baseUrl.trim()
|
|
: `http://${host}:${port}`;
|
|
|
|
return {
|
|
baseUrl,
|
|
host,
|
|
port,
|
|
pid: typeof readyData.pid === "number" && Number.isFinite(readyData.pid) ? readyData.pid : 0,
|
|
directories,
|
|
stop: async () => {
|
|
const stopResult = await input.runner.execute({
|
|
command: shellCommand,
|
|
args: shellCommandArgs(
|
|
[
|
|
`if [ -s ${shellQuote(directories.pidFile)} ]; then`,
|
|
` pid="$(cat ${shellQuote(directories.pidFile)})"`,
|
|
" kill \"$pid\" 2>/dev/null || true",
|
|
" i=0",
|
|
" while kill -0 \"$pid\" 2>/dev/null && [ \"$i\" -lt 40 ]; do",
|
|
" i=$((i + 1))",
|
|
" sleep 0.05",
|
|
" done",
|
|
"fi",
|
|
`rm -f ${shellQuote(directories.pidFile)} ${shellQuote(directories.readyFile)}`,
|
|
].join("\n"),
|
|
),
|
|
cwd: input.remoteCwd,
|
|
env: {
|
|
[SANDBOX_EXEC_CHANNEL_ENV]: SANDBOX_EXEC_CHANNEL_BRIDGE,
|
|
},
|
|
timeoutMs,
|
|
});
|
|
if (stopResult.timedOut) {
|
|
throw new Error(buildRunnerFailureMessage("stop sandbox callback bridge", stopResult));
|
|
}
|
|
},
|
|
};
|
|
}
|
|
|
|
function getSandboxCallbackBridgeServerSource(): string {
|
|
return `import { randomUUID, timingSafeEqual } from "node:crypto";
|
|
import { createServer } from "node:http";
|
|
import { promises as fs } from "node:fs";
|
|
import path from "node:path";
|
|
|
|
const queueDir = process.env.PAPERCLIP_BRIDGE_QUEUE_DIR;
|
|
const bridgeToken = process.env.PAPERCLIP_BRIDGE_TOKEN;
|
|
const host = process.env.PAPERCLIP_BRIDGE_HOST || "127.0.0.1";
|
|
const port = Number(process.env.PAPERCLIP_BRIDGE_PORT || "0");
|
|
const pollIntervalMs = Number(process.env.PAPERCLIP_BRIDGE_POLL_INTERVAL_MS || "100");
|
|
const responseTimeoutMs = Number(process.env.PAPERCLIP_BRIDGE_RESPONSE_TIMEOUT_MS || "30000");
|
|
const maxQueueDepth = Number(process.env.PAPERCLIP_BRIDGE_MAX_QUEUE_DEPTH || "${DEFAULT_BRIDGE_MAX_QUEUE_DEPTH}");
|
|
const maxBodyBytes = Number(process.env.PAPERCLIP_BRIDGE_MAX_BODY_BYTES || "${DEFAULT_BRIDGE_MAX_BODY_BYTES}");
|
|
const allowedHeaders = new Set(${JSON.stringify([...DEFAULT_SANDBOX_CALLBACK_BRIDGE_HEADER_ALLOWLIST])});
|
|
|
|
if (!queueDir || !bridgeToken) {
|
|
throw new Error("PAPERCLIP_BRIDGE_QUEUE_DIR and PAPERCLIP_BRIDGE_TOKEN are required.");
|
|
}
|
|
|
|
const requestsDir = path.posix.join(queueDir, "requests");
|
|
const responsesDir = path.posix.join(queueDir, "responses");
|
|
const logsDir = path.posix.join(queueDir, "logs");
|
|
const readyFile = path.posix.join(queueDir, "ready.json");
|
|
|
|
function sleep(ms) {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
function normalizeHeaders(headers) {
|
|
const out = {};
|
|
for (const [key, value] of Object.entries(headers)) {
|
|
if (value == null) continue;
|
|
const normalizedKey = key.toLowerCase();
|
|
if (!allowedHeaders.has(normalizedKey)) {
|
|
continue;
|
|
}
|
|
out[normalizedKey] = Array.isArray(value) ? value.join(", ") : String(value);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
async function readBody(req) {
|
|
const chunks = [];
|
|
let totalBytes = 0;
|
|
for await (const chunk of req) {
|
|
const nextChunk = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
chunks.push(nextChunk);
|
|
totalBytes += nextChunk.byteLength;
|
|
if (totalBytes > maxBodyBytes) {
|
|
throw new Error("Bridge request body exceeded the configured size limit.");
|
|
}
|
|
}
|
|
return Buffer.concat(chunks).toString("utf8");
|
|
}
|
|
|
|
async function queueDepth() {
|
|
const entries = await fs.readdir(requestsDir, { withFileTypes: true }).catch(() => []);
|
|
return entries.filter((entry) => entry.isFile() && entry.name.endsWith(".json")).length;
|
|
}
|
|
|
|
function tokensMatch(received) {
|
|
const expected = Buffer.from(bridgeToken, "utf8");
|
|
const actual = Buffer.from(typeof received === "string" ? received : "", "utf8");
|
|
if (expected.length !== actual.length) return false;
|
|
return timingSafeEqual(expected, actual);
|
|
}
|
|
|
|
async function waitForResponse(requestId) {
|
|
const responsePath = path.posix.join(responsesDir, \`\${requestId}.json\`);
|
|
const deadline = Date.now() + responseTimeoutMs;
|
|
while (Date.now() < deadline) {
|
|
const body = await fs.readFile(responsePath, "utf8").catch(() => null);
|
|
if (body != null) {
|
|
await fs.rm(responsePath, { force: true }).catch(() => undefined);
|
|
return JSON.parse(body);
|
|
}
|
|
await sleep(pollIntervalMs);
|
|
}
|
|
throw new Error("Timed out waiting for host bridge response.");
|
|
}
|
|
|
|
const server = createServer(async (req, res) => {
|
|
try {
|
|
const auth = req.headers.authorization || "";
|
|
const receivedToken = auth.startsWith("Bearer ") ? auth.slice("Bearer ".length) : "";
|
|
if (!tokensMatch(receivedToken)) {
|
|
res.statusCode = 401;
|
|
res.setHeader("content-type", "application/json");
|
|
res.end(JSON.stringify({ error: "Invalid bridge token." }));
|
|
return;
|
|
}
|
|
|
|
if (await queueDepth() >= maxQueueDepth) {
|
|
res.statusCode = 503;
|
|
res.setHeader("content-type", "application/json");
|
|
res.end(JSON.stringify({ error: "Bridge request queue is full." }));
|
|
return;
|
|
}
|
|
|
|
const url = new URL(req.url || "/", "http://127.0.0.1");
|
|
const contentType = typeof req.headers["content-type"] === "string" ? req.headers["content-type"] : "";
|
|
if (req.method && req.method !== "GET" && req.method !== "HEAD" && !/json/i.test(contentType)) {
|
|
res.statusCode = 415;
|
|
res.setHeader("content-type", "application/json");
|
|
res.end(JSON.stringify({ error: "Bridge only accepts JSON request bodies." }));
|
|
return;
|
|
}
|
|
const requestId = randomUUID();
|
|
const requestBody = await readBody(req);
|
|
const payload = {
|
|
id: requestId,
|
|
method: req.method || "GET",
|
|
path: url.pathname,
|
|
query: url.search,
|
|
headers: normalizeHeaders(req.headers),
|
|
body: requestBody,
|
|
createdAt: new Date().toISOString(),
|
|
};
|
|
const requestPath = path.posix.join(requestsDir, \`\${requestId}.json\`);
|
|
const tempPath = \`\${requestPath}.tmp\`;
|
|
await fs.writeFile(tempPath, \`\${JSON.stringify(payload)}\\n\`, "utf8");
|
|
await fs.rename(tempPath, requestPath);
|
|
|
|
const response = await waitForResponse(requestId);
|
|
res.statusCode = typeof response.status === "number" ? response.status : 200;
|
|
for (const [key, value] of Object.entries(response.headers || {})) {
|
|
if (typeof value !== "string" || key.toLowerCase() === "content-length") continue;
|
|
res.setHeader(key, value);
|
|
}
|
|
res.end(typeof response.body === "string" ? response.body : "");
|
|
} catch (error) {
|
|
res.statusCode = 502;
|
|
res.setHeader("content-type", "application/json");
|
|
res.end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }));
|
|
}
|
|
});
|
|
|
|
async function shutdown() {
|
|
server.close(() => {
|
|
process.exit(0);
|
|
});
|
|
}
|
|
|
|
process.on("SIGINT", () => void shutdown());
|
|
process.on("SIGTERM", () => void shutdown());
|
|
|
|
await fs.mkdir(requestsDir, { recursive: true });
|
|
await fs.mkdir(responsesDir, { recursive: true });
|
|
await fs.mkdir(logsDir, { recursive: true });
|
|
|
|
server.listen(port, host, async () => {
|
|
const address = server.address();
|
|
if (!address || typeof address === "string") {
|
|
throw new Error("Bridge server did not expose a TCP address.");
|
|
}
|
|
const ready = {
|
|
pid: process.pid,
|
|
host,
|
|
port: address.port,
|
|
baseUrl: \`http://\${host}:\${address.port}\`,
|
|
startedAt: new Date().toISOString(),
|
|
};
|
|
const tempReadyFile = \`\${readyFile}.tmp\`;
|
|
await fs.writeFile(tempReadyFile, JSON.stringify(ready), "utf8");
|
|
await fs.rename(tempReadyFile, readyFile);
|
|
});`;
|
|
}
|