Fail fast stalled Daytona Git network commands
## 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 <!-- No public GitHub issue exists for this internal infrastructure fix. Describing the issue inline. --> **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 <harold.kim@paperclip.ing> Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
cf8b6e1bdd
commit
3e63a7e3e5
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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<string, string> = {
|
||||
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<string, string>;
|
||||
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<PluginEnvironmentExecuteResult> {
|
||||
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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue