Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion src/adapters/protected-fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<FetchOutcome> {
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 };
}
2 changes: 1 addition & 1 deletion src/cli/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)}`);
Expand Down
1 change: 1 addition & 0 deletions src/cli/commands/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean> {
try {
const controller = new AbortController();
Expand Down
10 changes: 6 additions & 4 deletions src/core/agent-account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 } };
Expand Down Expand Up @@ -150,7 +151,8 @@ export async function signupAgent(
// `telemetry:"off"` / ZENROWS_TELEMETRY=off suppresses every X-ZR-* header.
const headers: Record<string, string> = {
"content-type": "application/json",
"User-Agent": "zenrows-cli",
"User-Agent": CLI_USER_AGENT,
...agentClientHeader(),
};
if (attributionEnabled()) {
const p = detectClient();
Expand Down Expand Up @@ -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();
Expand Down
113 changes: 113 additions & 0 deletions src/core/agent-client.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, string | undefined>>;

/** 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<string, string> {
if (!attributionEnabled(opts.projectRoot)) return {};
const client = detectAgentClient(opts.env ?? process.env);
return client ? { [CLIENT_HEADER]: client } : {};
}
5 changes: 4 additions & 1 deletion src/core/batch-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -109,7 +111,8 @@ export async function batchRequest<T>(method: string, path: string, opts: Reques
const headers: Record<string, string> = {
"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";

Expand Down
6 changes: 4 additions & 2 deletions src/core/browser-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -65,7 +66,8 @@ export async function browserRequest<T>(method: string, path: string, opts: Requ
const headers: Record<string, string> = {
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";

Expand Down
12 changes: 8 additions & 4 deletions src/core/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
7 changes: 4 additions & 3 deletions src/core/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -77,7 +78,7 @@ export async function scrape(
apiBase: string,
apiKey: string,
params: ScraperParams,
opts: { timeoutMs?: number } = {},
opts: { timeoutMs?: number; projectRoot?: string } = {},
): Promise<ScraperResult> {
registerSecret(apiKey);
const { full, redacted } = buildUrl(apiBase, apiKey, params);
Expand All @@ -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) {
Expand Down
4 changes: 3 additions & 1 deletion src/core/usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down
5 changes: 3 additions & 2 deletions src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
/**
Expand Down
Loading
Loading