paperclip/server/src/services/environment-execution-target.ts
Devin Foley a7b45938b7
Let sandbox providers declare shell defaults (#5114)
## 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
2026-05-03 12:19:35 -07:00

171 lines
6 KiB
TypeScript

import type { Db } from "@paperclipai/db";
import type { Environment, EnvironmentLease } from "@paperclipai/shared";
import {
adapterExecutionTargetToRemoteSpec,
type AdapterExecutionTarget,
} from "@paperclipai/adapter-utils/execution-target";
import { parseObject } from "../adapters/utils.js";
import { resolveEnvironmentDriverConfigForRuntime } from "./environment-config.js";
import type { EnvironmentRuntimeService } from "./environment-runtime.js";
export const DEFAULT_SANDBOX_REMOTE_CWD = "/tmp";
export async function resolveEnvironmentExecutionTarget(input: {
db: Db;
companyId: string;
adapterType: string;
environment: {
id?: string;
driver: string;
config: Record<string, unknown> | null;
};
leaseId?: string | null;
leaseMetadata: Record<string, unknown> | null;
lease?: EnvironmentLease | null;
environmentRuntime?: EnvironmentRuntimeService | null;
}): Promise<AdapterExecutionTarget | null> {
if (input.environment.driver === "local") {
return {
kind: "local",
environmentId: input.environment.id ?? null,
leaseId: input.leaseId ?? null,
};
}
if (input.environment.driver === "sandbox") {
if (
input.adapterType !== "acpx_local" &&
input.adapterType !== "codex_local" &&
input.adapterType !== "claude_local" &&
input.adapterType !== "gemini_local" &&
input.adapterType !== "opencode_local" &&
input.adapterType !== "pi_local" &&
input.adapterType !== "cursor"
) {
return null;
}
const parsed = await resolveEnvironmentDriverConfigForRuntime(input.db, input.companyId, {
driver: input.environment.driver as "sandbox",
config: parseObject(input.environment.config),
});
if (parsed.driver !== "sandbox") {
return null;
}
const remoteCwd =
typeof input.leaseMetadata?.remoteCwd === "string" && input.leaseMetadata.remoteCwd.trim().length > 0
? input.leaseMetadata.remoteCwd.trim()
: DEFAULT_SANDBOX_REMOTE_CWD;
const timeoutMs = "timeoutMs" in parsed.config ? parsed.config.timeoutMs : null;
const paperclipApiUrl =
typeof input.leaseMetadata?.paperclipApiUrl === "string" && input.leaseMetadata.paperclipApiUrl.trim().length > 0
? input.leaseMetadata.paperclipApiUrl.trim()
: null;
const shellCommand =
input.leaseMetadata?.shellCommand === "bash" || input.leaseMetadata?.shellCommand === "sh"
? input.leaseMetadata.shellCommand
: null;
return {
kind: "remote",
transport: "sandbox",
providerKey: parsed.config.provider,
shellCommand,
remoteCwd,
environmentId: input.environment.id ?? null,
leaseId: input.leaseId ?? null,
paperclipApiUrl,
paperclipTransport: paperclipApiUrl ? "direct" : "bridge",
timeoutMs,
runner: input.environmentRuntime && input.lease
? {
execute: async (commandInput) => {
const startedAt = new Date().toISOString();
const result = await input.environmentRuntime!.execute({
environment: input.environment as Environment,
lease: input.lease!,
command: commandInput.command,
args: commandInput.args,
cwd: commandInput.cwd ?? remoteCwd,
env: commandInput.env,
stdin: commandInput.stdin,
timeoutMs: commandInput.timeoutMs,
});
if (result.stdout) await commandInput.onLog?.("stdout", result.stdout);
if (result.stderr) await commandInput.onLog?.("stderr", result.stderr);
return {
exitCode: result.exitCode,
signal: result.signal ?? null,
timedOut: result.timedOut,
stdout: result.stdout,
stderr: result.stderr,
pid: null,
startedAt,
};
},
}
: undefined,
};
}
if (
(
input.adapterType !== "codex_local" &&
input.adapterType !== "acpx_local" &&
input.adapterType !== "claude_local" &&
input.adapterType !== "gemini_local" &&
input.adapterType !== "opencode_local" &&
input.adapterType !== "pi_local" &&
input.adapterType !== "cursor"
) ||
input.environment.driver !== "ssh"
) {
return null;
}
const parsed = await resolveEnvironmentDriverConfigForRuntime(input.db, input.companyId, {
driver: input.environment.driver as "ssh",
config: parseObject(input.environment.config),
});
if (parsed.driver !== "ssh") {
return null;
}
const remoteCwd =
typeof input.leaseMetadata?.remoteCwd === "string" && input.leaseMetadata.remoteCwd.trim().length > 0
? input.leaseMetadata.remoteCwd.trim()
: parsed.config.remoteWorkspacePath;
return {
kind: "remote",
transport: "ssh",
environmentId: input.environment.id ?? null,
leaseId: input.leaseId ?? null,
remoteCwd,
paperclipApiUrl:
typeof input.leaseMetadata?.paperclipApiUrl === "string" && input.leaseMetadata.paperclipApiUrl.trim().length > 0
? input.leaseMetadata.paperclipApiUrl.trim()
: null,
spec: {
host: parsed.config.host,
port: parsed.config.port,
username: parsed.config.username,
remoteWorkspacePath: parsed.config.remoteWorkspacePath,
privateKey: parsed.config.privateKey,
knownHosts: parsed.config.knownHosts,
strictHostKeyChecking: parsed.config.strictHostKeyChecking,
remoteCwd,
paperclipApiUrl:
typeof input.leaseMetadata?.paperclipApiUrl === "string" && input.leaseMetadata.paperclipApiUrl.trim().length > 0
? input.leaseMetadata.paperclipApiUrl.trim()
: null,
},
};
}
export async function resolveEnvironmentExecutionTransport(
input: Parameters<typeof resolveEnvironmentExecutionTarget>[0],
): Promise<Record<string, unknown> | null> {
return adapterExecutionTargetToRemoteSpec(await resolveEnvironmentExecutionTarget(input)) as Record<string, unknown> | null;
}