fix(cli): distinguish agent and execution harness telemetry (#4071)

This commit is contained in:
James Russo
2026-09-18 10:10:22 +00:00
committed by GitHub
parent bf39a72787
commit 2a559714ee
6 changed files with 125 additions and 8 deletions
@@ -12,6 +12,7 @@ const VENDOR_ENV_KEYS = [
"CODEX_CI",
"CODEX_SANDBOX_NETWORK_DISABLED",
"TERM_PROGRAM",
"CURSOR_AGENT",
"GITHUB_ACTIONS",
"COPILOT_AGENT_ID",
"RUNNER_NAME",
@@ -55,6 +56,35 @@ describe("detectAgentRuntime — base behavior", () => {
expect(detectAgentRuntime()).toBe("claude_code");
});
it("detects Cursor Agent without an IDE terminal marker", async () => {
process.env["CURSOR_AGENT"] = "1";
const { detectAgentRuntime } = await import("./agent_runtime.js");
expect(detectAgentRuntime()).toBe("cursor");
process.env["CURSOR_AGENT"] = "";
expect(detectAgentRuntime()).toBeNull();
});
it.each([
["CLAUDECODE", "claude_code"],
["CODEX_THREAD_ID", "codex"],
])("keeps child-agent attribution for %s inside Cursor", async (key, runtime) => {
process.env["CURSOR_AGENT"] = "1";
process.env[key] = "1";
const { detectAgentRuntime } = await import("./agent_runtime.js");
expect(detectAgentRuntime()).toBe(runtime);
});
it("keeps the Harbor marker separate from agent attribution and never emits its value", async () => {
const { detectExecutionHarnessHint, detectAgentRuntime } = await import("./agent_runtime.js");
expect(detectExecutionHarnessHint()).toBeNull();
process.env["HARBOR_AGENT"] = "private-harness-value";
expect(detectExecutionHarnessHint()).toBe("harbor");
expect(detectAgentRuntime()).toBeNull();
process.env["CLAUDECODE"] = "1";
expect(detectAgentRuntime()).toBe("claude_code");
expect(detectExecutionHarnessHint()).toBe("harbor");
});
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";
@@ -399,6 +429,29 @@ describe("detectAgentHints — new-agent discovery signals", () => {
expect(detectAgentHints().agent_env_hints).toContain("FOO_AGENT_SESSION_ID");
});
it("keeps specific markers when infrastructure keys exceed the discovery cap", async () => {
for (let i = 0; i < 20; i++) process.env[`AAA_AGENT_${i}_SERVICE_HOST`] = "private-host";
process.env["ANTIGRAVITY_AGENT"] = "private-value";
process.env["CODEBUDDY_SESSION_ID"] = "private-session";
process.env["HARBOR_AGENT"] = "private-harness";
const { detectAgentHints } = await import("./agent_runtime.js");
const hints = detectAgentHints().agent_env_hints ?? "";
expect(hints.split(",")).toHaveLength(16);
expect(hints).toContain("ANTIGRAVITY_AGENT");
expect(hints).toContain("CODEBUDDY_SESSION_ID");
expect(hints).toContain("HARBOR_AGENT");
expect(hints).not.toContain("private");
expect(hints.split(",")).toEqual(hints.split(",").sort());
});
it("excludes generic npm and login-session keys", async () => {
process.env["NPM_CONFIG_USER_AGENT"] = "npm/10";
process.env["TERM_SESSION_ID"] = "terminal";
process.env["XDG_SESSION_ID"] = "login";
const { detectAgentHints } = await import("./agent_runtime.js");
expect(detectAgentHints().agent_env_hints).toBeNull();
});
it("clears a full 16-key ambient cap so a fixture key still fits", async () => {
for (let i = 0; i < 16; i++) process.env[`AAA_AGENT_${String(i).padStart(2, "0")}`] = "1";
process.env["FOO_AGENT_SESSION_ID"] = "x";
+25 -6
View File
@@ -74,12 +74,12 @@ const VENDOR_RULES: VendorRule[] = [
typeof env["CODEX_CI"] === "string" ||
typeof env["CODEX_SANDBOX_NETWORK_DISABLED"] === "string",
},
// Cursor IDE integrated terminal — exports TERM_PROGRAM=cursor (exact,
// lowercase). Cursor Background Agent env vars are not publicly documented;
// if a canonical marker is identified later, add it here.
// Cursor Agent marks its shell with CURSOR_AGENT; TERM_PROGRAM identifies
// the IDE terminal only. Keep this after Claude/Codex for nested agents.
// Source: https://docs.cursor.com/en/agent/terminal
{
name: "cursor",
check: (env) => env["TERM_PROGRAM"] === "cursor",
check: (env) => Boolean(env["CURSOR_AGENT"]) || env["TERM_PROGRAM"] === "cursor",
},
// Windsurf (Codeium) integrated terminal — exports TERM_PROGRAM=windsurf.
// Attested across many independent detectors (nx
@@ -244,6 +244,12 @@ export function detectAgentRuntime(): AgentRuntime {
return null;
}
/** Observed harness marker, not proof of a vendor, benchmark, or human user. */
export function detectExecutionHarnessHint(): "harbor" | null {
// Read presence only. Preserve this context even when the child agent is known.
return typeof process.env["HARBOR_AGENT"] === "string" ? "harbor" : null;
}
// ---------------------------------------------------------------------------
// New-agent discovery signals.
//
@@ -345,9 +351,19 @@ export const HINT_KEY_PATTERN = /AGENT|ASSISTANT|COPILOT|CODEX|CLAUDE|LLM|_THREA
function isDiscoveryHintKey(upperKey: string): boolean {
if (upperKey === "AGENT" || upperKey === "AI_AGENT") return false;
if (upperKey.startsWith("SSH_") || upperKey.startsWith("GPG_")) return false;
if (["NPM_CONFIG_USER_AGENT", "TERM_SESSION_ID", "XDG_SESSION_ID"].includes(upperKey))
return false;
return HINT_KEY_PATTERN.test(upperKey);
}
function discoveryHintPriority(key: string): number {
// Kubernetes service expansion can otherwise consume the entire hint cap.
if (/_SERVICE_(HOST|PORT)(_|$)|_PORT(_\d+_(TCP|UDP)(_(ADDR|PORT|PROTO))?)?$/.test(key)) return 2;
if (/^(HARBOR_AGENT|CURSOR_AGENT|ANTIGRAVITY_AGENT)$|_(SESSION_ID|THREAD_ID)$/.test(key))
return 0;
return 1;
}
/**
* Residual discovery signals for the agent_runtime=null bucket. See the section
* comment above for intent and the privacy contract. Pure over process.env.
@@ -363,8 +379,11 @@ export function detectAgentHints(): AgentHints {
const upper = key.toUpperCase();
if (isDiscoveryHintKey(upper)) keys.add(upper);
}
const sorted = [...keys].sort();
const agent_env_hints = sorted.length ? sorted.slice(0, 16).join(",") : null;
const selected = [...keys]
.sort((a, b) => discoveryHintPriority(a) - discoveryHintPriority(b) || a.localeCompare(b))
.slice(0, 16)
.sort();
const agent_env_hints = selected.length ? selected.join(",") : null;
return { agent_hint, term_program, agent_env_hints };
}
+22
View File
@@ -37,6 +37,7 @@ vi.mock("node:child_process", () => ({
}));
const { trackEvent, flush, flushSync } = await import("./client.js");
const system = await import("./system.js");
type Batch = { uuid: string; event: string }[];
@@ -68,6 +69,27 @@ describe("telemetry queue delivery", () => {
vi.unstubAllGlobals();
});
it("delivers harness context alongside a recognized agent without leaking marker values", async () => {
const meta = vi.spyOn(system, "getSystemMeta").mockReturnValue({
...system.getSystemMeta(),
agent_runtime: "claude_code",
execution_harness_hint: "harbor",
});
try {
const fetchMock = vi.fn(() => Promise.resolve(new Response("")));
vi.stubGlobal("fetch", fetchMock);
trackEvent("cli_command", { command: "skills" });
await flush();
expect(eventProps(fetchMock)).toMatchObject({
agent_runtime: "claude_code",
execution_harness_hint: "harbor",
});
expect(eventProps(fetchMock)).not.toHaveProperty("HARBOR_AGENT");
} finally {
meta.mockRestore();
}
});
it("forgets events only after the request completes, and stamps each with a uuid", async () => {
const fetchMock = vi.fn(() => Promise.resolve(new Response("")));
vi.stubGlobal("fetch", fetchMock);
+3 -2
View File
@@ -94,6 +94,7 @@ export function trackEvent(
is_tty: sys.is_tty,
sandbox_runtime: sys.sandbox_runtime ?? undefined,
agent_runtime: sys.agent_runtime ?? undefined,
execution_harness_hint: sys.execution_harness_hint ?? undefined,
// New-agent discovery signals — populated only when agent_runtime is null.
agent_hint: sys.agent_hint ?? undefined,
term_program: sys.term_program ?? undefined,
@@ -111,8 +112,8 @@ export function trackEvent(
// next run: `durable` (loaded from a preexisting config), `unknown`
// (minted+persisted this run — an ephemeral HOME is indistinguishable
// from a genuine first run), `process_only` (not persisted). Install-
// grain metrics should count only durable identities; the identity-
// churn workloads (fresh id per run) are never durable.
// grain metrics must also consider environment and repeated-day use:
// a short-lived container can persist its id between two CLI processes.
identity_persistence: getIdentityPersistence(),
// Outcome of the identity-establishing config write; absent when that
// path did not write (including a durable id loaded from disk).
+18
View File
@@ -8,6 +8,24 @@ afterEach(() => {
vi.restoreAllMocks();
});
describe("getSystemMeta execution context", () => {
it("retains a harness marker after the child agent is recognized", async () => {
const savedEnv = { ...process.env };
try {
process.env["CLAUDECODE"] = "1";
process.env["HARBOR_AGENT"] = "private-harness-value";
const { getSystemMeta } = await import("./system.js");
const meta = getSystemMeta();
expect(meta.agent_runtime).toBe("claude_code");
expect(meta.execution_harness_hint).toBe("harbor");
expect(meta.agent_env_hints).toBeNull();
expect(JSON.stringify(meta)).not.toContain("private-harness-value");
} finally {
process.env = savedEnv;
}
});
});
describe("getAvailableMemoryMb", () => {
it("parses vm_stat on macOS to compute available memory", async () => {
vi.doMock("node:os", async () => ({
+4
View File
@@ -5,6 +5,7 @@ import { getSystemTotalMb } from "@hyperframes/engine";
import {
detectAgentRuntime,
detectAgentHints,
detectExecutionHarnessHint,
detectSandboxRuntime,
type AgentRuntime,
type SandboxRuntime,
@@ -50,6 +51,8 @@ export interface SystemMeta {
* null when no agent is detected.
*/
agent_runtime: AgentRuntime;
/** Observed harness context, independent of agent attribution; null if absent. */
execution_harness_hint: "harbor" | null;
/**
* New-agent discovery signals for the agent_runtime=null bucket, so an agent
* we have no rule for surfaces on its own instead of vanishing into null.
@@ -95,6 +98,7 @@ export function getSystemMeta(): SystemMeta {
is_tty: Boolean(process.stdout?.isTTY),
sandbox_runtime: detectSandboxRuntime(),
agent_runtime,
execution_harness_hint: detectExecutionHarnessHint(),
agent_hint: hints.agent_hint,
term_program: hints.term_program,
agent_env_hints: hints.agent_env_hints,