forked from farhoodlabs/paperclip
Stage stdin to a temp file so the e2b sandbox executor delivers it reliably (#5278)
## Thinking Path
> - Paperclip orchestrates AI agents for zero-human companies
> - The e2b sandbox provider implements `onEnvironmentExecute` so
adapters can spawn CLIs in an e2b sandbox
> - For commands that need stdin (e.g. piping a hello prompt to a CLI),
the previous implementation awaited a foreground `commands.run({ stdin:
true, ... })` and then tried to call `sendStdin(pid)` on the now-dead
PID
> - That call resolves only after the process exits, so stdin was never
delivered and e2b raised "process not found"
> - This pull request stages stdin to `/tmp/paperclip-stdin-<uuid>`
inside the sandbox and shell-redirects it (`exec '<cmd>' '<args>' <
'<file>'`), making the command synchronous regardless of whether stdin
is supplied
> - The benefit is adapter Test probes that pipe a hello prompt to a CLI
inside an e2b sandbox now actually deliver the prompt
## What Changed
- `packages/plugins/sandbox-providers/e2b/src/plugin.ts`: replace the
broken async `commands.run` + `sendStdin` flow with stdin-staging to a
sandbox temp file and shell-redirection
- Staged file is removed in a `finally` block; write failures propagate
after best-effort cleanup
## Verification
- `pnpm vitest run --no-coverage --project @paperclipai/sandbox-e2b` —
all 17 unit tests pass
- `pnpm typecheck` clean
- Manual: a sandboxed adapter Test probe that pipes a hello prompt now
receives the prompt
## Risks
Low risk — `plugin.test.ts` already encodes the temp-file design; the
change brings the implementation in line with the test.
## Model Used
Claude Opus 4.7 (1M context)
## 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
- [x] I have added or updated tests where applicable — existing tests
already encode the new design
- [x] If this change affects the UI, I have included before/after
screenshots — N/A (no UI)
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge
This commit is contained in:
@@ -1,5 +1,11 @@
|
||||
import path from "node:path";
|
||||
import { CommandExitError, Sandbox, SandboxNotFoundError, TimeoutError } from "e2b";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import {
|
||||
CommandExitError,
|
||||
Sandbox,
|
||||
SandboxNotFoundError,
|
||||
TimeoutError,
|
||||
} from "e2b";
|
||||
import { definePlugin } from "@paperclipai/plugin-sdk";
|
||||
import type {
|
||||
PluginEnvironmentAcquireLeaseParams,
|
||||
@@ -345,79 +351,66 @@ const plugin = definePlugin({
|
||||
|
||||
const config = parseDriverConfig(params.config);
|
||||
const sandbox = await connectSandbox(config, params.lease.providerLeaseId);
|
||||
const command = buildCommandLine(params.command, params.args);
|
||||
if (params.stdin == null) {
|
||||
const baseCommand = buildCommandLine(params.command, params.args);
|
||||
const timeoutMs = params.timeoutMs ?? config.timeoutMs;
|
||||
|
||||
// For commands with stdin, stage the payload to a temp file inside the
|
||||
// sandbox and shell-redirect it. Streaming stdin via `sendStdin` raced
|
||||
// with fast-failing commands (the process exits before the RPC lands),
|
||||
// and the previous code awaited a foreground `run` before sending stdin
|
||||
// at all, so the data was never delivered. The staged-file approach
|
||||
// keeps execution synchronous, avoids the race, and is unaffected by
|
||||
// whether the command exits in microseconds or minutes.
|
||||
let stagedStdinPath: string | null = null;
|
||||
if (params.stdin != null) {
|
||||
stagedStdinPath = `/tmp/paperclip-stdin-${randomUUID()}`;
|
||||
try {
|
||||
const result = await sandbox.commands.run(command, {
|
||||
cwd: params.cwd,
|
||||
envs: params.env,
|
||||
timeoutMs: params.timeoutMs ?? config.timeoutMs,
|
||||
}) as Awaited<ReturnType<Sandbox["commands"]["run"]>> & {
|
||||
exitCode: number;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
};
|
||||
return {
|
||||
exitCode: result.exitCode,
|
||||
timedOut: false,
|
||||
stdout: result.stdout,
|
||||
stderr: result.stderr,
|
||||
};
|
||||
await sandbox.files.write(stagedStdinPath, params.stdin);
|
||||
} catch (error) {
|
||||
if (error instanceof CommandExitError) {
|
||||
const commandError = error as CommandExitError;
|
||||
return {
|
||||
exitCode: commandError.exitCode,
|
||||
timedOut: false,
|
||||
stdout: commandError.stdout,
|
||||
stderr: commandError.stderr,
|
||||
};
|
||||
}
|
||||
if (error instanceof TimeoutError) {
|
||||
return buildTimeoutExecuteResult(error);
|
||||
}
|
||||
// Best-effort cleanup in case the write partially succeeded; ignore
|
||||
// remove failures so the original error is what propagates.
|
||||
await sandbox.files.remove(stagedStdinPath).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const started = await sandbox.commands.run(command, {
|
||||
stdin: true,
|
||||
cwd: params.cwd,
|
||||
envs: params.env,
|
||||
timeoutMs: params.timeoutMs ?? config.timeoutMs,
|
||||
}) as Awaited<ReturnType<Sandbox["commands"]["run"]>> & {
|
||||
pid: number;
|
||||
exitCode: number;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
};
|
||||
const command = stagedStdinPath
|
||||
? `${baseCommand} < ${shellQuote(stagedStdinPath)}`
|
||||
: baseCommand;
|
||||
|
||||
try {
|
||||
try {
|
||||
await sandbox.commands.sendStdin(started.pid, params.stdin);
|
||||
} finally {
|
||||
await sandbox.commands.closeStdin(started.pid);
|
||||
}
|
||||
const result = await sandbox.commands.run(command, {
|
||||
cwd: params.cwd,
|
||||
envs: params.env,
|
||||
timeoutMs,
|
||||
}) as Awaited<ReturnType<Sandbox["commands"]["run"]>> & {
|
||||
exitCode: number;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
};
|
||||
return {
|
||||
exitCode: started.exitCode,
|
||||
exitCode: result.exitCode,
|
||||
timedOut: false,
|
||||
stdout: started.stdout,
|
||||
stderr: started.stderr,
|
||||
stdout: result.stdout,
|
||||
stderr: result.stderr,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof CommandExitError) {
|
||||
const commandError = error as CommandExitError;
|
||||
return {
|
||||
exitCode: commandError.exitCode,
|
||||
exitCode: error.exitCode,
|
||||
timedOut: false,
|
||||
stdout: commandError.stdout,
|
||||
stderr: commandError.stderr,
|
||||
stdout: error.stdout,
|
||||
stderr: error.stderr,
|
||||
};
|
||||
}
|
||||
if (error instanceof TimeoutError) {
|
||||
return buildTimeoutExecuteResult(error);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
if (stagedStdinPath) {
|
||||
await sandbox.files.remove(stagedStdinPath).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user