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
34 changes: 31 additions & 3 deletions packages/cli/src/lib/detect-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
*
* Detection uses two strategies:
* 1. **Environment variables** (sync) — agents inject these into child
* processes. Adapted from Vercel's @vercel/detect-agent (Apache-2.0).
* processes. Selected rules adapted from Vercel's detect-agent v1.2.0
* (Apache-2.0): https://github.com/vercel/detect-agent/tree/3ab1df1
* 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.
Expand Down Expand Up @@ -37,6 +38,12 @@ export type AgentInfo = {
export const AGENT_ALIASES = new Map<string, string>([
["claude-code", "claude"],
["claudecode", "claude"],
["claude_code", "claude"],
["codex_cli", "codex"],
["gemini_cli", "gemini"],
["open_code", "opencode"],
["cursor-cli", "cursor"],
["augment-cli", "augment"],
]);

/** Truthy boolean-ish values — signal "an agent is present" but don't name it. */
Expand Down Expand Up @@ -120,25 +127,40 @@ export const ENV_VAR_AGENTS = new Map<string, string>([
// Cursor
["CURSOR_TRACE_ID", "cursor"],
["CURSOR_AGENT", "cursor"],
// Kimi Code plugin hooks — KIMI_CODE_HOME can be set outside a session
["KIMI_PLUGIN_ROOT", "kimi"],
// Grok plugin hooks must win over Claude compatibility markers
["GROK_PLUGIN_ROOT", "grok"],
["GROK_PLUGIN_DATA", "grok"],
// Gemini CLI
["GEMINI_CLI", "gemini"],
// Cline
["CLINE_ACTIVE", "cline"],
// OpenAI Codex
["CODEX_SANDBOX", "codex"],
["CODEX_CI", "codex"],
["CODEX_THREAD_ID", "codex"],
["CODEX_SANDBOX_NETWORK_DISABLED", "codex"],
// Antigravity
["ANTIGRAVITY_AGENT", "antigravity"],
["ANTIGRAVITY_CLI_ALIAS", "antigravity"],
// Augment
["AUGMENT_AGENT", "augment"],
// OpenCode
["OPENCODE_CLIENT", "opencode"],
["OPENCODE", "opencode"],
// Junie
["JUNIE_DATA", "junie"],
["JUNIE_SHIM_PATH", "junie"],
// OpenClaw
["OPENCLAW_SHELL", "openclaw"],
// 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: The OPENCODE environment variable may cause false-positive agent detection, as it could be set by non-agent tools or user configurations.
Severity: LOW

Suggested Fix

To prevent false positives, consider excluding the OPENCODE environment variable from agent detection, similar to how GOOSE_PROVIDER and KIMI_CODE_HOME are handled. If OPENCODE is confirmed to be safe, add a comment explaining why it doesn't pose a false-positive risk.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location: packages/cli/src/lib/detect-agent.ts#L159

Potential issue: The environment variable `OPENCODE` was added to the list of variables
used for agent detection. However, this introduces a risk of false-positive detections.
Similar, potentially ambiguous variables like `REPL_ID`, `COPILOT_GITHUB_TOKEN`,
`GOOSE_PROVIDER`, and `KIMI_CODE_HOME` are explicitly excluded because they might be set
in a user's shell for reasons unrelated to an agent. If a user has an `OPENCODE`
variable set for a different tool, the CLI will incorrectly believe it is being run by
the OpenCode agent, which could lead to incorrect behavior or analytics.

Did we get this right? 👍 / 👎 to inform future reviews.

// users may export it persistently for auth, causing false positives
["COPILOT_MODEL", "github-copilot"],
["COPILOT_ALLOW_ALL", "github-copilot"],
// Goose
// Goose — GOOSE_PROVIDER can be persistent configuration in a human shell
["GOOSE_TERMINAL", "goose"],
// Amp
["AMP_THREAD_ID", "amp"],
Expand Down Expand Up @@ -203,7 +225,7 @@ export function setProcessInfoProvider(provider: ProcessInfoProvider): void {
*
* Priority:
* 1. `AI_AGENT` env var — explicit override, any agent can self-identify
* 2. Agent-specific env vars from {@link ENV_VAR_AGENTS}
* 2. Cursor's exact agent-exec role, then 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
*
Expand All @@ -225,6 +247,12 @@ export function detectAgent(): AgentInfo | undefined {
}
}

// The role marker also exists outside agent execution; only this value
// identifies an agent. Check before the map to retain Cursor's priority.
if (env.CURSOR_EXTENSION_HOST_ROLE === "agent-exec") {
return normalizeAgent("cursor");
}

// 2. Table-driven env var check (Map iteration preserves insertion order).
// These values are our own clean strings — normalizeAgent won't return undefined.
for (const [envVar, agent] of ENV_VAR_AGENTS) {
Expand Down
107 changes: 106 additions & 1 deletion packages/cli/test/lib/detect-agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,13 @@ describe("detectAgent", () => {
// ── AI_AGENT override ──────────────────────────────────────────────

test("AI_AGENT takes highest priority", () => {
withEnv({ AI_AGENT: "custom-agent", CLAUDE_CODE: "1", CI: "true" });
withEnv({
AI_AGENT: "custom-agent",
CURSOR_EXTENSION_HOST_ROLE: "agent-exec",
GROK_PLUGIN_DATA: "/tmp/grok",
CLAUDE_CODE: "1",
CI: "true",
});
expect(detectAgent()).toEqual(named("custom-agent"));
});

Expand Down Expand Up @@ -94,6 +100,38 @@ describe("detectAgent", () => {
expect(detectAgent()).toEqual(named("claude"));
});

test.each([
["CLINE_ACTIVE", "cline"],
["OPENCLAW_SHELL", "openclaw"],
["KIMI_PLUGIN_ROOT", "kimi"],
["GROK_PLUGIN_ROOT", "grok"],
["GROK_PLUGIN_DATA", "grok"],
["JUNIE_DATA", "junie"],
["JUNIE_SHIM_PATH", "junie"],
["CODEX_SANDBOX_NETWORK_DISABLED", "codex"],
["ANTIGRAVITY_CLI_ALIAS", "antigravity"],
["OPENCODE", "opencode"],
])("%s detects %s before the AGENT fallback", (envVar, agent) => {
withEnv({ [envVar]: "1", AGENT: "other-agent" });
expect(detectAgent()).toEqual(named(agent));
});

test("empty agent-specific signals are ignored", () => {
withEnv({
CLINE_ACTIVE: "",
OPENCLAW_SHELL: "",
KIMI_PLUGIN_ROOT: "",
GROK_PLUGIN_ROOT: "",
GROK_PLUGIN_DATA: "",
JUNIE_DATA: "",
JUNIE_SHIM_PATH: "",
CODEX_SANDBOX_NETWORK_DISABLED: "",
ANTIGRAVITY_CLI_ALIAS: "",
OPENCODE: "",
});
expect(detectAgent()).toBeUndefined();
});

// ── Cursor ─────────────────────────────────────────────────────────

test("CURSOR_TRACE_ID → cursor", () => {
Expand All @@ -111,6 +149,37 @@ describe("detectAgent", () => {
expect(detectAgent()).toEqual(named("cursor"));
});

test("CURSOR_EXTENSION_HOST_ROLE=agent-exec → cursor", () => {
withEnv({ CURSOR_EXTENSION_HOST_ROLE: "agent-exec" });
expect(detectAgent()).toEqual(named("cursor"));
});

test("Cursor agent-exec takes priority over other agent markers", () => {
withEnv({
CURSOR_EXTENSION_HOST_ROLE: "agent-exec",
GEMINI_CLI: "1",
GROK_PLUGIN_ROOT: "/tmp/grok",
CLAUDE_CODE: "1",
AGENT: "other-agent",
});
expect(detectAgent()).toEqual(named("cursor"));
});

test.each([
"",
"terminal",
"agent-exec-helper",
"AGENT-EXEC",
])("CURSOR_EXTENSION_HOST_ROLE=%j does not trigger detection", (role) => {
withEnv({ CURSOR_EXTENSION_HOST_ROLE: role });
expect(detectAgent()).toBeUndefined();
});

test("a different Cursor role falls through to other agent markers", () => {
withEnv({ CURSOR_EXTENSION_HOST_ROLE: "terminal", CLINE_ACTIVE: "1" });
expect(detectAgent()).toEqual(named("cline"));
});

// ── Gemini ─────────────────────────────────────────────────────────

test("GEMINI_CLI → gemini", () => {
Expand Down Expand Up @@ -183,6 +252,20 @@ describe("detectAgent", () => {
expect(detectAgent()).toBeUndefined();
});

test.each([
["GROK_PLUGIN_ROOT", "CLAUDE_CODE", ""],
["GROK_PLUGIN_DATA", "CLAUDECODE", ""],
["GROK_PLUGIN_ROOT", "CLAUDECODE", "1"],
["GROK_PLUGIN_DATA", "CLAUDE_CODE", "1"],
])("%s takes priority over %s with CLAUDE_CODE_IS_COWORK=%j", (grokVar, claudeVar, cowork) => {
withEnv({
[grokVar]: "/tmp/grok",
[claudeVar]: "1",
CLAUDE_CODE_IS_COWORK: cowork,
});
expect(detectAgent()).toEqual(named("grok"));
});

// ── Excluded env vars (false positive risks) ──────────────────────

test("REPL_ID alone does not trigger detection (platform env, not agent signal)", () => {
Expand All @@ -195,6 +278,14 @@ describe("detectAgent", () => {
expect(detectAgent()).toBeUndefined();
});

test.each([
["GOOSE_PROVIDER", "openai"],
["KIMI_CODE_HOME", "/tmp/kimi"],
])("%s configuration alone does not trigger detection", (envVar, value) => {
withEnv({ [envVar]: value });
expect(detectAgent()).toBeUndefined();
});

// ── GitHub Copilot ─────────────────────────────────────────────────

test("COPILOT_MODEL → github-copilot", () => {
Expand Down Expand Up @@ -258,6 +349,20 @@ describe("detectAgent", () => {
expect(detectAgent()).toEqual({ name: "my-agent", version: "1.0.0" });
});

test.each([
["claude_code", "claude"],
["codex_cli", "codex"],
["gemini_cli", "gemini"],
["open_code", "opencode"],
["cursor-cli", "cursor"],
["augment-cli", "augment"],
])("%s identifies %s through AI_AGENT and AGENT", (alias, name) => {
for (const envVar of ["AI_AGENT", "AGENT"]) {
withEnv({ [envVar]: `${alias}/1.2.3/agent` });
expect(detectAgent()).toEqual({ name, version: "1.2.3", role: "agent" });
}
});

// ── No agent ───────────────────────────────────────────────────────

test("no env vars → undefined", () => {
Expand Down
1 change: 1 addition & 0 deletions packages/cli/test/lib/init/wizard-runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -641,6 +641,7 @@ describe("runWizard", () => {
"AGENT",
"CLAUDECODE",
"CLAUDE_CODE",
"CURSOR_EXTENSION_HOST_ROLE",
...ENV_VAR_AGENTS.keys(),
]);
const cleanEnv = Object.fromEntries(
Expand Down
Loading