mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-03 17:51:58 +01:00
## Thinking Path > - Paperclip keeps agent work on a controlled execution plane. > - Sandbox exec wrappers run on the hot path for agent commands. > - The current change removes the explicit `nvm.sh` load step from those wrappers. > - The sandbox image already restores PATH through profile startup. > - This pull request keeps profile sourcing where the wrapper still needs it and drops only the `nvm.sh` load step. > - The result is a smaller command path with the same node and agent CLI resolution. ## Linked Issues or Issue Description No public GitHub issue exists for this change. Problem: The sandbox exec wrappers spent extra time sourcing `nvm.sh` before each command. The sandbox image already restores PATH in `/etc/profile.d/00-restore-env.sh`, so that explicit `nvm.sh` work was redundant. Proposed solution: Remove the `nvm.sh` source step from all six wrappers. Keep the profile sourcing that the provider still needs for PATH setup. Alternatives considered: Keep the existing shell setup and accept the launch cost. That keeps the current behavior, but it leaves the hot path slower than needed. Roadmap alignment: This change keeps the sandbox command path small and predictable. It does not change the adapter contract or the node resolution rules. ## What Changed - Removed `nvm.sh` sourcing from all six sandbox exec wrappers. - Kept profile sourcing where the provider still needs it for PATH setup. - Switched Modal to a non-login shell because the script now sources profiles itself. - Updated wrapper tests to assert that built commands do not source `nvm.sh`. ## Verification - Local TypeScript typecheck passed in each changed package. - Focused provider tests passed for Daytona, E2B, Modal, exe-dev, Cloudflare bridge, and adapter-utils. - One Daytona test failure is pre-existing and unrelated to this change. ## Risks - This change alters shell startup for sandbox exec paths. - A provider that depends on implicit shell setup may need a follow-up. - The current tests cover command shape, but they do not cover every runtime shell path. ## Model Used OpenAI Codex, GPT-5, tool use. ## 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 or instance-local Paperclip issues or links - [x] My branch name describes the change 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 - [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>
149 lines
5.0 KiB
TypeScript
149 lines
5.0 KiB
TypeScript
import type { Sandbox as CloudflareSandbox } from "@cloudflare/sandbox";
|
|
import { shellQuote } from "./helpers.js";
|
|
import { isTimeoutError } from "./sandboxes.js";
|
|
import { cleanupTimedOutExecution, resolveExecutionTarget, type SessionStrategy } from "./sessions.js";
|
|
|
|
export interface BridgeExecuteParams {
|
|
sandbox: CloudflareSandbox;
|
|
command: string;
|
|
args?: string[];
|
|
cwd?: string;
|
|
env?: Record<string, string>;
|
|
stdin?: string | null;
|
|
timeoutMs?: number;
|
|
sessionStrategy: SessionStrategy;
|
|
sessionId?: string;
|
|
onOutput?: (stream: "stdout" | "stderr", data: string) => void | Promise<void>;
|
|
}
|
|
|
|
function isValidShellEnvKey(value: string): boolean {
|
|
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(value);
|
|
}
|
|
|
|
function randomToken(): string {
|
|
const uuid = globalThis.crypto?.randomUUID?.();
|
|
if (typeof uuid === "string" && uuid.length > 0) return uuid.replace(/[^a-zA-Z0-9-]/g, "");
|
|
return `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
}
|
|
|
|
export function buildLoginShellScript(input: {
|
|
command: string;
|
|
args: string[];
|
|
cwd?: string;
|
|
env?: Record<string, string>;
|
|
stdinFile?: string | null;
|
|
}): string {
|
|
const env = input.env ?? {};
|
|
for (const key of Object.keys(env)) {
|
|
if (!isValidShellEnvKey(key)) {
|
|
throw new Error(`Invalid sandbox environment variable key: ${key}`);
|
|
}
|
|
}
|
|
|
|
const envArgs = Object.entries(env)
|
|
.filter((entry): entry is [string, string] => typeof entry[1] === "string")
|
|
.map(([key, value]) => `${key}=${shellQuote(value)}`);
|
|
const commandParts = [shellQuote(input.command), ...input.args.map(shellQuote)].join(" ");
|
|
const stdinRedirect = input.stdinFile ? ` < ${shellQuote(input.stdinFile)}` : "";
|
|
// Source the common login profiles before exec so the command runs with the
|
|
// interactive-shell PATH. The wrapper sources no `nvm.sh`; the sandbox image
|
|
// supplies node on the PATH.
|
|
const lines = [
|
|
'if [ -f /etc/profile ]; then . /etc/profile >/dev/null 2>&1 || true; fi',
|
|
'if [ -f "$HOME/.profile" ]; then . "$HOME/.profile" >/dev/null 2>&1 || true; fi',
|
|
'if [ -f "$HOME/.bash_profile" ]; then . "$HOME/.bash_profile" >/dev/null 2>&1 || true; elif [ -f "$HOME/.bashrc" ]; then . "$HOME/.bashrc" >/dev/null 2>&1 || true; fi',
|
|
'if [ -f "$HOME/.zprofile" ]; then . "$HOME/.zprofile" >/dev/null 2>&1 || true; fi',
|
|
];
|
|
if (input.cwd) {
|
|
lines.push(`cd ${shellQuote(input.cwd)}`);
|
|
}
|
|
const execLine = envArgs.length > 0
|
|
? `exec env ${envArgs.join(" ")} ${commandParts}${stdinRedirect}`
|
|
: `exec ${commandParts}${stdinRedirect}`;
|
|
lines.push(execLine);
|
|
return lines.join(" && ");
|
|
}
|
|
|
|
function coerceExecuteResult(result: {
|
|
success?: boolean;
|
|
stdout?: string;
|
|
stderr?: string;
|
|
exitCode?: number | null;
|
|
}) {
|
|
return {
|
|
exitCode:
|
|
typeof result.exitCode === "number" || result.exitCode === null
|
|
? result.exitCode
|
|
: result.success === false
|
|
? 1
|
|
: 0,
|
|
signal: null,
|
|
timedOut: false,
|
|
stdout: result.stdout ?? "",
|
|
stderr: result.stderr ?? "",
|
|
};
|
|
}
|
|
|
|
export async function executeInSandbox(params: BridgeExecuteParams) {
|
|
// The @cloudflare/sandbox SDK's exec() takes a single command string and a
|
|
// narrow option set ({ cwd, env, timeout, ... }) — it does not accept `args`
|
|
// or `stdin`. We compose the full shell command ourselves and stage stdin
|
|
// through a temp file in the sandbox when the caller provides one.
|
|
const stdinPayload = typeof params.stdin === "string" && params.stdin.length > 0
|
|
? params.stdin
|
|
: null;
|
|
const stdinFile = stdinPayload ? `/tmp/.paperclip-bridge-stdin-${randomToken()}` : null;
|
|
|
|
if (stdinFile && stdinPayload) {
|
|
await params.sandbox.writeFile(stdinFile, stdinPayload, { encoding: "utf8" });
|
|
}
|
|
|
|
try {
|
|
const target = await resolveExecutionTarget(params.sandbox, {
|
|
sessionStrategy: params.sessionStrategy,
|
|
sessionId: params.sessionId,
|
|
cwd: params.cwd,
|
|
env: params.env,
|
|
timeoutMs: params.timeoutMs,
|
|
});
|
|
const script = buildLoginShellScript({
|
|
command: params.command,
|
|
args: params.args ?? [],
|
|
cwd: params.cwd,
|
|
env: params.env,
|
|
stdinFile,
|
|
});
|
|
const fullCommand = `sh -lc ${shellQuote(script)}`;
|
|
const result = await target.exec(fullCommand, {
|
|
cwd: "/",
|
|
timeout: params.timeoutMs,
|
|
...(typeof params.onOutput === "function"
|
|
? {
|
|
stream: true,
|
|
onOutput: params.onOutput,
|
|
}
|
|
: {}),
|
|
});
|
|
return coerceExecuteResult(result);
|
|
} catch (error) {
|
|
if (isTimeoutError(error)) {
|
|
await cleanupTimedOutExecution(params.sandbox, {
|
|
sessionStrategy: params.sessionStrategy,
|
|
sessionId: params.sessionId,
|
|
});
|
|
return {
|
|
exitCode: null,
|
|
signal: null,
|
|
timedOut: true,
|
|
stdout: typeof (error as { stdout?: unknown }).stdout === "string" ? (error as { stdout: string }).stdout : "",
|
|
stderr: `${error instanceof Error ? error.message : String(error)}\n`,
|
|
};
|
|
}
|
|
throw error;
|
|
} finally {
|
|
if (stdinFile) {
|
|
await params.sandbox.deleteFile?.(stdinFile).catch(() => undefined);
|
|
}
|
|
}
|
|
}
|