diff --git a/packages/adapter-utils/src/command-managed-runtime.test.ts b/packages/adapter-utils/src/command-managed-runtime.test.ts index a59fab3725..57e832b00b 100644 --- a/packages/adapter-utils/src/command-managed-runtime.test.ts +++ b/packages/adapter-utils/src/command-managed-runtime.test.ts @@ -17,7 +17,7 @@ const execFile = promisify(execFileCallback); interface SpawnRunnerHandle { runner: CommandManagedRuntimeRunner; - calls: Array<{ command: string; args?: string[]; cwd?: string; stdin?: string }>; + calls: Array<{ command: string; args?: string[]; cwd?: string; stdin?: string; noProfile?: boolean }>; } // A runner that actually executes the shell scripts (piping stdin through a real @@ -27,12 +27,18 @@ function makeSpawnRunner(options: { supportsSingleStreamStdinProgress?: boolean; maxStdoutBytes?: number; } = {}): SpawnRunnerHandle { - const calls: Array<{ command: string; args?: string[]; cwd?: string; stdin?: string }> = []; + const calls: Array<{ command: string; args?: string[]; cwd?: string; stdin?: string; noProfile?: boolean }> = []; const runner: CommandManagedRuntimeRunner = { supportsSingleStreamStdinProgress: options.supportsSingleStreamStdinProgress, execute: async (input) => await new Promise((resolve) => { - calls.push({ command: input.command, args: input.args, cwd: input.cwd, stdin: input.stdin }); + calls.push({ + command: input.command, + args: input.args, + cwd: input.cwd, + stdin: input.stdin, + noProfile: input.noProfile, + }); const startedAt = new Date().toISOString(); const command = input.command === "sh" ? "/bin/sh" : input.command === "bash" ? "/bin/bash" : input.command; @@ -147,6 +153,7 @@ describe("command managed runtime", () => { env?: Record; stdin?: string; timeoutMs?: number; + noProfile?: boolean; }> = []; const runner = { execute: async (input: { @@ -156,6 +163,7 @@ describe("command managed runtime", () => { env?: Record; stdin?: string; timeoutMs?: number; + noProfile?: boolean; }): Promise => { calls.push({ ...input }); const startedAt = new Date().toISOString(); @@ -228,6 +236,7 @@ describe("command managed runtime", () => { // The single-stream upload pipes the tarball through exactly one stdin-backed // process (the speed fix); nothing else streams stdin. expect(calls.filter((call) => call.stdin != null).length).toBe(1); + expect(calls.some((call) => call.noProfile === true)).toBe(true); await mkdir(path.join(remoteWorkspaceDir, ".paperclip-runtime"), { recursive: true }); await writeFile(path.join(remoteWorkspaceDir, "README.md"), "remote workspace\n", "utf8"); @@ -242,6 +251,7 @@ describe("command managed runtime", () => { // Restore streams the download through `base64`/onLog (no stdin), so the only // stdin-backed call remains the single upload from prepare. expect(calls.filter((call) => call.stdin != null).length).toBe(1); + expect(calls.some((call) => call.noProfile === true)).toBe(true); }); it("stages runtime assets without replacing or restoring an in-place workspace", async () => { @@ -298,6 +308,38 @@ describe("command managed runtime", () => { ); }); + it("keeps adapter detection on the profile-backed shell path", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-command-runtime-detect-")); + cleanupDirs.push(rootDir); + + const localWorkspaceDir = path.join(rootDir, "local-workspace"); + const remoteWorkspaceDir = path.join(rootDir, "remote-workspace"); + await mkdir(localWorkspaceDir, { recursive: true }); + await mkdir(remoteWorkspaceDir, { recursive: true }); + + const { runner, calls } = makeSpawnRunner(); + await prepareCommandManagedRuntime({ + runner, + spec: { + remoteCwd: remoteWorkspaceDir, + timeoutMs: 30_000, + }, + adapterKey: "claude", + workspaceLocalDir: localWorkspaceDir, + installCommand: "echo install", + detectCommand: "sh", + }); + + // The detection probe must be the first shell invocation and stay on the + // default profile-sourcing path (noProfile !== true) so a CLI provided by + // the login profile is discoverable before we decide whether to install. + expect(calls[0]?.noProfile).not.toBe(true); + expect(calls[0]?.args?.join(" ")).toContain("command -v 'sh'"); + // Detection succeeds here, so the install command must be skipped entirely; + // the remaining calls are workspace staging, never the install command. + expect(calls.some((call) => call.args?.join(" ").includes("echo install"))).toBe(false); + }); + it("runs setup commands from a stable root cwd when staging into a nested remote workspace dir", async () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-command-runtime-nested-")); cleanupDirs.push(rootDir); @@ -546,6 +588,15 @@ describe("command managed runtime", () => { expect(untarIdx).toBeGreaterThan(uploadIdx); expect(cmd1Idx).toBeGreaterThan(untarIdx); expect(cmd2Idx).toBeGreaterThan(cmd1Idx); + + // Fast path: the fixed internal transport helpers (tar upload + untar) ride + // the no-profile shell — they are trusted, fixed commands that never need a + // login-shell profile. The opaque post-upload commands stay profile-backed + // (noProfile !== true) so any env a caller-supplied command relies on is present. + expect(calls[uploadIdx]?.noProfile).toBe(true); + expect(calls[untarIdx]?.noProfile).toBe(true); + expect(calls[cmd1Idx]?.noProfile).not.toBe(true); + expect(calls[cmd2Idx]?.noProfile).not.toBe(true); }); it("fallback syncIn stages mode-constrained files before chmod and rename", async () => { @@ -579,6 +630,11 @@ describe("command managed runtime", () => { expect(scripts[3]).toContain(targetFile); expect(scripts[4]).toContain("rm -rf"); expect(scripts[4]).toContain(targetFile + ".paperclip-syncin."); + + // Fast path: the staged-write helpers (chmod + rename) are fixed internal + // commands, so they ride the no-profile shell alongside the upload/staging. + expect(calls[2]?.noProfile).toBe(true); // chmod + expect(calls[3]?.noProfile).toBe(true); // mv (rename into place) }); it("fallback syncIn cleans up a staged file when chmod fails before rename", async () => { diff --git a/packages/adapter-utils/src/command-managed-runtime.ts b/packages/adapter-utils/src/command-managed-runtime.ts index 58a5d08510..3d5a2f86f1 100644 --- a/packages/adapter-utils/src/command-managed-runtime.ts +++ b/packages/adapter-utils/src/command-managed-runtime.ts @@ -50,6 +50,7 @@ export interface CommandManagedRuntimeRunner { env?: Record; stdin?: string; timeoutMs?: number; + noProfile?: boolean; onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise; onSpawn?: (meta: { pid: number; startedAt: string }) => Promise; }): Promise; @@ -205,6 +206,7 @@ export function createCommandManagedRuntimeClient(input: { opts: { stdin?: string; timeoutMs?: number; + noProfile?: boolean; onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise; } = {}, ) => { @@ -214,6 +216,7 @@ export function createCommandManagedRuntimeClient(input: { cwd: input.commandCwd, stdin: opts.stdin, timeoutMs: opts.timeoutMs ?? input.timeoutMs, + noProfile: opts.noProfile === true, onLog: opts.onLog, }); requireSuccessfulResult(result, script); @@ -222,7 +225,7 @@ export function createCommandManagedRuntimeClient(input: { const client: SandboxManagedRuntimeClient = { makeDir: async (remotePath) => { - await runShell(`mkdir -p ${shellQuote(remotePath)}`); + await runShell(`mkdir -p ${shellQuote(remotePath)}`, { noProfile: true }); }, writeFile: async (remotePath, bytes, options) => { const buffer = toBuffer(bytes); @@ -249,7 +252,7 @@ export function createCommandManagedRuntimeClient(input: { `mkdir -p ${shellQuote(remoteDir)} && ` + `base64 -d > ${shellQuote(remoteTempPath)} && ` + `mv -f ${shellQuote(remoteTempPath)} ${shellQuote(remotePath)}`, - { stdin: body }, + { stdin: body, noProfile: true }, ); await options?.onProgress?.(total, total); return; @@ -263,14 +266,15 @@ export function createCommandManagedRuntimeClient(input: { await runShell( `mkdir -p ${shellQuote(remoteDir)} && ` + `rm -f ${shellQuote(remoteTempPath)} && : > ${shellQuote(remoteTempPath)}`, + { noProfile: true }, ); for (let offset = 0; offset < total; offset += REMOTE_WRITE_FALLBACK_DECODED_CHUNK_SIZE) { const end = Math.min(total, offset + REMOTE_WRITE_FALLBACK_DECODED_CHUNK_SIZE); const chunk = buffer.subarray(offset, end).toString("base64"); - await runShell(`base64 -d >> ${shellQuote(remoteTempPath)}`, { stdin: chunk }); + await runShell(`base64 -d >> ${shellQuote(remoteTempPath)}`, { stdin: chunk, noProfile: true }); await options?.onProgress?.(end, total); } - await runShell(`mv -f ${shellQuote(remoteTempPath)} ${shellQuote(remotePath)}`); + await runShell(`mv -f ${shellQuote(remoteTempPath)} ${shellQuote(remotePath)}`, { noProfile: true }); await options?.onProgress?.(total, total); } finally { await bestEffortRemoveRemotePath(client, remoteTempPath); @@ -280,7 +284,7 @@ export function createCommandManagedRuntimeClient(input: { // Chunked reads intentionally query the remote size first, even without // a progress sink, so each sandbox RPC stays bounded and truncation is // detected without materializing the whole file as one stdout string. - const sizeResult = await runShell(`wc -c < ${shellQuote(remotePath)}`); + const sizeResult = await runShell(`wc -c < ${shellQuote(remotePath)}`, { noProfile: true }); const totalBytes = Number.parseInt(sizeResult.stdout.trim(), 10); if (!Number.isFinite(totalBytes) || totalBytes < 0) { throw new Error(`Could not determine remote file size for ${remotePath}`); @@ -299,6 +303,7 @@ export function createCommandManagedRuntimeClient(input: { for (let chunkIndex = 0; decodedSoFar < totalBytes; chunkIndex++) { const result = await runShell( `dd if=${shellQuote(remotePath)} bs=${REMOTE_READ_CHUNK_BYTES} skip=${chunkIndex} count=1 2>/dev/null | base64`, + { noProfile: true }, ); const chunk = Buffer.from(result.stdout.replace(/\s+/g, ""), "base64"); if (chunk.byteLength === 0) break; @@ -321,6 +326,7 @@ export function createCommandManagedRuntimeClient(input: { `basename "$entry"; ` + `done; ` + `fi`, + { noProfile: true }, ); return result.stdout .split(/\r?\n/) @@ -334,6 +340,7 @@ export function createCommandManagedRuntimeClient(input: { args: shellCommandArgs(`rm -rf ${shellQuote(remotePath)}`), cwd: input.commandCwd, timeoutMs: input.timeoutMs, + noProfile: true, }); requireSuccessfulResult(result, `remove ${remotePath}`); }, @@ -343,6 +350,7 @@ export function createCommandManagedRuntimeClient(input: { args: shellCommandArgs(command), cwd: input.commandCwd, timeoutMs: options.timeoutMs, + noProfile: options.noProfile === true, }); requireSuccessfulResult(result, command); }, @@ -382,7 +390,7 @@ export function createCommandManagedRuntimeClient(input: { await client.writeFile(remoteTarPath, bufferToArrayBuffer(tarBytes)); await client.run( buildSyncInExtractDirectoryCommand({ remoteTarPath, targetDir: mapping.targetPath }), - { timeoutMs: input.timeoutMs }, + { timeoutMs: input.timeoutMs, noProfile: true }, ); bytesTransferred += tarBytes.byteLength; } else { @@ -395,11 +403,11 @@ export function createCommandManagedRuntimeClient(input: { if (mapping.mode != null) { await client.run( buildSyncInChmodCommand({ mode: mapping.mode, targetPath: targetPathForWrite }), - { timeoutMs: input.timeoutMs }, + { timeoutMs: input.timeoutMs, noProfile: true }, ); await client.run( buildSyncInRenameCommand({ sourcePath: targetPathForWrite, targetPath: mapping.targetPath }), - { timeoutMs: input.timeoutMs }, + { timeoutMs: input.timeoutMs, noProfile: true }, ); } bytesTransferred += fileBytes.byteLength; diff --git a/packages/adapter-utils/src/sandbox-managed-runtime.ts b/packages/adapter-utils/src/sandbox-managed-runtime.ts index bbf708021f..032cb1786a 100644 --- a/packages/adapter-utils/src/sandbox-managed-runtime.ts +++ b/packages/adapter-utils/src/sandbox-managed-runtime.ts @@ -198,7 +198,7 @@ export interface SandboxManagedRuntimeClient { ): Promise; listFiles(remotePath: string): Promise; remove(remotePath: string): Promise; - run(command: string, options: { timeoutMs: number }): Promise; + run(command: string, options: { timeoutMs: number; noProfile?: boolean }): Promise; /** * Optional native inbound transfer. Present only when the sandbox provider * advertises both `environmentSyncIn` and `environmentSyncOut`; otherwise the diff --git a/packages/plugins/sandbox-providers/daytona/src/plugin.test.ts b/packages/plugins/sandbox-providers/daytona/src/plugin.test.ts index 148a2366f0..bcb71cc1bc 100644 --- a/packages/plugins/sandbox-providers/daytona/src/plugin.test.ts +++ b/packages/plugins/sandbox-providers/daytona/src/plugin.test.ts @@ -1307,6 +1307,96 @@ 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 () => { + process.env.DAYTONA_API_KEY = "host-key"; + const sandbox = createMockSandbox(); + sandbox.process.executeCommand.mockResolvedValue({ + exitCode: 0, + result: "ok", + artifacts: { stdout: "ok" }, + }); + mockGet.mockResolvedValue(sandbox); + + const result = await plugin.definition.onEnvironmentExecute?.({ + driverKey: "daytona", + companyId: "company-1", + environmentId: "env-1", + config: { timeoutMs: 300000, reuseLease: false }, + lease: { providerLeaseId: "sandbox-123", metadata: {} }, + command: "base64", + 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 /); + 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(typeof (result!.metadata as Record)?.durationMs).toBe("number"); + }); + + it("test_default_path_still_sources_profile", 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. + await plugin.definition.onEnvironmentExecute?.({ + driverKey: "daytona", + companyId: "company-1", + environmentId: "env-1", + config: { timeoutMs: 300000, reuseLease: false }, + lease: { providerLeaseId: "sandbox-123", metadata: {} }, + command: "node", + args: ["--version"], + cwd: "/workspace", + timeoutMs: 1000, + }); + + const [command] = sandbox.process.executeCommand.mock.calls[0] as [string]; + expect(command).toMatch(/\/etc\/profile/); + expect(command).toMatch(/nvm\.sh/); + }); + // ─── Per-lease started-sandbox handle cache ──────────────────────────────── // These prove the security conditions: single-fetch-per-lease, strict // composite-key isolation (no cross-lease / cross-company / cross-env reuse), diff --git a/packages/plugins/sandbox-providers/daytona/src/plugin.ts b/packages/plugins/sandbox-providers/daytona/src/plugin.ts index 2a0a240d6b..0135274d3e 100644 --- a/packages/plugins/sandbox-providers/daytona/src/plugin.ts +++ b/packages/plugins/sandbox-providers/daytona/src/plugin.ts @@ -666,6 +666,7 @@ function buildLoginShellScript(input: { cwd?: string; env?: Record; stdinPath?: string; + noProfile?: boolean; }): string { const callerEnv = input.env ?? {}; for (const key of Object.keys(callerEnv)) { @@ -689,15 +690,20 @@ function buildLoginShellScript(input: { 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 = [ - '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', + ...profileSourcingLines, ]; if (input.cwd) { lines.push(`cd ${shellQuote(input.cwd)}`); @@ -1152,6 +1158,7 @@ async function executeOneShot( cwd: params.cwd, env: params.env, stdinPath: stdinPath ?? undefined, + noProfile: params.noProfile === true, }); // Pass cwd undefined: `buildLoginShellScript` already injects `cd` after diff --git a/packages/plugins/sdk/src/protocol.ts b/packages/plugins/sdk/src/protocol.ts index d9b78d416b..7335a58b62 100644 --- a/packages/plugins/sdk/src/protocol.ts +++ b/packages/plugins/sdk/src/protocol.ts @@ -651,6 +651,8 @@ export interface PluginEnvironmentExecuteParams extends PluginEnvironmentDriverB env?: Record; stdin?: string; timeoutMs?: number; + /** Skip login-shell profile sourcing when the command already resolves on the sandbox default PATH. */ + noProfile?: boolean; } export interface PluginEnvironmentExecuteResult { diff --git a/server/src/__tests__/environment-runtime.test.ts b/server/src/__tests__/environment-runtime.test.ts index d1557f65f2..0618dd087e 100644 --- a/server/src/__tests__/environment-runtime.test.ts +++ b/server/src/__tests__/environment-runtime.test.ts @@ -2277,6 +2277,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { env: { FOO: "bar" }, stdin: "", timeoutMs: 1000, + noProfile: true, }); const destroyed = await runtimeWithPlugin.destroyRunLease({ environment, @@ -2331,6 +2332,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { args: ["ok"], cwd: "/workspace/project", env: { FOO: "bar" }, + noProfile: true, }), 31000); expect(workerManager.call).toHaveBeenCalledWith(pluginId, "environmentDestroyLease", { driverKey: "fake-plugin", diff --git a/server/src/services/environment-execution-target.ts b/server/src/services/environment-execution-target.ts index 8e1828d07a..207e654fb0 100644 --- a/server/src/services/environment-execution-target.ts +++ b/server/src/services/environment-execution-target.ts @@ -118,6 +118,7 @@ export async function resolveEnvironmentExecutionTarget(input: { env: commandInput.env, stdin: commandInput.stdin, timeoutMs: commandInput.timeoutMs, + noProfile: commandInput.noProfile, }); accumulateProviderDurations(result.metadata); if (result.stdout) await commandInput.onLog?.("stdout", result.stdout); diff --git a/server/src/services/environment-runtime.ts b/server/src/services/environment-runtime.ts index 7e7ab0cd67..d4bea6611b 100644 --- a/server/src/services/environment-runtime.ts +++ b/server/src/services/environment-runtime.ts @@ -188,6 +188,11 @@ export interface EnvironmentDriverExecuteInput extends EnvironmentDriverLeaseInp env?: Record; stdin?: string; timeoutMs?: number; + /** + * Skip login-shell profile sourcing for commands that already + * resolve on the sandbox default PATH. + */ + noProfile?: boolean; } export interface EnvironmentDriverSyncInput extends EnvironmentDriverLeaseInput { @@ -1278,6 +1283,7 @@ function createSandboxEnvironmentDriver( env: input.env, stdin: input.stdin, timeoutMs: input.timeoutMs, + noProfile: input.noProfile === true, }, resolvePluginExecuteRpcTimeoutMs({ requestedTimeoutMs: input.timeoutMs, config: sanitizedConfig, @@ -1738,6 +1744,7 @@ function createPluginEnvironmentDriver( env: input.env, stdin: input.stdin, timeoutMs: input.timeoutMs, + noProfile: input.noProfile === true, }, }); },