perf(plugin-daytona): opt-in no-profile fast path for default-PATH execs (#10352)
## Thinking Path > - Paperclip is the control plane for autonomous AI companies > - The Daytona adapter turns tasks into shell commands and manages execution overhead > - Many short-lived exec calls still pay for login-shell profile sourcing even when the binary already resolves on the sandbox default PATH > - That extra startup work adds latency on the hot path for repeated command execution > - This pull request adds an opt-in fast path that skips profile sourcing only when the caller explicitly requests it and the command does not need shell initialization > - The benefit is lower per-call latency for eligible commands without changing the conservative default behavior for commands that need the profile ## Linked Issues or Issue Description This change does not reference a public GitHub issue. It follows the same Daytona startup-speed work as merged PR #10335 and narrows the execution path for eligible commands while keeping the default login-shell behavior intact. ## What Changed - Added an optional `noProfile` flag to `PluginEnvironmentExecuteParams`. - Refactored Daytona login-shell script assembly so the profile and nvm sourcing block is omitted only on the explicit fast path. - Preserved environment prefixing, `cd`, shell quoting, `NONINTERACTIVE_GIT_ENV`, stdin handling, and `durationMs` behavior on both paths. - Added regression tests for the fast path omission, the preserved execution parameters, and the default profile-sourcing path. ## Verification - `pnpm --filter @paperclipai/sandbox-provider-daytona exec vitest run src/plugin.test.ts` - `pnpm --filter @paperclipai/plugin-sdk tsc --noEmit` - Reverted the guard locally to confirm the two behavior tests fail again, then restored the change. ## Risks - If a caller opts into `noProfile` for a command that depends on shell initialization, the command can fail to resolve its binary. - The API comment and opt-in design keep that risk narrow; the default path remains unchanged. ## Model Used OpenAI GPT-5 (Codex tool-using coding agent) ## 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/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) 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:
parent
0ccba45e4d
commit
7797995038
|
|
@ -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<RunProcessResult>((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<string, string>;
|
||||
stdin?: string;
|
||||
timeoutMs?: number;
|
||||
noProfile?: boolean;
|
||||
}> = [];
|
||||
const runner = {
|
||||
execute: async (input: {
|
||||
|
|
@ -156,6 +163,7 @@ describe("command managed runtime", () => {
|
|||
env?: Record<string, string>;
|
||||
stdin?: string;
|
||||
timeoutMs?: number;
|
||||
noProfile?: boolean;
|
||||
}): Promise<RunProcessResult> => {
|
||||
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 () => {
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ export interface CommandManagedRuntimeRunner {
|
|||
env?: Record<string, string>;
|
||||
stdin?: string;
|
||||
timeoutMs?: number;
|
||||
noProfile?: boolean;
|
||||
onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
|
||||
onSpawn?: (meta: { pid: number; startedAt: string }) => Promise<void>;
|
||||
}): Promise<RunProcessResult>;
|
||||
|
|
@ -205,6 +206,7 @@ export function createCommandManagedRuntimeClient(input: {
|
|||
opts: {
|
||||
stdin?: string;
|
||||
timeoutMs?: number;
|
||||
noProfile?: boolean;
|
||||
onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
|
||||
} = {},
|
||||
) => {
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -198,7 +198,7 @@ export interface SandboxManagedRuntimeClient {
|
|||
): Promise<Buffer | Uint8Array | ArrayBuffer>;
|
||||
listFiles(remotePath: string): Promise<string[]>;
|
||||
remove(remotePath: string): Promise<void>;
|
||||
run(command: string, options: { timeoutMs: number }): Promise<void>;
|
||||
run(command: string, options: { timeoutMs: number; noProfile?: boolean }): Promise<void>;
|
||||
/**
|
||||
* Optional native inbound transfer. Present only when the sandbox provider
|
||||
* advertises both `environmentSyncIn` and `environmentSyncOut`; otherwise the
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>)?.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),
|
||||
|
|
|
|||
|
|
@ -666,6 +666,7 @@ function buildLoginShellScript(input: {
|
|||
cwd?: string;
|
||||
env?: Record<string, string>;
|
||||
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
|
||||
|
|
|
|||
|
|
@ -651,6 +651,8 @@ export interface PluginEnvironmentExecuteParams extends PluginEnvironmentDriverB
|
|||
env?: Record<string, string>;
|
||||
stdin?: string;
|
||||
timeoutMs?: number;
|
||||
/** Skip login-shell profile sourcing when the command already resolves on the sandbox default PATH. */
|
||||
noProfile?: boolean;
|
||||
}
|
||||
|
||||
export interface PluginEnvironmentExecuteResult {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -188,6 +188,11 @@ export interface EnvironmentDriverExecuteInput extends EnvironmentDriverLeaseInp
|
|||
env?: Record<string, string>;
|
||||
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,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in New Issue