feat(cli): detect Windsurf, Cline, Gemini CLI, and Crush agents (#1328)

Rebased onto main after #1294 merged. Adds four coding-agent vendors to
detectAgentRuntime() (existence-only checks, source/runtime-verified):
- windsurf — TERM_PROGRAM=windsurf (case-insensitive)
- cline — CLINE_ACTIVE (default vscode-terminal path)
- gemini_cli — GEMINI_CLI (runtime-confirmed; distinct from the managed-agent
  /.agents/ detector, which runs ahead of VENDOR_RULES and wins when both match)
- crush — CRUSH (runtime-confirmed)

Also makes the cursor rule case-insensitive for parity with windsurf, and adds
a code-resident "deliberately NOT added" section (OpenHands/Aider/Goose/
opencode/Roo/Amp/Devin/Jules/Factory) carrying the empirical rejection
rationale.

Test isolation: the Gemini managed-agent suite now clears its node:os/node:fs
doMock registrations in afterEach so they don't leak into the env-var-only
suites that follow it in the same file.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
James Russo
2026-06-10 18:10:25 -07:00
committed by GitHub
co-authored by Claude Fable 5
parent 9b18fadccd
commit e0ecd4d2d1
2 changed files with 145 additions and 4 deletions
@@ -20,6 +20,9 @@ const VENDOR_ENV_KEYS = [
"OPENCLAW_STATE_DIR",
"OPENCLAW_CONFIG_PATH",
"PI_CODING_AGENT",
"CLINE_ACTIVE",
"GEMINI_CLI",
"CRUSH",
] as const;
function stripVendorEnv(): void {
@@ -117,6 +120,12 @@ describe("detectAgentRuntime — Cursor / Copilot / cohort", () => {
expect(detectAgentRuntime()).toBe("cursor");
});
it("detects Cursor case-insensitively (TERM_PROGRAM=Cursor)", async () => {
process.env["TERM_PROGRAM"] = "Cursor";
const { detectAgentRuntime } = await import("./agent_runtime.js");
expect(detectAgentRuntime()).toBe("cursor");
});
it("detects Copilot Coding Agent via GITHUB_ACTIONS + COPILOT_AGENT_ID", async () => {
process.env["GITHUB_ACTIONS"] = "true";
process.env["COPILOT_AGENT_ID"] = "abc123";
@@ -175,6 +184,11 @@ describe("detectAgentRuntime — Gemini managed agent", () => {
});
afterEach(() => {
// Clear the node:os / node:fs doMock registrations so they don't leak into
// the env-var-only suites that follow (restoreAllMocks does not undo
// doMock, and those suites don't resetModules in beforeEach).
vi.doUnmock("node:os");
vi.doUnmock("node:fs");
vi.resetModules();
vi.restoreAllMocks();
});
@@ -278,6 +292,50 @@ describe("detectAgentRuntime — Gemini managed agent", () => {
});
});
describe("detectAgentRuntime — Windsurf / Cline / Gemini CLI / Crush", () => {
const savedEnv = { ...process.env };
beforeEach(stripVendorEnv);
afterEach(() => {
process.env = { ...savedEnv };
});
it("detects Windsurf via TERM_PROGRAM=windsurf", async () => {
process.env["TERM_PROGRAM"] = "windsurf";
const { detectAgentRuntime } = await import("./agent_runtime.js");
expect(detectAgentRuntime()).toBe("windsurf");
});
it("detects Windsurf case-insensitively (TERM_PROGRAM=Windsurf)", async () => {
process.env["TERM_PROGRAM"] = "Windsurf";
const { detectAgentRuntime } = await import("./agent_runtime.js");
expect(detectAgentRuntime()).toBe("windsurf");
});
it("detects Cline via CLINE_ACTIVE (default vscode-terminal path)", async () => {
process.env["CLINE_ACTIVE"] = "true";
const { detectAgentRuntime } = await import("./agent_runtime.js");
expect(detectAgentRuntime()).toBe("cline");
});
it("detects Gemini CLI via GEMINI_CLI", async () => {
process.env["GEMINI_CLI"] = "1";
const { detectAgentRuntime } = await import("./agent_runtime.js");
expect(detectAgentRuntime()).toBe("gemini_cli");
});
it("detects Crush via CRUSH (set unconditionally on every spawned shell)", async () => {
process.env["CRUSH"] = "1";
const { detectAgentRuntime } = await import("./agent_runtime.js");
expect(detectAgentRuntime()).toBe("crush");
});
it("does NOT misread the user-set value (existence only) — GEMINI_CLI key shape ignored", async () => {
process.env["GEMINI_CLI"] = "anything";
const { detectAgentRuntime } = await import("./agent_runtime.js");
expect(detectAgentRuntime()).toBe("gemini_cli");
});
});
describe("detectSandboxRuntime — file-system path", () => {
beforeEach(() => {
vi.resetModules();
+87 -4
View File
@@ -31,6 +31,10 @@ export type AgentRuntime =
| "openclaw"
| "pi"
| "gemini_managed_agent"
| "windsurf"
| "cline"
| "gemini_cli"
| "crush"
| null;
interface VendorRule {
@@ -70,12 +74,25 @@ const VENDOR_RULES: VendorRule[] = [
typeof env["CODEX_CI"] === "string" ||
typeof env["CODEX_SANDBOX_NETWORK_DISABLED"] === "string",
},
// Cursor IDE integrated terminal — exports TERM_PROGRAM=cursor.
// Cursor Background Agent env vars are not publicly documented; if a
// canonical marker is identified later, add it here.
// Cursor IDE integrated terminal — exports TERM_PROGRAM=cursor. Compared
// case-insensitively for parity with the Windsurf rule below: Cursor sets
// lowercase today, but matching loosely costs nothing and won't miss a
// capitalized variant. Cursor Background Agent env vars are not publicly
// documented; if a canonical marker is identified later, add it here.
{
name: "cursor",
check: (env) => env["TERM_PROGRAM"] === "cursor",
check: (env) => env["TERM_PROGRAM"]?.toLowerCase() === "cursor",
},
// Windsurf (Codeium) integrated terminal — exports TERM_PROGRAM=windsurf, the
// direct analog of the Cursor rule above. Attested across many independent
// detectors (nx packages/nx/src/native/ide/detection.rs, adonisjs/application,
// ag-grid git-hooks). Compared case-insensitively because sources disagree on
// casing ("windsurf" vs "Windsurf"). Like Cursor this marks the editor's
// integrated terminal, not specifically that the Cascade agent is driving;
// under WSL/remote it can also fall back to TERM_PROGRAM=vscode.
{
name: "windsurf",
check: (env) => env["TERM_PROGRAM"]?.toLowerCase() === "windsurf",
},
// GitHub Copilot Coding Agent — runs inside GitHub Actions and the
// workflow injects an additional marker to distinguish from generic CI.
@@ -125,8 +142,74 @@ const VENDOR_RULES: VendorRule[] = [
name: "pi",
check: (env) => typeof env["PI_CODING_AGENT"] === "string",
},
// Cline (cline/cline) VS Code extension — injects CLINE_ACTIVE=true into the
// integrated terminal via vscode.TerminalOptions.env, which the terminal
// exports to every shell command run in it
// (apps/vscode/src/hosts/vscode/terminal/VscodeTerminalRegistry.ts:29).
// Caveat: present only on the default "vscodeTerminal" exec path — the opt-in
// backgroundExec/YOLO path spawns via child_process without the marker. Same
// integrated-terminal-only scope as the Cursor/Windsurf rules above.
// Source: https://github.com/cline/cline (VscodeTerminalRegistry.ts:29)
{
name: "cline",
check: (env) => typeof env["CLINE_ACTIVE"] === "string",
},
// Google Gemini CLI (open-source @google/gemini-cli) — DISTINCT from the
// Gemini managed-agent sandbox. (If a /.agents/ filesystem detector is
// present in detectAgentRuntime() it runs ahead of this loop and wins for a
// managed-agent sandbox, leaving this rule to match only the local CLI.)
// The shell-execution service
// sets GEMINI_CLI=1 on the child env of every shell command it spawns, so
// downstream executables can tell they were launched by Gemini CLI
// (packages/core/src/services/shellExecutionService.ts:56,486-487 — spread
// onto baseEnv after sanitizeEnvironment, passed as env: to both the
// child_process and node-pty spawn paths).
// Caveat: under STRICT sanitization (when GITHUB_SHA is set / the GitHub
// Action surface) GEMINI_CLI is not allow-listed and gets stripped — reliable
// for the local CLI, not inside Gemini's GitHub Action runner.
// Source: https://github.com/google-gemini/gemini-cli (shellExecutionService.ts:56,486-487)
{
name: "gemini_cli",
check: (env) => typeof env["GEMINI_CLI"] === "string",
},
// Crush (charmbracelet/crush) — internal/shell/shell.go:43-48,98
// unconditionally appends CRUSH=1 (plus generic AGENT=crush / AI_AGENT=crush)
// to the env of every shell it spawns: both the interactive bash tool and the
// hook runner. We key on CRUSH since AGENT/AI_AGENT are generic and collide.
// Source: https://github.com/charmbracelet/crush (internal/shell/shell.go:43-48,98)
{
name: "crush",
check: (env) => typeof env["CRUSH"] === "string",
},
];
// Agents evaluated and deliberately NOT added. Each fails the bar the rules
// above meet — a marker reliably present in the environment of the
// shell/subprocess the agent spawns. Recorded here (not only in the PR) so the
// next person doesn't re-derive it:
// - OpenHands — OPENHANDS_BUILD_GIT_SHA/_REF exists in the agent-server
// Dockerfile (base-image-minimal stage, added 2025-11-09 in PR #1100) but
// is empirically ABSENT from the runtime env of every published
// ghcr.io/openhands/agent-server image inspected (12+ tags, 2025-10 →
// 2026-01, incl. the introducing PR's merge commit). The declared ENV
// never reaches the published image. Re-add only if a real published image
// carries it in `docker inspect .Config.Env`.
// - Aider — sets no self-identifying env var; both shell-spawn sites
// (run_cmd.py Popen / pexpect.spawn) pass no env=, so children inherit
// os.environ verbatim.
// - Goose — AGENT=goose/GOOSE_TERMINAL=1 are set on the recipe-retry path
// and the computercontroller MCP extension, but NOT on the default
// developer `shell` tool (sets only PATH+cwd), so the primary path is
// undetected.
// - opencode — OPENCODE_TERMINAL=1 is set only on the interactive PTY panel,
// not on the model's bash/shell tool.
// - Roo Code — ROO_ACTIVE is set only on the `vscode` terminal provider; the
// shipped default is the execa provider (terminalShellIntegrationDisabled
// defaults true), which sets no marker.
// - Amp / Devin / Jules / Factory Droid — no verifiable unconditional runtime
// marker (Amp is closed/minified; Devin/Jules are closed sandboxes;
// Factory's FACTORY_PROJECT_DIR / DROID_PLUGIN_ROOT are hook-scoped only).
/**
* Identify the managed sandbox runtime hosting this CLI invocation.
* Returns null on a normal developer machine. Dispatches to runtime-specific