mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-14 01:50:39 +09:00
## 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
188 lines
6.5 KiB
TypeScript
188 lines
6.5 KiB
TypeScript
import path from "node:path";
|
|
import {
|
|
prepareSandboxManagedRuntime,
|
|
type PreparedSandboxManagedRuntime,
|
|
type SandboxManagedRuntimeAsset,
|
|
type SandboxManagedRuntimeClient,
|
|
type SandboxRemoteExecutionSpec,
|
|
} from "./sandbox-managed-runtime.js";
|
|
import { preferredShellForSandbox } from "./sandbox-shell.js";
|
|
import type { RunProcessResult } from "./server-utils.js";
|
|
|
|
export interface CommandManagedRuntimeRunner {
|
|
execute(input: {
|
|
command: string;
|
|
args?: string[];
|
|
cwd?: string;
|
|
env?: Record<string, string>;
|
|
stdin?: string;
|
|
timeoutMs?: number;
|
|
onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
|
|
onSpawn?: (meta: { pid: number; startedAt: string }) => Promise<void>;
|
|
}): Promise<RunProcessResult>;
|
|
}
|
|
|
|
export interface CommandManagedRuntimeSpec {
|
|
providerKey?: string | null;
|
|
shellCommand?: "bash" | "sh" | null;
|
|
leaseId?: string | null;
|
|
remoteCwd: string;
|
|
timeoutMs?: number | null;
|
|
paperclipApiUrl?: string | null;
|
|
}
|
|
|
|
export type CommandManagedRuntimeAsset = SandboxManagedRuntimeAsset;
|
|
|
|
function shellQuote(value: string) {
|
|
return `'${value.replace(/'/g, `'"'"'`)}'`;
|
|
}
|
|
|
|
function mergeRuntimeExcludes(entries: string[] | undefined): string[] {
|
|
return [...new Set([".paperclip-runtime", ...(entries ?? [])])];
|
|
}
|
|
|
|
const REMOTE_WRITE_BASE64_CHUNK_SIZE = 32 * 1024;
|
|
|
|
function toBuffer(bytes: Buffer | Uint8Array | ArrayBuffer): Buffer {
|
|
if (Buffer.isBuffer(bytes)) return bytes;
|
|
if (bytes instanceof ArrayBuffer) return Buffer.from(bytes);
|
|
return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
}
|
|
|
|
function requireSuccessfulResult(result: RunProcessResult, action: string): void {
|
|
if (result.exitCode === 0 && !result.timedOut) return;
|
|
const stderr = result.stderr.trim();
|
|
const detail = stderr.length > 0 ? `: ${stderr}` : "";
|
|
throw new Error(`${action} failed with exit code ${result.exitCode ?? "null"}${detail}`);
|
|
}
|
|
|
|
export function createCommandManagedRuntimeClient(input: {
|
|
runner: CommandManagedRuntimeRunner;
|
|
remoteCwd: string;
|
|
timeoutMs: number;
|
|
shellCommand?: "bash" | "sh" | null;
|
|
}): SandboxManagedRuntimeClient {
|
|
const shellCommand = preferredShellForSandbox(input.shellCommand);
|
|
const runShell = async (script: string, opts: { stdin?: string; timeoutMs?: number } = {}) => {
|
|
const result = await input.runner.execute({
|
|
command: shellCommand,
|
|
args: ["-lc", script],
|
|
cwd: input.remoteCwd,
|
|
stdin: opts.stdin,
|
|
timeoutMs: opts.timeoutMs ?? input.timeoutMs,
|
|
});
|
|
requireSuccessfulResult(result, script);
|
|
return result;
|
|
};
|
|
|
|
return {
|
|
makeDir: async (remotePath) => {
|
|
await runShell(`mkdir -p ${shellQuote(remotePath)}`);
|
|
},
|
|
writeFile: async (remotePath, bytes) => {
|
|
const body = toBuffer(bytes).toString("base64");
|
|
const remoteDir = path.posix.dirname(remotePath);
|
|
const remoteTempPath = `${remotePath}.paperclip-upload.b64`;
|
|
|
|
await runShell(
|
|
`mkdir -p ${shellQuote(remoteDir)} && rm -f ${shellQuote(remoteTempPath)} && : > ${shellQuote(remoteTempPath)}`,
|
|
);
|
|
for (let offset = 0; offset < body.length; offset += REMOTE_WRITE_BASE64_CHUNK_SIZE) {
|
|
const chunk = body.slice(offset, offset + REMOTE_WRITE_BASE64_CHUNK_SIZE);
|
|
await runShell(`printf '%s' ${shellQuote(chunk)} >> ${shellQuote(remoteTempPath)}`);
|
|
}
|
|
await runShell(
|
|
`base64 -d < ${shellQuote(remoteTempPath)} > ${shellQuote(remotePath)} && rm -f ${shellQuote(remoteTempPath)}`,
|
|
);
|
|
},
|
|
readFile: async (remotePath) => {
|
|
const result = await runShell(`base64 < ${shellQuote(remotePath)}`);
|
|
return Buffer.from(result.stdout.replace(/\s+/g, ""), "base64");
|
|
},
|
|
listFiles: async (remotePath) => {
|
|
const result = await runShell(
|
|
`if [ -d ${shellQuote(remotePath)} ]; then ` +
|
|
`for entry in ${shellQuote(remotePath)}/*; do ` +
|
|
`[ -f "$entry" ] || continue; ` +
|
|
`basename "$entry"; ` +
|
|
`done; ` +
|
|
`fi`,
|
|
);
|
|
return result.stdout
|
|
.split(/\r?\n/)
|
|
.map((entry) => entry.trim())
|
|
.filter((entry) => entry.length > 0)
|
|
.sort((left, right) => left.localeCompare(right));
|
|
},
|
|
remove: async (remotePath) => {
|
|
const result = await input.runner.execute({
|
|
command: shellCommand,
|
|
args: ["-lc", `rm -rf ${shellQuote(remotePath)}`],
|
|
cwd: input.remoteCwd,
|
|
timeoutMs: input.timeoutMs,
|
|
});
|
|
requireSuccessfulResult(result, `remove ${remotePath}`);
|
|
},
|
|
run: async (command, options) => {
|
|
const result = await input.runner.execute({
|
|
command: shellCommand,
|
|
args: ["-lc", command],
|
|
cwd: input.remoteCwd,
|
|
timeoutMs: options.timeoutMs,
|
|
});
|
|
requireSuccessfulResult(result, command);
|
|
},
|
|
};
|
|
}
|
|
|
|
export async function prepareCommandManagedRuntime(input: {
|
|
runner: CommandManagedRuntimeRunner;
|
|
spec: CommandManagedRuntimeSpec;
|
|
adapterKey: string;
|
|
workspaceLocalDir: string;
|
|
workspaceRemoteDir?: string;
|
|
workspaceExclude?: string[];
|
|
preserveAbsentOnRestore?: string[];
|
|
assets?: CommandManagedRuntimeAsset[];
|
|
installCommand?: string | null;
|
|
}): Promise<PreparedSandboxManagedRuntime> {
|
|
const timeoutMs = input.spec.timeoutMs && input.spec.timeoutMs > 0 ? input.spec.timeoutMs : 300_000;
|
|
const workspaceRemoteDir = input.workspaceRemoteDir ?? input.spec.remoteCwd;
|
|
const runtimeSpec: SandboxRemoteExecutionSpec = {
|
|
transport: "sandbox",
|
|
provider: input.spec.providerKey ?? "sandbox",
|
|
sandboxId: input.spec.leaseId ?? "managed",
|
|
remoteCwd: workspaceRemoteDir,
|
|
timeoutMs,
|
|
apiKey: null,
|
|
paperclipApiUrl: input.spec.paperclipApiUrl ?? null,
|
|
};
|
|
const client = createCommandManagedRuntimeClient({
|
|
runner: input.runner,
|
|
remoteCwd: workspaceRemoteDir,
|
|
timeoutMs,
|
|
shellCommand: input.spec.shellCommand,
|
|
});
|
|
const shellCommand = preferredShellForSandbox(input.spec.shellCommand);
|
|
|
|
if (input.installCommand?.trim()) {
|
|
const result = await input.runner.execute({
|
|
command: shellCommand,
|
|
args: ["-lc", input.installCommand.trim()],
|
|
cwd: workspaceRemoteDir,
|
|
timeoutMs,
|
|
});
|
|
requireSuccessfulResult(result, input.installCommand.trim());
|
|
}
|
|
|
|
return await prepareSandboxManagedRuntime({
|
|
spec: runtimeSpec,
|
|
client,
|
|
adapterKey: input.adapterKey,
|
|
workspaceLocalDir: input.workspaceLocalDir,
|
|
workspaceRemoteDir,
|
|
workspaceExclude: mergeRuntimeExcludes(input.workspaceExclude),
|
|
preserveAbsentOnRestore: input.preserveAbsentOnRestore,
|
|
assets: input.assets,
|
|
});
|
|
}
|