Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions packages/cli/src/telemetry/agent_runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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();
});
Expand Down Expand Up @@ -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();
Expand Down
91 changes: 87 additions & 4 deletions packages/cli/src/telemetry/agent_runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ export type AgentRuntime =
| "openclaw"
| "pi"
| "gemini_managed_agent"
| "windsurf"
| "cline"
| "gemini_cli"
| "crush"
| null;

interface VendorRule {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
Loading