diff --git a/docs/packages/cli.mdx b/docs/packages/cli.mdx index 435a20edd3..fe6c874950 100644 --- a/docs/packages/cli.mdx +++ b/docs/packages/cli.mdx @@ -813,7 +813,7 @@ This is suppressed in CI environments, non-TTY shells, and when `HYPERFRAMES_NO_ npx hyperframes telemetry status ``` - Telemetry collects command names, render performance, example choices, and system info. It does **not** collect file paths, project names, video content, or personally identifiable information. Disable with `HYPERFRAMES_NO_TELEMETRY=1` or the command above. + Telemetry collects command names, render performance, example choices, and system info — including a coarse environment fingerprint (OS, kernel string, CPU/memory shape, sandbox runtime such as gVisor or Docker, and the *name* of a coding agent driving the CLI when one is detected, e.g. `claude_code` / `codex` / `cursor`). The agent name is derived from the existence of well-known environment variables; their values are never read. Telemetry does **not** collect file paths, project names, video content, environment variable values, or personally identifiable information. Disable with `HYPERFRAMES_NO_TELEMETRY=1` or the command above. ### `skills` diff --git a/packages/cli/src/telemetry/agent_runtime.test.ts b/packages/cli/src/telemetry/agent_runtime.test.ts new file mode 100644 index 0000000000..bef81385c9 --- /dev/null +++ b/packages/cli/src/telemetry/agent_runtime.test.ts @@ -0,0 +1,272 @@ +import { describe, expect, it, beforeEach, afterEach, vi } from "vitest"; + +// agent_runtime.ts reads node:os via release/platform and node:fs for the +// /proc files. detectAgentRuntime is exercised by mutating process.env; +// detectSandboxRuntime is exercised through a small set of node:os mocks. + +const VENDOR_ENV_KEYS = [ + "CLAUDECODE", + "CLAUDE_CODE_ENTRYPOINT", + "CODEX_THREAD_ID", + "CODEX_CI", + "CODEX_SANDBOX_NETWORK_DISABLED", + "TERM_PROGRAM", + "GITHUB_ACTIONS", + "COPILOT_AGENT_ID", + "RUNNER_NAME", + "REPL_ID", + "REPLIT_USER", + "HERMES_QUIET", + "OPENCLAW_STATE_DIR", + "OPENCLAW_CONFIG_PATH", + "PI_CODING_AGENT", +] as const; + +function stripVendorEnv(): void { + for (const key of VENDOR_ENV_KEYS) delete process.env[key]; +} + +describe("detectAgentRuntime — base behavior", () => { + const savedEnv = { ...process.env }; + beforeEach(stripVendorEnv); + afterEach(() => { + process.env = { ...savedEnv }; + }); + + it("returns null on a plain shell with no agent markers", async () => { + const { detectAgentRuntime } = await import("./agent_runtime.js"); + expect(detectAgentRuntime()).toBeNull(); + }); + + it("first matching vendor wins (rule order)", async () => { + // Claude Code marker set alongside a Codex marker — Claude Code is the + // first rule, so it wins. + process.env["CLAUDECODE"] = "1"; + process.env["CODEX_THREAD_ID"] = "thread-1"; + const { detectAgentRuntime } = await import("./agent_runtime.js"); + expect(detectAgentRuntime()).toBe("claude_code"); + }); + + it("never reads env-var values — even API-key-shaped values stay unread", async () => { + process.env["CODEX_THREAD_ID"] = "thread-1"; + process.env["CODEX_API_KEY"] = "sk-supersecret-DO-NOT-LEAK"; + const { detectAgentRuntime } = await import("./agent_runtime.js"); + const result = detectAgentRuntime(); + expect(result).toBe("codex"); + expect(typeof result).toBe("string"); + expect((result ?? "").includes("supersecret")).toBe(false); + }); +}); + +describe("detectAgentRuntime — Claude Code", () => { + const savedEnv = { ...process.env }; + beforeEach(stripVendorEnv); + afterEach(() => { + process.env = { ...savedEnv }; + }); + + it("detects via CLAUDECODE=1", async () => { + process.env["CLAUDECODE"] = "1"; + const { detectAgentRuntime } = await import("./agent_runtime.js"); + expect(detectAgentRuntime()).toBe("claude_code"); + }); + + it("detects via CLAUDE_CODE_ENTRYPOINT", async () => { + process.env["CLAUDE_CODE_ENTRYPOINT"] = "cli"; + const { detectAgentRuntime } = await import("./agent_runtime.js"); + expect(detectAgentRuntime()).toBe("claude_code"); + }); +}); + +describe("detectAgentRuntime — OpenAI Codex", () => { + const savedEnv = { ...process.env }; + beforeEach(stripVendorEnv); + afterEach(() => { + process.env = { ...savedEnv }; + }); + + it("detects via CODEX_THREAD_ID (set on every spawned shell command)", async () => { + process.env["CODEX_THREAD_ID"] = "01234567-89ab-cdef-0123-456789abcdef"; + const { detectAgentRuntime } = await import("./agent_runtime.js"); + expect(detectAgentRuntime()).toBe("codex"); + }); + + it("detects via CODEX_CI (hardcoded in UNIFIED_EXEC_ENV)", async () => { + process.env["CODEX_CI"] = "1"; + const { detectAgentRuntime } = await import("./agent_runtime.js"); + expect(detectAgentRuntime()).toBe("codex"); + }); + + it("detects via CODEX_SANDBOX_NETWORK_DISABLED (default-on)", async () => { + process.env["CODEX_SANDBOX_NETWORK_DISABLED"] = "1"; + const { detectAgentRuntime } = await import("./agent_runtime.js"); + expect(detectAgentRuntime()).toBe("codex"); + }); +}); + +describe("detectAgentRuntime — Cursor / Copilot / cohort", () => { + const savedEnv = { ...process.env }; + beforeEach(stripVendorEnv); + afterEach(() => { + process.env = { ...savedEnv }; + }); + + it("detects Cursor via 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"; + const { detectAgentRuntime } = await import("./agent_runtime.js"); + expect(detectAgentRuntime()).toBe("copilot_agent"); + }); + + it("does NOT flag generic GitHub Actions as copilot_agent", async () => { + process.env["GITHUB_ACTIONS"] = "true"; + const { detectAgentRuntime } = await import("./agent_runtime.js"); + expect(detectAgentRuntime()).toBeNull(); + }); +}); + +describe("detectAgentRuntime — Replit / Hermes / openclaw / Pi", () => { + const savedEnv = { ...process.env }; + beforeEach(stripVendorEnv); + afterEach(() => { + process.env = { ...savedEnv }; + }); + + it("detects Replit via REPL_ID", async () => { + process.env["REPL_ID"] = "repl-1"; + const { detectAgentRuntime } = await import("./agent_runtime.js"); + expect(detectAgentRuntime()).toBe("replit"); + }); + + it("detects Hermes via HERMES_QUIET (set unconditionally by cli.py:50)", async () => { + process.env["HERMES_QUIET"] = "1"; + const { detectAgentRuntime } = await import("./agent_runtime.js"); + expect(detectAgentRuntime()).toBe("hermes"); + }); + + it("detects openclaw via inherited OPENCLAW_STATE_DIR", async () => { + process.env["OPENCLAW_STATE_DIR"] = "/tmp/openclaw"; + const { detectAgentRuntime } = await import("./agent_runtime.js"); + expect(detectAgentRuntime()).toBe("openclaw"); + }); + + it("detects Pi via PI_CODING_AGENT (set unconditionally by cli.ts:13)", async () => { + process.env["PI_CODING_AGENT"] = "true"; + const { detectAgentRuntime } = await import("./agent_runtime.js"); + expect(detectAgentRuntime()).toBe("pi"); + }); +}); + +describe("detectSandboxRuntime — file-system path", () => { + beforeEach(() => { + vi.resetModules(); + }); + + afterEach(() => { + vi.resetModules(); + vi.restoreAllMocks(); + }); + + it("reports docker when /.dockerenv exists", async () => { + vi.doMock("node:os", async () => { + const actual = await vi.importActual("node:os"); + return { ...actual, release: () => "6.8.0-100-generic", platform: () => "linux" }; + }); + vi.doMock("node:fs", async () => { + const actual = await vi.importActual("node:fs"); + return { + ...actual, + existsSync: (path: string) => path === "/.dockerenv" || actual.existsSync(path), + readFileSync: (path: string) => + path === "/proc/version" ? "Linux version 6.8.0-100-generic" : actual.readFileSync(path), + }; + }); + const { detectSandboxRuntime } = await import("./agent_runtime.js"); + expect(detectSandboxRuntime()).toBe("docker"); + }); + + it("returns null on a plain non-sandboxed Linux laptop", async () => { + vi.doMock("node:os", async () => { + const actual = await vi.importActual("node:os"); + return { ...actual, release: () => "6.8.0-100-generic", platform: () => "linux" }; + }); + vi.doMock("node:fs", async () => { + const actual = await vi.importActual("node:fs"); + return { + ...actual, + existsSync: () => false, + readFileSync: (path: string) => + path === "/proc/version" + ? "Linux version 6.8.0-100-generic (buildd@lcy01)" + : path === "/proc/1/cgroup" + ? "0::/user.slice/user-1000.slice" + : actual.readFileSync(path), + }; + }); + const { detectSandboxRuntime } = await import("./agent_runtime.js"); + expect(detectSandboxRuntime()).toBeNull(); + }); +}); + +describe("detectSandboxRuntime — kernel-string path", () => { + beforeEach(() => { + vi.resetModules(); + }); + + afterEach(() => { + vi.resetModules(); + vi.restoreAllMocks(); + }); + + it("reports gvisor for a 4.19.0-gvisor kernel string", async () => { + vi.doMock("node:os", async () => { + const actual = await vi.importActual("node:os"); + return { ...actual, release: () => "4.19.0-gvisor", platform: () => "linux" }; + }); + const { detectSandboxRuntime } = await import("./agent_runtime.js"); + expect(detectSandboxRuntime()).toBe("gvisor"); + }); + + it("reports gvisor for kernel 4.4.0 only when /proc/version confirms gVisor", async () => { + vi.doMock("node:os", async () => { + const actual = await vi.importActual("node:os"); + return { ...actual, release: () => "4.4.0", platform: () => "linux" }; + }); + vi.doMock("node:fs", async () => { + const actual = await vi.importActual("node:fs"); + return { + ...actual, + readFileSync: (path: string) => + path === "/proc/version" ? "Linux version 4.4.0 (gVisor)" : actual.readFileSync(path), + }; + }); + const { detectSandboxRuntime } = await import("./agent_runtime.js"); + expect(detectSandboxRuntime()).toBe("gvisor"); + }); + + it("does NOT report gvisor for kernel 4.4.0 on a real Ubuntu 16.04 box (no gVisor in /proc/version)", async () => { + // Ubuntu 16.04 LTS ships kernel 4.4.0 too — make sure we don't false-positive. + vi.doMock("node:os", async () => { + const actual = await vi.importActual("node:os"); + return { ...actual, release: () => "4.4.0", platform: () => "linux" }; + }); + vi.doMock("node:fs", async () => { + const actual = await vi.importActual("node:fs"); + return { + ...actual, + readFileSync: (path: string) => + path === "/proc/version" + ? "Linux version 4.4.0-1128-aws (buildd@lcy01)" + : actual.readFileSync(path), + }; + }); + const { detectSandboxRuntime } = await import("./agent_runtime.js"); + expect(detectSandboxRuntime()).not.toBe("gvisor"); + }); +}); diff --git a/packages/cli/src/telemetry/agent_runtime.ts b/packages/cli/src/telemetry/agent_runtime.ts new file mode 100644 index 0000000000..a146bfd94a --- /dev/null +++ b/packages/cli/src/telemetry/agent_runtime.ts @@ -0,0 +1,218 @@ +import { existsSync, readFileSync } from "node:fs"; +import { platform, release } from "node:os"; +import { detectWSL } from "./platform.js"; + +// --------------------------------------------------------------------------- +// Sandbox runtime + agent vendor fingerprinting. +// +// Goal: distinguish "real developer laptop" from "ephemeral managed sandbox +// driving the CLI on someone's behalf" (Codex Cloud, Claude Code Web, Cursor +// Background Agents, etc.) without collecting any PII. +// +// We only read: +// - well-known kernel strings (release(), /proc/version) +// - sandbox marker files (/.dockerenv etc.) +// - the *existence* of vendor environment variables — never the value +// (some are API keys). +// +// Output is two opaque strings: sandbox_runtime ('gvisor' | 'docker' | ...) +// and agent_runtime ('claude_code' | 'codex' | ...). Both null when unknown. +// --------------------------------------------------------------------------- + +export type SandboxRuntime = "gvisor" | "firecracker" | "docker" | "kvm" | "wsl" | null; + +export type AgentRuntime = + | "claude_code" + | "codex" + | "cursor" + | "copilot_agent" + | "replit" + | "hermes" + | "openclaw" + | "pi" + | null; + +interface VendorRule { + name: Exclude; + /** Check returns true when the named agent is driving the CLI. */ + check: (env: NodeJS.ProcessEnv) => boolean; +} + +// Ordering matters: the FIRST rule that matches wins. Put more specific rules +// before more generic ones (e.g. copilot_agent before a hypothetical generic +// 'github_actions' rule). +const VENDOR_RULES: VendorRule[] = [ + // Anthropic Claude Code — sets CLAUDECODE=1 on every Bash/PowerShell tool + // spawn (Shell.ts:321) and CLAUDE_CODE_ENTRYPOINT at startup, inherited by + // every child (main.tsx:527). Both propagate to spawned subprocesses. + // Source: confirmed by @magi from Claude Code internal source. + { + name: "claude_code", + check: (env) => + typeof env["CLAUDECODE"] === "string" || typeof env["CLAUDE_CODE_ENTRYPOINT"] === "string", + }, + // OpenAI Codex (https://github.com/openai/codex). + // - CODEX_THREAD_ID — set unconditionally on every spawned shell command + // (codex-rs/protocol/src/shell_environment.rs:6 constant, set by + // codex-rs/core/src/unified_exec/process_manager.rs:1010 and + // codex-rs/core/src/tools/runtimes/mod.rs:164). + // - CODEX_CI — hardcoded in the UNIFIED_EXEC_ENV array, always set on + // every unified-exec child (process_manager.rs:70). + // - CODEX_SANDBOX_NETWORK_DISABLED — set when network sandbox is active + // (codex-rs/core/src/sandboxing/mod.rs:135-138, default-on). + // CODEX_HOME is deliberately NOT used — it's a config override read at + // Codex startup, not propagated to spawned subprocesses. + { + name: "codex", + check: (env) => + typeof env["CODEX_THREAD_ID"] === "string" || + 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. + { + name: "cursor", + check: (env) => env["TERM_PROGRAM"] === "cursor", + }, + // GitHub Copilot Coding Agent — runs inside GitHub Actions and the + // workflow injects an additional marker to distinguish from generic CI. + // Not yet verified from a public-source citation in this audit; the var + // names below match GitHub Copilot Coding Agent documentation but + // should be confirmed before relying on attribution. + { + name: "copilot_agent", + check: (env) => + env["GITHUB_ACTIONS"] === "true" && + (typeof env["COPILOT_AGENT_ID"] === "string" || env["RUNNER_NAME"] === "Copilot"), + }, + // Replit — REPL_ID and REPLIT_USER are long-documented environment + // variables exposed inside every Replit workspace. + // Source: https://docs.replit.com/replit-workspace/configuring-the-environment + { + name: "replit", + check: (env) => typeof env["REPL_ID"] === "string" || typeof env["REPLIT_USER"] === "string", + }, + // Nous Research Hermes Agent — cli.py:50 unconditionally executes + // os.environ["HERMES_QUIET"] = "1" + // at module load, so the marker propagates via os.environ to every + // subprocess spawned by Hermes. Keying on existence (not the literal + // "1") so we still match if Hermes ever changes the value. + // Source: https://github.com/NousResearch/hermes-agent (cli.py:50) + { + name: "hermes", + check: (env) => typeof env["HERMES_QUIET"] === "string", + }, + // openclaw — multi-channel AI gateway. When openclaw spawns a CLI + // subprocess it builds the child env with OPENCLAW_STATE_DIR / + // OPENCLAW_CONFIG_PATH / OPENCLAW_DISABLE_AUTO_UPDATE set explicitly + // (extensions/qa-matrix/src/runners/contract/scenario-runtime-cli.ts:344-351). + // We key on OPENCLAW_STATE_DIR since it's a path scope-bound to openclaw. + // Source: https://github.com/openclaw/openclaw + { + name: "openclaw", + check: (env) => + typeof env["OPENCLAW_STATE_DIR"] === "string" || + typeof env["OPENCLAW_CONFIG_PATH"] === "string", + }, + // Pi coding agent (https://pi.dev, https://github.com/earendil-works/pi). + // packages/coding-agent/src/cli.ts:13 unconditionally executes + // process.env.PI_CODING_AGENT = "true"; + // at module entry, so every subprocess Pi spawns sees this marker. + { + name: "pi", + check: (env) => typeof env["PI_CODING_AGENT"] === "string", + }, +]; + +/** + * Identify the managed sandbox runtime hosting this CLI invocation. + * Returns null on a normal developer machine. Dispatches to runtime-specific + * detectors that each return a boolean; the priority order encoded here is + * deliberate (WSL > gVisor > Docker > Firecracker > KVM). + */ +export function detectSandboxRuntime(): SandboxRuntime { + if (platform() === "win32") return null; + if (detectWSL()) return "wsl"; + if (isGVisor()) return "gvisor"; + if (isDocker()) return "docker"; + if (isFirecracker()) return "firecracker"; + if (isKVM()) return "kvm"; + return null; +} + +/** + * Identify the coding-agent vendor that spawned this process, if any. + * Returns null on a regular interactive shell. Only checks for the + * EXISTENCE of well-known env vars — never reads their values. + */ +export function detectAgentRuntime(): AgentRuntime { + for (const rule of VENDOR_RULES) { + if (rule.check(process.env)) return rule.name; + } + return null; +} + +// --------------------------------------------------------------------------- +// Sandbox runtime detectors — one per runtime, kept small and side-effect-free. +// --------------------------------------------------------------------------- + +/** + * gVisor reports kernel string `4.19.0-gvisor` (current) or `4.4.0` (legacy + * Sentry kernel). + * + * `4.19.0-gvisor` is unambiguous — no real Linux box reports that string. + * `4.4.0` collides with Ubuntu 16.04 LTS / older real kernels, so we only + * accept it as a gVisor signal when /proc/version ALSO contains "gVisor". + */ +function isGVisor(): boolean { + const kernel = release(); + if (kernel.includes("gvisor")) return true; + if (platform() !== "linux") return false; + try { + const procVersion = readFileSync("/proc/version", "utf-8"); + return procVersion.includes("gVisor"); + } catch { + return false; + } +} + +function isDocker(): boolean { + if (existsSync("/.dockerenv")) return true; + if (platform() !== "linux") return false; + try { + const cgroup = readFileSync("/proc/1/cgroup", "utf-8"); + return cgroup.includes("docker") || cgroup.includes("containerd"); + } catch { + return false; + } +} + +/** + * AWS Firecracker microVMs expose /dev/vsock and report sys_vendor='Amazon EC2' + * with product_name containing 'Firecracker'. Full EC2 reports a real instance + * type like 't3.large', so the product_name check distinguishes them. + */ +function isFirecracker(): boolean { + if (platform() !== "linux") return false; + if (!existsSync("/dev/vsock")) return false; + try { + const sysVendor = readFileSync("/sys/class/dmi/id/sys_vendor", "utf-8").trim(); + if (sysVendor !== "Amazon EC2") return false; + const productName = readFileSync("/sys/class/dmi/id/product_name", "utf-8").trim(); + return productName.toLowerCase().includes("firecracker"); + } catch { + return false; + } +} + +function isKVM(): boolean { + if (platform() !== "linux") return false; + try { + const sysVendor = readFileSync("/sys/class/dmi/id/sys_vendor", "utf-8").trim(); + return sysVendor === "QEMU" || sysVendor.includes("KVM"); + } catch { + return false; + } +} diff --git a/packages/cli/src/telemetry/client.ts b/packages/cli/src/telemetry/client.ts index 039c0d4505..feb55989d3 100644 --- a/packages/cli/src/telemetry/client.ts +++ b/packages/cli/src/telemetry/client.ts @@ -17,7 +17,7 @@ const FLUSH_TIMEOUT_MS = 5_000; // --------------------------------------------------------------------------- interface EventProperties { - [key: string]: string | number | boolean | undefined; + [key: string]: string | number | boolean | null | undefined; } let eventQueue: Array<{ @@ -81,30 +81,41 @@ export function trackEvent(event: string, properties: EventProperties = {}): voi ci_name: sys.ci_name ?? undefined, is_wsl: sys.is_wsl, is_tty: sys.is_tty, + sandbox_runtime: sys.sandbox_runtime ?? undefined, + agent_runtime: sys.agent_runtime ?? undefined, }, timestamp: new Date().toISOString(), }); } /** - * Flush all queued events to PostHog via async HTTP POST. - * Called before normal process exit via `beforeExit`. + * Drain the in-memory queue into a PostHog `/batch/` payload string. + * Returns null when there's nothing to send. Resets the queue as a side effect + * so callers can fire-and-forget the resulting payload. + * + * $ip:null tells PostHog not to record the request IP for any of these events. + * Server-side "Discard client IP data" is also enabled in project settings. */ -export async function flush(): Promise { - if (eventQueue.length === 0) { - return; - } - +function drainQueueToPayload(): string | null { + if (eventQueue.length === 0) return null; const config = readConfig(); const batch = eventQueue.map((e) => ({ event: e.event, - // $ip: null tells PostHog to not record the request IP for this event. - // Server-side "Discard client IP data" is also enabled in project settings. properties: { ...e.properties, $ip: null }, distinct_id: config.anonymousId, timestamp: e.timestamp, })); eventQueue = []; + return JSON.stringify({ api_key: POSTHOG_API_KEY, batch }); +} + +/** + * Flush all queued events to PostHog via async HTTP POST. + * Called before normal process exit via `beforeExit`. + */ +export async function flush(): Promise { + const payload = drainQueueToPayload(); + if (payload == null) return; const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), FLUSH_TIMEOUT_MS); @@ -113,7 +124,7 @@ export async function flush(): Promise { await fetch(`${POSTHOG_HOST}/batch/`, { method: "POST", headers: { "Content-Type": "application/json", Connection: "close" }, - body: JSON.stringify({ api_key: POSTHOG_API_KEY, batch }), + body: payload, signal: controller.signal, }); } catch { @@ -129,20 +140,8 @@ export async function flush(): Promise { * so the parent process exits immediately without waiting. */ export function flushSync(): void { - if (eventQueue.length === 0) { - return; - } - - const config = readConfig(); - const batch = eventQueue.map((e) => ({ - event: e.event, - properties: { ...e.properties, $ip: null }, - distinct_id: config.anonymousId, - timestamp: e.timestamp, - })); - eventQueue = []; - - const payload = JSON.stringify({ api_key: POSTHOG_API_KEY, batch }); + const payload = drainQueueToPayload(); + if (payload == null) return; try { const { spawn } = require("node:child_process") as typeof import("node:child_process"); diff --git a/packages/cli/src/telemetry/platform.ts b/packages/cli/src/telemetry/platform.ts new file mode 100644 index 0000000000..ac8b89fbc1 --- /dev/null +++ b/packages/cli/src/telemetry/platform.ts @@ -0,0 +1,18 @@ +import { platform, release } from "node:os"; +import { readFileSync } from "node:fs"; + +// Shared host-platform detectors used by both system.ts (overall metadata) +// and agent_runtime.ts (sandbox fingerprinting). Lives in its own module +// to avoid an import cycle between those two files. + +export function detectWSL(): boolean { + if (platform() !== "linux") return false; + try { + const osRelease = release().toLowerCase(); + if (osRelease.includes("microsoft") || osRelease.includes("wsl")) return true; + const procVersion = readFileSync("/proc/version", "utf-8").toLowerCase(); + return procVersion.includes("microsoft") || procVersion.includes("wsl"); + } catch { + return false; + } +} diff --git a/packages/cli/src/telemetry/system.ts b/packages/cli/src/telemetry/system.ts index 3719533e11..53660ebbe7 100644 --- a/packages/cli/src/telemetry/system.ts +++ b/packages/cli/src/telemetry/system.ts @@ -1,5 +1,12 @@ import { cpus, totalmem, platform, release } from "node:os"; import { existsSync, readFileSync, statfsSync } from "node:fs"; +import { + detectAgentRuntime, + detectSandboxRuntime, + type AgentRuntime, + type SandboxRuntime, +} from "./agent_runtime.js"; +import { detectWSL } from "./platform.js"; // --------------------------------------------------------------------------- // System metadata collected once per CLI session and attached to all events. @@ -23,6 +30,22 @@ export interface SystemMeta { ci_name: string | null; is_wsl: boolean; is_tty: boolean; + /** + * Managed sandbox runtime hosting this invocation, when one is detectable + * (gvisor / firecracker / docker / kvm / wsl). null on a normal dev + * machine. Lets us distinguish "real laptop" from "ephemeral cloud + * sandbox driving the CLI" without geo guesswork. + */ + sandbox_runtime: SandboxRuntime; + /** + * Coding-agent vendor that spawned this process, if any (claude_code, + * codex, cursor, copilot_agent, replit, hermes, openclaw, pi). + * Detected by env-var existence only — values are never read. Every rule + * keys on a marker that has a public-source citation in agent_runtime.ts; + * unverified guesses are deliberately omitted (false-negative > guess). + * null when no agent is detected. + */ + agent_runtime: AgentRuntime; } let cached: SystemMeta | null = null; @@ -48,6 +71,8 @@ export function getSystemMeta(): SystemMeta { ci_name: getCIName(), is_wsl: detectWSL(), is_tty: Boolean(process.stdout?.isTTY), + sandbox_runtime: detectSandboxRuntime(), + agent_runtime: detectAgentRuntime(), }; return cached; } @@ -70,42 +95,38 @@ function detectDocker(): boolean { return false; } -function detectCI(): boolean { - return ( - process.env["CI"] === "true" || - process.env["CI"] === "1" || - process.env["CONTINUOUS_INTEGRATION"] === "true" || - process.env["GITHUB_ACTIONS"] === "true" || - process.env["GITLAB_CI"] === "true" || - process.env["CIRCLECI"] === "true" || - process.env["JENKINS_URL"] != null || - process.env["BUILDKITE"] === "true" || - process.env["TRAVIS"] === "true" || - false - ); +// Named providers come first so getCIName() picks the most specific match. +// `truthy` accepts 'true' or '1'; `presence` matches any non-null value. +type CIProvider = + | { name: string | null; envVar: string; mode: "truthy" } + | { name: string | null; envVar: string; mode: "presence" }; + +const CI_PROVIDERS: CIProvider[] = [ + { name: "github_actions", envVar: "GITHUB_ACTIONS", mode: "truthy" }, + { name: "gitlab_ci", envVar: "GITLAB_CI", mode: "truthy" }, + { name: "circleci", envVar: "CIRCLECI", mode: "truthy" }, + { name: "jenkins", envVar: "JENKINS_URL", mode: "presence" }, + { name: "buildkite", envVar: "BUILDKITE", mode: "truthy" }, + { name: "travis", envVar: "TRAVIS", mode: "truthy" }, + { name: null, envVar: "CONTINUOUS_INTEGRATION", mode: "truthy" }, + { name: null, envVar: "CI", mode: "truthy" }, +]; + +function matchesProvider(p: CIProvider): boolean { + const v = process.env[p.envVar]; + if (p.mode === "presence") return v != null; + return v === "true" || v === "1"; } -function getCIName(): string | null { - if (process.env["GITHUB_ACTIONS"] === "true") return "github_actions"; - if (process.env["GITLAB_CI"] === "true") return "gitlab_ci"; - if (process.env["CIRCLECI"] === "true") return "circleci"; - if (process.env["JENKINS_URL"] != null) return "jenkins"; - if (process.env["BUILDKITE"] === "true") return "buildkite"; - if (process.env["TRAVIS"] === "true") return "travis"; - if (detectCI()) return "unknown"; - return null; +function detectCI(): boolean { + return CI_PROVIDERS.some(matchesProvider); } -function detectWSL(): boolean { - if (platform() !== "linux") return false; - try { - const osRelease = release().toLowerCase(); - if (osRelease.includes("microsoft") || osRelease.includes("wsl")) return true; - const procVersion = readFileSync("/proc/version", "utf-8").toLowerCase(); - return procVersion.includes("microsoft") || procVersion.includes("wsl"); - } catch { - return false; +function getCIName(): string | null { + for (const provider of CI_PROVIDERS) { + if (provider.name && matchesProvider(provider)) return provider.name; } + return detectCI() ? "unknown" : null; } // ---------------------------------------------------------------------------