diff --git a/src/lib/detect-agent.ts b/src/lib/detect-agent.ts new file mode 100644 index 000000000..2ebc349e9 --- /dev/null +++ b/src/lib/detect-agent.ts @@ -0,0 +1,242 @@ +/** + * AI agent detection — determines whether the CLI is being driven by + * a specific AI coding agent. + * + * Detection uses two strategies: + * 1. **Environment variables** (sync) — agents inject these into child + * processes. Adapted from Vercel's @vercel/detect-agent (Apache-2.0). + * 2. **Process tree walking** (async) — scan parent/grandparent process + * names for known agent executables. Runs as a non-blocking background + * task so it never delays CLI startup. + * + * To add a new agent, add entries to {@link ENV_VAR_AGENTS} and/or + * {@link PROCESS_NAME_AGENTS}. + */ + +import { execFile } from "node:child_process"; +import { readFile } from "node:fs/promises"; +import { basename } from "node:path"; + +import { getEnv } from "./env.js"; + +/** + * Env var → agent name. Checked in insertion order — first match wins. + * Each env var maps directly to the agent that sets it. + */ +export const ENV_VAR_AGENTS = new Map([ + // Cursor + ["CURSOR_TRACE_ID", "cursor"], + ["CURSOR_AGENT", "cursor"], + // Gemini CLI + ["GEMINI_CLI", "gemini"], + // OpenAI Codex + ["CODEX_SANDBOX", "codex"], + ["CODEX_CI", "codex"], + ["CODEX_THREAD_ID", "codex"], + // Antigravity + ["ANTIGRAVITY_AGENT", "antigravity"], + // Augment + ["AUGMENT_AGENT", "augment"], + // OpenCode + ["OPENCODE_CLIENT", "opencode"], + // Replit — REPL_ID intentionally excluded because it's set in ALL Replit + // workspaces, not just when the AI agent is driving the CLI + // GitHub Copilot — COPILOT_GITHUB_TOKEN intentionally excluded because + // users may export it persistently for auth, causing false positives + ["COPILOT_MODEL", "github-copilot"], + ["COPILOT_ALLOW_ALL", "github-copilot"], + // Goose + ["GOOSE_TERMINAL", "goose"], + // Amp + ["AMP_THREAD_ID", "amp"], +]); + +/** + * Process executable basename (lowercase) → agent name. + * Used when scanning the parent process tree as a fallback. + */ +export const PROCESS_NAME_AGENTS = new Map([ + ["cursor", "cursor"], + ["claude", "claude"], + ["goose", "goose"], + ["windsurf", "windsurf"], + ["amp", "amp"], + ["codex", "codex"], + ["augment", "augment"], + ["opencode", "opencode"], + ["gemini", "gemini"], +]); + +/** Max levels to walk up the process tree before giving up. */ +const MAX_ANCESTOR_DEPTH = 5; + +/** Pattern to extract `Name:` from `/proc//status`. */ +const PROC_STATUS_NAME_RE = /^Name:\s+(.+)$/m; + +/** Pattern to extract `PPid:` from `/proc//status`. */ +const PROC_STATUS_PPID_RE = /^PPid:\s+(\d+)$/m; + +/** Pattern to parse `ps -o ppid=,comm=` output: " ". */ +const PS_PPID_COMM_RE = /^(\d+)\s+(.+)$/; + +/** Name + parent PID of a process. */ +type ProcessInfo = { + /** Basename of the executable (e.g. "cursor", "bash"). */ + name: string; + /** Parent process ID, or 0 if unavailable. */ + ppid: number; +}; + +/** + * Async process info provider signature. Default reads from `/proc/` or `ps(1)`. + * Override via {@link setProcessInfoProvider} for testing. + */ +type ProcessInfoProvider = (pid: number) => Promise; + +let _getProcessInfo: ProcessInfoProvider = getProcessInfoFromOS; + +/** + * Override the process info provider. Follows the same pattern as + * {@link setEnv} — call with a mock in tests, reset in `afterEach`. + * + * Pass `getProcessInfoFromOS` to restore the real implementation. + */ +export function setProcessInfoProvider(provider: ProcessInfoProvider): void { + _getProcessInfo = provider; +} + +/** + * Detect agent from environment variables only (synchronous, no I/O). + * + * Priority: + * 1. `AI_AGENT` env var — explicit override, any agent can self-identify + * 2. Agent-specific env vars from {@link ENV_VAR_AGENTS} + * 3. Claude Code with Cowork variant (conditional, can't be in the map) + * 4. `AGENT` env var — generic fallback set by Goose, Amp, and others + * + * Returns the agent name string, or `undefined` if no agent is detected. + * For process tree fallback, use {@link detectAgentFromProcessTree} separately. + */ +export function detectAgent(): string | undefined { + const env = getEnv(); + + // 1. Highest priority: explicit override — any agent can self-identify + const aiAgent = env.AI_AGENT?.trim(); + if (aiAgent) { + return aiAgent; + } + + // 2. Table-driven env var check (Map iteration preserves insertion order) + for (const [envVar, agent] of ENV_VAR_AGENTS) { + if (env[envVar]) { + return agent; + } + } + + // 3. Claude Code / Cowork — requires branching logic, so not in the map + if (env.CLAUDECODE || env.CLAUDE_CODE) { + return env.CLAUDE_CODE_IS_COWORK ? "cowork" : "claude"; + } + + // 4. Lowest priority: generic AGENT fallback + return env.AGENT?.trim() || undefined; +} + +/** + * Walk the ancestor process tree looking for known agent executables. + * + * Fully async — never blocks CLI startup. Starts at the direct parent + * (`process.ppid`) and walks up to {@link MAX_ANCESTOR_DEPTH} levels. + * Stops at PID 1 (init/launchd) or on any read error. + * + * - **Linux**: reads `/proc//status` (in-memory filesystem, fast). + * - **macOS**: uses `ps(1)` with a 500ms timeout per invocation. + * - **Windows**: not supported (env var detection still works). + */ +export async function detectAgentFromProcessTree(): Promise< + string | undefined +> { + let pid = process.ppid; + + for (let depth = 0; depth < MAX_ANCESTOR_DEPTH && pid > 1; depth++) { + const info = await _getProcessInfo(pid); + if (!info) { + break; + } + + const agent = PROCESS_NAME_AGENTS.get(info.name.toLowerCase()); + if (agent) { + return agent; + } + + pid = info.ppid; + } + + return; +} + +/** + * Read process name and parent PID for a given PID. + * + * Tries `/proc//status` first (Linux, no subprocess overhead), + * falls back to `ps(1)` (macOS and other Unix systems). + * Windows is unsupported — returns `undefined`. + */ +export async function getProcessInfoFromOS( + pid: number +): Promise { + // Linux: /proc is an in-memory filesystem — fast even though async + try { + const status = await readFile(`/proc/${pid}/status`, "utf-8"); + const nameMatch = status.match(PROC_STATUS_NAME_RE); + const ppidMatch = status.match(PROC_STATUS_PPID_RE); + if (nameMatch?.[1] && ppidMatch?.[1]) { + return { name: nameMatch[1].trim(), ppid: Number(ppidMatch[1]) }; + } + } catch { + // Not Linux or process is gone — fall through to ps + } + + // macOS / other Unix: use ps(1) asynchronously + if (process.platform !== "win32") { + try { + const result = await execFileUnreffed( + "ps", + ["-p", String(pid), "-o", "ppid=,comm="], + { timeout: 500 } + ); + const match = result.trim().match(PS_PPID_COMM_RE); + if (match?.[1] && match?.[2]) { + return { name: basename(match[2].trim()), ppid: Number(match[1]) }; + } + } catch { + // Process gone, ps not available, or timeout + } + } +} + +/** + * Spawn `execFile` with the child process unreffed so it never + * prevents the CLI from exiting. Resolves with stdout on success. + */ +function execFileUnreffed( + cmd: string, + args: readonly string[], + opts: { timeout?: number } +): Promise { + return new Promise((resolve, reject) => { + const child = execFile( + cmd, + args, + { encoding: "utf-8", ...opts }, + (err, stdout) => { + if (err) { + reject(err); + } else { + resolve(stdout); + } + } + ); + child.unref(); + }); +} diff --git a/src/lib/telemetry.ts b/src/lib/telemetry.ts index e90238f04..d8ecc3ad1 100644 --- a/src/lib/telemetry.ts +++ b/src/lib/telemetry.ts @@ -19,9 +19,10 @@ import { SENTRY_CLI_DSN, } from "./constants.js"; import { isReadonlyError, tryRepairAndRetry } from "./db/schema.js"; +import { detectAgent, detectAgentFromProcessTree } from "./detect-agent.js"; import { getEnv } from "./env.js"; import { ApiError, AuthError, OutputError } from "./errors.js"; -import { attachSentryReporter } from "./logger.js"; +import { attachSentryReporter, logger } from "./logger.js"; import { getSentryBaseUrl, isSentrySaasUrl } from "./sentry-urls.js"; import { getRealUsername } from "./utils.js"; @@ -522,6 +523,25 @@ export function initSentry( // Tag whether running in an interactive terminal or agent/CI environment Sentry.setTag("is_tty", !!process.stdout.isTTY); + // Tag which AI agent (if any) is driving the CLI. + // Env var detection is sync (instant). If no env var matches, fire off + // async process tree detection in the background — it sets the tag + // before the transaction finishes without blocking CLI startup. + const agent = detectAgent(); + if (agent) { + Sentry.setTag("agent", agent); + } else { + detectAgentFromProcessTree() + .then((processAgent) => { + if (processAgent) { + Sentry.setTag("agent", processAgent); + } + }) + .catch((error) => { + logger.withTag("agent").warn("Process tree detection failed:", error); + }); + } + // Wire up consola → Sentry log forwarding now that the client is active attachSentryReporter(); diff --git a/test/lib/detect-agent.test.ts b/test/lib/detect-agent.test.ts new file mode 100644 index 000000000..59d0dc074 --- /dev/null +++ b/test/lib/detect-agent.test.ts @@ -0,0 +1,354 @@ +import { afterEach, describe, expect, test } from "bun:test"; + +import { + detectAgent, + detectAgentFromProcessTree, + ENV_VAR_AGENTS, + getProcessInfoFromOS, + PROCESS_NAME_AGENTS, + setProcessInfoProvider, +} from "../../src/lib/detect-agent.js"; +import { setEnv } from "../../src/lib/env.js"; + +function withEnv(vars: Record) { + setEnv(vars as NodeJS.ProcessEnv); +} + +/** No-op async provider typed to satisfy ProcessInfoProvider. */ +async function noProcessInfo(_pid: number): Promise { + return; +} + +describe("detectAgent", () => { + afterEach(() => { + setEnv(process.env); + setProcessInfoProvider(getProcessInfoFromOS); + }); + + // ── AI_AGENT override ────────────────────────────────────────────── + + test("AI_AGENT takes highest priority", () => { + withEnv({ AI_AGENT: "custom-agent", CLAUDE_CODE: "1", CI: "true" }); + expect(detectAgent()).toBe("custom-agent"); + }); + + test("AI_AGENT empty string is ignored", () => { + withEnv({ AI_AGENT: "" }); + expect(detectAgent()).toBeUndefined(); + }); + + test("AI_AGENT whitespace-only is ignored", () => { + withEnv({ AI_AGENT: " " }); + expect(detectAgent()).toBeUndefined(); + }); + + test("AI_AGENT is trimmed", () => { + withEnv({ AI_AGENT: " my-agent " }); + expect(detectAgent()).toBe("my-agent"); + }); + + // ── Cursor ───────────────────────────────────────────────────────── + + test("CURSOR_TRACE_ID → cursor", () => { + withEnv({ CURSOR_TRACE_ID: "abc123" }); + expect(detectAgent()).toBe("cursor"); + }); + + test("CURSOR_AGENT → cursor", () => { + withEnv({ CURSOR_AGENT: "1" }); + expect(detectAgent()).toBe("cursor"); + }); + + test("CURSOR_TRACE_ID takes priority over CURSOR_AGENT", () => { + withEnv({ CURSOR_TRACE_ID: "abc", CURSOR_AGENT: "1" }); + expect(detectAgent()).toBe("cursor"); + }); + + // ── Gemini ───────────────────────────────────────────────────────── + + test("GEMINI_CLI → gemini", () => { + withEnv({ GEMINI_CLI: "1" }); + expect(detectAgent()).toBe("gemini"); + }); + + // ── Codex ────────────────────────────────────────────────────────── + + test("CODEX_SANDBOX → codex", () => { + withEnv({ CODEX_SANDBOX: "1" }); + expect(detectAgent()).toBe("codex"); + }); + + test("CODEX_CI → codex", () => { + withEnv({ CODEX_CI: "1" }); + expect(detectAgent()).toBe("codex"); + }); + + test("CODEX_THREAD_ID → codex", () => { + withEnv({ CODEX_THREAD_ID: "thread-123" }); + expect(detectAgent()).toBe("codex"); + }); + + // ── Antigravity ──────────────────────────────────────────────────── + + test("ANTIGRAVITY_AGENT → antigravity", () => { + withEnv({ ANTIGRAVITY_AGENT: "1" }); + expect(detectAgent()).toBe("antigravity"); + }); + + // ── Augment ──────────────────────────────────────────────────────── + + test("AUGMENT_AGENT → augment", () => { + withEnv({ AUGMENT_AGENT: "1" }); + expect(detectAgent()).toBe("augment"); + }); + + // ── OpenCode ─────────────────────────────────────────────────────── + + test("OPENCODE_CLIENT → opencode", () => { + withEnv({ OPENCODE_CLIENT: "1" }); + expect(detectAgent()).toBe("opencode"); + }); + + // ── Claude Code ──────────────────────────────────────────────────── + + test("CLAUDE_CODE → claude", () => { + withEnv({ CLAUDE_CODE: "1" }); + expect(detectAgent()).toBe("claude"); + }); + + test("CLAUDECODE → claude", () => { + withEnv({ CLAUDECODE: "1" }); + expect(detectAgent()).toBe("claude"); + }); + + test("CLAUDE_CODE + CLAUDE_CODE_IS_COWORK → cowork", () => { + withEnv({ CLAUDE_CODE: "1", CLAUDE_CODE_IS_COWORK: "1" }); + expect(detectAgent()).toBe("cowork"); + }); + + test("CLAUDECODE + CLAUDE_CODE_IS_COWORK → cowork", () => { + withEnv({ CLAUDECODE: "1", CLAUDE_CODE_IS_COWORK: "1" }); + expect(detectAgent()).toBe("cowork"); + }); + + // ── Excluded env vars (false positive risks) ────────────────────── + + test("REPL_ID alone does not trigger detection (platform env, not agent signal)", () => { + withEnv({ REPL_ID: "abc123" }); + expect(detectAgent()).toBeUndefined(); + }); + + test("COPILOT_GITHUB_TOKEN alone does not trigger detection (false positive risk)", () => { + withEnv({ COPILOT_GITHUB_TOKEN: "ghu_xxx" }); + expect(detectAgent()).toBeUndefined(); + }); + + // ── GitHub Copilot ───────────────────────────────────────────────── + + test("COPILOT_MODEL → github-copilot", () => { + withEnv({ COPILOT_MODEL: "gpt-4" }); + expect(detectAgent()).toBe("github-copilot"); + }); + + test("COPILOT_ALLOW_ALL → github-copilot", () => { + withEnv({ COPILOT_ALLOW_ALL: "1" }); + expect(detectAgent()).toBe("github-copilot"); + }); + + // ── Goose ────────────────────────────────────────────────────────── + + test("GOOSE_TERMINAL → goose", () => { + withEnv({ GOOSE_TERMINAL: "1" }); + expect(detectAgent()).toBe("goose"); + }); + + // ── Amp ──────────────────────────────────────────────────────────── + + test("AMP_THREAD_ID → amp", () => { + withEnv({ AMP_THREAD_ID: "thread-456" }); + expect(detectAgent()).toBe("amp"); + }); + + // ── AGENT generic fallback ───────────────────────────────────────── + + test("AGENT as generic fallback", () => { + withEnv({ AGENT: "some-new-agent" }); + expect(detectAgent()).toBe("some-new-agent"); + }); + + test("AGENT is trimmed", () => { + withEnv({ AGENT: " goose " }); + expect(detectAgent()).toBe("goose"); + }); + + test("AGENT empty string is ignored", () => { + withEnv({ AGENT: "" }); + expect(detectAgent()).toBeUndefined(); + }); + + test("specific env vars take priority over AGENT", () => { + withEnv({ AGENT: "goose", CLAUDE_CODE: "1" }); + expect(detectAgent()).toBe("claude"); + }); + + // ── No agent ─────────────────────────────────────────────────────── + + test("no env vars → undefined", () => { + withEnv({}); + expect(detectAgent()).toBeUndefined(); + }); +}); + +describe("ENV_VAR_AGENTS map structure", () => { + test("is a Map instance", () => { + expect(ENV_VAR_AGENTS).toBeInstanceOf(Map); + }); + + test("all values are non-empty strings", () => { + for (const [envVar, agent] of ENV_VAR_AGENTS) { + expect(envVar.length).toBeGreaterThan(0); + expect(agent.length).toBeGreaterThan(0); + } + }); + + test("env var keys are UPPER_SNAKE_CASE", () => { + for (const envVar of ENV_VAR_AGENTS.keys()) { + expect(envVar).toMatch(/^[A-Z][A-Z0-9_]*$/); + } + }); + + test("agent names are lowercase with optional hyphens", () => { + for (const agent of ENV_VAR_AGENTS.values()) { + expect(agent).toMatch(/^[a-z][a-z0-9-]*$/); + } + }); +}); + +describe("PROCESS_NAME_AGENTS map structure", () => { + test("is a Map instance", () => { + expect(PROCESS_NAME_AGENTS).toBeInstanceOf(Map); + }); + + test("all keys are lowercase", () => { + for (const name of PROCESS_NAME_AGENTS.keys()) { + expect(name).toBe(name.toLowerCase()); + } + }); + + test("agent names are lowercase with optional hyphens", () => { + for (const agent of PROCESS_NAME_AGENTS.values()) { + expect(agent).toMatch(/^[a-z][a-z0-9-]*$/); + } + }); +}); + +describe("detectAgentFromProcessTree", () => { + afterEach(() => { + setProcessInfoProvider(getProcessInfoFromOS); + }); + + test("returns agent when parent matches", async () => { + setProcessInfoProvider(async (pid) => { + if (pid === process.ppid) { + return { name: "cursor", ppid: 1 }; + } + }); + expect(await detectAgentFromProcessTree()).toBe("cursor"); + }); + + test("walks up to grandparent", async () => { + const shellPid = 100; + setProcessInfoProvider(async (pid) => { + // parent is bash, grandparent is cursor + if (pid === process.ppid) { + return { name: "bash", ppid: shellPid }; + } + if (pid === shellPid) { + return { name: "Cursor", ppid: 1 }; + } + }); + expect(await detectAgentFromProcessTree()).toBe("cursor"); + }); + + test("case-insensitive matching", async () => { + setProcessInfoProvider(async (pid) => { + if (pid === process.ppid) { + return { name: "Claude", ppid: 1 }; + } + }); + expect(await detectAgentFromProcessTree()).toBe("claude"); + }); + + test("returns undefined when no agent in tree", async () => { + setProcessInfoProvider(async (pid) => { + if (pid === process.ppid) { + return { name: "bash", ppid: 1 }; + } + }); + expect(await detectAgentFromProcessTree()).toBeUndefined(); + }); + + test("stops at PID 1 (init/launchd)", async () => { + setProcessInfoProvider(async (pid) => { + if (pid === process.ppid) { + return { name: "bash", ppid: 1 }; + } + // PID 1 should not be checked + if (pid === 1) { + return { name: "cursor", ppid: 0 }; + } + }); + expect(await detectAgentFromProcessTree()).toBeUndefined(); + }); + + test("stops when getProcessInfo returns undefined", async () => { + setProcessInfoProvider(noProcessInfo); + expect(await detectAgentFromProcessTree()).toBeUndefined(); + }); + + test("respects max depth", async () => { + // Create a chain deeper than MAX_ANCESTOR_DEPTH (5) + let nextPid = process.ppid; + const chain = new Map(); + for (let i = 0; i < 10; i++) { + const ppid = nextPid + 1; + chain.set(nextPid, { name: "bash", ppid }); + nextPid = ppid; + } + // Put cursor at depth 8 (beyond the limit) + chain.set(nextPid, { name: "cursor", ppid: 1 }); + + setProcessInfoProvider(async (pid) => chain.get(pid)); + expect(await detectAgentFromProcessTree()).toBeUndefined(); + }); +}); + +describe("getProcessInfoFromOS", () => { + test("returns info for own process", async () => { + const info = await getProcessInfoFromOS(process.pid); + expect(info).toBeDefined(); + if (info) { + expect(info.name.length).toBeGreaterThan(0); + expect(info.ppid).toBeGreaterThan(0); + } + }); + + test("returns info for parent process", async () => { + const info = await getProcessInfoFromOS(process.ppid); + expect(info).toBeDefined(); + if (info) { + expect(info.name.length).toBeGreaterThan(0); + expect(info.ppid).toBeGreaterThanOrEqual(0); + } + }); + + test("returns undefined for non-existent PID", async () => { + const info = await getProcessInfoFromOS(99_999_999); + expect(info).toBeUndefined(); + }); + + test("returns undefined for PID 0", async () => { + const info = await getProcessInfoFromOS(0); + expect(info).toBeUndefined(); + }); +});