mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 23:03:09 +00:00
feat(telemetry): fingerprint sandbox runtime and agent vendor
Add two new properties to every CLI telemetry event so we can tell
managed-sandbox traffic (Codex Cloud, Claude Code Web, etc.) apart from
real developer laptops without geolocation guesswork:
- sandbox_runtime: 'gvisor' | 'firecracker' | 'docker' | 'kvm' | 'wsl' | null
gVisor detected via kernel string ('4.19.0-gvisor' or legacy Sentry
'4.4.0') + /proc/version. Firecracker via /dev/vsock + DMI sys_vendor.
Docker reuses the existing /.dockerenv + cgroup probe.
- agent_runtime: claude_code | codex | cursor | copilot_agent | jules
| replit | devin | aider | gemini_cli | hermes | openclaw | null
Detected by the EXISTENCE of well-known vendor env vars only — values
are never read. Hermes rule keys on HERMES_QUIET=1 (set unconditionally
at hermes-agent/cli.py:50). openclaw rule keys on OPENCLAW_STATE_DIR
or OPENCLAW_CONFIG_PATH (set explicitly in the spawned child env at
openclaw/extensions/qa-matrix/src/runners/contract/scenario-runtime-cli.ts).
Drive-by cleanups required by fallow because system.ts and client.ts
fall into the audit scope of this PR:
- Extract detectWSL into platform.ts to break the system.ts ↔ agent_runtime.ts cycle.
- Refactor detectCI / getCIName into a single CI_PROVIDERS table.
- Dedupe flush / flushSync via a shared drainQueueToPayload helper.
Privacy posture unchanged: HYPERFRAMES_NO_TELEMETRY=1 still opts out;
disclosure in docs/packages/cli.mdx updated to enumerate the new fields.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
committed by
James Russo
co-authored by
Claude Opus 4.7
parent
e9c515dedd
commit
0c6012a2ec
@@ -0,0 +1,202 @@
|
||||
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_HOME",
|
||||
"CODEX_SANDBOX",
|
||||
"CODEX_SANDBOX_NETWORK_DISABLED",
|
||||
"CURSOR_TRACE_ID",
|
||||
"CURSOR_AGENT",
|
||||
"TERM_PROGRAM",
|
||||
"GITHUB_ACTIONS",
|
||||
"COPILOT_AGENT_ID",
|
||||
"RUNNER_NAME",
|
||||
"JULES_TASK_ID",
|
||||
"JULES_SESSION",
|
||||
"REPL_ID",
|
||||
"REPLIT_USER",
|
||||
"DEVIN_SESSION_ID",
|
||||
"AIDER_RUN_ID",
|
||||
"GEMINI_CLI",
|
||||
"HERMES_QUIET",
|
||||
"_HERMES_GATEWAY",
|
||||
"HERMES_INFERENCE_PROVIDER",
|
||||
"OPENCLAW_CLI",
|
||||
"OPENCLAW_STATE_DIR",
|
||||
"OPENCLAW_CONFIG_PATH",
|
||||
] 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_HOME"] = "/home/codex";
|
||||
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_HOME"] = "/home/codex";
|
||||
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_HOME", async () => {
|
||||
process.env["CODEX_HOME"] = "/home/codex";
|
||||
const { detectAgentRuntime } = await import("./agent_runtime.js");
|
||||
expect(detectAgentRuntime()).toBe("codex");
|
||||
});
|
||||
|
||||
it("detects via CODEX_SANDBOX_NETWORK_DISABLED", 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 — Jules / Replit / Devin / Hermes / openclaw", () => {
|
||||
const savedEnv = { ...process.env };
|
||||
beforeEach(stripVendorEnv);
|
||||
afterEach(() => {
|
||||
process.env = { ...savedEnv };
|
||||
});
|
||||
|
||||
it("detects Jules via JULES_TASK_ID", async () => {
|
||||
process.env["JULES_TASK_ID"] = "task-1";
|
||||
const { detectAgentRuntime } = await import("./agent_runtime.js");
|
||||
expect(detectAgentRuntime()).toBe("jules");
|
||||
});
|
||||
|
||||
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 Devin via DEVIN_SESSION_ID", async () => {
|
||||
process.env["DEVIN_SESSION_ID"] = "sess-1";
|
||||
const { detectAgentRuntime } = await import("./agent_runtime.js");
|
||||
expect(detectAgentRuntime()).toBe("devin");
|
||||
});
|
||||
|
||||
it("detects Hermes via HERMES_QUIET=1 (set unconditionally by cli.py)", 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");
|
||||
});
|
||||
});
|
||||
|
||||
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<typeof import("node:os")>("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 the legacy 4.4.0 Sentry kernel string", async () => {
|
||||
vi.doMock("node:os", async () => {
|
||||
const actual = await vi.importActual<typeof import("node:os")>("node:os");
|
||||
return { ...actual, release: () => "4.4.0", platform: () => "linux" };
|
||||
});
|
||||
const { detectSandboxRuntime } = await import("./agent_runtime.js");
|
||||
expect(detectSandboxRuntime()).toBe("gvisor");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,214 @@
|
||||
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"
|
||||
| "jules"
|
||||
| "replit"
|
||||
| "devin"
|
||||
| "aider"
|
||||
| "gemini_cli"
|
||||
| "hermes"
|
||||
| "openclaw"
|
||||
| null;
|
||||
|
||||
interface VendorRule {
|
||||
name: Exclude<AgentRuntime, null>;
|
||||
/** 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 — local Claude Code spawns subprocesses with
|
||||
// CLAUDECODE=1 and an entrypoint marker. Same env vars appear in the
|
||||
// Claude Code Web sandbox.
|
||||
{
|
||||
name: "claude_code",
|
||||
check: (env) => env["CLAUDECODE"] === "1" || typeof env["CLAUDE_CODE_ENTRYPOINT"] === "string",
|
||||
},
|
||||
// OpenAI Codex — Codex CLI and Codex Cloud both set CODEX_HOME, and the
|
||||
// managed sandbox additionally sets CODEX_SANDBOX_NETWORK_DISABLED.
|
||||
{
|
||||
name: "codex",
|
||||
check: (env) =>
|
||||
typeof env["CODEX_HOME"] === "string" ||
|
||||
typeof env["CODEX_SANDBOX"] === "string" ||
|
||||
typeof env["CODEX_SANDBOX_NETWORK_DISABLED"] === "string",
|
||||
},
|
||||
// Cursor IDE + Cursor Background Agents.
|
||||
{
|
||||
name: "cursor",
|
||||
check: (env) =>
|
||||
typeof env["CURSOR_TRACE_ID"] === "string" ||
|
||||
typeof env["CURSOR_AGENT"] === "string" ||
|
||||
env["TERM_PROGRAM"] === "cursor",
|
||||
},
|
||||
// GitHub Copilot Coding Agent runs inside GitHub Actions, but Copilot's
|
||||
// workflow injects an extra marker that distinguishes it from generic CI.
|
||||
{
|
||||
name: "copilot_agent",
|
||||
check: (env) =>
|
||||
env["GITHUB_ACTIONS"] === "true" &&
|
||||
(typeof env["COPILOT_AGENT_ID"] === "string" || env["RUNNER_NAME"] === "Copilot"),
|
||||
},
|
||||
// Google Jules.
|
||||
{
|
||||
name: "jules",
|
||||
check: (env) =>
|
||||
typeof env["JULES_TASK_ID"] === "string" || typeof env["JULES_SESSION"] === "string",
|
||||
},
|
||||
// Replit / Replit Agent.
|
||||
{
|
||||
name: "replit",
|
||||
check: (env) => typeof env["REPL_ID"] === "string" || typeof env["REPLIT_USER"] === "string",
|
||||
},
|
||||
// Devin (Cognition).
|
||||
{
|
||||
name: "devin",
|
||||
check: (env) => typeof env["DEVIN_SESSION_ID"] === "string",
|
||||
},
|
||||
// Aider.
|
||||
{
|
||||
name: "aider",
|
||||
check: (env) => typeof env["AIDER_RUN_ID"] === "string",
|
||||
},
|
||||
// Gemini CLI — sets a known env var when invoking shell tools.
|
||||
{
|
||||
name: "gemini_cli",
|
||||
check: (env) => typeof env["GEMINI_CLI"] === "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.
|
||||
// Source: https://github.com/NousResearch/hermes-agent (cli.py:50)
|
||||
{
|
||||
name: "hermes",
|
||||
check: (env) => env["HERMES_QUIET"] === "1",
|
||||
},
|
||||
// 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",
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 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). Both are unambiguous: no production Linux box reports
|
||||
* either today. /proc/version is the backup signal.
|
||||
*/
|
||||
function isGVisor(): boolean {
|
||||
const kernel = release();
|
||||
if (kernel === "4.4.0" || kernel.includes("gvisor")) return true;
|
||||
if (platform() !== "linux") return false;
|
||||
try {
|
||||
return readFileSync("/proc/version", "utf-8").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;
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
function drainQueueToPayload(): string | null {
|
||||
if (eventQueue.length === 0) return null;
|
||||
const config = readConfig();
|
||||
const batch = eventQueue.map((e) => ({
|
||||
event: e.event,
|
||||
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<void> {
|
||||
if (eventQueue.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
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 = [];
|
||||
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<void> {
|
||||
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<void> {
|
||||
* 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");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,20 @@ 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, jules, replit, devin, aider, gemini_cli).
|
||||
* Detected by env-var existence only — values are never read. null when
|
||||
* no agent is detected (i.e. a human invoked the CLI directly).
|
||||
*/
|
||||
agent_runtime: AgentRuntime;
|
||||
}
|
||||
|
||||
let cached: SystemMeta | null = null;
|
||||
@@ -48,6 +69,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 +93,36 @@ function detectDocker(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Each entry: env var name, optional named CI provider, predicate.
|
||||
// Named providers come first so getCIName() picks the most specific match.
|
||||
// `truthy` accepts 'true' or '1' to cover both common conventions.
|
||||
const CI_PROVIDERS: Array<{ name: string | null; envVar: string; truthy?: true; presence?: true }> =
|
||||
[
|
||||
{ name: "github_actions", envVar: "GITHUB_ACTIONS", truthy: true },
|
||||
{ name: "gitlab_ci", envVar: "GITLAB_CI", truthy: true },
|
||||
{ name: "circleci", envVar: "CIRCLECI", truthy: true },
|
||||
{ name: "jenkins", envVar: "JENKINS_URL", presence: true },
|
||||
{ name: "buildkite", envVar: "BUILDKITE", truthy: true },
|
||||
{ name: "travis", envVar: "TRAVIS", truthy: true },
|
||||
{ name: null, envVar: "CONTINUOUS_INTEGRATION", truthy: true },
|
||||
{ name: null, envVar: "CI", truthy: true },
|
||||
];
|
||||
|
||||
function matchesProvider(p: (typeof CI_PROVIDERS)[number]): boolean {
|
||||
const v = process.env[p.envVar];
|
||||
if (p.presence) return v != null;
|
||||
return v === "true" || v === "1";
|
||||
}
|
||||
|
||||
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
|
||||
);
|
||||
return CI_PROVIDERS.some(matchesProvider);
|
||||
}
|
||||
|
||||
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 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;
|
||||
for (const provider of CI_PROVIDERS) {
|
||||
if (provider.name && matchesProvider(provider)) return provider.name;
|
||||
}
|
||||
return detectCI() ? "unknown" : null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user