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];