From bf8b02a54a7c49b96243c0d4938e87b1ca52e195 Mon Sep 17 00:00:00 2001 From: Ander Rodriguez Date: Sat, 3 Oct 2026 19:29:54 +0200 Subject: [PATCH] feat(http): tell the API which AI agent is running the CLI Requests from the CLI only said "zenrows-cli", so the Activity Log could not show that Claude Code, Cursor or another agent drove them. The CLI now sends X-ZenRows-Client with the agent's name when an agent's own shell variable is set (CLAUDE_CODE_CHILD_SESSION, CURSOR_AGENT, COPILOT_AGENT, CODEX_THREAD_ID/CODEX_SANDBOX, GEMINI_CLI), or the user's ZENROWS_CLIENT override. Nothing is sent when no agent is detected or when ZENROWS_TELEMETRY=off (or config telemetry "off"). Claude Code is detected from CLAUDE_CODE_CHILD_SESSION, not CLAUDECODE: the IDE extensions export CLAUDECODE into every integrated terminal, so a person typing `zenrows` there would read as Claude Code. agentClientHeader() takes the workspace root, so `zenrows init --workspace --no-telemetry` run outside honours that workspace's opt-out. Batch, usage and the agent-account calls now send the versioned User-Agent the scrape and browser clients already sent. Tests cover a 22-row detection table with precedence for every neighbouring pair of signals, the override, and, for every API client, the header with an agent, no header without one, and both telemetry opt-outs. Each check runs from a fresh workspace so a developer's own .zenrows config cannot change it. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_011qxnmqucWraLJLseY6HcRx --- src/adapters/protected-fetch.ts | 4 +- src/cli/commands/init.ts | 2 +- src/cli/commands/status.ts | 1 + src/core/agent-account.ts | 10 +- src/core/agent-client.ts | 113 +++++++++++++ src/core/batch-api.ts | 5 +- src/core/browser-api.ts | 6 +- src/core/config.ts | 12 +- src/core/http.ts | 7 +- src/core/usage.ts | 4 +- src/types/index.ts | 5 +- tests/agent-client.test.ts | 287 ++++++++++++++++++++++++++++++++ tests/setup.ts | 6 + 13 files changed, 443 insertions(+), 19 deletions(-) create mode 100644 src/core/agent-client.ts create mode 100644 tests/agent-client.test.ts diff --git a/src/adapters/protected-fetch.ts b/src/adapters/protected-fetch.ts index 660fcd4..db317f7 100644 --- a/src/adapters/protected-fetch.ts +++ b/src/adapters/protected-fetch.ts @@ -145,11 +145,13 @@ export async function runFetch( config: ToolkitConfig, policy: Policy, apiKey: string, + /** The workspace `config` came from, when it is not the one above the cwd. */ + projectRoot?: string, ): Promise { assertDomainAllowed(opts.url, policy); validateAutoManual(opts, config); const params = buildParams(opts, config); const mode: "auto" | "manual" = params.mode === "auto" ? "auto" : "manual"; - const result = await scrape(config.apiBase, apiKey, params, { timeoutMs: opts.timeoutMs }); + const result = await scrape(config.apiBase, apiKey, params, { timeoutMs: opts.timeoutMs, projectRoot }); return { result, params, mode }; } diff --git a/src/cli/commands/init.ts b/src/cli/commands/init.ts index e20b014..4f0f077 100644 --- a/src/cli/commands/init.ts +++ b/src/cli/commands/init.ts @@ -138,7 +138,7 @@ export const init: Command = { section("Test Protected Fetch"); try { const apiKey = requireApiKey(root); - const { result } = await runFetch({ url: SMOKE_URL }, loadConfig(root), loadPolicy(root), apiKey); + const { result } = await runFetch({ url: SMOKE_URL }, loadConfig(root), loadPolicy(root), apiKey, root); log.success(`Protected Fetch OK — HTTP ${result.status}, ${result.body.length} bytes, ${formatRequestCost(result.costUsd, result.costCredits)}.`); } catch (err) { log.warn(`Test fetch did not pass: ${err instanceof Error ? err.message : String(err)}`); diff --git a/src/cli/commands/status.ts b/src/cli/commands/status.ts index 6aedb47..1c2f406 100644 --- a/src/cli/commands/status.ts +++ b/src/cli/commands/status.ts @@ -98,6 +98,7 @@ export const status: Command = { }; /** Reachability probe that does not consume credits. */ +// Deliberately sends no X-ZenRows-Client: unauthenticated, non-billable reachability check. async function probe(apiBase: string): Promise { try { const controller = new AbortController(); diff --git a/src/core/agent-account.ts b/src/core/agent-account.ts index 882930a..42c9ab1 100644 --- a/src/core/agent-account.ts +++ b/src/core/agent-account.ts @@ -7,7 +7,8 @@ */ import { chmodSync, existsSync, unlinkSync } from "node:fs"; import type { AgentAccount } from "../types/index.ts"; -import { attributionEnabled, getOrCreateTelemetryId, loadConfig, CLI_VERSION } from "./config.ts"; +import { attributionEnabled, getOrCreateTelemetryId, loadConfig, CLI_USER_AGENT, CLI_VERSION } from "./config.ts"; +import { agentClientHeader } from "./agent-client.ts"; import { ToolkitError } from "./errors.ts"; import { AGENT_SIGNUP_API_URL, WELL_KNOWN_PROTECTED_RESOURCE } from "./open-url.ts"; import { detectClient } from "./provenance.ts"; @@ -51,7 +52,7 @@ export async function discoverSignupUrl( const doFetch = opts.fetchImpl ?? fetch; const res = await doFetch(url, { method: "GET", - headers: { Accept: "application/json", "User-Agent": "zenrows-cli" }, + headers: { Accept: "application/json", "User-Agent": CLI_USER_AGENT, ...agentClientHeader({ projectRoot }) }, }); if (!res.ok) return null; const json = (await res.json()) as { agent_auth?: { signup_endpoint?: unknown } }; @@ -150,7 +151,8 @@ export async function signupAgent( // `telemetry:"off"` / ZENROWS_TELEMETRY=off suppresses every X-ZR-* header. const headers: Record = { "content-type": "application/json", - "User-Agent": "zenrows-cli", + "User-Agent": CLI_USER_AGENT, + ...agentClientHeader(), }; if (attributionEnabled()) { const p = detectClient(); @@ -252,7 +254,7 @@ export async function fetchAccountStatus( const doFetch = opts.fetchImpl ?? fetch; const res = await doFetch(url, { method: "GET", - headers: { "X-API-Key": apiKey, Accept: "application/json", "User-Agent": "zenrows-cli" }, + headers: { "X-API-Key": apiKey, Accept: "application/json", "User-Agent": CLI_USER_AGENT, ...agentClientHeader() }, }); if (res.status !== 200) { const body = await res.text(); diff --git a/src/core/agent-client.ts b/src/core/agent-client.ts new file mode 100644 index 0000000..30ba0c3 --- /dev/null +++ b/src/core/agent-client.ts @@ -0,0 +1,113 @@ +/** + * Which AI coding agent is running the CLI, sent to Zenrows as the + * `X-ZenRows-Client` header so the dashboard Activity Log can say "CLI, driven + * by Claude Code" instead of just "CLI". + * + * Only the derived name is sent, never an environment value: the header carries + * one of the fixed names below, or the user's own `ZENROWS_CLIENT` override. + * When no agent is detected, no header is sent at all. `ZENROWS_TELEMETRY=off` + * (or config `telemetry: "off"`) suppresses it, override included. + * + * The gateway resolves the value against its own list of client names and + * stores anything it does not know as "other", so a name that is new here is + * harmless there. + */ +import { attributionEnabled } from "./config.ts"; + +type Env = Readonly>; + +/** The request header the Zenrows gateway reads the client name from. */ +export const CLIENT_HEADER = "X-ZenRows-Client"; +/** Env var a user sets to name the client themselves. It wins over detection. */ +export const CLIENT_OVERRIDE_ENV = "ZENROWS_CLIENT"; + +/** + * The gateway considers at most 32 bytes of the header. + * Anything we send must also be a legal header value, or `fetch` throws on + * every request, so the override is held to a plain-token charset. + */ +const CLIENT_NAME = /^[a-z0-9][a-z0-9._-]{0,31}$/; + +interface AgentSignal { + /** The name sent in the header. Matches the gateway's name where it has one. */ + client: string; + /** Detected when any of these is set to a non-empty value. */ + vars: readonly string[]; +} + +/** + * Each agent's signal is a variable the agent itself sets in the shells it runs + * commands in, verified against the agent's docs or source. Agents we found no + * such variable for (Windsurf, Aider, Copilot CLI, the Copilot cloud agent) are + * left out rather than guessed. + * + * The first match wins. Every variable here is set by the agent itself when it + * launches a command, never by an IDE or extension in a terminal a person types + * in, so a human at the keyboard is never labelled as an agent. + */ +const AGENT_SIGNALS: readonly AgentSignal[] = [ + // Cursor agent terminals: "Use the CURSOR_AGENT environment variable in your + // shell config to detect when Cursor is running". + // https://cursor.com/docs/agent/tools/terminal + { client: "cursor", vars: ["CURSOR_AGENT"] }, + // GitHub Copilot agent mode in VS Code sets COPILOT_AGENT=1 in agent terminals. + // Sent as "vscode", the name the gateway already has for VS Code's agent. + // https://github.com/microsoft/vscode/pull/316267 + // https://github.com/microsoft/vscode/blob/45373f06ff77cc97a7754a376548d8937fb3af54/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/toolTerminalCreator.ts#L156-L159 + { client: "vscode", vars: ["COPILOT_AGENT"] }, + // OpenAI Codex CLI injects CODEX_THREAD_ID into every shell-tool environment, + // and CODEX_SANDBOX when the command runs sandboxed. + // https://github.com/openai/codex/blob/b741e480e203f037ca726bc2a76d99a8e8668e66/codex-rs/protocol/src/shell_environment.rs#L151-L154 + // https://github.com/openai/codex/blob/b741e480e203f037ca726bc2a76d99a8e8668e66/codex-rs/core/src/spawn.rs#L23-L26 + { client: "codex", vars: ["CODEX_THREAD_ID", "CODEX_SANDBOX"] }, + // Gemini CLI sets GEMINI_CLI=1 for every shell command it executes. + // https://google-gemini.github.io/gemini-cli/docs/cli/commands.html + // https://github.com/google-gemini/gemini-cli/blob/fb972b2f87fe7d5b06d37eac711490162d98de2c/packages/core/src/services/shellExecutionService.ts#L582-L585 + { client: "gemini-cli", vars: ["GEMINI_CLI"] }, + // Claude Code sets CLAUDE_CODE_CHILD_SESSION=1 in what its Bash, PowerShell and + // Monitor tools, hooks and status line spawn, "only set by Claude Code itself + // ... and not by IDE extensions" (v2.1.172+). Not CLAUDECODE: the IDE extensions + // export that into every integrated terminal, so a person typing `zenrows` there + // would read as Claude Code. Older versions send no name rather than a wrong one, + // and so does a stdio MCP server, which gets neither variable's guarantee. + // https://code.claude.com/docs/en/env-vars + { client: "claude-code", vars: ["CLAUDE_CODE_CHILD_SESSION"] }, +]; + +/** Every variable detection reads, so tests can keep the ambient shell out. */ +export const AGENT_ENV_VARS: readonly string[] = AGENT_SIGNALS.flatMap((s) => s.vars); + +/** + * The client name to send, or `undefined` to send nothing. Pure: reads only + * `env`. A valid `ZENROWS_CLIENT` override is sent even when no agent is + * detected: setting it is an explicit opt-in. It replaces detection entirely, so + * an override that is not a valid name sends nothing rather than a detected name + * the user asked not to send. + */ +export function detectAgentClient(env: Env): string | undefined { + const override = env[CLIENT_OVERRIDE_ENV]?.trim(); + if (override) { + // Same normalisation the gateway applies: lower-case, spaces to dashes. + const name = override.toLowerCase().split(/\s+/).join("-"); + return CLIENT_NAME.test(name) ? name : undefined; + } + return AGENT_SIGNALS.find((s) => s.vars.some((v) => isSet(env[v])))?.client; +} + +/** Empty, `0` and `false` (any case) count as unset, the way `CLAUDE_CODE_CHILD_SESSION=0` reads. */ +function isSet(value: string | undefined): boolean { + const v = value?.trim().toLowerCase(); + return Boolean(v) && v !== "0" && v !== "false"; +} + +/** + * The header to spread into a request to a Zenrows API: `{ "X-ZenRows-Client": + * name }` when there is a name and attribution is on, `{}` otherwise. Pass + * `projectRoot` when the caller works on a workspace other than the one above + * the cwd (`init --workspace`), so that workspace's `telemetry: "off"` applies. + */ +export function agentClientHeader(opts: { projectRoot?: string; env?: Env } = {}): Record { + if (!attributionEnabled(opts.projectRoot)) return {}; + const client = detectAgentClient(opts.env ?? process.env); + return client ? { [CLIENT_HEADER]: client } : {}; +} diff --git a/src/core/batch-api.ts b/src/core/batch-api.ts index e2c0af1..6a0e456 100644 --- a/src/core/batch-api.ts +++ b/src/core/batch-api.ts @@ -19,6 +19,8 @@ import { join } from "node:path"; import { ToolkitError, isKeyCapReached, keyCapReached, quotaExhausted } from "./errors.ts"; import { readAccount } from "./agent-account.ts"; import { registerSecret } from "./logger.ts"; +import { CLI_USER_AGENT } from "./config.ts"; +import { agentClientHeader } from "./agent-client.ts"; /** Confirmed Batch API base (no trailing slash). */ export const DEFAULT_BATCH_API_BASE = "https://async.api.zenrows.com/v1"; @@ -109,7 +111,8 @@ export async function batchRequest(method: string, path: string, opts: Reques const headers: Record = { "X-API-Key": opts.apiKey, Accept: "application/json", - "User-Agent": "zenrows-cli", + "User-Agent": CLI_USER_AGENT, + ...agentClientHeader(), }; if (opts.body !== undefined) headers["Content-Type"] = "application/json"; diff --git a/src/core/browser-api.ts b/src/core/browser-api.ts index 884e5e1..230ac37 100644 --- a/src/core/browser-api.ts +++ b/src/core/browser-api.ts @@ -14,7 +14,8 @@ import { ToolkitError, isKeyCapReached, keyCapReached, quotaExhausted } from "./errors.ts"; import { readAccount } from "./agent-account.ts"; import { registerSecret } from "./logger.ts"; -import { CLI_VERSION } from "./config.ts"; +import { CLI_USER_AGENT } from "./config.ts"; +import { agentClientHeader } from "./agent-client.ts"; /** Managed Browser session API base (no trailing slash). Tied to the MCP host. */ export const DEFAULT_BROWSER_BASE = "https://mcp.zenrows.com"; @@ -65,7 +66,8 @@ export async function browserRequest(method: string, path: string, opts: Requ const headers: Record = { Authorization: `Bearer ${opts.apiKey}`, Accept: "application/json", - "User-Agent": `zenrows-cli/${CLI_VERSION}`, + "User-Agent": CLI_USER_AGENT, + ...agentClientHeader(), }; if (opts.body !== undefined) headers["Content-Type"] = "application/json"; diff --git a/src/core/config.ts b/src/core/config.ts index dbcab9b..c4ce996 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -14,19 +14,23 @@ export const CONFIG_VERSION = "0.1.0"; * without an import cycle. `VERSION` in `cli/index.ts` re-exports this. */ export const CLI_VERSION = "1.3.0"; +/** The User-Agent on every request to a Zenrows API. The gateway reads the version from it. */ +export const CLI_USER_AGENT = `zenrows-cli/${CLI_VERSION}`; /** Env var to override the Fetch and Extract API base (local/staging testing). */ export const API_BASE_ENV = "ZENROWS_API_BASE"; /** * Env var to opt out of anonymous attribution. The toolkit never POSTs to a * telemetry endpoint; attribution is only anonymous provenance headers on the - * signup request + `utm_*` params on the browser URLs a human opens. Setting - * this to `off` (or config `telemetry: "off"`) suppresses all of it. + * signup request, the `X-ZenRows-Client` agent name on API requests + * (`agent-client.ts`), and `utm_*` params on the browser URLs a human opens. + * Setting this to `off` (or config `telemetry: "off"`) suppresses all of it. */ export const TELEMETRY_ENV = "ZENROWS_TELEMETRY"; /** - * Whether to attach anonymous attribution (signup provenance headers + `utm_*` - * on browser URLs). Off when `ZENROWS_TELEMETRY=off` or config `telemetry:"off"`. + * Whether to attach anonymous attribution (signup provenance headers, the + * `X-ZenRows-Client` agent name, `utm_*` on browser URLs). Off when + * `ZENROWS_TELEMETRY=off` or config `telemetry:"off"`. * There is no telemetry beacon — this only gates what rides on requests/URLs * the toolkit already makes. */ diff --git a/src/core/http.ts b/src/core/http.ts index 513dedd..92ba89e 100644 --- a/src/core/http.ts +++ b/src/core/http.ts @@ -9,7 +9,8 @@ import { ToolkitError, isKeyCapReached, keyCapReached, quotaExhausted } from "./ import { readAccount } from "./agent-account.ts"; import { ENV_KEY, resolveApiKey } from "./auth.ts"; import { registerSecret } from "./logger.ts"; -import { CLI_VERSION } from "./config.ts"; +import { CLI_USER_AGENT } from "./config.ts"; +import { agentClientHeader } from "./agent-client.ts"; export interface ScraperResult { status: number; @@ -77,7 +78,7 @@ export async function scrape( apiBase: string, apiKey: string, params: ScraperParams, - opts: { timeoutMs?: number } = {}, + opts: { timeoutMs?: number; projectRoot?: string } = {}, ): Promise { registerSecret(apiKey); const { full, redacted } = buildUrl(apiBase, apiKey, params); @@ -97,7 +98,7 @@ export async function scrape( try { res = await fetch(full, { method: "GET", - headers: { "User-Agent": `zenrows-cli/${CLI_VERSION}`, "Accept-Encoding": "gzip, deflate" }, + headers: { "User-Agent": CLI_USER_AGENT, "Accept-Encoding": "gzip, deflate", ...agentClientHeader({ projectRoot: opts.projectRoot }) }, signal: controller.signal, }); } catch (err) { diff --git a/src/core/usage.ts b/src/core/usage.ts index a4b3a77..aba0ba0 100644 --- a/src/core/usage.ts +++ b/src/core/usage.ts @@ -11,6 +11,8 @@ import { isQuotaError, zrErrorDetail } from "./http.ts"; import { readAccount } from "./agent-account.ts"; import { ENV_KEY, resolveApiKey } from "./auth.ts"; import { registerSecret } from "./logger.ts"; +import { CLI_USER_AGENT } from "./config.ts"; +import { agentClientHeader } from "./agent-client.ts"; export interface UsageConcurrency { limit?: number; @@ -93,7 +95,7 @@ export async function fetchUsage( try { res = await doFetch(url, { method: "GET", - headers: { "X-API-Key": apiKey, Accept: "application/json", "User-Agent": "zenrows-cli" }, + headers: { "X-API-Key": apiKey, Accept: "application/json", "User-Agent": CLI_USER_AGENT, ...agentClientHeader() }, signal: controller.signal, }); } catch (err) { diff --git a/src/types/index.ts b/src/types/index.ts index 01278ed..3eea0a4 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -62,8 +62,9 @@ export interface ToolkitConfig { /** * Anonymous attribution toggle. The toolkit never POSTs to a telemetry * endpoint; "anonymous" only attaches provenance headers to the signup - * request and `utm_*` params to the browser URLs a human opens. "off" (or - * `ZENROWS_TELEMETRY=off`) suppresses both. + * request, the `X-ZenRows-Client` agent name to API requests, and `utm_*` + * params to the browser URLs a human opens. "off" (or + * `ZENROWS_TELEMETRY=off`) suppresses all three. */ telemetry: "anonymous" | "off"; /** diff --git a/tests/agent-client.test.ts b/tests/agent-client.test.ts new file mode 100644 index 0000000..6f36ed9 --- /dev/null +++ b/tests/agent-client.test.ts @@ -0,0 +1,287 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + AGENT_ENV_VARS, + CLIENT_HEADER, + CLIENT_OVERRIDE_ENV, + agentClientHeader, + detectAgentClient, +} from "../src/core/agent-client.ts"; +import { scrape } from "../src/core/http.ts"; +import { batchRequest } from "../src/core/batch-api.ts"; +import { browserRequest } from "../src/core/browser-api.ts"; +import { fetchUsage } from "../src/core/usage.ts"; +import { discoverSignupUrl, fetchAccountStatus, signupAgent } from "../src/core/agent-account.ts"; +import { CLI_VERSION, TELEMETRY_ENV, defaultConfig, loadConfig, saveConfig } from "../src/core/config.ts"; +import { loadPolicy } from "../src/core/policy.ts"; +import { runFetch } from "../src/adapters/protected-fetch.ts"; +import { createWorkspace } from "../src/core/workspace.ts"; +import { tempRoot } from "./helpers.ts"; +import type { ToolkitConfig } from "../src/types/index.ts"; + +const cases: Array<{ name: string; env: Record; want: string | undefined }> = [ + { name: "no agent", env: { HOME: "/home/u", PATH: "/usr/bin", TERM_PROGRAM: "vscode" }, want: undefined }, + { name: "Claude Code", env: { CLAUDE_CODE_CHILD_SESSION: "1" }, want: "claude-code" }, + { name: "Cursor agent", env: { CURSOR_AGENT: "1" }, want: "cursor" }, + { name: "Copilot agent in VS Code", env: { COPILOT_AGENT: "1", TERM_PROGRAM: "vscode" }, want: "vscode" }, + { name: "Codex shell tool", env: { CODEX_THREAD_ID: "019a-thread" }, want: "codex" }, + { name: "Codex sandbox only", env: { CODEX_SANDBOX: "seatbelt" }, want: "codex" }, + { name: "Gemini CLI", env: { GEMINI_CLI: "1" }, want: "gemini-cli" }, + // Claude Code's IDE extensions export CLAUDECODE into every integrated terminal, + // so it says nothing about who is typing. + { name: "a terminal in an IDE with Claude Code's extension is not Claude Code", env: { CLAUDECODE: "1", TERM_PROGRAM: "vscode" }, want: undefined }, + { name: "Cursor agent with Claude Code's extension installed", env: { CURSOR_AGENT: "1", CLAUDECODE: "1" }, want: "cursor" }, + { name: "Copilot agent with Claude Code's extension installed", env: { COPILOT_AGENT: "1", CLAUDECODE: "1" }, want: "vscode" }, + // Precedence: one row per neighbouring pair in AGENT_SIGNALS, so any reordering + // flips at least one of them. + { name: "Cursor agent over Copilot agent", env: { CURSOR_AGENT: "1", COPILOT_AGENT: "1" }, want: "cursor" }, + { name: "Copilot agent over Codex", env: { COPILOT_AGENT: "1", CODEX_THREAD_ID: "t" }, want: "vscode" }, + { name: "Codex over Gemini CLI", env: { CODEX_THREAD_ID: "t", GEMINI_CLI: "1" }, want: "codex" }, + { name: "Codex thread over Claude Code", env: { CODEX_THREAD_ID: "t", CLAUDE_CODE_CHILD_SESSION: "1" }, want: "codex" }, + { name: "Codex sandbox over Claude Code", env: { CODEX_SANDBOX: "seatbelt", CLAUDE_CODE_CHILD_SESSION: "1" }, want: "codex" }, + { name: "Gemini CLI over Claude Code", env: { GEMINI_CLI: "1", CLAUDE_CODE_CHILD_SESSION: "1" }, want: "gemini-cli" }, + { name: "an empty value is not a signal", env: { CLAUDE_CODE_CHILD_SESSION: "", GEMINI_CLI: "" }, want: undefined }, + { name: "0 and false are not signals", env: { CLAUDE_CODE_CHILD_SESSION: "0", CURSOR_AGENT: "false", GEMINI_CLI: " FALSE " }, want: undefined }, + // Variables that only say a tool is installed or configured, not that it is running the CLI. + { name: "an OpenAI key is not Codex", env: { OPENAI_API_KEY: "sk-x", CODEX_HOME: "/home/u/.codex" }, want: undefined }, + { name: "a Cursor terminal is not the Cursor agent", env: { CURSOR_TRACE_ID: "abc", TERM_PROGRAM: "cursor" }, want: undefined }, + { name: "a Claude Code setting is not Claude Code", env: { CLAUDE_CONFIG_DIR: "/home/u/.claude" }, want: undefined }, + { name: "Windsurf has no verified signal", env: { WINDSURF_SESSION: "x" }, want: undefined }, +]; + +for (const c of cases) { + test(`detectAgentClient: ${c.name}`, () => { + assert.equal(detectAgentClient(c.env), c.want); + }); +} + +test("detectAgentClient: the override wins over a detected agent", () => { + assert.equal(detectAgentClient({ CLAUDE_CODE_CHILD_SESSION: "1", [CLIENT_OVERRIDE_ENV]: "cursor" }), "cursor"); + assert.equal(detectAgentClient({ [CLIENT_OVERRIDE_ENV]: "my-pipeline" }), "my-pipeline"); +}); + +test("detectAgentClient: the override is normalised the way the gateway reads it", () => { + assert.equal(detectAgentClient({ [CLIENT_OVERRIDE_ENV]: " Claude Code " }), "claude-code"); + assert.equal(detectAgentClient({ [CLIENT_OVERRIDE_ENV]: "JetBrains" }), "jetbrains"); +}); + +test("detectAgentClient: an invalid override sends nothing, not the detected agent", () => { + for (const bad of ["a\r\nX-Injected: 1", "café", "x".repeat(33), "-leading-dash", "/home/u/secret", "a;b"]) { + assert.equal(detectAgentClient({ CLAUDE_CODE_CHILD_SESSION: "1", [CLIENT_OVERRIDE_ENV]: bad }), undefined, bad); + } +}); + +test("detectAgentClient: an empty override falls back to detection", () => { + assert.equal(detectAgentClient({ CLAUDE_CODE_CHILD_SESSION: "1", [CLIENT_OVERRIDE_ENV]: " " }), "claude-code"); +}); + +test("detectAgentClient: sends the agent's name, never the variable's value", () => { + assert.equal(detectAgentClient({ CODEX_THREAD_ID: "zr-api-key-lookalike" }), "codex"); +}); + +test("agentClientHeader: the header when an agent is detected, nothing otherwise", async () => { + await withEnv({}, async () => { + assert.deepEqual(agentClientHeader({ env: { CLAUDE_CODE_CHILD_SESSION: "1" } }), { [CLIENT_HEADER]: "claude-code" }); + assert.deepEqual(agentClientHeader({}), {}); + }); +}); + +test("agentClientHeader: ZENROWS_TELEMETRY=off suppresses it, override included", async () => { + await withEnv({ [TELEMETRY_ENV]: "off" }, async () => { + assert.deepEqual(agentClientHeader({ env: { CLAUDE_CODE_CHILD_SESSION: "1" } }), {}); + assert.deepEqual(agentClientHeader({ env: { [CLIENT_OVERRIDE_ENV]: "cursor" } }), {}); + }); +}); + +test("agentClientHeader: a projectRoot's telemetry \"off\" applies from another cwd", async () => { + await withProject({ ...defaultConfig(), telemetry: "off" }, async (root) => { + await withEnv({ CLAUDE_CODE_CHILD_SESSION: "1" }, async () => { + assert.deepEqual(agentClientHeader({ projectRoot: root }), {}); + assert.deepEqual(agentClientHeader(), { [CLIENT_HEADER]: "claude-code" }, "the cwd's own workspace still sends it"); + }); + }); +}); + +// `zenrows init --workspace --no-telemetry`, run from outside : the +// smoke fetch must honour 's config, not the cwd's. +test("runFetch: init's smoke fetch honours --workspace telemetry off from another cwd", async () => { + await withProject({ ...defaultConfig(), telemetry: "off" }, async (root) => { + const sent = await withEnv({ CLAUDE_CODE_CHILD_SESSION: "1" }, () => + withStubbedFetch(() => runFetch({ url: "https://x" }, loadConfig(root), loadPolicy(root), "k", root)), + ); + assert.ok(sent.length > 0, "the stub saw no request"); + for (const h of sent) assert.equal(h.has(CLIENT_HEADER), false); + }); +}); + +test("discoverSignupUrl: honours its projectRoot's telemetry off from another cwd", async () => { + await withProject({ ...defaultConfig(), telemetry: "off" }, async (root) => { + const { impl, sent } = recorder(() => json({})); + await withEnv({ CLAUDE_CODE_CHILD_SESSION: "1" }, () => discoverSignupUrl(root, { fetchImpl: impl })); + assert.ok(sent.length > 0, "the stub saw no request"); + for (const h of sent) assert.equal(h.has(CLIENT_HEADER), false); + }); +}); + +/** + * Every client that talks to a Zenrows API, each driven through a stub that + * records the headers it sent. The clients read `process.env`, so each check + * runs under `withEnv`. + */ +type Sent = Headers; +const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }); + +function recorder(respond: () => Response) { + const sent: Sent[] = []; + const impl = (async (_url: string, init?: RequestInit) => { + sent.push(new Headers(init?.headers)); + return respond(); + }) as unknown as typeof fetch; + return { impl, sent }; +} + +const clients: Array<{ name: string; call: () => Promise }> = [ + { + name: "scrape", + call: () => withStubbedFetch(() => scrape("https://api.zenrows.com/v1/", "k", { url: "https://x" })), + }, + { + name: "batch", + call: async () => { + const { impl, sent } = recorder(() => json({ job_id: "j" })); + await batchRequest("GET", "/jobs/j", { apiKey: "k", fetchImpl: impl }); + return sent; + }, + }, + { + name: "browser", + call: async () => { + const { impl, sent } = recorder(() => json({ ok: true })); + await browserRequest("POST", "/browser/sessions", { apiKey: "k", body: {}, fetchImpl: impl }); + return sent; + }, + }, + { + name: "usage", + call: async () => { + const { impl, sent } = recorder(() => + json({ status: "ACTIVE", usage: 0, usage_percent: 0, plan: { name: "Free", products: {} }, top_ups: [] }), + ); + await fetchUsage("https://api.zenrows.com/v1/", "k", { fetchImpl: impl }); + return sent; + }, + }, + { + name: "signup discovery", + call: async () => { + const { impl, sent } = recorder(() => json({})); + await discoverSignupUrl(undefined, { fetchImpl: impl }); + return sent; + }, + }, + { + name: "signup", + call: async () => { + const { impl, sent } = recorder(() => json({ apiKey: "k", accountId: "u", claimUrl: "https://x/c" }, 201)); + await signupAgent({ url: "https://x/api/agent/signup", fetchImpl: impl }); + return sent; + }, + }, + { + name: "account status", + call: async () => { + const { impl, sent } = recorder(() => json({ accountId: "u", claimed: false, isAgent: true })); + await fetchAccountStatus("k", { url: "https://x/api/agent/account", fetchImpl: impl }); + return sent; + }, + }, +]; + +for (const c of clients) { + test(`${c.name}: sends ${CLIENT_HEADER} when run by an agent`, async () => { + const sent = await withEnv({ CLAUDE_CODE_CHILD_SESSION: "1" }, c.call); + assert.ok(sent.length > 0, "the stub saw no request"); + for (const h of sent) assert.equal(h.get(CLIENT_HEADER), "claude-code"); + }); + + test(`${c.name}: sends no ${CLIENT_HEADER} when no agent is detected`, async () => { + const sent = await withEnv({}, c.call); + assert.ok(sent.length > 0, "the stub saw no request"); + for (const h of sent) assert.equal(h.has(CLIENT_HEADER), false); + }); + + test(`${c.name}: ZENROWS_TELEMETRY=off sends no ${CLIENT_HEADER}`, async () => { + const sent = await withEnv({ CLAUDE_CODE_CHILD_SESSION: "1", [TELEMETRY_ENV]: "off" }, c.call); + assert.ok(sent.length > 0, "the stub saw no request"); + for (const h of sent) assert.equal(h.has(CLIENT_HEADER), false); + }); + + test(`${c.name}: config telemetry "off" sends no ${CLIENT_HEADER}`, async () => { + const sent = await withEnv({ CLAUDE_CODE_CHILD_SESSION: "1" }, c.call, { ...defaultConfig(), telemetry: "off" }); + assert.ok(sent.length > 0, "the stub saw no request"); + for (const h of sent) assert.equal(h.has(CLIENT_HEADER), false); + }); + + test(`${c.name}: sends a User-Agent carrying the CLI version`, async () => { + const sent = await withEnv({}, c.call); + for (const h of sent) assert.equal(h.get("user-agent"), `zenrows-cli/${CLI_VERSION}`); + }); +} + +/** Run `fn` with a stub as the global `fetch`; returns the headers it was sent. */ +async function withStubbedFetch(fn: () => Promise): Promise { + const { impl, sent } = recorder(() => new Response("ok", { status: 200, headers: { "content-type": "text/html" } })); + const orig = globalThis.fetch; + globalThis.fetch = impl; + try { + await fn(); + } finally { + globalThis.fetch = orig; + } + return sent; +} + +/** A workspace holding `config` that is not the cwd's, for `projectRoot` callers. */ +async function withProject(config: ToolkitConfig, fn: (root: string) => Promise): Promise { + const { root, cleanup } = tempRoot(); + try { + createWorkspace(root); + saveConfig(config, root); + await fn(root); + } finally { + cleanup(); + } +} + +/** + * Run `fn` with exactly `vars` set among the variables detection and attribution + * read, from a fresh workspace holding `config`, then restore both. setup.ts + * clears the variables for the process; the workspace keeps a developer's own + * `.zenrows/config.json` above the cwd (say `telemetry: "off"`) out of the result. + */ +async function withEnv( + vars: Record, + fn: () => Promise, + config: ToolkitConfig = defaultConfig(), +): Promise { + const keys = new Set([...AGENT_ENV_VARS, CLIENT_OVERRIDE_ENV, TELEMETRY_ENV, ...Object.keys(vars)]); + const saved = new Map([...keys].map((k) => [k, process.env[k]])); + const { root, cleanup } = tempRoot(); + const cwd = process.cwd(); + createWorkspace(root); + saveConfig(config, root); + for (const k of keys) delete process.env[k]; + Object.assign(process.env, vars); + process.chdir(root); + try { + return await fn(); + } finally { + process.chdir(cwd); + cleanup(); + for (const [k, v] of saved) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + } +} diff --git a/tests/setup.ts b/tests/setup.ts index c2678de..35d4881 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -4,10 +4,16 @@ * Scrubs every `ZENROWS_*` environment variable at process startup so a * developer's ambient config (e.g. `ZENROWS_API_BASE` pointing at a local * server, or an exported `ZENROWS_API_KEY`) can't leak in and change results. + * Also scrubs the variables `agent-client.ts` detects an AI agent from. * Wired via `node --test --import ./dist/tests/setup.js`, so it runs once in * each test-file worker before any test module loads. Tests that need a * specific override set it explicitly (and restore it) themselves. */ +import { AGENT_ENV_VARS } from "../src/core/agent-client.ts"; + for (const key of Object.keys(process.env)) { if (key.startsWith("ZENROWS_")) delete process.env[key]; } +// Running the suite from inside an AI agent (CLAUDE_CODE_CHILD_SESSION=1 in +// Claude Code's shell) would otherwise add X-ZenRows-Client to every request. +for (const key of AGENT_ENV_VARS) delete process.env[key];