Dev #11
@@ -232,6 +232,7 @@ export async function ensureAdapterExecutionTargetCommandResolvable(
|
||||
env: NodeJS.ProcessEnv,
|
||||
) {
|
||||
if (target?.kind === "remote" && target.transport === "sandbox") {
|
||||
await ensureSandboxCommandResolvable(command, target);
|
||||
return;
|
||||
}
|
||||
await ensureCommandResolvable(command, cwd, env, {
|
||||
@@ -239,6 +240,36 @@ export async function ensureAdapterExecutionTargetCommandResolvable(
|
||||
});
|
||||
}
|
||||
|
||||
async function ensureSandboxCommandResolvable(
|
||||
command: string,
|
||||
target: AdapterSandboxExecutionTarget,
|
||||
): Promise<void> {
|
||||
// Probe whether the binary is resolvable inside the sandbox. We previously
|
||||
// short-circuited this for sandbox targets, which let the caller report a
|
||||
// success message even when the CLI was missing from the image. Now we run
|
||||
// a real `command -v` through the same runner the hello probe will use, so
|
||||
// the first step honestly reflects whether the binary is on PATH. The
|
||||
// sandbox provider is responsible for sourcing login profiles (e2b mirrors
|
||||
// SSH's buildSshSpawnTarget) so this and the hello probe agree on PATH.
|
||||
const runner = requireSandboxRunner(target);
|
||||
const probeScript = `command -v ${shellQuote(command)}`;
|
||||
const result = await runner.execute({
|
||||
command: "sh",
|
||||
args: ["-c", probeScript],
|
||||
cwd: target.remoteCwd,
|
||||
timeoutMs: target.timeoutMs ?? 15_000,
|
||||
});
|
||||
if (result.timedOut) {
|
||||
throw new Error(`Timed out checking command "${command}" on sandbox target.`);
|
||||
}
|
||||
if ((result.exitCode ?? 1) === 0) return;
|
||||
const stderr = result.stderr.trim();
|
||||
const detail = stderr.length > 0 ? ` (${stderr})` : "";
|
||||
throw new Error(
|
||||
`Command "${command}" is not installed or not on PATH in the sandbox environment${detail}.`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function resolveAdapterExecutionTargetCommandForLogs(
|
||||
command: string,
|
||||
target: AdapterExecutionTarget | null | undefined,
|
||||
|
||||
@@ -303,17 +303,14 @@ describe("E2B sandbox provider plugin", () => {
|
||||
|
||||
expect(mockConnect).toHaveBeenCalledWith("sandbox-123", expect.objectContaining({ apiKey: "resolved-key" }));
|
||||
expect(sandbox.files.write).toHaveBeenCalledWith(expect.stringMatching(/^\/tmp\/paperclip-stdin-/), "input");
|
||||
expect(sandbox.commands.run).toHaveBeenCalledWith(expect.stringMatching(
|
||||
/^exec 'printf' 'hello' < '\/tmp\/paperclip-stdin-/,
|
||||
), expect.objectContaining({
|
||||
cwd: "/workspace",
|
||||
envs: { FOO: "bar" },
|
||||
timeoutMs: 1000,
|
||||
}));
|
||||
expect(sandbox.commands.run).not.toHaveBeenCalledWith(
|
||||
"exec 'printf' 'hello'",
|
||||
expect.objectContaining({ background: true }),
|
||||
);
|
||||
const stdinCall = sandbox.commands.run.mock.calls.find(([cmd]: [string]) => cmd.includes("'printf'"));
|
||||
expect(stdinCall).toBeDefined();
|
||||
if (!stdinCall) throw new Error("stdinCall not found");
|
||||
expect(stdinCall[0]).toMatch(/\.profile/);
|
||||
expect(stdinCall[0]).toMatch(/exec env FOO='bar' 'printf' 'hello' < '\/tmp\/paperclip-stdin-/);
|
||||
expect(stdinCall[1]).toEqual(expect.objectContaining({ cwd: "/workspace", timeoutMs: 1000 }));
|
||||
expect(stdinCall[1]).not.toHaveProperty("envs");
|
||||
expect(stdinCall[1]).not.toHaveProperty("background");
|
||||
expect(sandbox.commands.sendStdin).not.toHaveBeenCalled();
|
||||
expect(sandbox.commands.closeStdin).not.toHaveBeenCalled();
|
||||
expect(sandbox.handle.wait).not.toHaveBeenCalled();
|
||||
@@ -363,15 +360,14 @@ describe("E2B sandbox provider plugin", () => {
|
||||
timeoutMs: 1000,
|
||||
});
|
||||
|
||||
expect(sandbox.commands.run).toHaveBeenCalledWith("exec 'printf' 'hello'", expect.objectContaining({
|
||||
cwd: "/workspace",
|
||||
envs: { FOO: "bar" },
|
||||
timeoutMs: 1000,
|
||||
}));
|
||||
expect(sandbox.commands.run).not.toHaveBeenCalledWith(
|
||||
"exec 'printf' 'hello'",
|
||||
expect.objectContaining({ background: true }),
|
||||
);
|
||||
const fgCall = sandbox.commands.run.mock.calls.find(([cmd]: [string]) => cmd.includes("'printf'"));
|
||||
expect(fgCall).toBeDefined();
|
||||
if (!fgCall) throw new Error("fgCall not found");
|
||||
expect(fgCall[0]).toMatch(/\.profile/);
|
||||
expect(fgCall[0]).toMatch(/exec env FOO='bar' 'printf' 'hello'$/);
|
||||
expect(fgCall[1]).toEqual(expect.objectContaining({ cwd: "/workspace", timeoutMs: 1000 }));
|
||||
expect(fgCall[1]).not.toHaveProperty("envs");
|
||||
expect(fgCall[1]).not.toHaveProperty("background");
|
||||
expect(sandbox.commands.sendStdin).not.toHaveBeenCalled();
|
||||
expect(sandbox.commands.closeStdin).not.toHaveBeenCalled();
|
||||
expect(sandbox.handle.wait).not.toHaveBeenCalled();
|
||||
|
||||
@@ -148,8 +148,48 @@ function shellQuote(value: string) {
|
||||
return `'${value.replace(/'/g, `'"'"'`)}'`;
|
||||
}
|
||||
|
||||
function buildCommandLine(command: string, args: string[] = []) {
|
||||
return `exec ${[command, ...args].map(shellQuote).join(" ")}`;
|
||||
function isValidShellEnvKey(value: string) {
|
||||
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(value);
|
||||
}
|
||||
|
||||
// Mirror SSH's buildSshSpawnTarget: source the user's login profiles (and nvm)
|
||||
// before exec so commands run with the same PATH the user sees in an
|
||||
// interactive shell. e2b's `sandbox.commands.run` otherwise spawns a
|
||||
// non-login, non-interactive shell whose PATH does not include npm-globals,
|
||||
// nvm shims, or anything else the template installs via .profile/.bashrc —
|
||||
// which makes the hello probe fail with `exec: <cli>: not found` even when
|
||||
// the binary is on disk.
|
||||
function buildLoginShellScript(input: {
|
||||
command: string;
|
||||
args: string[];
|
||||
env?: Record<string, string>;
|
||||
}): 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 execLine = envArgs.length > 0
|
||||
? `exec env ${envArgs.join(" ")} ${commandParts}`
|
||||
: `exec ${commandParts}`;
|
||||
return [
|
||||
'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',
|
||||
// .bash_profile typically sources .bashrc itself; only source .bashrc
|
||||
// directly when no .bash_profile exists to avoid re-running idempotency-
|
||||
// sensitive setup (nvm, PATH prepends) twice on templates that wire
|
||||
// .bash_profile -> .bashrc.
|
||||
'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',
|
||||
'export NVM_DIR="${NVM_DIR:-$HOME/.nvm}"',
|
||||
'[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh" >/dev/null 2>&1 || true',
|
||||
execLine,
|
||||
].join(" && ");
|
||||
}
|
||||
|
||||
async function killSandboxBestEffort(sandbox: Sandbox, reason: string): Promise<void> {
|
||||
@@ -351,7 +391,11 @@ const plugin = definePlugin({
|
||||
|
||||
const config = parseDriverConfig(params.config);
|
||||
const sandbox = await connectSandbox(config, params.lease.providerLeaseId);
|
||||
const baseCommand = buildCommandLine(params.command, params.args);
|
||||
const baseCommand = buildLoginShellScript({
|
||||
command: params.command,
|
||||
args: params.args ?? [],
|
||||
env: params.env,
|
||||
});
|
||||
const timeoutMs = params.timeoutMs ?? config.timeoutMs;
|
||||
|
||||
// For commands with stdin, stage the payload to a temp file inside the
|
||||
@@ -379,9 +423,11 @@ const plugin = definePlugin({
|
||||
: baseCommand;
|
||||
|
||||
try {
|
||||
// Env is interpolated into the script via `exec env KEY=val …` after
|
||||
// profile sourcing so user-configured env wins over anything profiles
|
||||
// export. No need to pass `envs:` separately.
|
||||
const result = await sandbox.commands.run(command, {
|
||||
cwd: params.cwd,
|
||||
envs: params.env,
|
||||
timeoutMs,
|
||||
}) as Awaited<ReturnType<Sandbox["commands"]["run"]>> & {
|
||||
exitCode: number;
|
||||
|
||||
Reference in New Issue
Block a user