forked from farhoodlabs/paperclip
a7b45938b7
## Thinking Path
> - Paperclip orchestrates AI agents for zero-human companies
> - Agents execute in sandboxed remote environments served by pluggable
sandbox
> providers (E2B today, more later)
> - Today every sandbox command runs under `sh -lc` regardless of what
the
> provider's container actually ships
> - That misses bash-only shell init on E2B (which ships bash) and
prevents
> future providers from declaring a different default — there's no way
for a
> provider to say "I have bash, use it"
> - This PR adds a `shellCommand` field to sandbox execution targets so
providers
> can declare their preferred shell ("bash" for E2B), threads it through
the
> sandbox-managed-runtime client, callback bridge, and execution-target
shell
> helper, and validates the value at the lease-metadata boundary
> - The benefit is that sandbox commands run under the right shell on
the right
> provider, and adding new sandbox providers only needs to declare a
shell
> preference
## What Changed
- Added `packages/adapter-utils/src/sandbox-shell.ts` exporting
`preferredShellForSandbox(shellCommand)` (returns `"bash"` if input is
`"bash"`,
else `"sh"`)
- Added `shellCommand?: "bash" | "sh" | null` to
`AdapterSandboxExecutionTarget`
and `CommandManagedRuntimeSpec`; threaded it through
`runAdapterExecutionTargetShellCommand`,
`prepareAdapterExecutionTargetRuntime`,
and `startAdapterExecutionTargetPaperclipBridge`
- `createCommandManagedRuntimeClient`, `prepareCommandManagedRuntime`,
and
`createCommandManagedSandboxCallbackBridgeQueueClient` now take an
optional
`shellCommand` and use `preferredShellForSandbox` to pick the shell
- `startSandboxCallbackBridgeServer` accepts a `shellCommand` for its
server
startup, readiness probe, and stop hook
- E2B sandbox plugin declares `shellCommand: "bash"` in `leaseMetadata`
- `resolveEnvironmentExecutionTarget` reads `shellCommand` from lease
metadata
(validating against `"bash" | "sh" | null`)
- `environment-runtime.ts` adds `"shellCommand"` to
`INTERNAL_PLUGIN_SANDBOX_CONFIG_KEYS`
so the field round-trips through internal plugin config without leaking
to
external plugin metadata
- Updated tests in `command-managed-runtime.test.ts`,
`execution-target-sandbox.test.ts`, `sandbox-callback-bridge.test.ts`,
`environment-execution-target.test.ts`
## Verification
- `pnpm --filter @paperclipai/adapter-utils test`
- `pnpm --filter @paperclipai/server test --
environment-execution-target`
- `pnpm --filter @paperclipai/sandbox-providers-e2b test`
- Manual QA: boot a Paperclip instance, create an E2B-backed
environment, run a
claude_local agent against it, and confirm the run completes (verifies
bash
shell semantics flow through the callback bridge end-to-end)
## Risks
- E2B sandbox commands now run under `bash -lc` instead of `sh -lc`.
Bash is a
strict superset for the commands we issue (no busybox-only flags in our
shell
scripts), so risk is low. The shellCommand field is opt-in via lease
metadata —
providers that don't declare it stay on `sh`.
- New optional field on `CommandManagedRuntimeSpec` and
`AdapterSandboxExecutionTarget`.
Consumers ignoring the field retain previous behaviour (sh).
- Lease metadata now carries an additional field. Existing leases
without
`shellCommand` resolve to `null` and fall back to sh — backwards
compatible.
## Model Used
- OpenAI GPT-5.4 (reasoning effort: high) via Codex CLI
- Provider: OpenAI
- Used to author the code changes in this PR
## 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
- [ ] If this change affects the UI, I have included before/after
screenshots — N/A (no UI changes)
- [ ] I have updated relevant documentation to reflect my changes — N/A
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge
426 lines
13 KiB
TypeScript
426 lines
13 KiB
TypeScript
import path from "node:path";
|
|
import { CommandExitError, Sandbox, SandboxNotFoundError, TimeoutError } from "e2b";
|
|
import { definePlugin } from "@paperclipai/plugin-sdk";
|
|
import type {
|
|
PluginEnvironmentAcquireLeaseParams,
|
|
PluginEnvironmentDestroyLeaseParams,
|
|
PluginEnvironmentExecuteParams,
|
|
PluginEnvironmentExecuteResult,
|
|
PluginEnvironmentLease,
|
|
PluginEnvironmentProbeParams,
|
|
PluginEnvironmentProbeResult,
|
|
PluginEnvironmentRealizeWorkspaceParams,
|
|
PluginEnvironmentRealizeWorkspaceResult,
|
|
PluginEnvironmentReleaseLeaseParams,
|
|
PluginEnvironmentResumeLeaseParams,
|
|
PluginEnvironmentValidateConfigParams,
|
|
PluginEnvironmentValidationResult,
|
|
} from "@paperclipai/plugin-sdk";
|
|
|
|
interface E2bDriverConfig {
|
|
template: string;
|
|
apiKey: string | null;
|
|
timeoutMs: number;
|
|
reuseLease: boolean;
|
|
}
|
|
|
|
function parseDriverConfig(raw: Record<string, unknown>): E2bDriverConfig {
|
|
const template = typeof raw.template === "string" && raw.template.trim().length > 0
|
|
? raw.template.trim()
|
|
: "base";
|
|
const timeoutMs = Number(raw.timeoutMs ?? 300_000);
|
|
return {
|
|
template,
|
|
apiKey: typeof raw.apiKey === "string" && raw.apiKey.trim().length > 0 ? raw.apiKey.trim() : null,
|
|
timeoutMs: Number.isFinite(timeoutMs) ? Math.trunc(timeoutMs) : 300_000,
|
|
reuseLease: raw.reuseLease === true,
|
|
};
|
|
}
|
|
|
|
function resolveApiKey(config: E2bDriverConfig): string {
|
|
if (config.apiKey) {
|
|
return config.apiKey;
|
|
}
|
|
const envApiKey = process.env.E2B_API_KEY?.trim() ?? "";
|
|
if (!envApiKey) {
|
|
throw new Error("E2B sandbox environments require an API key in config or E2B_API_KEY.");
|
|
}
|
|
return envApiKey;
|
|
}
|
|
|
|
async function createSandbox(config: E2bDriverConfig): Promise<Sandbox> {
|
|
const options = {
|
|
apiKey: resolveApiKey(config),
|
|
timeoutMs: config.timeoutMs,
|
|
metadata: {
|
|
paperclipProvider: "e2b",
|
|
},
|
|
};
|
|
return await Sandbox.create(config.template, options);
|
|
}
|
|
|
|
function formatErrorMessage(error: unknown): string {
|
|
return error instanceof Error ? error.message : String(error);
|
|
}
|
|
|
|
function readTimeoutStream(error: TimeoutError, key: "stdout" | "stderr"): string {
|
|
const record = error as unknown as Record<string, unknown>;
|
|
const direct = record[key];
|
|
if (typeof direct === "string" && direct.length > 0) return direct;
|
|
const nested = (record as { result?: Record<string, unknown> }).result?.[key];
|
|
if (typeof nested === "string") return nested;
|
|
return typeof direct === "string" ? direct : "";
|
|
}
|
|
|
|
function buildTimeoutExecuteResult(error: TimeoutError): PluginEnvironmentExecuteResult {
|
|
const stdout = readTimeoutStream(error, "stdout");
|
|
const stderrOutput = readTimeoutStream(error, "stderr");
|
|
const message = error.message.trim();
|
|
const stderr = stderrOutput.length > 0
|
|
? message.length > 0 && !stderrOutput.includes(message)
|
|
? `${stderrOutput}${stderrOutput.endsWith("\n") ? "" : "\n"}${message}\n`
|
|
: stderrOutput
|
|
: message.length > 0
|
|
? `${message}\n`
|
|
: "";
|
|
return {
|
|
exitCode: null,
|
|
timedOut: true,
|
|
stdout,
|
|
stderr,
|
|
};
|
|
}
|
|
|
|
async function ensureSandboxWorkspace(sandbox: Sandbox, remoteCwd: string): Promise<void> {
|
|
await sandbox.commands.run(`mkdir -p ${shellQuote(remoteCwd)}`);
|
|
}
|
|
|
|
async function resolveSandboxWorkingDirectory(sandbox: Sandbox): Promise<string> {
|
|
const result = await sandbox.commands.run("pwd");
|
|
const cwd = result.stdout.trim();
|
|
const remoteCwd = path.posix.join(cwd.length > 0 ? cwd : "/", "paperclip-workspace");
|
|
await ensureSandboxWorkspace(sandbox, remoteCwd);
|
|
return remoteCwd;
|
|
}
|
|
|
|
async function connectSandbox(config: E2bDriverConfig, providerLeaseId: string): Promise<Sandbox> {
|
|
return await Sandbox.connect(providerLeaseId, {
|
|
apiKey: resolveApiKey(config),
|
|
timeoutMs: config.timeoutMs,
|
|
});
|
|
}
|
|
|
|
async function connectForCleanup(config: E2bDriverConfig, providerLeaseId: string): Promise<Sandbox | null> {
|
|
try {
|
|
return await connectSandbox(config, providerLeaseId);
|
|
} catch (error) {
|
|
if (error instanceof SandboxNotFoundError) return null;
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function leaseMetadata(input: {
|
|
config: E2bDriverConfig;
|
|
sandbox: Sandbox;
|
|
remoteCwd: string;
|
|
resumedLease: boolean;
|
|
}) {
|
|
return {
|
|
provider: "e2b",
|
|
shellCommand: "bash",
|
|
template: input.config.template,
|
|
timeoutMs: input.config.timeoutMs,
|
|
reuseLease: input.config.reuseLease,
|
|
sandboxId: input.sandbox.sandboxId,
|
|
sandboxDomain: input.sandbox.sandboxDomain,
|
|
remoteCwd: input.remoteCwd,
|
|
resumedLease: input.resumedLease,
|
|
};
|
|
}
|
|
|
|
function shellQuote(value: string) {
|
|
return `'${value.replace(/'/g, `'"'"'`)}'`;
|
|
}
|
|
|
|
function buildCommandLine(command: string, args: string[] = []) {
|
|
return `exec ${[command, ...args].map(shellQuote).join(" ")}`;
|
|
}
|
|
|
|
async function killSandboxBestEffort(sandbox: Sandbox, reason: string): Promise<void> {
|
|
await sandbox.kill().catch((error) => {
|
|
console.warn(`Failed to kill E2B sandbox during ${reason}: ${formatErrorMessage(error)}`);
|
|
});
|
|
}
|
|
|
|
async function releaseSandboxBestEffort(sandbox: Sandbox, reuseLease: boolean): Promise<void> {
|
|
if (!reuseLease) {
|
|
await killSandboxBestEffort(sandbox, "lease release");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await sandbox.pause();
|
|
} catch (error) {
|
|
console.warn(
|
|
`Failed to pause E2B sandbox during lease release: ${formatErrorMessage(error)}. Attempting kill instead.`,
|
|
);
|
|
await killSandboxBestEffort(sandbox, "lease release fallback cleanup");
|
|
}
|
|
}
|
|
|
|
const plugin = definePlugin({
|
|
async setup(ctx) {
|
|
ctx.logger.info("E2B sandbox provider plugin ready");
|
|
},
|
|
|
|
async onHealth() {
|
|
return { status: "ok", message: "E2B sandbox provider plugin healthy" };
|
|
},
|
|
|
|
async onEnvironmentValidateConfig(
|
|
params: PluginEnvironmentValidateConfigParams,
|
|
): Promise<PluginEnvironmentValidationResult> {
|
|
const config = parseDriverConfig(params.config);
|
|
const errors: string[] = [];
|
|
|
|
if (typeof params.config.template === "string" && params.config.template.trim().length === 0) {
|
|
errors.push("E2B sandbox environments require a template.");
|
|
}
|
|
if (config.timeoutMs < 1 || config.timeoutMs > 86_400_000) {
|
|
errors.push("timeoutMs must be between 1 and 86400000.");
|
|
}
|
|
|
|
if (errors.length > 0) {
|
|
return { ok: false, errors };
|
|
}
|
|
|
|
return {
|
|
ok: true,
|
|
normalizedConfig: { ...config },
|
|
};
|
|
},
|
|
|
|
async onEnvironmentProbe(
|
|
params: PluginEnvironmentProbeParams,
|
|
): Promise<PluginEnvironmentProbeResult> {
|
|
const config = parseDriverConfig(params.config);
|
|
try {
|
|
const sandbox = await createSandbox(config);
|
|
try {
|
|
await sandbox.setTimeout(config.timeoutMs);
|
|
const remoteCwd = await resolveSandboxWorkingDirectory(sandbox);
|
|
return {
|
|
ok: true,
|
|
summary: `Connected to E2B sandbox template ${config.template}.`,
|
|
metadata: {
|
|
provider: "e2b",
|
|
template: config.template,
|
|
timeoutMs: config.timeoutMs,
|
|
reuseLease: config.reuseLease,
|
|
sandboxId: sandbox.sandboxId,
|
|
sandboxDomain: sandbox.sandboxDomain,
|
|
remoteCwd,
|
|
},
|
|
};
|
|
} finally {
|
|
await sandbox.kill().catch(() => undefined);
|
|
}
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
return {
|
|
ok: false,
|
|
summary: `E2B sandbox probe failed for template ${config.template}.`,
|
|
metadata: {
|
|
provider: "e2b",
|
|
template: config.template,
|
|
timeoutMs: config.timeoutMs,
|
|
reuseLease: config.reuseLease,
|
|
error: message,
|
|
},
|
|
};
|
|
}
|
|
},
|
|
|
|
async onEnvironmentAcquireLease(
|
|
params: PluginEnvironmentAcquireLeaseParams,
|
|
): Promise<PluginEnvironmentLease> {
|
|
const config = parseDriverConfig(params.config);
|
|
const sandbox = await createSandbox(config);
|
|
try {
|
|
await sandbox.setTimeout(config.timeoutMs);
|
|
const remoteCwd = await resolveSandboxWorkingDirectory(sandbox);
|
|
|
|
return {
|
|
providerLeaseId: sandbox.sandboxId,
|
|
metadata: leaseMetadata({ config, sandbox, remoteCwd, resumedLease: false }),
|
|
};
|
|
} catch (error) {
|
|
await sandbox.kill().catch(() => undefined);
|
|
throw error;
|
|
}
|
|
},
|
|
|
|
async onEnvironmentResumeLease(
|
|
params: PluginEnvironmentResumeLeaseParams,
|
|
): Promise<PluginEnvironmentLease> {
|
|
const config = parseDriverConfig(params.config);
|
|
try {
|
|
const sandbox = await connectSandbox(config, params.providerLeaseId);
|
|
try {
|
|
await sandbox.setTimeout(config.timeoutMs);
|
|
const remoteCwd = await resolveSandboxWorkingDirectory(sandbox);
|
|
|
|
return {
|
|
providerLeaseId: sandbox.sandboxId,
|
|
metadata: leaseMetadata({ config, sandbox, remoteCwd, resumedLease: true }),
|
|
};
|
|
} catch (error) {
|
|
await sandbox.kill().catch(() => undefined);
|
|
throw error;
|
|
}
|
|
} catch (error) {
|
|
if (error instanceof SandboxNotFoundError) {
|
|
return { providerLeaseId: null, metadata: { expired: true } };
|
|
}
|
|
throw error;
|
|
}
|
|
},
|
|
|
|
async onEnvironmentReleaseLease(
|
|
params: PluginEnvironmentReleaseLeaseParams,
|
|
): Promise<void> {
|
|
if (!params.providerLeaseId) return;
|
|
const config = parseDriverConfig(params.config);
|
|
const sandbox = await connectForCleanup(config, params.providerLeaseId);
|
|
if (!sandbox) return;
|
|
|
|
await releaseSandboxBestEffort(sandbox, config.reuseLease);
|
|
},
|
|
|
|
async onEnvironmentDestroyLease(
|
|
params: PluginEnvironmentDestroyLeaseParams,
|
|
): Promise<void> {
|
|
if (!params.providerLeaseId) return;
|
|
const config = parseDriverConfig(params.config);
|
|
const sandbox = await connectForCleanup(config, params.providerLeaseId);
|
|
if (!sandbox) return;
|
|
await killSandboxBestEffort(sandbox, "lease destroy");
|
|
},
|
|
|
|
async onEnvironmentRealizeWorkspace(
|
|
params: PluginEnvironmentRealizeWorkspaceParams,
|
|
): Promise<PluginEnvironmentRealizeWorkspaceResult> {
|
|
const config = parseDriverConfig(params.config);
|
|
const remoteCwd =
|
|
typeof params.lease.metadata?.remoteCwd === "string" &&
|
|
params.lease.metadata.remoteCwd.trim().length > 0
|
|
? params.lease.metadata.remoteCwd.trim()
|
|
: params.workspace.remotePath ?? params.workspace.localPath ?? "/paperclip-workspace";
|
|
|
|
if (params.lease.providerLeaseId) {
|
|
const sandbox = await connectSandbox(config, params.lease.providerLeaseId);
|
|
await ensureSandboxWorkspace(sandbox, remoteCwd);
|
|
}
|
|
|
|
return {
|
|
cwd: remoteCwd,
|
|
metadata: {
|
|
provider: "e2b",
|
|
remoteCwd,
|
|
},
|
|
};
|
|
},
|
|
|
|
async onEnvironmentExecute(
|
|
params: PluginEnvironmentExecuteParams,
|
|
): Promise<PluginEnvironmentExecuteResult> {
|
|
if (!params.lease.providerLeaseId) {
|
|
return {
|
|
exitCode: 1,
|
|
timedOut: false,
|
|
stdout: "",
|
|
stderr: "No provider lease ID available for execution.",
|
|
};
|
|
}
|
|
|
|
const config = parseDriverConfig(params.config);
|
|
const sandbox = await connectSandbox(config, params.lease.providerLeaseId);
|
|
const command = buildCommandLine(params.command, params.args);
|
|
if (params.stdin == null) {
|
|
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,
|
|
};
|
|
} 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);
|
|
}
|
|
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;
|
|
};
|
|
|
|
try {
|
|
try {
|
|
await sandbox.commands.sendStdin(started.pid, params.stdin);
|
|
} finally {
|
|
await sandbox.commands.closeStdin(started.pid);
|
|
}
|
|
return {
|
|
exitCode: started.exitCode,
|
|
timedOut: false,
|
|
stdout: started.stdout,
|
|
stderr: started.stderr,
|
|
};
|
|
} 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);
|
|
}
|
|
throw error;
|
|
}
|
|
},
|
|
});
|
|
|
|
export default plugin;
|