From fc2c3c2c7ce8bd549e4fc1cd4b1228f1c993aa30 Mon Sep 17 00:00:00 2001 From: can1357 Date: Sat, 21 Feb 2026 16:52:37 +0100 Subject: [PATCH] fix(coding-agent): corrected shell session state reset and hard timeout handling for command execution - Fixed persistent shell session state not being reset after command abort or hard timeout. - Fixed hard timeout handling to properly interrupt long-running commands exceeding grace period. - Introduced hard timeout mechanism with Promise.race() to enforce absolute timeout limit and prevent command hangs. - Replaced shell command execution with explicit timeout and SIGKILL signal handling in shell-snapshot. - Simplified bash command normalization to use only explicit head/tail parameters from tool input. - Exported getAntigravityUserAgent() function for centralized User-Agent header construction. --- packages/ai/CHANGELOG.md | 8 + packages/ai/src/usage/google-antigravity.ts | 2 +- .../ai/src/utils/discovery/antigravity.ts | 2 +- packages/coding-agent/CHANGELOG.md | 9 + .../coding-agent/src/exec/bash-executor.ts | 162 +++++++++++------- packages/coding-agent/src/system-prompt.ts | 1 + packages/coding-agent/src/tools/bash.ts | 14 +- .../coding-agent/src/utils/shell-snapshot.ts | 21 ++- .../coding-agent/test/bash-executor.test.ts | 29 ++++ 9 files changed, 171 insertions(+), 77 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 8ecd72ea8..0d0c9f10a 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -1,6 +1,14 @@ # Changelog ## [Unreleased] +### Added + +- Exported `getAntigravityUserAgent()` function for constructing Antigravity User-Agent headers + +### Changed + +- Updated default Antigravity version from 1.15.8 to 1.18.3 +- Unified User-Agent header generation across Antigravity API calls to use centralized `getAntigravityUserAgent()` function ## [12.17.1] - 2026-02-21 ### Added diff --git a/packages/ai/src/usage/google-antigravity.ts b/packages/ai/src/usage/google-antigravity.ts index f6f91a141..9858c7c58 100644 --- a/packages/ai/src/usage/google-antigravity.ts +++ b/packages/ai/src/usage/google-antigravity.ts @@ -1,3 +1,4 @@ +import { getAntigravityUserAgent } from "../providers/google-gemini-cli"; import type { UsageAmount, UsageFetchContext, @@ -9,7 +10,6 @@ import type { UsageWindow, } from "../usage"; import { refreshAntigravityToken } from "../utils/oauth/google-antigravity"; -import { getAntigravityUserAgent } from "../providers/google-gemini-cli"; interface AntigravityQuotaInfo { remainingFraction?: number; diff --git a/packages/ai/src/utils/discovery/antigravity.ts b/packages/ai/src/utils/discovery/antigravity.ts index 5b3cc0b09..63be58176 100644 --- a/packages/ai/src/utils/discovery/antigravity.ts +++ b/packages/ai/src/utils/discovery/antigravity.ts @@ -1,6 +1,6 @@ import { z } from "zod"; -import type { Model } from "../../types"; import { getAntigravityUserAgent } from "../../providers/google-gemini-cli"; +import type { Model } from "../../types"; const DEFAULT_ANTIGRAVITY_DISCOVERY_ENDPOINT = "https://daily-cloudcode-pa.sandbox.googleapis.com"; const FETCH_AVAILABLE_MODELS_PATH = "/v1internal:fetchAvailableModels"; diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index c4f269020..1f1364e3c 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,6 +1,15 @@ # Changelog ## [Unreleased] +### Changed + +- Modified bash command normalization to only apply explicit head/tail parameters from tool input, removing automatic extraction from command pipes +- Updated shell snapshot creation to use explicit timeout and kill signal configuration for more reliable process termination + +### Fixed + +- Fixed persistent shell session state not being reset after command abort or hard timeout, preventing stale environment variables from affecting subsequent commands +- Fixed hard timeout handling to properly interrupt long-running commands that exceed the grace period beyond the configured timeout ## [12.17.1] - 2026-02-21 ### Added diff --git a/packages/coding-agent/src/exec/bash-executor.ts b/packages/coding-agent/src/exec/bash-executor.ts index 53c52b4b6..d7c664c91 100644 --- a/packages/coding-agent/src/exec/bash-executor.ts +++ b/packages/coding-agent/src/exec/bash-executor.ts @@ -34,6 +34,8 @@ export interface BashResult { artifactId?: string; } +const HARD_TIMEOUT_GRACE_MS = 5_000; + const shellSessions = new Map(); export async function executeBash(command: string, options?: BashExecutorOptions): Promise { @@ -65,74 +67,106 @@ export async function executeBash(command: string, options?: BashExecutorOptions }; } + const sessionKey = buildSessionKey(shell, prefix, snapshotPath, shellEnv, options?.sessionKey); + let shellSession = shellSessions.get(sessionKey); + if (!shellSession) { + shellSession = new Shell({ sessionEnv: shellEnv, snapshotPath: snapshotPath ?? undefined }); + shellSessions.set(sessionKey, shellSession); + } + const signal = options?.signal; + const abortHandler = () => { + shellSession.abort(signal?.reason instanceof Error ? signal.reason.message : undefined); + }; + if (signal) { + signal.addEventListener("abort", abortHandler, { once: true }); + } + + let hardTimeoutTimer: NodeJS.Timeout | undefined; + const hardTimeoutDeferred = Promise.withResolvers<"hard-timeout">(); + const baseTimeoutMs = Math.max(1_000, options?.timeout ?? 300_000); + const hardTimeoutMs = baseTimeoutMs + HARD_TIMEOUT_GRACE_MS; + hardTimeoutTimer = setTimeout(() => { + shellSession.abort(`Hard timeout after ${Math.round(hardTimeoutMs / 1000)}s`); + hardTimeoutDeferred.resolve("hard-timeout"); + }, hardTimeoutMs); + + let resetSession = false; + try { - const sessionKey = buildSessionKey(shell, prefix, snapshotPath, shellEnv, options?.sessionKey); - let shellSession = shellSessions.get(sessionKey); - if (!shellSession) { - shellSession = new Shell({ sessionEnv: shellEnv, snapshotPath: snapshotPath ?? undefined }); - shellSessions.set(sessionKey, shellSession); - } + const runPromise = shellSession.run( + { + command: finalCommand, + cwd: options?.cwd, + env: options?.env, + timeoutMs: options?.timeout, + signal, + }, + (err, chunk) => { + if (!err) { + enqueueChunk(chunk); + } + }, + ); - const signal = options?.signal; - const abortHandler = () => { - shellSession.abort(signal?.reason instanceof Error ? signal.reason.message : undefined); - }; - if (signal) { - signal.addEventListener("abort", abortHandler, { once: true }); - } + const winner = await Promise.race([ + runPromise.then(result => ({ kind: "result" as const, result })), + hardTimeoutDeferred.promise.then(() => ({ kind: "hard-timeout" as const })), + ]); - try { - const result = await shellSession.run( - { - command: finalCommand, - cwd: options?.cwd, - env: options?.env, - timeoutMs: options?.timeout, - signal, - }, - (err, chunk) => { - if (!err) { - enqueueChunk(chunk); - } - }, - ); - - await pendingChunks; - - // Handle timeout - if (result.timedOut) { - const annotation = options?.timeout - ? `Command timed out after ${Math.round(options.timeout / 1000)} seconds` - : "Command timed out"; - return { - exitCode: undefined, - cancelled: true, - ...(await sink.dump(annotation)), - }; - } - - // Handle cancellation - if (result.cancelled) { - return { - exitCode: undefined, - cancelled: true, - ...(await sink.dump("Command cancelled")), - }; - } - - // Normal completion - return { - exitCode: result.exitCode, - cancelled: false, - ...(await sink.dump()), - }; - } finally { - if (signal) { - signal.removeEventListener("abort", abortHandler); - } - } - } finally { await pendingChunks; + + if (winner.kind === "hard-timeout") { + resetSession = true; + return { + exitCode: undefined, + cancelled: true, + ...(await sink.dump(`Command exceeded hard timeout after ${Math.round(hardTimeoutMs / 1000)} seconds`)), + }; + } + + // Handle timeout + if (winner.result.timedOut) { + const annotation = options?.timeout + ? `Command timed out after ${Math.round(options.timeout / 1000)} seconds` + : "Command timed out"; + resetSession = true; + return { + exitCode: undefined, + cancelled: true, + ...(await sink.dump(annotation)), + }; + } + + // Handle cancellation + if (winner.result.cancelled) { + resetSession = true; + return { + exitCode: undefined, + cancelled: true, + ...(await sink.dump("Command cancelled")), + }; + } + + // Normal completion + return { + exitCode: winner.result.exitCode, + cancelled: false, + ...(await sink.dump()), + }; + } catch (err) { + resetSession = true; + throw err; + } finally { + if (hardTimeoutTimer) { + clearTimeout(hardTimeoutTimer); + } + if (signal) { + signal.removeEventListener("abort", abortHandler); + } + await pendingChunks; + if (resetSession) { + shellSessions.delete(sessionKey); + } } } diff --git a/packages/coding-agent/src/system-prompt.ts b/packages/coding-agent/src/system-prompt.ts index 71622021b..335914329 100644 --- a/packages/coding-agent/src/system-prompt.ts +++ b/packages/coding-agent/src/system-prompt.ts @@ -61,6 +61,7 @@ export async function loadGitContext(cwd: string): Promise { stdout: "pipe", stderr: "ignore", timeout: timeout, + killSignal: "SIGKILL", }); return untilAborted(abortSignal, async () => { const exitCode = await proc.exited; diff --git a/packages/coding-agent/src/tools/bash.ts b/packages/coding-agent/src/tools/bash.ts index be56ff67f..73bcf5de2 100644 --- a/packages/coding-agent/src/tools/bash.ts +++ b/packages/coding-agent/src/tools/bash.ts @@ -17,7 +17,7 @@ import { CachedOutputBlock } from "../tui/output-block"; import type { ToolSession } from "."; import { type BashInteractiveResult, runInteractiveBashPty } from "./bash-interactive"; import { checkBashInterception } from "./bash-interceptor"; -import { applyHeadTail, normalizeBashCommand } from "./bash-normalize"; +import { applyHeadTail } from "./bash-normalize"; import { expandInternalUrls } from "./bash-skill-urls"; import type { OutputMeta } from "./output-meta"; import { allocateOutputArtifact, createTailBuffer } from "./output-utils"; @@ -75,13 +75,11 @@ export class BashTool implements AgentTool { onUpdate?: AgentToolUpdateCallback, ctx?: AgentToolContext, ): Promise> { - // Normalize command: strip head/tail pipes and 2>&1 - const normalized = normalizeBashCommand(rawCommand); - let command = normalized.command; + let command = rawCommand; - // Merge explicit params with extracted ones (explicit takes precedence) - const headLines = head ?? normalized.headLines; - const tailLines = tail ?? normalized.tailLines; + // Only apply explicit head/tail params from tool input. + const headLines = head; + const tailLines = tail; // Check interception if enabled and available tools are known if (this.session.settings.get("bashInterceptor.enabled")) { @@ -139,8 +137,8 @@ export class BashTool implements AgentTool { artifactId, }) : await executeBash(command, { - sessionKey: this.session.getSessionId?.() ?? undefined, cwd: commandCwd, + sessionKey: this.session.getSessionId?.() ?? undefined, timeout: timeoutMs, signal, env: extraEnv, diff --git a/packages/coding-agent/src/utils/shell-snapshot.ts b/packages/coding-agent/src/utils/shell-snapshot.ts index 657afb732..0eb97782d 100644 --- a/packages/coding-agent/src/utils/shell-snapshot.ts +++ b/packages/coding-agent/src/utils/shell-snapshot.ts @@ -9,9 +9,9 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { postmortem } from "@oh-my-pi/pi-utils"; -import { $ } from "bun"; const cachedSnapshotPaths = new Map(); +const SNAPSHOT_TIMEOUT_MS = 2_000; function sanitizeSnapshotEnv(env: Record): Record { const sanitized = { ...env }; @@ -153,8 +153,23 @@ export async function getOrCreateSnapshot( try { const snapshotEnv = sanitizeSnapshotEnv(env); - await $`${shell} -c ${script}`.env(snapshotEnv).quiet().text(); - if (fs.existsSync(snapshotPath)) { + const spawnEnv: Record = {}; + for (const [key, value] of Object.entries(snapshotEnv)) { + if (value !== undefined) { + spawnEnv[key] = value; + } + } + const child = Bun.spawn([shell, "-c", script], { + env: spawnEnv, + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + timeout: SNAPSHOT_TIMEOUT_MS, + killSignal: "SIGKILL", + }); + + await child.exited; + if (child.exitCode === 0 && fs.existsSync(snapshotPath)) { cachedSnapshotPaths.set(cacheKey, snapshotPath); return snapshotPath; } diff --git a/packages/coding-agent/test/bash-executor.test.ts b/packages/coding-agent/test/bash-executor.test.ts index b817d8986..e53125eb2 100644 --- a/packages/coding-agent/test/bash-executor.test.ts +++ b/packages/coding-agent/test/bash-executor.test.ts @@ -131,6 +131,35 @@ describe("executeBash", () => { expect(result.output).not.toContain("done"); }); + it("resets persistent session state after abort", async () => { + if (process.platform === "win32") { + return; + } + + const sessionKey = "reset-on-abort"; + await executeBash("export PI_RESET_VAR=alive", { cwd: tempDir, timeout: 5000, sessionKey }); + const beforeAbort = await executeBash("echo $PI_RESET_VAR", { cwd: tempDir, timeout: 5000, sessionKey }); + expect(beforeAbort.output.trim()).toBe("alive"); + + const controller = new AbortController(); + const abortPromise = executeBash("sleep 10", { + cwd: tempDir, + timeout: 5000, + signal: controller.signal, + sessionKey, + }); + await Bun.sleep(50); + controller.abort(); + const aborted = await abortPromise; + expect(aborted.cancelled).toBe(true); + + const afterAbort = await executeBash("echo ${PI_RESET_VAR:-unset}", { + cwd: tempDir, + timeout: 5000, + sessionKey, + }); + expect(afterAbort.output.trim()).toBe("unset"); + }); it("streams output chunks", async () => { const chunks: string[] = []; const result = await executeBash("i=1; while [ $i -le 20 ]; do echo line$i; i=$((i+1)); done", {