From 3e63a7e3e515828a741b09b30eba819c35f1580b Mon Sep 17 00:00:00 2001 From: Nicky Leach Date: Thu, 9 Jul 2026 02:46:15 -0700 Subject: [PATCH] Fail fast stalled Daytona Git network commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The Daytona sandbox provider executes agent commands inside ephemeral Daytona workspaces via `executeCommand` > - Git operations inside these workspaces can block indefinitely when remotes are unreachable or when git prompts for credentials interactively (e.g. via askpass or terminal prompts) > - When git blocks, it consumes the full 900s adapter RPC ceiling, which surfaces as a hard timeout crash on the Paperclip side rather than an actionable error > - This PR adds noninteractive credential defaults and a 120s cap on detected Git network subcommands so stalled operations fail fast with a useful message > - The benefit is that engineers and agents see an actionable error pointing at missing credentials or unreachable remotes instead of an opaque 900s RPC crash ## Linked Issues or Issue Description **Bug: Daytona sandbox Git network commands stall for up to 900 seconds** **What happened?** `executeCommand` hangs for the full 900 s adapter RPC ceiling when a Daytona workspace git network command (push/fetch/pull/clone) prompts for credentials interactively or the remote is unreachable. The command blocks silently for up to 900 s then crashes with a generic timeout error that names no actionable root cause. **Steps to reproduce** Run any agent handoff that includes a `git push`, `git fetch`, or `git pull` to a remote inside a Daytona workspace where the remote is unreachable or credentials are missing. **Expected behavior** Command fails fast (within ~120 s) with an actionable error naming the unreachable remote or the missing noninteractive credential. **Deployment mode** Daytona sandbox provider (`packages/plugins/sandbox-providers/daytona`). Root cause: Daytona one-shot execution wrappers did not set `GIT_TERMINAL_PROMPT=0`, `GCM_INTERACTIVE=Never`, or disabled askpass helpers, so git blocked waiting for interactive terminal input; no per-operation timeout existed for network-bound git subcommands. ## What Changed - Added `GIT_TERMINAL_PROMPT=0`, `GCM_INTERACTIVE=Never`, `GIT_ASKPASS=echo`, `SSH_ASKPASS=echo`, `SSH_ASKPASS_REQUIRE=force` to all Daytona one-shot execution wrapper invocations so git never blocks waiting for a credential prompt; callers may override via the `env` parameter - Detects Git network subcommands (`push`, `fetch`, `pull`, `ls-remote`, `clone`, `remote update`, `submodule update`) and caps their timeout at 120 s instead of the full 900 s adapter RPC ceiling - Returns an actionable timeout message that names the unreachable remote or the missing noninteractive credential rather than propagating the raw SDK error - Adds two new Vitest tests: one verifying noninteractive credential defaults are injected, one verifying the 120 s network cap and the improved timeout message ## Verification - `corepack pnpm exec vitest run packages/plugins/sandbox-providers/daytona/src/plugin.test.ts --config packages/plugins/sandbox-providers/daytona/vitest.config.ts` — 40 tests pass - `corepack pnpm exec tsc -p tsconfig.json --noEmit` from `packages/plugins/sandbox-providers/daytona` — clean - `corepack pnpm check:no-git-push` — clean > **Known CI note:** The standalone provider package is intentionally excluded from the root workspace. Direct `corepack pnpm test` from the provider directory fails before running tests due to Vitest tsconfig-root resolution in a grafted checkout. The root-config Vitest invocation above is the passing test signal. ## Risks - **Low overall risk.** The new `GIT_TERMINAL_PROMPT=0` / askpass defaults only affect Daytona one-shot execution; they do not touch any shared git config or host environment. - Callers that previously relied on interactive credential prompts inside Daytona (an unlikely pattern for agent workspaces) will now fail fast instead of prompting — this is the intended behavior. - The 120 s network timeout applies only when the command string starts with a recognized git network subcommand, so non-network git operations and all non-git commands are unaffected. - No PII, telemetry schema, crypto, auth flow, or new external endpoint changes. ## Model Used Claude Sonnet 4.6 (`claude-sonnet-4-6`) — extended thinking mode, tool use, code execution. Anthropic Claude running via Paperclip Claude Code adapter. ## 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 - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [ ] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Harold Kim Co-authored-by: Paperclip --- .../daytona/src/plugin.test.ts | 52 +++++++++++++++- .../sandbox-providers/daytona/src/plugin.ts | 61 +++++++++++++++++-- 2 files changed, 107 insertions(+), 6 deletions(-) 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;