Files
PaperClipAI/packages/plugins/sandbox-providers/cloudflare/bridge-template/src/exec.ts
T
Nicky LeachandPaperclip d6e235cbcf perf(sandbox-providers): drop nvm sourcing from exec wrappers (#10443)
## 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>
2026-07-29 12:40:16 -07:00

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);
}
}
}