perf(sandbox-providers): drop nvm sourcing from exec wrappers (#10443)

## Thinking Path

> - Paperclip keeps agent work on a controlled execution plane.
> - Sandbox exec wrappers run on the hot path for agent commands.
> - The current change removes the explicit `nvm.sh` load step from
those wrappers.
> - The sandbox image already restores PATH through profile startup.
> - This pull request keeps profile sourcing where the wrapper still
needs it and drops only the `nvm.sh` load step.
> - The result is a smaller command path with the same node and agent
CLI resolution.

## Linked Issues or Issue Description

No public GitHub issue exists for this change.

Problem:
The sandbox exec wrappers spent extra time sourcing `nvm.sh` before each
command.
The sandbox image already restores PATH in
`/etc/profile.d/00-restore-env.sh`, so that explicit `nvm.sh` work was
redundant.

Proposed solution:
Remove the `nvm.sh` source step from all six wrappers.
Keep the profile sourcing that the provider still needs for PATH setup.

Alternatives considered:
Keep the existing shell setup and accept the launch cost.
That keeps the current behavior, but it leaves the hot path slower than
needed.

Roadmap alignment:
This change keeps the sandbox command path small and predictable.
It does not change the adapter contract or the node resolution rules.

## What Changed

- Removed `nvm.sh` sourcing from all six sandbox exec wrappers.
- Kept profile sourcing where the provider still needs it for PATH
setup.
- Switched Modal to a non-login shell because the script now sources
profiles itself.
- Updated wrapper tests to assert that built commands do not source
`nvm.sh`.

## Verification

- Local TypeScript typecheck passed in each changed package.
- Focused provider tests passed for Daytona, E2B, Modal, exe-dev,
Cloudflare bridge, and adapter-utils.
- One Daytona test failure is pre-existing and unrelated to this change.

## Risks

- This change alters shell startup for sandbox exec paths.
- A provider that depends on implicit shell setup may need a follow-up.
- The current tests cover command shape, but they do not cover every
runtime shell path.

## Model Used

OpenAI Codex, GPT-5, tool use.

## 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 searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal or instance-local Paperclip issues
or links
- [x] My branch name describes the change and contains no internal
Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Nicky Leach 2026-07-29 12:40:16 -07:00 committed by GitHub
parent 4c8d92f086
commit d6e235cbcf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 225 additions and 108 deletions

View File

@ -170,6 +170,49 @@ describe("ssh env-lab fixture", () => {
await stopSshEnvLabFixture(statePath);
}, SSH_FIXTURE_TEST_TIMEOUT_MS);
it("builds a remote script that sources login profiles but no nvm", async () => {
const target = await buildSshSpawnTarget({
spec: {
host: "ssh.example.test",
port: 22,
username: "ssh-user",
remoteCwd: "/srv/paperclip/workspace",
remoteWorkspacePath: "/srv/paperclip/workspace",
privateKey: null,
knownHosts: null,
strictHostKeyChecking: true,
},
command: "node",
args: ["--version"],
env: { FOO: "bar" },
});
// The remote script rides the last ssh argument. The SSH target is an
// operator-configured host that can expose `node` only through a login
// profile, so the wrapper sources the profiles. It no longer sources
// `nvm.sh`; a profile that adds nvm still runs.
const remoteScript = String(target.args.at(-1) ?? "");
expect(remoteScript).not.toContain("nvm.sh");
expect(remoteScript).not.toContain("NVM_DIR");
// Source /etc/profile so a host that exposes the PATH through
// /etc/profile.d scripts still resolves node and the agent CLI.
expect(remoteScript).toContain("/etc/profile");
expect(remoteScript).toContain(".profile");
expect(remoteScript).toContain(".bash_profile");
expect(remoteScript).toContain(".zprofile");
// Fall back to .bashrc when no .bash_profile exists, so a host that adds
// nvm in .bashrc still resolves node under a non-login SSH command.
expect(remoteScript).toContain(".bashrc");
// The last ssh argument wraps the script as `sh -c '...'`, so the inner
// quotes are escaped. Assert the command still runs: cd, env, and the argv.
expect(remoteScript).toContain("cd ");
expect(remoteScript).toContain("/srv/paperclip/workspace");
expect(remoteScript).toContain("exec env ");
expect(remoteScript).toContain("node");
expect(remoteScript).toContain("--version");
await target.cleanup();
});
it("rejects invalid environment variable keys when constructing SSH spawn targets", async () => {
await expect(
buildSshSpawnTarget({

View File

@ -1166,14 +1166,22 @@ export async function runSshCommand(
}
}
// Mirror buildSshSpawnTarget: source login profiles first, then run
// `env KEY=VAL cmd` so user-supplied identity overrides win over anything
// a profile re-exports. Without this, a remote profile that resets HOME
// / NVM_DIR / etc. would silently undo the explicit env passed in here.
// Mirror buildSshSpawnTarget: source the login profiles first, then run
// `env KEY=VAL cmd` so user-supplied identity overrides win over anything a
// profile re-exports. The SSH target is an operator-configured host, not a
// Paperclip sandbox image, so it can expose `node` or an agent CLI only
// through a login profile; a non-login SSH command would miss that PATH.
// Source `/etc/profile` first so a host that exposes the PATH through
// `/etc/profile.d` scripts still resolves node and the agent CLI.
// The script no longer sources `nvm.sh`; a profile that adds nvm still runs.
// .bash_profile typically sources .bashrc itself; only source .bashrc
// directly when no .bash_profile exists, so a host that adds nvm in
// .bashrc still resolves node without a double-run of the setup.
const envArgs = envEntries.map(([key, value]) => `${key}=${shellQuote(value)}`);
const remoteScript = [
'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',
'if [ -f "$HOME/.bash_profile" ]; then . "$HOME/.bash_profile" >/dev/null 2>&1 || true; fi',
'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',
envArgs.length > 0
? `exec env ${envArgs.join(" ")} sh -c ${shellQuote(remoteCommand)}`
@ -1223,12 +1231,22 @@ export async function buildSshSpawnTarget(input: {
.filter((entry): entry is [string, string] => typeof entry[1] === "string")
.map(([key, value]) => `${key}=${shellQuote(value)}`);
const remoteCommandParts = [shellQuote(input.command), ...input.args.map((arg) => shellQuote(arg))].join(" ");
// Source the login profiles first, then run `env KEY=VAL cmd` so
// user-supplied identity overrides win over anything a profile re-exports.
// The SSH target is an operator-configured host, not a Paperclip sandbox
// image, so it can expose `node` or an agent CLI only through a login
// profile; a non-login SSH command would miss that PATH. Source
// `/etc/profile` first so a host that exposes the PATH through
// `/etc/profile.d` scripts still resolves node and the agent CLI. The script
// no longer sources `nvm.sh`; a profile that adds nvm still runs.
// .bash_profile typically sources .bashrc itself; only source .bashrc
// directly when no .bash_profile exists, so a host that adds nvm in
// .bashrc still resolves node without a double-run of the setup.
const remoteScript = [
'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',
'if [ -f "$HOME/.bash_profile" ]; then . "$HOME/.bash_profile" >/dev/null 2>&1 || true; fi',
'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',
`cd ${shellQuote(input.spec.remoteCwd)}`,
envArgs.length > 0
? `exec env ${envArgs.join(" ")} ${remoteCommandParts}`

View File

@ -0,0 +1,70 @@
# Sandbox Runtime Requirements
This document states the sandbox environment as a contract. The sandbox owner
must meet this contract. The Paperclip runtime does not build the environment at
exec time. The environment is a requirement, not a build step.
This document states requirements. It does not state build steps.
## Required on PATH
- `node` must be installed and on the PATH.
- Each agent CLI that the run uses must be installed and on the PATH. The set of
agent CLIs includes `claude`, `codex`, `gemini`, and similar CLIs.
- The owner installs only the CLIs that the run uses. The owner does not need to
install a CLI that no run uses.
## Runtime dependencies
The sandbox execution and synchronization paths need more than `node` and the
agent CLIs. The owner must also supply these:
- A POSIX shell as `sh`, normally `/bin/sh`. The runtime runs each command with
`sh -c <script>`. The runtime uses `bash` only when the adapter sets the shell
to `bash`.
- `tar`. The synchronization path extracts and creates archives with `tar`. A
sandbox without `tar` cannot receive or return workspace files.
- A writable workspace directory. The runtime extracts the workspace archive
into this directory.
- A writable home directory. The agent CLIs write state and credentials under
the home directory.
- A writable cache directory and a writable temporary directory. The runtime and
the agent CLIs write intermediate files to these locations.
## Detection contract
Paperclip probes each CLI before launch. Paperclip uses the same detection
pattern that the runtime Dockerfiles use:
```bash
command -v <cmd> || exit 1
```
Paperclip probes each CLI with `command -v <cmd>`. Paperclip fails loudly when
the CLI is absent and no install command is configured for the CLI.
## Optional CLI installation
An adapter can configure an install command for a CLI. When an install command
is configured, the runtime obeys this flow:
1. The runtime probes the CLI with `command -v <cmd>`.
2. If the CLI is already on the PATH, the runtime skips the install.
3. If the CLI is absent, the runtime runs the configured install command one
time.
4. A failed install is not fatal. The runtime writes a log line and continues.
The launch-time probe still reports a missing CLI and fails loudly.
An owner who relies on a configured install command must also supply the network
access, the filesystem write access, and the package tooling that the install
command needs. When no install command is configured, the runtime does not
install the CLI. The owner must supply the CLI on the PATH.
## Firm rule
- The Paperclip runtime never modifies the login profile. The runtime never
writes a profile file. The runtime never writes an rc file.
- The Paperclip runtime never sources `nvm` on the exec path.
- The sandbox owner supplies a ready PATH. The PATH must resolve `node` and each
used agent CLI without any action from the runtime, except for a configured
install command.

View File

@ -38,6 +38,9 @@ describe("bridge exec", () => {
expect(optionsArg).not.toHaveProperty("args");
expect(optionsArg).not.toHaveProperty("stdin");
expect(commandArg).toContain('. /etc/profile');
// The wrapper sources no `nvm.sh`; the sandbox image supplies node on PATH.
expect(commandArg).not.toContain("nvm.sh");
expect(commandArg).not.toContain("NVM_DIR");
expect(commandArg).toContain("cd ");
expect(commandArg).toContain("/workspace/paperclip");
expect(commandArg).toContain("PAPERCLIP_TEST_FLAG");

View File

@ -45,13 +45,14 @@ export function buildLoginShellScript(input: {
.map(([key, value]) => `${key}=${shellQuote(value)}`);
const commandParts = [shellQuote(input.command), ...input.args.map(shellQuote)].join(" ");
const stdinRedirect = input.stdinFile ? ` < ${shellQuote(input.stdinFile)}` : "";
// Source the common login profiles before exec so the command runs with the
// interactive-shell PATH. The wrapper sources no `nvm.sh`; the sandbox image
// supplies node on the PATH.
const lines = [
'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',
'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',
];
if (input.cwd) {
lines.push(`cd ${shellQuote(input.cwd)}`);

View File

@ -1101,11 +1101,12 @@ describe("Daytona sandbox provider plugin", () => {
const [command, cwdArg, envArg, timeoutArg] = sandbox.process.executeCommand.mock.calls[0] as [string, unknown, unknown, number];
expect(command).toMatch(/\/etc\/profile/);
expect(command).toMatch(/"\$HOME\/\.profile"/);
expect(command).toMatch(/cd '\/workspace'/);
expect(command).not.toMatch(/nvm\.sh/);
expect(command).toMatch(/&& cd '\/workspace'/);
expect(command).toMatch(/&& env GIT_TERMINAL_PROMPT='0' GCM_INTERACTIVE='Never' GIT_ASKPASS='echo' SSH_ASKPASS='echo' SSH_ASKPASS_REQUIRE='force' FOO='bar' 'printf' 'hello'$/);
expect(command).not.toMatch(/(?:^|&& )exec /);
// cwd/env are baked into the login-shell command itself; we pass undefined
// to the SDK so it doesn't run the cd before profile sourcing.
// cwd/env are baked into the command itself; we pass undefined to the SDK
// so its own cwd argument does not run before the caller env is applied.
expect(cwdArg).toBeUndefined();
expect(envArg).toBeUndefined();
expect(timeoutArg).toBe(1);
@ -1182,7 +1183,8 @@ describe("Daytona sandbox provider plugin", () => {
);
const [command] = sandbox.process.executeCommand.mock.calls[0] as [string];
expect(command).toMatch(/\/etc\/profile/);
expect(command).toMatch(/cd '\/workspace'/);
expect(command).not.toMatch(/nvm\.sh/);
expect(command).toMatch(/&& cd '\/workspace'/);
expect(command).toMatch(/env .* 'cat' < '\/tmp\/paperclip-stdin-/);
expect(command).not.toMatch(/(?:^|&& )exec /);
expect(sandbox.fs.deleteFile).toHaveBeenCalledWith(expect.stringMatching(/^\/tmp\/paperclip-stdin-/));
@ -1307,37 +1309,12 @@ describe("Daytona sandbox provider plugin", () => {
expect(result?.stderr).toMatch(/unreachable|credentials/i);
});
// ─── No-profile fast path (A2) ─────────────────────────────────────────────
// The opt-in `noProfile` flag sheds the ~600 ms login-shell profile/nvm
// sourcing for command classes whose binary resolves on the default PATH
// (file-sync `tar`/`base64`/`mkdir`/`mv`), while every other exec surface
// (env prefix, cwd, quoting, stdin, durationMs) is preserved byte-for-byte.
it("test_no_profile_fast_path_omits_profile_sourcing", async () => {
process.env.DAYTONA_API_KEY = "host-key";
const sandbox = createMockSandbox();
mockGet.mockResolvedValue(sandbox);
await plugin.definition.onEnvironmentExecute?.({
driverKey: "daytona",
companyId: "company-1",
environmentId: "env-1",
config: { timeoutMs: 300000, reuseLease: false },
lease: { providerLeaseId: "sandbox-123", metadata: {} },
command: "tar",
args: ["-xf", "/workspace/upload.tar", "-C", "/workspace"],
cwd: "/workspace",
noProfile: true,
timeoutMs: 1000,
});
const [command] = sandbox.process.executeCommand.mock.calls[0] as [string];
expect(command).not.toMatch(/\/etc\/profile/);
expect(command).not.toMatch(/nvm\.sh/);
expect(command).not.toMatch(/NVM_DIR/);
expect(command).not.toMatch(/\.bash_profile/);
});
it("test_no_profile_fast_path_preserves_env_cwd_and_duration", async () => {
// ─── Exec command shape ────────────────────────────────────────────────────
// The wrapper sources the login profiles so `node` resolves on the reference
// image, then runs the command. It no longer sources `nvm.sh`, while every
// other exec surface (env prefix, cwd, quoting, stdin, durationMs) stays
// intact.
it("test_exec_command_preserves_env_cwd_and_duration", async () => {
process.env.DAYTONA_API_KEY = "host-key";
const sandbox = createMockSandbox();
sandbox.process.executeCommand.mockResolvedValue({
@ -1357,29 +1334,30 @@ describe("Daytona sandbox provider plugin", () => {
args: ["-d"],
cwd: "/workspace",
env: { FOO: "bar" },
noProfile: true,
timeoutMs: 1000,
});
const [command] = sandbox.process.executeCommand.mock.calls[0] as [string];
// The full exec surface is preserved on the fast path — only profile sourcing
// is dropped. The command must still start with the `cd` (no profile lines
// ahead of it) and carry the env prefix and noninteractive git defaults.
expect(command).toMatch(/^cd '\/workspace' && env /);
// The command sources the login profiles first, then runs the `cd` and the
// env prefix with the noninteractive git defaults.
expect(command).toMatch(/^if \[ -f \/etc\/profile \]/);
expect(command).toMatch(/&& cd '\/workspace' && env /);
expect(command).toMatch(/GIT_TERMINAL_PROMPT='0'/);
expect(command).toMatch(/FOO='bar' 'base64' '-d'$/);
expect(command).not.toMatch(/\/etc\/profile/);
// durationMs attribution is unchanged on the fast path.
expect(command).toMatch(/\/etc\/profile/);
expect(command).not.toMatch(/nvm\.sh/);
// durationMs attribution stays intact.
expect(typeof (result!.metadata as Record<string, unknown>)?.durationMs).toBe("number");
});
it("test_default_path_still_sources_profile", async () => {
it("test_exec_command_sources_profile_without_nvm", async () => {
process.env.DAYTONA_API_KEY = "host-key";
const sandbox = createMockSandbox();
mockGet.mockResolvedValue(sandbox);
// No `noProfile` flag: the fail-safe default must still source the login
// profile so node-launching execs resolve their nvm/profile PATH.
// A node-launching exec resolves `node` through the login profiles, which
// Daytona's non-login `executeCommand` shell does not source on its own. The
// wrapper sources the profiles but no longer sources `nvm.sh`.
await plugin.definition.onEnvironmentExecute?.({
driverKey: "daytona",
companyId: "company-1",
@ -1394,7 +1372,9 @@ describe("Daytona sandbox provider plugin", () => {
const [command] = sandbox.process.executeCommand.mock.calls[0] as [string];
expect(command).toMatch(/\/etc\/profile/);
expect(command).toMatch(/nvm\.sh/);
expect(command).toMatch(/"\$HOME\/\.profile"/);
expect(command).not.toMatch(/nvm\.sh/);
expect(command).not.toMatch(/NVM_DIR/);
});
// ─── Per-lease started-sandbox handle cache ────────────────────────────────

View File

@ -655,18 +655,20 @@ function isGitNetworkCommand(command: string, args: string[]): boolean {
return false;
}
// Mirror the E2B sandbox executor: source common login profiles (and nvm)
// before running the command so Daytona one-shot calls see the same PATH an
// interactive shell would. Without this, adapter probes can fail to resolve
// CLIs that are installed via profile-driven PATH mutations inside the
// sandbox image.
// Build the one-shot exec command. Daytona's `executeCommand` runs the script
// in a non-login shell, so it does not source `/etc/profile` on its own. The
// Daytona reference image puts `node`, `claude`, and the other CLIs on the PATH
// through `/etc/profile.d/00-restore-env.sh`, which only `/etc/profile` sources.
// So the wrapper sources the login profiles itself; a non-login shell is then
// enough to resolve the CLIs. The wrapper no longer sources `nvm.sh`; the
// sandbox image supplies `node` on the PATH. See the sandbox runtime
// requirements document.
function buildLoginShellScript(input: {
command: string;
args: string[];
cwd?: string;
env?: Record<string, string>;
stdinPath?: string;
noProfile?: boolean;
}): string {
const callerEnv = input.env ?? {};
for (const key of Object.keys(callerEnv)) {
@ -685,25 +687,17 @@ function buildLoginShellScript(input: {
: commandParts;
// Each `executeCommand` call runs in its own shell, so we don't `exec`-
// replace it; running the command as the last `&&`-chained line is enough to
// surface the right exit code. Env is interpolated after profile sourcing so
// the caller's env wins over any defaults the profile exports.
// surface the right exit code.
const finalLine = envArgs.length > 0
? `env ${envArgs.join(" ")} ${redirectedCommand}`
: redirectedCommand;
const profileSourcingLines = input.noProfile === true
? []
: [
'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 double-running setup.
'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',
];
const lines = [
...profileSourcingLines,
'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 double-running setup.
'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',
];
if (input.cwd) {
lines.push(`cd ${shellQuote(input.cwd)}`);
@ -1158,16 +1152,15 @@ async function executeOneShot(
cwd: params.cwd,
env: params.env,
stdinPath: stdinPath ?? undefined,
noProfile: params.noProfile === true,
});
// Pass cwd undefined: `buildLoginShellScript` already injects `cd` after
// profile sourcing when params.cwd is set, and the Daytona executor's own
// cwd argument runs before our login-shell init, which is the wrong order
// (env from .bashrc would override caller env).
// Time only the `executeCommand` REST round-trip (Open Q1) — the ~600ms
// login-shell wrapper — so the caller can attribute a step's exec time to
// the provider boundary via the free-form `metadata.durationMs`.
// Pass cwd undefined: `buildLoginShellScript` already injects the `cd` after
// it sources the login profiles, when params.cwd is set. The Daytona
// executor's own cwd argument runs before that profile sourcing, which is
// the wrong order (a profile could reset the caller env).
// Time only the `executeCommand` REST round-trip so the caller can
// attribute a step's exec time to the provider boundary through the
// free-form `metadata.durationMs`.
execStart = timingNow();
const result = await sandbox.process.executeCommand(command, undefined, undefined, timeoutSeconds);
const durationMs = timingNow() - execStart;

View File

@ -364,6 +364,9 @@ describe("E2B sandbox provider plugin", () => {
expect(fgCall).toBeDefined();
if (!fgCall) throw new Error("fgCall not found");
expect(fgCall[0]).toMatch(/\.profile/);
// The wrapper sources no `nvm.sh`; the sandbox image supplies node on PATH.
expect(fgCall[0]).not.toMatch(/nvm\.sh/);
expect(fgCall[0]).not.toMatch(/NVM_DIR/);
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");

View File

@ -152,13 +152,13 @@ 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.
// Source the user's login profiles 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 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. The wrapper no
// longer sources `nvm.sh`; the sandbox image supplies `node` on the PATH.
function buildLoginShellScript(input: {
command: string;
args: string[];
@ -186,8 +186,6 @@ function buildLoginShellScript(input: {
// .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(" && ");
}

View File

@ -576,8 +576,12 @@ describe("exe.dev sandbox provider plugin", () => {
expect(spawnMock).toHaveBeenCalledTimes(1);
expect(spawnMock.mock.calls[0]?.[0]).toBe("ssh");
expect(String(spawnMock.mock.calls[0]?.[1]?.at(-1) ?? "")).toContain("/workspace");
expect(String(spawnMock.mock.calls[0]?.[1]?.at(-1) ?? "")).toContain("FOO='");
const remoteScript = String(spawnMock.mock.calls[0]?.[1]?.at(-1) ?? "");
expect(remoteScript).toContain("/workspace");
expect(remoteScript).toContain("FOO='");
// The wrapper sources no `nvm.sh`; the sandbox image supplies node on PATH.
expect(remoteScript).not.toContain("nvm.sh");
expect(remoteScript).not.toContain("NVM_DIR");
const child = spawnMock.mock.results[0]?.value as MockChildProcess;
expect(child.stdin.written).toBe("input-body");
expect(child.stdin.ended).toBe(true);

View File

@ -537,13 +537,14 @@ function buildLoginShellScript(input: {
const finalLine = envArgs.length > 0
? `exec env ${envArgs.join(" ")} ${commandParts}`
: `exec ${commandParts}`;
// Source the common login profiles before exec so the command runs with the
// interactive-shell PATH. The wrapper sources no `nvm.sh`; the sandbox image
// supplies node on the PATH.
const lines = [
'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',
'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',
];
if (input.cwd) {
lines.push(`cd ${shellQuote(input.cwd)}`);

View File

@ -541,7 +541,7 @@ describe("Modal sandbox provider plugin", () => {
});
});
it("executes commands with a login-shell wrapper that injects env after profile sourcing", async () => {
it("executes commands with a non-login-shell wrapper that injects env after profile sourcing", async () => {
const sandbox = createFakeSandbox({
execImpl: async (argv: string[]) =>
makeFakeProcess({
@ -568,9 +568,12 @@ describe("Modal sandbox provider plugin", () => {
expect(sandbox.execCalls).toHaveLength(1);
const call = sandbox.execCalls[0]!;
expect(call.argv[0]).toBe("sh");
expect(call.argv[1]).toBe("-lc");
expect(call.argv[1]).toBe("-c");
const script = call.argv[2]!;
expect(script).toMatch(/\/etc\/profile/);
// The wrapper sources no `nvm.sh`; the sandbox image supplies node on PATH.
expect(script).not.toMatch(/nvm\.sh/);
expect(script).not.toMatch(/NVM_DIR/);
expect(script).toMatch(/cd '\/srv\/work'/);
expect(script).toMatch(/&& exec env FOO='bar' 'printf' 'hello'$/);
expect(call.params).toMatchObject({

View File

@ -213,11 +213,13 @@ function isValidShellEnvKey(value: string): boolean {
// Modal's `sandbox.exec` takes an argv array and bypasses the shell entirely,
// so adapter probes that rely on PATH mutations from /etc/profile or ~/.bashrc
// do not work without an explicit login shell. Mirroring the Daytona / E2B
// providers, wrap the user command in a `sh -lc` script that sources common
// login profiles plus nvm before invoking it. Env is set after profile sourcing
// so caller env wins; stdin is staged to a temp file and shell-redirected so
// fast-failing commands do not race a streaming stdin writer.
// do not work without an explicit profile source. Wrap the user command in a
// `sh -c` script that sources the common login profiles before it runs. The
// script sources no `nvm.sh`; the sandbox image supplies node on the PATH. The
// script sources the profiles itself, so a non-login shell (`sh -c`) is enough.
// Env is set after profile sourcing so caller env wins; stdin is staged to a
// temp file and shell-redirected so fast-failing commands do not race a
// streaming stdin writer.
function buildLoginShellScript(input: {
command: string;
args: string[];
@ -244,8 +246,6 @@ function buildLoginShellScript(input: {
'if [ -f "$HOME/.profile" ]; then . "$HOME/.profile" >/dev/null 2>&1 || true; fi',
'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',
];
if (input.cwd) {
lines.push(`cd ${shellQuote(input.cwd)}`);
@ -624,7 +624,7 @@ const plugin = definePlugin({
env: params.env,
stdinPath: stdinPath ?? undefined,
});
const proc = await sandbox.exec(["sh", "-lc", script], {
const proc = await sandbox.exec(["sh", "-c", script], {
timeoutMs: callerTimeoutMs,
stdout: "pipe",
stderr: "pipe",