diff --git a/packages/cli/src/telemetry/agent_runtime.test.ts b/packages/cli/src/telemetry/agent_runtime.test.ts index b50f99804..b5e2b4de5 100644 --- a/packages/cli/src/telemetry/agent_runtime.test.ts +++ b/packages/cli/src/telemetry/agent_runtime.test.ts @@ -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(); diff --git a/packages/cli/src/telemetry/agent_runtime.ts b/packages/cli/src/telemetry/agent_runtime.ts index 757b21294..521756635 100644 --- a/packages/cli/src/telemetry/agent_runtime.ts +++ b/packages/cli/src/telemetry/agent_runtime.ts @@ -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