diff --git a/packages/plugins/sandbox-providers/daytona/src/plugin.test.ts b/packages/plugins/sandbox-providers/daytona/src/plugin.test.ts index 7ae859228b..d8d3502a20 100644 --- a/packages/plugins/sandbox-providers/daytona/src/plugin.test.ts +++ b/packages/plugins/sandbox-providers/daytona/src/plugin.test.ts @@ -1031,7 +1031,7 @@ describe("Daytona sandbox provider plugin", () => { expect(command).toMatch(/\/etc\/profile/); expect(command).toMatch(/"\$HOME\/\.profile"/); expect(command).toMatch(/cd '\/workspace'/); - expect(command).toMatch(/&& env FOO='bar' 'printf' 'hello'$/); + 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. @@ -1075,7 +1075,7 @@ 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).toMatch(/&& 'cat' < '\/tmp\/paperclip-stdin-/); + expect(command).toMatch(/env .* 'cat' < '\/tmp\/paperclip-stdin-/); expect(command).not.toMatch(/(?:^|&& )exec /); expect(sandbox.fs.deleteFile).toHaveBeenCalledWith(expect.stringMatching(/^\/tmp\/paperclip-stdin-/)); expect(result).toMatchObject({ @@ -1134,6 +1134,54 @@ describe("Daytona sandbox provider plugin", () => { stderr: "command timed out\n", }); }); + + it("injects noninteractive git credential defaults for every one-shot command", 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: "git", + args: ["status"], + timeoutMs: 5000, + }); + + const [command] = sandbox.process.executeCommand.mock.calls[0] as [string]; + expect(command).toContain("GIT_TERMINAL_PROMPT='0'"); + expect(command).toContain("GCM_INTERACTIVE='Never'"); + expect(command).toContain("GIT_ASKPASS='echo'"); + expect(command).toContain("SSH_ASKPASS='echo'"); + expect(command).toContain("SSH_ASKPASS_REQUIRE='force'"); + }); + + it("caps git network commands at 120 s and returns an actionable message on timeout", async () => { + process.env.DAYTONA_API_KEY = "host-key"; + const sandbox = createMockSandbox(); + sandbox.process.executeCommand.mockRejectedValue(new MockDaytonaTimeoutError("timed out")); + 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: "git", + args: ["push", "origin", "HEAD"], + cwd: "/workspace", + timeoutMs: 300000, + }); + + const [, , , timeoutArg] = sandbox.process.executeCommand.mock.calls[0] as [string, unknown, unknown, number]; + expect(timeoutArg).toBe(120); + expect(result).toMatchObject({ exitCode: null, timedOut: true }); + expect(result?.stderr).toMatch(/unreachable|credentials/i); + }); }); describe("daytona manifest memory config", () => { diff --git a/packages/plugins/sandbox-providers/daytona/src/plugin.ts b/packages/plugins/sandbox-providers/daytona/src/plugin.ts index 499a4ddabf..c9fb0a565d 100644 --- a/packages/plugins/sandbox-providers/daytona/src/plugin.ts +++ b/packages/plugins/sandbox-providers/daytona/src/plugin.ts @@ -92,6 +92,22 @@ const WORKSPACE_SENTINEL_RELATIVE_PATH = ".paperclip-runtime/reusable-sandbox-le const DEFAULT_AUTO_STOP_INTERVAL_MINUTES = 15; const DEFAULT_AUTO_ARCHIVE_INTERVAL_MINUTES = 60; const DEFAULT_AUTO_DELETE_INTERVAL_MINUTES = 7 * 24 * 60; // 7 days + +// Fail-fast cap for git network operations (push, fetch, pull, ls-remote, etc.) +// so a stalled remote or missing credential never consumes the full 900 s adapter +// RPC ceiling; callers always see an actionable error within this window. +const GIT_NETWORK_TIMEOUT_MS = 120_000; + +// Noninteractive git credential defaults injected into every Daytona one-shot +// command so that git operations never stall waiting for a terminal prompt. +// Callers can override any of these via the env parameter. +const NONINTERACTIVE_GIT_ENV: Record = { + GIT_TERMINAL_PROMPT: "0", + GCM_INTERACTIVE: "Never", + GIT_ASKPASS: "echo", + SSH_ASKPASS: "echo", + SSH_ASKPASS_REQUIRE: "force", +}; const DEFAULT_SSH_ACCESS_MINUTES = 60; const DAYTONA_SSH_GATEWAY_HOST = "ssh.app.daytona.io"; @@ -558,6 +574,36 @@ function isValidShellEnvKey(value: string): boolean { return /^[A-Za-z_][A-Za-z0-9_]*$/.test(value); } +const GIT_NETWORK_SUBCOMMANDS = new Set(["push", "fetch", "pull", "ls-remote", "clone"]); + +function isGitNetworkCommand(command: string, args: string[]): boolean { + if (path.basename(command) !== "git") return false; + // Find the first positional arg (the git subcommand), skipping flags and their values. + let i = 0; + while (i < args.length) { + const arg = args[i]; + if (arg === "-C" || arg === "-c" || arg === "--git-dir" || arg === "--work-tree") { + i += 2; + continue; + } + if (arg.startsWith("-")) { + i++; + continue; + } + if (GIT_NETWORK_SUBCOMMANDS.has(arg)) return true; + if (arg === "remote") { + const next = args.slice(i + 1).find(a => !a.startsWith("-")); + return next === "update"; + } + if (arg === "submodule") { + const next = args.slice(i + 1).find(a => !a.startsWith("-")); + return next === "update"; + } + return false; + } + 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 @@ -570,12 +616,14 @@ function buildLoginShellScript(input: { env?: Record; stdinPath?: string; }): string { - const env = input.env ?? {}; - for (const key of Object.keys(env)) { + const callerEnv = input.env ?? {}; + for (const key of Object.keys(callerEnv)) { if (!isValidShellEnvKey(key)) { throw new Error(`Invalid sandbox environment variable key: ${key}`); } } + // Caller env takes priority over noninteractive git credential defaults + const env = { ...NONINTERACTIVE_GIT_ENV, ...callerEnv }; const envArgs = Object.entries(env) .filter((entry): entry is [string, string] => typeof entry[1] === "string") .map(([key, value]) => `${key}=${shellQuote(value)}`); @@ -663,8 +711,10 @@ async function executeOneShot( params: PluginEnvironmentExecuteParams, config: DaytonaDriverConfig, ): Promise { + const gitNet = isGitNetworkCommand(params.command, params.args ?? []); const timeoutMs = resolveTimeoutMs(params.timeoutMs, config); - const timeoutSeconds = toTimeoutSeconds(timeoutMs); + const effectiveTimeoutMs = gitNet ? Math.min(timeoutMs, GIT_NETWORK_TIMEOUT_MS) : timeoutMs; + const timeoutSeconds = toTimeoutSeconds(effectiveTimeoutMs); const stdinPath = params.stdin != null ? `/tmp/paperclip-stdin-${randomUUID()}` : null; try { @@ -694,11 +744,14 @@ async function executeOneShot( }; } catch (error) { if (error instanceof DaytonaTimeoutError) { + const timeoutMessage = gitNet + ? `Git network operation timed out after ${Math.round(effectiveTimeoutMs / 1000)} s — the remote may be unreachable or noninteractive credentials are not configured.` + : error.message.trim(); return { exitCode: null, timedOut: true, stdout: "", - stderr: `${error.message.trim()}\n`, + stderr: `${timeoutMessage}\n`, }; } throw error;