From ddcff316db2762f47326b9964bc7d923ec2f8f0d Mon Sep 17 00:00:00 2001 From: Ander Rodriguez Date: Sun, 18 Oct 2026 12:34:03 +0200 Subject: [PATCH 1/5] fix(provenance): an exported OPENAI_API_KEY labelled any shell as Codex, so attribute Codex only from its own CODEX_* variables --- src/core/provenance.ts | 6 +++++- tests/provenance.test.ts | 11 +++++++++-- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/core/provenance.ts b/src/core/provenance.ts index 2d8ccf0..aa88020 100644 --- a/src/core/provenance.ts +++ b/src/core/provenance.ts @@ -34,7 +34,11 @@ export function detectClient(env: Env = process.env): Provenance { client = "claude-code"; } else if (env.CURSOR_TRACE_ID || hasPrefix(env, "CURSOR_") || term === "cursor") { client = "cursor"; - } else if (hasPrefix(env, "OPENAI_") || hasPrefix(env, "CODEX_")) { + } else if (hasPrefix(env, "CODEX_")) { + // Codex CLI sets CODEX_SANDBOX / CODEX_SANDBOX_NETWORK_DISABLED / CODEX_THREAD_ID + // in the shells it runs commands in, and CODEX_HOME / CODEX_MANAGED_BY_NPM come + // from its install. OPENAI_* is not a signal: OPENAI_API_KEY is in many + // shells that have nothing to do with Codex. client = "openai-codex"; } else if (hasPrefix(env, "WINDSURF")) { client = "windsurf"; diff --git a/tests/provenance.test.ts b/tests/provenance.test.ts index fb7da85..05b37e4 100644 --- a/tests/provenance.test.ts +++ b/tests/provenance.test.ts @@ -18,9 +18,16 @@ test("detectClient: cursor from TERM_PROGRAM", () => { assert.equal(detectClient({ TERM_PROGRAM: "cursor" }).client, "cursor"); }); -test("detectClient: openai-codex from OPENAI_/CODEX_", () => { - assert.equal(detectClient({ OPENAI_API_KEY: "x" }).client, "openai-codex"); +test("detectClient: openai-codex from Codex's own variables", () => { assert.equal(detectClient({ CODEX_HOME: "x" }).client, "openai-codex"); + assert.equal(detectClient({ CODEX_SANDBOX: "seatbelt" }).client, "openai-codex"); + assert.equal(detectClient({ CODEX_SANDBOX_NETWORK_DISABLED: "1" }).client, "openai-codex"); + assert.equal(detectClient({ CODEX_THREAD_ID: "t" }).client, "openai-codex"); +}); + +test("detectClient: OPENAI_API_KEY alone is unknown, not openai-codex", () => { + assert.equal(detectClient({ OPENAI_API_KEY: "x" }).client, "unknown"); + assert.equal(detectClient({ OPENAI_API_KEY: "x", OPENAI_BASE_URL: "y", OPENAI_ORG_ID: "z" }).client, "unknown"); }); test("detectClient: windsurf", () => { From 903205dba4d3f2e254b543bfb621f6234741056c Mon Sep 17 00:00:00 2001 From: Ander Rodriguez Date: Sun, 18 Oct 2026 12:34:03 +0200 Subject: [PATCH 2/5] feat(account): claim links expire after 30 days and accounts get claimed in the browser, so refresh the stored link and stop offering it once claimed `zenrows account claim` asks for a fresh link when the stored one is missing or 25+ days old, and reports an already-claimed account. Quota errors check claim status at most every 6 hours, keep a stored link only while it is under 30 days old, point to `zenrows account claim` otherwise, and accept only https zenrows.com claim URLs. --- README.md | 5 +- agent-plugin/skills/zenrows/SKILL.md | 3 +- skills/zenrows/SKILL.md | 4 +- src/cli/commands/account.ts | 57 +++++--- src/cli/commands/batch.ts | 5 +- src/cli/commands/browser.ts | 5 +- src/cli/commands/crawl.ts | 7 +- src/cli/commands/extract.ts | 5 +- src/cli/commands/fetch.ts | 5 +- src/cli/commands/signup.ts | 5 +- src/cli/index.ts | 3 +- src/core/agent-account.ts | 55 +++++++ src/core/batch-api.ts | 3 +- src/core/browser-api.ts | 3 +- src/core/claim-link.ts | 176 +++++++++++++++++++++++ src/core/crawl-api.ts | 3 +- src/core/ensure-key.ts | 1 + src/core/errors.ts | 42 ++++-- src/core/http.ts | 7 +- src/core/nudge.ts | 4 +- src/core/usage.ts | 3 +- src/types/index.ts | 8 ++ tests/account-command.test.ts | 40 +++++- tests/claim-link.test.ts | 207 +++++++++++++++++++++++++++ tests/nudge.test.ts | 2 +- 25 files changed, 602 insertions(+), 56 deletions(-) create mode 100644 src/core/claim-link.ts create mode 100644 tests/claim-link.test.ts diff --git a/README.md b/README.md index 1844868..a884c0d 100644 --- a/README.md +++ b/README.md @@ -84,11 +84,12 @@ unclaimed Zenrows Free plan account**, stores the key in `.zenrows/secrets.json` (no secret — just the accountId, Free-period info, and a claim link). When you (or your agent) want to keep the usage and history, **claim** the -account — this sets an email + password; the same key keeps working: +account — this confirms an email (Google, GitHub or a 6-digit code); the same +key keeps working: ```bash zenrows account status # show account + Free period + claim link -zenrows account claim # open the claim URL in the browser +zenrows account claim # open the claim URL (fresh one if the stored link is near its 30-day expiry) zenrows usage # plan usage, credits & concurrency (does not count against your limit) ``` diff --git a/agent-plugin/skills/zenrows/SKILL.md b/agent-plugin/skills/zenrows/SKILL.md index 1cae7d3..9b16b7a 100644 --- a/agent-plugin/skills/zenrows/SKILL.md +++ b/agent-plugin/skills/zenrows/SKILL.md @@ -21,7 +21,8 @@ You do **not** need an API key in the plugin package. - **stdio** (`npx -y @zenrows/mcp`): on first cloud call the server can auto-create a **free, unclaimed** Zenrows Free plan account and persist the key locally. When the Free plan nears its limit (or the user asks), surface - the **claim URL** so a human can claim the account (email + password). The + the **claim URL** so a human can claim the account (confirming an email + with Google, GitHub or a 6-digit code). Claim links expire after 30 days. The same key keeps working after claim. Never handle payment or upgrades — only show the claim link. - **Remote** (`https://mcp.zenrows.com/mcp`): the client runs **OAuth** diff --git a/skills/zenrows/SKILL.md b/skills/zenrows/SKILL.md index c782a36..2f2bf90 100644 --- a/skills/zenrows/SKILL.md +++ b/skills/zenrows/SKILL.md @@ -22,7 +22,9 @@ key locally — no human step required. To opt out, pass `--no-signup` or set When the Free plan nears its limit (or the user asks), tell the human they can **claim** the account to keep their usage and history: run `zenrows account claim` (or open the claim URL -shown on quota errors). Claiming sets an email + password; the same key keeps working. +shown on quota errors). It prints a valid link, requesting a fresh one when the stored link is +near its 30-day expiry. Claiming confirms an email (Google, GitHub or a 6-digit code); the same +key keeps working. Never attempt to handle payment or upgrades — only surface the claim link. ## The decision tree (follow this exactly) diff --git a/src/cli/commands/account.ts b/src/cli/commands/account.ts index 39f7b85..b114d75 100644 --- a/src/cli/commands/account.ts +++ b/src/cli/commands/account.ts @@ -1,11 +1,14 @@ /** * `zenrows account ` — inspect the auto-provisioned Zenrows * account and surface the human-facing claim link. Never handles payment or - * upgrades; it only reports status and opens the claim URL. + * upgrades; it only reports status and opens the claim URL, asking the API for + * a fresh one when the stored link is missing or close to its 30-day expiry. */ import { fetchAccountStatus, readAccount, writeAccount } from "../../core/agent-account.ts"; import { authState, ENV_KEY, resolveApiKey } from "../../core/auth.ts"; +import { refreshClaimState, usableClaimUrl } from "../../core/claim-link.ts"; import { loadConfig } from "../../core/config.ts"; +import { ToolkitError } from "../../core/errors.ts"; import { log } from "../../core/logger.ts"; import { openUrl } from "../../core/open-url.ts"; import { fetchUsage } from "../../core/usage.ts"; @@ -18,12 +21,38 @@ export const account: Command = { async run(argv: string[], ctx: RunContext): Promise { const { values, positionals } = parse(argv, { "no-open": { type: "boolean" } }); const sub = positionals[0] ?? "status"; - let acct = readAccount(); const auth = authState(); - // Claiming happens in the browser, out-of-band from the CLI. If we hold an - // unclaimed record and a key, ask the backend whether it has since been - // claimed and refresh account.json. Best-effort — stay on local state offline. + if (sub === "claim") { + const state = await refreshClaimState({ force: true }); + if (state.status === "none") { + log.info("No agent-created account to claim. Use `zenrows login --api-key` if you already have a key."); + return 0; + } + if (state.status === "claimed") { + log.success("This account is already claimed. Nothing to do: the same API key keeps working."); + if (ctx.json) log.out(JSON.stringify({ ok: true, claimed: true }, null, 2)); + return 0; + } + if (!state.claimUrl) { + throw new ToolkitError({ + code: "FETCH_FAILED", + message: "No valid claim link is available.", + likely_cause: auth.hasKey + ? "The stored claim link expired and a fresh one could not be requested." + : "The stored claim link expired, and no API key is configured to request a fresh one.", + next_action: "Check connectivity and run `zenrows account claim` again.", + suggested_commands: ["zenrows account claim"], + }); + } + log.info("Claim this Zenrows account: open the link and confirm your email (Google, GitHub or a 6-digit code)."); + log.info(state.claimUrl); + if (!values["no-open"]) await openUrl(state.claimUrl); + if (ctx.json) log.out(JSON.stringify({ ok: true, claimUrl: state.claimUrl, refreshed: state.refreshed }, null, 2)); + return 0; + } + + let acct = readAccount(); if (acct && acct.unclaimed && auth.hasKey) { const key = resolveApiKey().key; if (key) { @@ -34,26 +63,10 @@ export const account: Command = { writeAccount(acct); } } catch { - // endpoint unavailable / offline — keep the local record } } } - if (sub === "claim") { - if (!acct) { log.info("No agent-created account to claim. Use `zenrows login --api-key` if you already have a key."); return 0; } - if (!acct.unclaimed) { - log.success("This account is already claimed."); - if (ctx.json) log.out(JSON.stringify({ ok: true, claimed: true }, null, 2)); - return 0; - } - log.info("Claim this Zenrows account (set your email + password):"); - log.info(acct.claimUrl); - if (!values["no-open"]) await openUrl(acct.claimUrl); - if (ctx.json) log.out(JSON.stringify({ ok: true, claimUrl: acct.claimUrl }, null, 2)); - return 0; - } - - // status — cycle dates from subscriptions/self/details let periodEndsAt: string | undefined; if (auth.hasKey) { const key = resolveApiKey().key; @@ -79,7 +92,7 @@ export const account: Command = { log.info(`Account: ${acct.accountId} (${acct.unclaimed ? "unclaimed" : "claimed"})`); if (auth.hasKey) log.dim(`Key source: ${auth.source}`); if (periodEndsAt) log.dim(`Billing period ends: ${periodEndsAt}`); - if (acct.unclaimed) log.dim(`Claim it: ${acct.claimUrl}`); + if (acct.unclaimed) log.dim(`Claim it: ${usableClaimUrl(acct) ?? "run `zenrows account claim` for a fresh link"}`); } else { if (auth.hasKey) { log.info( diff --git a/src/cli/commands/batch.ts b/src/cli/commands/batch.ts index d109cf9..69b6e5e 100644 --- a/src/cli/commands/batch.ts +++ b/src/cli/commands/batch.ts @@ -20,6 +20,7 @@ import { createJob, downloadResults, getJob, listResults, rerunJob, stopJob, wai import { asNumber, asString, parse, type Command, type RunContext } from "../command.ts"; import { ToolkitError, isKeyCapReached, keyCapReached } from "../../core/errors.ts"; import { printError, writeOut } from "../output.ts"; +import { refreshClaimAdvice } from "../../core/claim-link.ts"; export const batch: Command = { name: "batch", @@ -74,7 +75,9 @@ export const batch: Command = { next_action: "Use estimate | create | status | results | cancel | wait | retry-failed .", }); } - } catch (err) { + } catch (caught) { + // A quota error's claim advice is built from local state; check it is still current. + const err = await refreshClaimAdvice(caught); printError(err, ctx.json); return 1; } diff --git a/src/cli/commands/browser.ts b/src/cli/commands/browser.ts index b6d6d8a..13452d5 100644 --- a/src/cli/commands/browser.ts +++ b/src/cli/commands/browser.ts @@ -23,6 +23,7 @@ import { newRunId, writeRun } from "../../core/artifacts.ts"; import { ToolkitError } from "../../core/errors.ts"; import { parse, asString, asNumber, type Command, type RunContext } from "../command.ts"; import { printError, writeOut } from "../output.ts"; +import { refreshClaimAdvice } from "../../core/claim-link.ts"; import { createSession, sessionCall, @@ -180,7 +181,9 @@ export const browser: Command = { next_action: "Run `zenrows browser --help` to see the available subcommands.", }); } - } catch (err) { + } catch (caught) { + // A quota error's claim advice is built from local state; check it is still current. + const err = await refreshClaimAdvice(caught); printError(err, ctx.json); return err instanceof ToolkitError && err.code === "POLICY_BROWSER_DISABLED" ? 2 : 1; } diff --git a/src/cli/commands/crawl.ts b/src/cli/commands/crawl.ts index e4a7b3f..083b8d1 100644 --- a/src/cli/commands/crawl.ts +++ b/src/cli/commands/crawl.ts @@ -33,6 +33,7 @@ import { asNumber, asString, parse, type Command, type RunContext } from "../com import { ToolkitError, isKeyCapReached, keyCapReached, quotaExhausted } from "../../core/errors.ts"; import { readAccount } from "../../core/agent-account.ts"; import { printError, writeOut, writeOutStream } from "../output.ts"; +import { refreshClaimAdvice, storedClaimHint } from "../../core/claim-link.ts"; /** The API's default for `max_pages` (and `max_items`) when the flag is unset. */ const DEFAULT_MAX_PAGES = 100; @@ -113,7 +114,9 @@ export const crawl: Command = { suggested_commands: ["zenrows crawl --help"], }); } - } catch (err) { + } catch (caught) { + // A quota error's claim advice is built from local state; check it is still current. + const err = await refreshClaimAdvice(caught); printError(err, ctx.json); return 1; } @@ -487,7 +490,7 @@ export function crawlFailure(c: Crawl | CrawlStop): ToolkitError | null { } if (code === "insufficient_credits") { const acct = readAccount(); - const quota = quotaExhausted(where, acct?.unclaimed ? acct.claimUrl : undefined, { status: null, detail }).toJSON(); + const quota = quotaExhausted(where, storedClaimHint(acct), { status: null, detail }).toJSON(); return new ToolkitError({ ...quota, ...ids, code: "CRAWL_INSUFFICIENT_CREDITS", suggested_commands: [...(quota.suggested_commands ?? []), getCmd] }); } const why = [code, c.error?.detail].filter(Boolean).join(": "); diff --git a/src/cli/commands/extract.ts b/src/cli/commands/extract.ts index 3128720..50445db 100644 --- a/src/cli/commands/extract.ts +++ b/src/cli/commands/extract.ts @@ -17,6 +17,7 @@ import { runExtract, type ExtractMethod, type ExtractOptions } from "../../adapt import { formatRequestCost } from "../../core/http.ts"; import { parse, asString, asNumber, type Command, type RunContext } from "../command.ts"; import { printError, writeOut } from "../output.ts"; +import { refreshClaimAdvice } from "../../core/claim-link.ts"; export const extract: Command = { name: "extract", @@ -200,7 +201,9 @@ export const extract: Command = { } await maybeNudgeClaim(config, {}); return 0; - } catch (err) { + } catch (caught) { + // A quota error's claim advice is built from local state; check it is still current. + const err = await refreshClaimAdvice(caught); writeRun({ runId, command: "zenrows extract", diff --git a/src/cli/commands/fetch.ts b/src/cli/commands/fetch.ts index 73ae447..f5437a3 100644 --- a/src/cli/commands/fetch.ts +++ b/src/cli/commands/fetch.ts @@ -16,6 +16,7 @@ import { runFetch, type FetchOptions, type ResponseFormat } from "../../adapters import { formatRequestCost } from "../../core/http.ts"; import { parse, asString, asNumber, type Command, type RunContext } from "../command.ts"; import { printError, writeOut } from "../output.ts"; +import { refreshClaimAdvice } from "../../core/claim-link.ts"; export const fetch_: Command = { name: "fetch", @@ -166,7 +167,9 @@ export const fetch_: Command = { } await maybeNudgeClaim(config, {}); return 0; - } catch (err) { + } catch (caught) { + // A quota error's claim advice is built from local state; check it is still current. + const err = await refreshClaimAdvice(caught); writeRun({ runId, command: "zenrows fetch", diff --git a/src/cli/commands/signup.ts b/src/cli/commands/signup.ts index cb35dac..15feb81 100644 --- a/src/cli/commands/signup.ts +++ b/src/cli/commands/signup.ts @@ -22,10 +22,11 @@ export const signup: Command = { if (values.agent) { const account = readAccount(); - if (account) { log.info("An agent account already exists. Claim it: " + account.claimUrl); return 0; } + if (account) { log.info("An agent account already exists. Claim it with: zenrows account claim"); return 0; } const res = await signupAgent(); saveApiKey(res.apiKey); - writeAccount({ accountId: res.accountId, unclaimed: true, claimUrl: res.claimUrl, createdAt: new Date().toISOString() }); + const now = new Date().toISOString(); + writeAccount({ accountId: res.accountId, unclaimed: true, claimUrl: res.claimUrl, createdAt: now, claimUrlIssuedAt: now }); log.success("Created a Zenrows Free plan account."); log.dim(`Claim it anytime: ${res.claimUrl}`); if (ctx.json) log.out(JSON.stringify({ ok: true, accountId: res.accountId, claimUrl: res.claimUrl }, null, 2)); diff --git a/src/cli/index.ts b/src/cli/index.ts index 2185bcb..f089f7f 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -8,6 +8,7 @@ */ import { CLI_VERSION } from "../core/config.ts"; import { ToolkitError } from "../core/errors.ts"; +import { refreshClaimAdvice } from "../core/claim-link.ts"; import { log, ANSI, c } from "../core/logger.ts"; import { makeAssetCommand } from "./asset-command.ts"; import { printError } from "./output.ts"; @@ -94,7 +95,7 @@ export async function main(rawArgv: string[]): Promise { try { return await cmd.run(argv, ctx); } catch (err) { - printError(err, json); + printError(await refreshClaimAdvice(err), json); // Any error exits 1 — the exit code doesn't encode error kind. Machine // consumers read the precise `error.code` from the --json output instead. return 1; diff --git a/src/core/agent-account.ts b/src/core/agent-account.ts index 42c9ab1..fdeccd0 100644 --- a/src/core/agent-account.ts +++ b/src/core/agent-account.ts @@ -241,6 +241,61 @@ export async function resolveAccountUrl( return (await resolveSignupUrl(projectRoot, opts)).replace(/\/signup\/?$/, "/account"); } +/** The fresh-claim-link endpoint, derived from the signup URL like the status one. */ +export async function resolveClaimUrl( + projectRoot?: string, + opts: { fetchImpl?: typeof fetch } = {}, +): Promise { + return (await resolveSignupUrl(projectRoot, opts)).replace(/\/signup\/?$/, "/claim"); +} + +export type ClaimLinkResponse = + | { status: "unclaimed"; claimUrl: string; expiresAt?: string } + | { status: "claimed" }; + +/** + * Mint a fresh claim link for the account behind `apiKey`. Each call + * invalidates the previous link and the endpoint is rate-limited per key, so + * callers only ask when the link they hold is missing or near expiry. + * A 409 `already_claimed` means a person owns the account. Throws ToolkitError + * on any other failure. + */ +export async function requestClaimLink( + apiKey: string, + opts: { url?: string; fetchImpl?: typeof fetch } = {}, +): Promise { + const url = opts.url ?? (await resolveClaimUrl()); + const doFetch = opts.fetchImpl ?? fetch; + const res = await doFetch(url, { + method: "POST", + headers: { "X-API-Key": apiKey, Accept: "application/json", "User-Agent": CLI_USER_AGENT, ...agentClientHeader() }, + }); + const body = await res.text(); + if (res.status === 200) { + const json = JSON.parse(body) as { claimUrl?: unknown; expiresAt?: unknown }; + if (typeof json.claimUrl !== "string") { + throw new ToolkitError({ + code: "FETCH_FAILED", + message: "The claim-link response had no claimUrl.", + likely_cause: body.slice(0, 240), + next_action: "Retry later.", + }); + } + return { + status: "unclaimed", + claimUrl: json.claimUrl, + ...(typeof json.expiresAt === "string" ? { expiresAt: json.expiresAt } : {}), + }; + } + if (res.status === 409 && /already_claimed/.test(body)) return { status: "claimed" }; + throw new ToolkitError({ + code: res.status === 401 || res.status === 403 ? "AUTH_INVALID" : "FETCH_FAILED", + message: `Claim link request failed (HTTP ${res.status}).`, + likely_cause: body.slice(0, 240), + next_action: res.status === 429 ? "Too many claim links requested for this key; retry in an hour." : "Retry later.", + }); +} + /** * Ask the backend whether the account behind `apiKey` has been claimed * (claiming happens in the browser, out-of-band from the CLI). Auth via the diff --git a/src/core/batch-api.ts b/src/core/batch-api.ts index 2b5cf28..abb8a44 100644 --- a/src/core/batch-api.ts +++ b/src/core/batch-api.ts @@ -17,6 +17,7 @@ import { mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { ToolkitError, isKeyCapReached, keyCapReached, quotaExhausted } from "./errors.ts"; +import { storedClaimHint } from "./claim-link.ts"; import { readAccount } from "./agent-account.ts"; import { registerSecret } from "./logger.ts"; import { CLI_USER_AGENT } from "./config.ts"; @@ -224,7 +225,7 @@ function problemToError(status: number, body: string, method: string, path: stri // a claim link (unclaimed Free plan) or dashboard/upgrade pointer, not a // generic "fix the problem and retry". const acct = readAccount(); - return quotaExhausted(`${method} ${path}`, acct?.unclaimed ? acct.claimUrl : undefined, { + return quotaExhausted(`${method} ${path}`, storedClaimHint(acct), { status: 402, detail: problem.detail || problem.title || undefined, }); diff --git a/src/core/browser-api.ts b/src/core/browser-api.ts index 230ac37..a15097e 100644 --- a/src/core/browser-api.ts +++ b/src/core/browser-api.ts @@ -12,6 +12,7 @@ * them (`closeSession`) — the `browser run` command does so in a `finally`. */ import { ToolkitError, isKeyCapReached, keyCapReached, quotaExhausted } from "./errors.ts"; +import { storedClaimHint } from "./claim-link.ts"; import { readAccount } from "./agent-account.ts"; import { registerSecret } from "./logger.ts"; import { CLI_USER_AGENT } from "./config.ts"; @@ -139,7 +140,7 @@ export function browserProblemToError(status: number, body: string, method: stri if (status === 402) { // Out of credits — same exhausted state as the scraper/batch 402. const acct = readAccount(); - return quotaExhausted(`${method} ${path}`, acct?.unclaimed ? acct.claimUrl : undefined, { + return quotaExhausted(`${method} ${path}`, storedClaimHint(acct), { status: 402, detail: parsed.error || parsed.detail || parsed.title || undefined, }); diff --git a/src/core/claim-link.ts b/src/core/claim-link.ts new file mode 100644 index 0000000..0995ad5 --- /dev/null +++ b/src/core/claim-link.ts @@ -0,0 +1,176 @@ +/** + * Keeping the stored claim link usable. + * + * A claim link expires 30 days after it is issued, and a human may claim the + * account in the browser at any time without the CLI knowing. This module + * decides, from `account.json` plus the claim-status and claim-link endpoints, + * what the CLI should tell the user: a link that still works, "run + * `zenrows account claim`", or nothing because the account is already claimed. + */ +import type { AgentAccount } from "../types/index.ts"; +import { fetchAccountStatus, readAccount, requestClaimLink, writeAccount } from "./agent-account.ts"; +import { resolveApiKey } from "./auth.ts"; +import { CLAIM_LINK_REFRESH, CLAIM_NEXT_ACTION_PREFIX, ToolkitError, quotaNextAction, type ClaimHint } from "./errors.ts"; + +const DAY_MS = 24 * 60 * 60 * 1000; +/** The backend's claim-link lifetime. A link this old no longer works. */ +export const CLAIM_LINK_TTL_MS = 30 * DAY_MS; +/** Ask for a fresh link once the held one is this old, before it lapses. */ +export const CLAIM_LINK_REFRESH_AGE_MS = 25 * DAY_MS; +/** On error paths, check claim status at most this often. */ +export const CLAIM_CHECK_THROTTLE_MS = 6 * 60 * 60 * 1000; +/** Each claim request gives up after this long, so an error is never held up for long. */ +const CLAIM_REQUEST_TIMEOUT_MS = 5000; + +/** Only an https link on zenrows.com (or a subdomain) is shown or opened. */ +export function isTrustedClaimUrl(value: unknown): value is string { + if (typeof value !== "string") return false; + try { + const u = new URL(value); + const host = u.hostname.toLowerCase(); + return u.protocol === "https:" && (host === "zenrows.com" || host.endsWith(".zenrows.com")); + } catch { + return false; + } +} + +/** Age of the held link in ms, or Infinity when its issue time is unknown. */ +function linkAgeMs(acct: AgentAccount, now: Date): number { + const issued = new Date(acct.claimUrlIssuedAt ?? acct.createdAt).getTime(); + return Number.isNaN(issued) ? Infinity : now.getTime() - issued; +} + +/** The held link if it is trusted and not yet expired, else undefined. No network. */ +export function usableClaimUrl(acct: AgentAccount, now: Date = new Date()): string | undefined { + if (!isTrustedClaimUrl(acct.claimUrl)) return undefined; + return linkAgeMs(acct, now) < CLAIM_LINK_TTL_MS ? acct.claimUrl : undefined; +} + +/** The claim hint for an error from local state alone. No network. */ +export function storedClaimHint(acct: AgentAccount | null, now: Date = new Date()): ClaimHint { + if (!acct?.unclaimed) return undefined; + return usableClaimUrl(acct, now) ?? CLAIM_LINK_REFRESH; +} + +export type ClaimState = + | { status: "none" } + | { status: "claimed" } + | { status: "unclaimed"; claimUrl?: string; refreshed: boolean }; + +export interface RefreshOptions { + projectRoot?: string; + apiKey?: string; + /** Skip the error-path throttle (an explicit `zenrows account claim`). */ + force?: boolean; + now?: Date; + fetchImpl?: typeof fetch; + /** Endpoint overrides (tests). Default: derived from the signup URL. */ + accountUrl?: string; + claimUrl?: string; +} + +/** + * Bring the stored claim state up to date, as far as the network allows, and + * report it. Steps: ask whether the account was claimed; if not, and the held + * link is missing, untrusted or 25+ days old, mint a fresh one and store it. + * Network failures are swallowed: the held link is still offered while it is + * under 30 days old. Unless `force` is set, the network is skipped entirely + * when the last check was under 6 hours ago. Throws only for a forced call + * whose fresh-link request failed with no usable link to fall back on. + */ +export async function refreshClaimState(opts: RefreshOptions = {}): Promise { + const { projectRoot } = opts; + const now = opts.now ?? new Date(); + let acct = readAccount(projectRoot); + if (!acct) return { status: "none" }; + if (!acct.unclaimed) return { status: "claimed" }; + + const offline = (): ClaimState => ({ status: "unclaimed", claimUrl: usableClaimUrl(acct!, now), refreshed: false }); + + if (!opts.force && acct.lastClaimCheckAt) { + const last = new Date(acct.lastClaimCheckAt).getTime(); + if (!Number.isNaN(last) && now.getTime() - last < CLAIM_CHECK_THROTTLE_MS) return offline(); + } + + const key = opts.apiKey ?? resolveApiKey(projectRoot).key; + if (!key) return offline(); + + const base = opts.fetchImpl ?? fetch; + const fetchImpl = ((url: string, init?: RequestInit) => + base(url, { ...init, signal: AbortSignal.timeout(CLAIM_REQUEST_TIMEOUT_MS) })) as typeof fetch; + + // Record the attempt first, so a run of failing calls does not retry each time. + acct = { ...acct, lastClaimCheckAt: now.toISOString() }; + writeAccount(acct, projectRoot); + + try { + const remote = await fetchAccountStatus(key, { url: opts.accountUrl, fetchImpl }); + if (remote.claimed === true) return markClaimed(acct, projectRoot); + } catch { + // Status unknown: carry on and judge the held link on its own. + } + + const needsNew = !isTrustedClaimUrl(acct.claimUrl) || linkAgeMs(acct, now) >= CLAIM_LINK_REFRESH_AGE_MS; + if (!needsNew) return offline(); + + let fresh; + try { + fresh = await requestClaimLink(key, { url: opts.claimUrl, fetchImpl }); + } catch (err) { + if (opts.force && !usableClaimUrl(acct, now)) { + if (err instanceof ToolkitError) throw err; + throw new ToolkitError({ + code: "BACKEND_UNAVAILABLE", + message: "Could not reach Zenrows to get a fresh claim link.", + likely_cause: err instanceof Error ? err.message : String(err), + next_action: "The stored claim link has expired. Check connectivity and run `zenrows account claim` again.", + suggested_commands: ["zenrows account claim"], + }); + } + return offline(); + } + if (fresh.status === "claimed") return markClaimed(acct, projectRoot); + if (!isTrustedClaimUrl(fresh.claimUrl)) { + if (opts.force && !usableClaimUrl(acct, now)) { + throw new ToolkitError({ + code: "FETCH_FAILED", + message: "The claim-link endpoint returned a URL outside zenrows.com; it was not used.", + likely_cause: "The signup/claim endpoint override points at an unexpected server.", + next_action: "Check ZENROWS_AGENT_SIGNUP_URL / `signupUrl` in the zenrows config, then retry.", + }); + } + return offline(); + } + acct = { ...acct, claimUrl: fresh.claimUrl, claimUrlIssuedAt: now.toISOString() }; + writeAccount(acct, projectRoot); + return { status: "unclaimed", claimUrl: fresh.claimUrl, refreshed: true }; +} + +function markClaimed(acct: AgentAccount, projectRoot?: string): ClaimState { + writeAccount({ ...acct, unclaimed: false }, projectRoot); + return { status: "claimed" }; +} + +/** The error hint for a refreshed state. */ +export function claimHintFor(state: ClaimState): ClaimHint { + if (state.status !== "unclaimed") return undefined; + return state.claimUrl ?? CLAIM_LINK_REFRESH; +} + +/** + * Bring a quota error's claim advice up to date before it is shown. Errors are + * built synchronously from `account.json`; this runs the throttled refresh and + * rewrites `next_action` if the account was claimed or the link changed. Never + * throws: on any failure the error is returned as it was. + */ +export async function refreshClaimAdvice(err: unknown, opts: RefreshOptions = {}): Promise { + try { + if (!(err instanceof ToolkitError) || !err.next_action.startsWith(CLAIM_NEXT_ACTION_PREFIX)) return err; + const state = await refreshClaimState({ ...opts, force: false }); + const next = quotaNextAction(claimHintFor(state)); + if (next === err.next_action) return err; + return new ToolkitError({ ...err.toJSON(), next_action: next }); + } catch { + return err; + } +} diff --git a/src/core/crawl-api.ts b/src/core/crawl-api.ts index e32a1e2..91d6e43 100644 --- a/src/core/crawl-api.ts +++ b/src/core/crawl-api.ts @@ -15,6 +15,7 @@ */ import { Readable } from "node:stream"; import { ToolkitError, isKeyCapReached, keyCapReached, quotaExhausted } from "./errors.ts"; +import { storedClaimHint } from "./claim-link.ts"; import { readAccount } from "./agent-account.ts"; import { registerSecret } from "./logger.ts"; import { CLI_USER_AGENT, loadConfig } from "./config.ts"; @@ -398,7 +399,7 @@ function classifyProblem( return new ToolkitError({ ...keyCapReached(request, { status, detail }).toJSON(), code: "CRAWL_KEY_CAP_REACHED" }); } const acct = readAccount(); - const quota = quotaExhausted(request, acct?.unclaimed ? acct.claimUrl : undefined, { status, detail }); + const quota = quotaExhausted(request, storedClaimHint(acct), { status, detail }); return new ToolkitError({ ...quota.toJSON(), code: "CRAWL_INSUFFICIENT_CREDITS" }); } if (status === 409) { diff --git a/src/core/ensure-key.ts b/src/core/ensure-key.ts index 4416f36..07ae1c9 100644 --- a/src/core/ensure-key.ts +++ b/src/core/ensure-key.ts @@ -31,6 +31,7 @@ export async function ensureApiKey( claimUrl: res.claimUrl, createdAt: new Date().toISOString(), }; + account.claimUrlIssuedAt = account.createdAt; writeAccount(account, opts.projectRoot); opts.onProvision?.(account); return res.apiKey; diff --git a/src/core/errors.ts b/src/core/errors.ts index 118d9f8..ceb80a4 100644 --- a/src/core/errors.ts +++ b/src/core/errors.ts @@ -115,29 +115,49 @@ export class ToolkitError extends Error { */ export function quotaExhausted( url: string, - claimUrl?: string, + claim?: ClaimHint, opts: { status?: number | null; detail?: string } = {}, ): ToolkitError { - // Say that the allowance comes back. Without it this reads as a permanent paywall, - // which is how the API's own AUTH004 text reads ("Purchase a new subscription to - // continue") and why exhausted clients retry-loop instead of waiting or upgrading — - // one account spent seven days at ~3 req/s against this wall. `zenrows usage` prints - // the exact `period_ends_at`, so the date is one command away rather than guessed here. - const renewLine = "Credits renew at the end of the billing period — run `zenrows usage` for the date."; - const claimLine = claimUrl - ? `You are on the Zenrows Free plan. Claim your account to keep your usage and add credits: ${claimUrl}. ${renewLine}` - : `You are out of Zenrows credits. ${renewLine} To carry on now, add a credit pack (${BILLING_TOPUP_URL} opens the purchase directly) or upgrade your plan: ${PLANS_URL}`; const detail = opts.detail ? `${opts.detail.replace(/\.\s*$/, "")}. ` : ""; const where = opts.status === null ? `Stopped ${url}` : `HTTP ${opts.status ?? 429} for ${url}`; return new ToolkitError({ code: "POLICY_MAX_CREDITS_EXCEEDED", message: "Zenrows request quota exhausted.", likely_cause: `${detail}${where}`, - next_action: claimLine, + next_action: quotaNextAction(claim), suggested_commands: ["zenrows usage"], }); } +/** + * What the CLI knows about claiming, for an error that has to say it: + * a claim URL that is still valid; `CLAIM_LINK_REFRESH` when the account is + * unclaimed but no usable link is held (missing or expired); or `undefined` + * when there is nothing to claim (claimed, or not an agent-created account). + */ +export type ClaimHint = string | typeof CLAIM_LINK_REFRESH | undefined; +export const CLAIM_LINK_REFRESH = Symbol("claim-link-refresh"); + +/** Start of `next_action` on a quota error for an unclaimed account. */ +export const CLAIM_NEXT_ACTION_PREFIX = "You are on the Zenrows Free plan."; + +/** The `next_action` of a quota error for the given claim state. */ +export function quotaNextAction(claim: ClaimHint): string { + // Say that the allowance comes back. Without it this reads as a permanent paywall, + // which is how the API's own AUTH004 text reads ("Purchase a new subscription to + // continue") and why exhausted clients retry-loop instead of waiting or upgrading. + // `zenrows usage` prints the exact `period_ends_at`, so the date is one command away + // rather than guessed here. + const renewLine = "Credits renew at the end of the billing period — run `zenrows usage` for the date."; + if (claim === CLAIM_LINK_REFRESH) { + return `${CLAIM_NEXT_ACTION_PREFIX} Claim your account to keep your usage and add credits: run \`zenrows account claim\` for a fresh claim link. ${renewLine}`; + } + if (claim) { + return `${CLAIM_NEXT_ACTION_PREFIX} Claim your account to keep your usage and add credits: ${claim}. ${renewLine}`; + } + return `You are out of Zenrows credits. ${renewLine} To carry on now, add a credit pack (${BILLING_TOPUP_URL} opens the purchase directly) or upgrade your plan: ${PLANS_URL}`; +} + /** Where an account manages its API keys and their credit caps. */ export const API_KEYS_SETTINGS_URL = "https://app.zenrows.com/settings/api-keys"; diff --git a/src/core/http.ts b/src/core/http.ts index 8619ccb..0e3e4af 100644 --- a/src/core/http.ts +++ b/src/core/http.ts @@ -6,6 +6,7 @@ * from any logged URL. */ import { ToolkitError, isKeyCapReached, keyCapReached, quotaExhausted } from "./errors.ts"; +import { storedClaimHint, usableClaimUrl } from "./claim-link.ts"; import { readAccount } from "./agent-account.ts"; import { ENV_KEY, resolveApiKey } from "./auth.ts"; import { registerSecret } from "./logger.ts"; @@ -193,7 +194,7 @@ export async function scrape( likely_cause: `${cause}. The signup endpoint issued this key, but the scraping API did not accept it.`, next_action: "If you are testing against a local/staging backend, ensure `apiBase` (zenrows config) points at the same environment that issued the key. Otherwise re-provision with `zenrows signup --agent`, or claim the account: " + - acct.claimUrl, + (usableClaimUrl(acct) ?? "run `zenrows account claim` for a link"), suggested_commands: ["zenrows status", "zenrows signup --agent"], }); } @@ -237,7 +238,7 @@ export async function scrape( // out of credits. This is NOT scraped content — surface it as a credits // error (claim link for an unclaimed Free plan, dashboard/upgrade otherwise). const acct = readAccount(); - throw quotaExhausted(redacted, acct?.unclaimed ? acct.claimUrl : undefined, { + throw quotaExhausted(redacted, storedClaimHint(acct), { status: 402, detail: zrErrorDetail(body) ?? undefined, }); @@ -249,7 +250,7 @@ export async function scrape( // limit; otherwise advise a brief retry. if (isQuotaError(body)) { const acct = readAccount(); - throw quotaExhausted(redacted, acct?.unclaimed ? acct.claimUrl : undefined); + throw quotaExhausted(redacted, storedClaimHint(acct)); } const detail = zrErrorDetail(body) ?? snippet(body); throw new ToolkitError({ diff --git a/src/core/nudge.ts b/src/core/nudge.ts index 0eafd7c..7607fc0 100644 --- a/src/core/nudge.ts +++ b/src/core/nudge.ts @@ -9,6 +9,7 @@ import type { AgentAccount, ToolkitConfig } from "../types/index.ts"; import { readAccount, writeAccount } from "./agent-account.ts"; import { resolveApiKey } from "./auth.ts"; +import { usableClaimUrl } from "./claim-link.ts"; import { log } from "./logger.ts"; import { fetchUsage } from "./usage.ts"; @@ -29,7 +30,8 @@ export function nudgeMessage( ): string | null { if (!account.unclaimed) return null; if (typeof usagePercent === "number" && usagePercent >= USAGE_NUDGE_PERCENT) { - return `You've used ${usagePercent}% of your Zenrows Free plan — claim your account to keep your usage & history: ${account.claimUrl}`; + const link = usableClaimUrl(account, _now) ?? "run `zenrows account claim` for a claim link"; + return `You've used ${usagePercent}% of your Zenrows Free plan — claim your account to keep your usage & history: ${link}`; } return null; } diff --git a/src/core/usage.ts b/src/core/usage.ts index aba0ba0..ac022bd 100644 --- a/src/core/usage.ts +++ b/src/core/usage.ts @@ -7,6 +7,7 @@ * safe to poll. The API key is registered as a secret so it is redacted in logs. */ import { ToolkitError, quotaExhausted } from "./errors.ts"; +import { storedClaimHint } from "./claim-link.ts"; import { isQuotaError, zrErrorDetail } from "./http.ts"; import { readAccount } from "./agent-account.ts"; import { ENV_KEY, resolveApiKey } from "./auth.ts"; @@ -131,7 +132,7 @@ export async function fetchUsage( // scraper 402. Surface it as a credits error (claim link for an unclaimed // Free plan account, dashboard/upgrade otherwise) instead of a generic "retry". const acct = readAccount(); - throw quotaExhausted(url, acct?.unclaimed ? acct.claimUrl : undefined, { + throw quotaExhausted(url, storedClaimHint(acct), { status: res.status, detail: zrErrorDetail(body) ?? undefined, }); diff --git a/src/types/index.ts b/src/types/index.ts index b6b4fd5..53476ca 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -115,6 +115,14 @@ export interface AgentAccount { createdAt: string; /** ISO-8601 timestamp of the last usage check (throttles the claim nudge). */ lastUsageCheckAt?: string; + /** + * ISO-8601 time `claimUrl` was issued. Claim links expire 30 days after + * issue; absent on accounts written before this field existed, where + * `createdAt` (the signup that issued the first link) stands in. + */ + claimUrlIssuedAt?: string; + /** ISO-8601 timestamp of the last claim-status check (throttles it on errors). */ + lastClaimCheckAt?: string; } export interface Secrets { diff --git a/tests/account-command.test.ts b/tests/account-command.test.ts index 3f70732..70a3924 100644 --- a/tests/account-command.test.ts +++ b/tests/account-command.test.ts @@ -2,7 +2,8 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { tempRoot } from "./helpers.ts"; import { createWorkspace } from "../src/core/workspace.ts"; -import { writeAccount } from "../src/core/agent-account.ts"; +import { readAccount, writeAccount } from "../src/core/agent-account.ts"; +import { saveApiKey } from "../src/core/auth.ts"; import { account } from "../src/cli/commands/account.ts"; test("account status returns 0 with an existing account", async () => { @@ -16,3 +17,40 @@ test("account status returns 0 with an existing account", async () => { } finally { process.chdir(prev); } } finally { cleanup(); } }); + +test("account claim: an expired stored link is replaced by a fresh one; a claimed account says so", async () => { + const { root, cleanup } = tempRoot(); + const prevFetch = globalThis.fetch; + const prevEnv = process.env.ZENROWS_AGENT_SIGNUP_URL; + const prevCwd = process.cwd(); + const out: string[] = []; + const prevWrite = process.stdout.write.bind(process.stdout); + try { + createWorkspace(root); + saveApiKey("zr-key", root); + writeAccount({ accountId: "u1", unclaimed: true, claimUrl: "https://app.zenrows.com/claim/old", createdAt: "2020-01-01T00:00:00Z" }, root); + process.env.ZENROWS_AGENT_SIGNUP_URL = "https://stub.test/api/agent/signup"; + let claimed = false; + globalThis.fetch = (async (url: string) => { + if (url.endsWith("/account")) return new Response(JSON.stringify({ accountId: "u1", claimed, isAgent: true }), { status: 200 }); + if (url.endsWith("/claim")) return new Response(JSON.stringify({ status: "unclaimed", claimUrl: "https://app.zenrows.com/claim/fresh" }), { status: 200 }); + throw new Error("unexpected " + url); + }) as unknown as typeof fetch; + process.chdir(root); + process.stdout.write = ((s: string) => { out.push(String(s)); return true; }) as typeof process.stdout.write; + assert.equal(await account.run(["claim", "--no-open"], { json: true, yes: true }), 0); + assert.equal(readAccount(root)?.claimUrl, "https://app.zenrows.com/claim/fresh"); + assert.match(out.join(""), /"claimUrl": "https:\/\/app\.zenrows\.com\/claim\/fresh"/); + claimed = true; + out.length = 0; + assert.equal(await account.run(["claim", "--no-open"], { json: true, yes: true }), 0); + assert.match(out.join(""), /"claimed": true/); + assert.equal(readAccount(root)?.unclaimed, false); + } finally { + process.stdout.write = prevWrite; + process.chdir(prevCwd); + globalThis.fetch = prevFetch; + if (prevEnv === undefined) delete process.env.ZENROWS_AGENT_SIGNUP_URL; else process.env.ZENROWS_AGENT_SIGNUP_URL = prevEnv; + cleanup(); + } +}); diff --git a/tests/claim-link.test.ts b/tests/claim-link.test.ts new file mode 100644 index 0000000..1f6ea59 --- /dev/null +++ b/tests/claim-link.test.ts @@ -0,0 +1,207 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { statSync } from "node:fs"; +import { tempRoot } from "./helpers.ts"; +import { createWorkspace, workspacePaths } from "../src/core/workspace.ts"; +import { saveApiKey } from "../src/core/auth.ts"; +import { readAccount, writeAccount, requestClaimLink } from "../src/core/agent-account.ts"; +import { + isTrustedClaimUrl, + refreshClaimAdvice, + refreshClaimState, + storedClaimHint, + usableClaimUrl, +} from "../src/core/claim-link.ts"; +import { CLAIM_LINK_REFRESH, ToolkitError, quotaExhausted } from "../src/core/errors.ts"; +import type { AgentAccount } from "../src/types/index.ts"; + +const ACCOUNT_URL = "https://stub.test/api/agent/account"; +const CLAIM_ENDPOINT = "https://stub.test/api/agent/claim"; +const OLD_LINK = "https://app.zenrows.com/claim/old?utm_source=agent"; +const NEW_LINK = "https://app.zenrows.com/claim/new?utm_source=agent"; +const NOW = new Date("2026-08-01T00:00:00Z"); +const daysAgo = (d: number) => new Date(NOW.getTime() - d * 86_400_000).toISOString(); + +function acct(over: Partial = {}): AgentAccount { + return { accountId: "u1", unclaimed: true, claimUrl: OLD_LINK, createdAt: daysAgo(1), ...over }; +} + +interface Stub { + fetchImpl: typeof fetch; + calls: { method: string; url: string; key: string | null }[]; +} + +/** A fake backend: `account` answers GET /account, `claim` answers POST /claim; "throw" simulates a network error. */ +function stub(opts: { account?: { claimed: boolean } | number | "throw"; claim?: { claimUrl: string } | number | "throw" }): Stub { + const calls: Stub["calls"] = []; + const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }); + const fetchImpl = (async (url: string, init?: RequestInit) => { + const method = init?.method ?? "GET"; + const key = (init?.headers as Record)?.["X-API-Key"] ?? null; + calls.push({ method, url, key }); + const spec = url === ACCOUNT_URL ? opts.account : url === CLAIM_ENDPOINT ? opts.claim : undefined; + if (spec === undefined) throw new Error(`unexpected ${method} ${url}`); + if (spec === "throw") throw new TypeError("fetch failed"); + if (typeof spec === "number") { + return spec === 409 ? json({ error: "already_claimed" }, 409) : json({ error: "x" }, spec); + } + if ("claimed" in spec) return json({ accountId: "u1", claimed: spec.claimed, isAgent: true }); + return json({ status: "unclaimed", claimUrl: spec.claimUrl, expiresAt: "2026-08-31T00:00:00Z" }); + }) as unknown as typeof fetch; + return { fetchImpl, calls }; +} + +function withWorkspace(a: AgentAccount | null, fn: (root: string) => Promise): Promise { + const { root, cleanup } = tempRoot(); + createWorkspace(root); + saveApiKey("zr-key", root); + if (a) writeAccount(a, root); + return fn(root).finally(cleanup); +} + +const endpoints = { accountUrl: ACCOUNT_URL, claimUrl: CLAIM_ENDPOINT, now: NOW }; + +test("isTrustedClaimUrl: only https zenrows.com links", () => { + assert.equal(isTrustedClaimUrl("https://app.zenrows.com/claim/t"), true); + assert.equal(isTrustedClaimUrl("https://zenrows.com/claim/t"), true); + assert.equal(isTrustedClaimUrl("http://app.zenrows.com/claim/t"), false); + assert.equal(isTrustedClaimUrl("https://zenrows.com.evil.test/claim/t"), false); + assert.equal(isTrustedClaimUrl("https://evilzenrows.com/claim/t"), false); + assert.equal(isTrustedClaimUrl("javascript:alert(1)"), false); + assert.equal(isTrustedClaimUrl(undefined), false); +}); + +test("usableClaimUrl: a link is usable under 30 days, falling back to createdAt for its age", () => { + assert.equal(usableClaimUrl(acct({ createdAt: daysAgo(29) }), NOW), OLD_LINK); + assert.equal(usableClaimUrl(acct({ createdAt: daysAgo(30) }), NOW), undefined); + assert.equal(usableClaimUrl(acct({ createdAt: daysAgo(60), claimUrlIssuedAt: daysAgo(2) }), NOW), OLD_LINK); + assert.equal(usableClaimUrl(acct({ claimUrl: "https://x.test/claim/t" }), NOW), undefined); +}); + +test("storedClaimHint: link, refresh marker, or nothing", () => { + assert.equal(storedClaimHint(acct(), NOW), OLD_LINK); + assert.equal(storedClaimHint(acct({ createdAt: daysAgo(31) }), NOW), CLAIM_LINK_REFRESH); + assert.equal(storedClaimHint(acct({ unclaimed: false }), NOW), undefined); + assert.equal(storedClaimHint(null, NOW), undefined); +}); + +test("quotaExhausted: the refresh marker tells the user to run `zenrows account claim`", () => { + const err = quotaExhausted("u", CLAIM_LINK_REFRESH, { status: 402 }); + assert.match(err.next_action, /zenrows account claim/); + assert.doesNotMatch(err.next_action, /https:\/\//); +}); + +test("requestClaimLink: POSTs with X-API-Key; 409 already_claimed → claimed", async () => { + const s = stub({ claim: { claimUrl: NEW_LINK } }); + const res = await requestClaimLink("zr-key", { url: CLAIM_ENDPOINT, fetchImpl: s.fetchImpl }); + assert.deepEqual(res, { status: "unclaimed", claimUrl: NEW_LINK, expiresAt: "2026-08-31T00:00:00Z" }); + assert.deepEqual(s.calls, [{ method: "POST", url: CLAIM_ENDPOINT, key: "zr-key" }]); + const s2 = stub({ claim: 409 }); + assert.deepEqual(await requestClaimLink("zr-key", { url: CLAIM_ENDPOINT, fetchImpl: s2.fetchImpl }), { status: "claimed" }); + const s3 = stub({ claim: 429 }); + await assert.rejects(requestClaimLink("zr-key", { url: CLAIM_ENDPOINT, fetchImpl: s3.fetchImpl }), ToolkitError); +}); + +test("refreshClaimState: fresh link (<25 days) is kept, no new link requested", () => + withWorkspace(acct({ claimUrlIssuedAt: daysAgo(24) }), async (root) => { + const s = stub({ account: { claimed: false } }); + const st = await refreshClaimState({ projectRoot: root, force: true, fetchImpl: s.fetchImpl, ...endpoints }); + assert.deepEqual(st, { status: "unclaimed", claimUrl: OLD_LINK, refreshed: false }); + assert.equal(s.calls.filter((c) => c.method === "POST").length, 0); + })); + +test("refreshClaimState: link 25+ days old is replaced and stored (0600) with its issue time", () => + withWorkspace(acct({ claimUrlIssuedAt: daysAgo(25) }), async (root) => { + const s = stub({ account: { claimed: false }, claim: { claimUrl: NEW_LINK } }); + const st = await refreshClaimState({ projectRoot: root, force: true, fetchImpl: s.fetchImpl, ...endpoints }); + assert.deepEqual(st, { status: "unclaimed", claimUrl: NEW_LINK, refreshed: true }); + const saved = readAccount(root)!; + assert.equal(saved.claimUrl, NEW_LINK); + assert.equal(saved.claimUrlIssuedAt, NOW.toISOString()); + if (process.platform !== "win32") assert.equal(statSync(workspacePaths(root).account).mode & 0o777, 0o600); + })); + +test("refreshClaimState: missing link is requested", () => + withWorkspace(acct({ claimUrl: "" }), async (root) => { + const s = stub({ account: { claimed: false }, claim: { claimUrl: NEW_LINK } }); + const st = await refreshClaimState({ projectRoot: root, force: true, fetchImpl: s.fetchImpl, ...endpoints }); + assert.equal(st.status === "unclaimed" && st.claimUrl, NEW_LINK); + })); + +test("refreshClaimState: claimed account is recorded and hints stop", () => + withWorkspace(acct(), async (root) => { + const s = stub({ account: { claimed: true } }); + const st = await refreshClaimState({ projectRoot: root, force: true, fetchImpl: s.fetchImpl, ...endpoints }); + assert.deepEqual(st, { status: "claimed" }); + assert.equal(readAccount(root)?.unclaimed, false); + assert.equal(storedClaimHint(readAccount(root), NOW), undefined); + })); + +test("refreshClaimState: 409 already_claimed from the claim endpoint also marks claimed", () => + withWorkspace(acct({ createdAt: daysAgo(40) }), async (root) => { + const s = stub({ account: "throw", claim: 409 }); + const st = await refreshClaimState({ projectRoot: root, force: true, fetchImpl: s.fetchImpl, ...endpoints }); + assert.deepEqual(st, { status: "claimed" }); + assert.equal(readAccount(root)?.unclaimed, false); + })); + +test("refreshClaimState: a non-zenrows claimUrl from the server is never stored", () => + withWorkspace(acct({ createdAt: daysAgo(26) }), async (root) => { + const s = stub({ account: { claimed: false }, claim: { claimUrl: "https://evil.test/claim/x" } }); + const st = await refreshClaimState({ projectRoot: root, force: false, fetchImpl: s.fetchImpl, ...endpoints }); + assert.deepEqual(st, { status: "unclaimed", claimUrl: OLD_LINK, refreshed: false }); + assert.equal(readAccount(root)?.claimUrl, OLD_LINK); + })); + +test("refreshClaimState: network down, link under 30 days → keep it", () => + withWorkspace(acct({ createdAt: daysAgo(27) }), async (root) => { + const s = stub({ account: "throw", claim: "throw" }); + const st = await refreshClaimState({ projectRoot: root, fetchImpl: s.fetchImpl, ...endpoints }); + assert.deepEqual(st, { status: "unclaimed", claimUrl: OLD_LINK, refreshed: false }); + })); + +test("refreshClaimState: network down, link 30+ days old → no link (error path) / throws (forced)", () => + withWorkspace(acct({ createdAt: daysAgo(31) }), async (root) => { + const s = stub({ account: "throw", claim: "throw" }); + const st = await refreshClaimState({ projectRoot: root, fetchImpl: s.fetchImpl, ...endpoints }); + assert.deepEqual(st, { status: "unclaimed", claimUrl: undefined, refreshed: false }); + await assert.rejects( + refreshClaimState({ projectRoot: root, force: true, fetchImpl: s.fetchImpl, ...endpoints }), + (e: unknown) => e instanceof ToolkitError && e.code === "BACKEND_UNAVAILABLE" && /zenrows account claim/.test(e.next_action), + ); + })); + +test("refreshClaimState: error-path checks are throttled to one per 6 hours; force bypasses", () => + withWorkspace(acct(), async (root) => { + const s = stub({ account: { claimed: false } }); + await refreshClaimState({ projectRoot: root, fetchImpl: s.fetchImpl, ...endpoints }); + assert.equal(s.calls.length, 1); + const later = new Date(NOW.getTime() + 5 * 3_600_000); + await refreshClaimState({ projectRoot: root, fetchImpl: s.fetchImpl, ...endpoints, now: later }); + assert.equal(s.calls.length, 1, "within 6h: no network"); + await refreshClaimState({ projectRoot: root, force: true, fetchImpl: s.fetchImpl, ...endpoints, now: later }); + assert.equal(s.calls.length, 2, "forced: checks"); + const muchLater = new Date(NOW.getTime() + 12 * 3_600_000); + await refreshClaimState({ projectRoot: root, fetchImpl: s.fetchImpl, ...endpoints, now: muchLater }); + assert.equal(s.calls.length, 3, "after 6h: checks again"); + })); + +test("refreshClaimAdvice: rewrites a quota error once the account turns out claimed", () => + withWorkspace(acct(), async (root) => { + const s = stub({ account: { claimed: true } }); + const before = quotaExhausted("u", OLD_LINK, { status: 402 }); + const after = (await refreshClaimAdvice(before, { projectRoot: root, fetchImpl: s.fetchImpl, ...endpoints })) as ToolkitError; + assert.doesNotMatch(after.next_action, /claim/i); + assert.match(after.next_action, /out of Zenrows credits/); + assert.equal(after.code, before.code); + })); + +test("refreshClaimAdvice: swaps in a fresh link; leaves unrelated errors alone", () => + withWorkspace(acct({ createdAt: daysAgo(28) }), async (root) => { + const s = stub({ account: { claimed: false }, claim: { claimUrl: NEW_LINK } }); + const after = (await refreshClaimAdvice(quotaExhausted("u", OLD_LINK), { projectRoot: root, fetchImpl: s.fetchImpl, ...endpoints })) as ToolkitError; + assert.ok(after.next_action.includes(NEW_LINK)); + const other = new ToolkitError({ code: "FETCH_FAILED", message: "m", likely_cause: "c", next_action: "n" }); + assert.equal(await refreshClaimAdvice(other, { projectRoot: root, fetchImpl: s.fetchImpl, ...endpoints }), other); + })); diff --git a/tests/nudge.test.ts b/tests/nudge.test.ts index b50df70..01a74b5 100644 --- a/tests/nudge.test.ts +++ b/tests/nudge.test.ts @@ -9,7 +9,7 @@ import { nudgeMessage, maybeNudgeClaim } from "../src/core/nudge.ts"; import { log } from "../src/core/logger.ts"; import type { AgentAccount } from "../src/types/index.ts"; -const CLAIM_URL = "https://x/claim/tok"; +const CLAIM_URL = "https://app.zenrows.com/claim/tok"; function acct(over: Partial = {}): AgentAccount { return { From 4cae0bdb9ba9dc28acbf15a06378511f423ac1fa Mon Sep 17 00:00:00 2001 From: Ander Rodriguez Date: Sun, 18 Oct 2026 12:34:03 +0200 Subject: [PATCH 3/5] fix(provenance): CODEX_HOME and any CLAUDE_* variable only show an agent is installed, so attribute Claude Code and Codex from the variables they set when running a command --- src/core/provenance.ts | 17 +++++++++++------ tests/provenance.test.ts | 16 +++++++++++++--- 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/src/core/provenance.ts b/src/core/provenance.ts index aa88020..bdb69a7 100644 --- a/src/core/provenance.ts +++ b/src/core/provenance.ts @@ -25,20 +25,25 @@ function hasPrefix(env: Env, prefix: string): boolean { return Object.keys(env).some((k) => k.startsWith(prefix)); } +/** + * Variables the agent itself sets in the shells it runs commands in, so they + * mean the agent is driving this command (the same signals as agent-client.ts). + * Variables that only show the agent is installed or configured (CODEX_HOME, + * ANTHROPIC_API_KEY, OPENAI_API_KEY, other CLAUDE_* settings) are not used. + */ +const CLAUDE_VARS = ["CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT", "CLAUDE_CODE_CHILD_SESSION"] as const; +const CODEX_VARS = ["CODEX_SANDBOX", "CODEX_SANDBOX_NETWORK_DISABLED", "CODEX_THREAD_ID"] as const; + /** Detect the host agent brand + runtime, best-effort, from env vars. */ export function detectClient(env: Env = process.env): Provenance { const term = env.TERM_PROGRAM; let client = "unknown"; - if (env.CLAUDECODE || hasPrefix(env, "CLAUDE_CODE") || hasPrefix(env, "CLAUDE_")) { + if (CLAUDE_VARS.some((v) => env[v])) { client = "claude-code"; } else if (env.CURSOR_TRACE_ID || hasPrefix(env, "CURSOR_") || term === "cursor") { client = "cursor"; - } else if (hasPrefix(env, "CODEX_")) { - // Codex CLI sets CODEX_SANDBOX / CODEX_SANDBOX_NETWORK_DISABLED / CODEX_THREAD_ID - // in the shells it runs commands in, and CODEX_HOME / CODEX_MANAGED_BY_NPM come - // from its install. OPENAI_* is not a signal: OPENAI_API_KEY is in many - // shells that have nothing to do with Codex. + } else if (CODEX_VARS.some((v) => env[v])) { client = "openai-codex"; } else if (hasPrefix(env, "WINDSURF")) { client = "windsurf"; diff --git a/tests/provenance.test.ts b/tests/provenance.test.ts index 05b37e4..502d1d6 100644 --- a/tests/provenance.test.ts +++ b/tests/provenance.test.ts @@ -6,8 +6,15 @@ test("detectClient: claude-code from CLAUDECODE", () => { assert.equal(detectClient({ CLAUDECODE: "1" }).client, "claude-code"); }); -test("detectClient: claude-code from a CLAUDE_ prefixed var", () => { - assert.equal(detectClient({ CLAUDE_SESSION: "x" }).client, "claude-code"); +test("detectClient: claude-code from Claude Code's own variables", () => { + assert.equal(detectClient({ CLAUDE_CODE_ENTRYPOINT: "cli" }).client, "claude-code"); + assert.equal(detectClient({ CLAUDE_CODE_CHILD_SESSION: "1" }).client, "claude-code"); +}); + +test("detectClient: unrelated CLAUDE_*/ANTHROPIC_* variables are unknown", () => { + assert.equal(detectClient({ CLAUDE_SESSION: "x" }).client, "unknown"); + assert.equal(detectClient({ CLAUDE_CONFIG_DIR: "x" }).client, "unknown"); + assert.equal(detectClient({ ANTHROPIC_API_KEY: "x" }).client, "unknown"); }); test("detectClient: cursor from CURSOR_TRACE_ID", () => { @@ -19,12 +26,15 @@ test("detectClient: cursor from TERM_PROGRAM", () => { }); test("detectClient: openai-codex from Codex's own variables", () => { - assert.equal(detectClient({ CODEX_HOME: "x" }).client, "openai-codex"); assert.equal(detectClient({ CODEX_SANDBOX: "seatbelt" }).client, "openai-codex"); assert.equal(detectClient({ CODEX_SANDBOX_NETWORK_DISABLED: "1" }).client, "openai-codex"); assert.equal(detectClient({ CODEX_THREAD_ID: "t" }).client, "openai-codex"); }); +test("detectClient: CODEX_HOME alone (Codex installed, not running) is unknown", () => { + assert.equal(detectClient({ CODEX_HOME: "x" }).client, "unknown"); +}); + test("detectClient: OPENAI_API_KEY alone is unknown, not openai-codex", () => { assert.equal(detectClient({ OPENAI_API_KEY: "x" }).client, "unknown"); assert.equal(detectClient({ OPENAI_API_KEY: "x", OPENAI_BASE_URL: "y", OPENAI_ORG_ID: "z" }).client, "unknown"); From d944f9521aee5523785b0be2fbeb01af7a318fb3 Mon Sep 17 00:00:00 2001 From: Ander Rodriguez Date: Sun, 18 Oct 2026 12:57:36 +0200 Subject: [PATCH 4/5] fix(account): parallel commands could burn the claim-link rate limit and an untrusted discovery document could redirect the API key Only one process refreshes the claim link at a time (an exclusive lock file next to account.json), the 6-hour slot is taken before any network call, and account.json is re-read before minting so a link another process just stored is reused. A discovered signup endpoint is used only when it is https on zenrows.com, and the account/claim URLs fall back to the built-in endpoint when the signup URL does not end in /signup. A 409 not_agent_account is final like already_claimed, a non-JSON 200 is reported as such, claim URLs with userinfo are rejected, and secrets.json/account.json are written 0600 from creation (temp file + rename) in a 0700 directory. --- src/core/agent-account.ts | 67 +++++++++++++++------ src/core/auth.ts | 6 +- src/core/claim-link.ts | 117 +++++++++++++++++++++++++++--------- src/core/workspace.ts | 28 ++++++++- tests/agent-account.test.ts | 43 +++++++++++-- tests/claim-link.test.ts | 61 ++++++++++++++++++- tests/workspace.test.ts | 22 ++++++- 7 files changed, 285 insertions(+), 59 deletions(-) diff --git a/src/core/agent-account.ts b/src/core/agent-account.ts index fdeccd0..0cafa34 100644 --- a/src/core/agent-account.ts +++ b/src/core/agent-account.ts @@ -5,14 +5,14 @@ * It never holds the API key (that lives in secrets.json, 0600) — only the * accountId, the human-facing claim URL, and claim metadata. */ -import { chmodSync, existsSync, unlinkSync } from "node:fs"; +import { existsSync, unlinkSync } from "node:fs"; import type { AgentAccount } from "../types/index.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"; -import { findWorkspace, readJson, workspacePaths, writeJson } from "./workspace.ts"; +import { findWorkspace, readJson, workspacePaths, writePrivateJson } from "./workspace.ts"; /** Env var to override the signup endpoint (highest priority; for local testing). */ export const SIGNUP_URL_ENV = "ZENROWS_AGENT_SIGNUP_URL"; @@ -57,7 +57,9 @@ export async function discoverSignupUrl( if (!res.ok) return null; const json = (await res.json()) as { agent_auth?: { signup_endpoint?: unknown } }; const endpoint = json?.agent_auth?.signup_endpoint; - if (typeof endpoint === "string" && endpoint.trim()) return endpoint.trim(); + // The key is later sent to URLs derived from this one, so only a Zenrows + // https endpoint is accepted from a document fetched over the network. + if (typeof endpoint === "string" && isZenrowsHttpsUrl(endpoint.trim())) return endpoint.trim(); return null; } catch { return null; @@ -101,7 +103,7 @@ export async function resolveSignupUrl( return (await signupCandidates(projectRoot, opts))[0]!; } -function accountPath(projectRoot?: string): string { +export function accountPath(projectRoot?: string): string { const ws = projectRoot ? workspacePaths(projectRoot) : (findWorkspace() ?? workspacePaths()); return ws.account; } @@ -112,13 +114,8 @@ export function readAccount(projectRoot?: string): AgentAccount | null { export function writeAccount(acct: AgentAccount, projectRoot?: string): void { const file = accountPath(projectRoot); - writeJson(file, acct); - try { - // account.json holds the single-use claim token — restrict to owner-only. - chmodSync(file, 0o600); - } catch { - // best-effort on platforms without POSIX permissions - } + // account.json holds the single-use claim token: owner-only from creation. + writePrivateJson(file, acct); } /** Remove local agent account metadata (used by `zenrows logout`). */ @@ -238,7 +235,35 @@ export async function resolveAccountUrl( projectRoot?: string, opts: { fetchImpl?: typeof fetch } = {}, ): Promise { - return (await resolveSignupUrl(projectRoot, opts)).replace(/\/signup\/?$/, "/account"); + return siblingOfSignup(await resolveSignupUrl(projectRoot, opts), "account"); +} + +/** + * `…/agent/signup` → `…/agent/`. A signup URL that does not end in + * `/signup` gives no safe sibling, so the built-in endpoint's sibling is used + * rather than sending the key somewhere unexpected. + */ +export function siblingOfSignup(signupUrl: string, name: "account" | "claim"): string { + const re = /\/signup\/?$/; + const base = re.test(signupUrl) ? signupUrl : AGENT_SIGNUP_API_URL; + return base.replace(re, `/${name}`); +} + +/** True for an https URL on zenrows.com or a subdomain, with no userinfo. */ +export function isZenrowsHttpsUrl(value: unknown): value is string { + if (typeof value !== "string") return false; + try { + const u = new URL(value); + const host = u.hostname.toLowerCase(); + return ( + u.protocol === "https:" && + u.username === "" && + u.password === "" && + (host === "zenrows.com" || host.endsWith(".zenrows.com")) + ); + } catch { + return false; + } } /** The fresh-claim-link endpoint, derived from the signup URL like the status one. */ @@ -246,12 +271,14 @@ export async function resolveClaimUrl( projectRoot?: string, opts: { fetchImpl?: typeof fetch } = {}, ): Promise { - return (await resolveSignupUrl(projectRoot, opts)).replace(/\/signup\/?$/, "/claim"); + return siblingOfSignup(await resolveSignupUrl(projectRoot, opts), "claim"); } export type ClaimLinkResponse = | { status: "unclaimed"; claimUrl: string; expiresAt?: string } - | { status: "claimed" }; + | { status: "claimed" } + /** 409 not_agent_account: never an agent signup, so it already has an owner. Final. */ + | { status: "not_agent" }; /** * Mint a fresh claim link for the account behind `apiKey`. Each call @@ -272,11 +299,16 @@ export async function requestClaimLink( }); const body = await res.text(); if (res.status === 200) { - const json = JSON.parse(body) as { claimUrl?: unknown; expiresAt?: unknown }; - if (typeof json.claimUrl !== "string") { + let json: { claimUrl?: unknown; expiresAt?: unknown } | null = null; + try { + json = JSON.parse(body) as { claimUrl?: unknown; expiresAt?: unknown }; + } catch { + // not JSON: reported below + } + if (!json || typeof json.claimUrl !== "string") { throw new ToolkitError({ code: "FETCH_FAILED", - message: "The claim-link response had no claimUrl.", + message: "The claim-link endpoint answered HTTP 200 without a claim link (unexpected response body).", likely_cause: body.slice(0, 240), next_action: "Retry later.", }); @@ -288,6 +320,7 @@ export async function requestClaimLink( }; } if (res.status === 409 && /already_claimed/.test(body)) return { status: "claimed" }; + if (res.status === 409 && /not_agent_account/.test(body)) return { status: "not_agent" }; throw new ToolkitError({ code: res.status === 401 || res.status === 403 ? "AUTH_INVALID" : "FETCH_FAILED", message: `Claim link request failed (HTTP ${res.status}).`, diff --git a/src/core/auth.ts b/src/core/auth.ts index a9fa240..612aa71 100644 --- a/src/core/auth.ts +++ b/src/core/auth.ts @@ -9,7 +9,7 @@ import type { AuthState, Secrets } from "../types/index.ts"; import { ToolkitError } from "./errors.ts"; import { registerSecret } from "./logger.ts"; import { mask } from "./redact.ts"; -import { findWorkspace, readJson, workspacePaths, writeJson } from "./workspace.ts"; +import { findWorkspace, readJson, workspacePaths, writePrivateJson } from "./workspace.ts"; export const ENV_KEY = "ZENROWS_API_KEY"; @@ -55,7 +55,7 @@ export function saveApiKey(apiKey: string, projectRoot?: string): void { }); } const file = secretsPath(projectRoot); - writeJson(file, { apiKey: trimmed } satisfies Secrets); + writePrivateJson(file, { apiKey: trimmed } satisfies Secrets); try { chmodSync(file, 0o600); } catch { @@ -67,7 +67,7 @@ export function saveApiKey(apiKey: string, projectRoot?: string): void { export function clearApiKey(projectRoot?: string): boolean { const file = secretsPath(projectRoot); if (!existsSync(file)) return false; - writeJson(file, {} satisfies Secrets); + writePrivateJson(file, {} satisfies Secrets); return true; } diff --git a/src/core/claim-link.ts b/src/core/claim-link.ts index 0995ad5..7d22e31 100644 --- a/src/core/claim-link.ts +++ b/src/core/claim-link.ts @@ -8,7 +8,8 @@ * `zenrows account claim`", or nothing because the account is already claimed. */ import type { AgentAccount } from "../types/index.ts"; -import { fetchAccountStatus, readAccount, requestClaimLink, writeAccount } from "./agent-account.ts"; +import { statSync, unlinkSync, writeFileSync } from "node:fs"; +import { accountPath, fetchAccountStatus, isZenrowsHttpsUrl, readAccount, requestClaimLink, writeAccount } from "./agent-account.ts"; import { resolveApiKey } from "./auth.ts"; import { CLAIM_LINK_REFRESH, CLAIM_NEXT_ACTION_PREFIX, ToolkitError, quotaNextAction, type ClaimHint } from "./errors.ts"; @@ -22,16 +23,9 @@ export const CLAIM_CHECK_THROTTLE_MS = 6 * 60 * 60 * 1000; /** Each claim request gives up after this long, so an error is never held up for long. */ const CLAIM_REQUEST_TIMEOUT_MS = 5000; -/** Only an https link on zenrows.com (or a subdomain) is shown or opened. */ +/** Only an https link on zenrows.com (or a subdomain), with no userinfo, is shown or opened. */ export function isTrustedClaimUrl(value: unknown): value is string { - if (typeof value !== "string") return false; - try { - const u = new URL(value); - const host = u.hostname.toLowerCase(); - return u.protocol === "https:" && (host === "zenrows.com" || host.endsWith(".zenrows.com")); - } catch { - return false; - } + return isZenrowsHttpsUrl(value); } /** Age of the held link in ms, or Infinity when its issue time is unknown. */ @@ -81,28 +75,42 @@ export interface RefreshOptions { export async function refreshClaimState(opts: RefreshOptions = {}): Promise { const { projectRoot } = opts; const now = opts.now ?? new Date(); - let acct = readAccount(projectRoot); - if (!acct) return { status: "none" }; - if (!acct.unclaimed) return { status: "claimed" }; + const first = readAccount(projectRoot); + if (!first) return { status: "none" }; + if (!first.unclaimed) return { status: "claimed" }; + if (!opts.force && throttled(first, now)) return offlineState(first, now); - const offline = (): ClaimState => ({ status: "unclaimed", claimUrl: usableClaimUrl(acct!, now), refreshed: false }); + const key = opts.apiKey ?? resolveApiKey(projectRoot).key; + if (!key) return offlineState(first, now); - if (!opts.force && acct.lastClaimCheckAt) { - const last = new Date(acct.lastClaimCheckAt).getTime(); - if (!Number.isNaN(last) && now.getTime() - last < CLAIM_CHECK_THROTTLE_MS) return offline(); + // Several `zenrows` processes can hit a 402 at once. Only the one holding the + // lock talks to the claim endpoints; the others use what is stored. + const release = acquireClaimLock(projectRoot); + if (!release) return offlineState(readAccount(projectRoot) ?? first, now); + try { + return await refreshLocked(key, opts, now); + } finally { + release(); } +} - const key = opts.apiKey ?? resolveApiKey(projectRoot).key; - if (!key) return offline(); +async function refreshLocked(key: string, opts: RefreshOptions, now: Date): Promise { + const { projectRoot } = opts; + // Re-read under the lock: another process may have just finished a refresh. + let acct = readAccount(projectRoot); + if (!acct) return { status: "none" }; + if (!acct.unclaimed) return { status: "claimed" }; + if (!opts.force && throttled(acct, now)) return offlineState(acct, now); + + // Take the 6-hour slot before any network call, so a failing backend is not + // retried by every command that follows. + acct = { ...acct, lastClaimCheckAt: now.toISOString() }; + writeAccount(acct, projectRoot); const base = opts.fetchImpl ?? fetch; const fetchImpl = ((url: string, init?: RequestInit) => base(url, { ...init, signal: AbortSignal.timeout(CLAIM_REQUEST_TIMEOUT_MS) })) as typeof fetch; - // Record the attempt first, so a run of failing calls does not retry each time. - acct = { ...acct, lastClaimCheckAt: now.toISOString() }; - writeAccount(acct, projectRoot); - try { const remote = await fetchAccountStatus(key, { url: opts.accountUrl, fetchImpl }); if (remote.claimed === true) return markClaimed(acct, projectRoot); @@ -110,8 +118,10 @@ export async function refreshClaimState(opts: RefreshOptions = {}): Promise= CLAIM_LINK_REFRESH_AGE_MS; - if (!needsNew) return offline(); + // Re-read before minting: minting replaces the link everyone else holds. + acct = readAccount(projectRoot) ?? acct; + if (!acct.unclaimed) return { status: "claimed" }; + if (!needsNewLink(acct, now)) return offlineState(acct, now); let fresh; try { @@ -127,9 +137,10 @@ export async function refreshClaimState(opts: RefreshOptions = {}): Promise= CLAIM_LINK_REFRESH_AGE_MS; +} + +function offlineState(acct: AgentAccount, now: Date): ClaimState { + if (!acct.unclaimed) return { status: "claimed" }; + return { status: "unclaimed", claimUrl: usableClaimUrl(acct, now), refreshed: false }; +} + +/** A lock older than this is from a process that died mid-refresh. */ +const CLAIM_LOCK_STALE_MS = 60 * 1000; + +/** + * Create `account.json.lock` exclusively. Returns a release function, or null + * when another live process holds the lock. + */ +function acquireClaimLock(projectRoot: string | undefined): (() => void) | null { + const lock = `${accountPath(projectRoot)}.lock`; + for (let attempt = 0; attempt < 2; attempt++) { + try { + writeFileSync(lock, String(process.pid), { flag: "wx", mode: 0o600 }); + return () => { + try { + unlinkSync(lock); + } catch { + } + }; + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== "EEXIST") return null; + try { + if (Date.now() - statSync(lock).mtimeMs < CLAIM_LOCK_STALE_MS) return null; + unlinkSync(lock); + } catch { + return null; + } + } + } + return null; +} + function markClaimed(acct: AgentAccount, projectRoot?: string): ClaimState { writeAccount({ ...acct, unclaimed: false }, projectRoot); return { status: "claimed" }; diff --git a/src/core/workspace.ts b/src/core/workspace.ts index 5d33af0..41e186d 100644 --- a/src/core/workspace.ts +++ b/src/core/workspace.ts @@ -5,7 +5,7 @@ * assets, traces, and logs. Secrets live in `.zenrows/secrets.json`, which is * gitignored and never written into run artifacts. */ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; export const WORKSPACE_DIRNAME = ".zenrows"; @@ -110,3 +110,29 @@ export function writeJson(file: string, value: unknown): void { mkdirSync(dirname(file), { recursive: true }); writeFileSync(file, JSON.stringify(value, null, 2) + "\n"); } + +/** + * Write a file only its owner may read (secrets, account metadata). The data + * goes to a temp file created 0600 and is renamed into place, so the target is + * never readable by others, not even for a moment, and a reader never sees a + * half-written file. A directory this creates is 0700. + */ +export function writePrivateJson(file: string, value: unknown): void { + mkdirSync(dirname(file), { recursive: true, mode: 0o700 }); + const tmp = `${file}.${process.pid}.${Date.now()}.tmp`; + try { + writeFileSync(tmp, JSON.stringify(value, null, 2) + "\n", { mode: 0o600, flag: "wx" }); + try { + chmodSync(tmp, 0o600); // the umask can only remove bits, but be explicit + } catch { + // best-effort on platforms without POSIX permissions + } + renameSync(tmp, file); + } catch (err) { + try { + unlinkSync(tmp); + } catch { + } + throw err; + } +} diff --git a/tests/agent-account.test.ts b/tests/agent-account.test.ts index f333266..2ff1807 100644 --- a/tests/agent-account.test.ts +++ b/tests/agent-account.test.ts @@ -2,7 +2,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { tempRoot } from "./helpers.ts"; import { createWorkspace } from "../src/core/workspace.ts"; -import { readAccount, writeAccount, clearAccount, signupAgent, resolveSignupUrl, SIGNUP_URL_ENV, DISCOVERY_URL_ENV, resolveAccountUrl, fetchAccountStatus, _resetDiscoveryCache } from "../src/core/agent-account.ts"; +import { discoverSignupUrl, siblingOfSignup, requestClaimLink, readAccount, writeAccount, clearAccount, signupAgent, resolveSignupUrl, SIGNUP_URL_ENV, DISCOVERY_URL_ENV, resolveAccountUrl, fetchAccountStatus, _resetDiscoveryCache } from "../src/core/agent-account.ts"; import { saveConfig } from "../src/core/config.ts"; import { AGENT_SIGNUP_API_URL } from "../src/core/open-url.ts"; import { ToolkitError } from "../src/core/errors.ts"; @@ -66,7 +66,7 @@ test("signupAgent falls back to the built-in default when a discovered endpoint process.env.ZENROWS_TELEMETRY = "off"; // skip provenance/telemetry-id side effects _resetDiscoveryCache(); - const BLOCKED = "https://www.blocked.example/api/agent/signup"; + const BLOCKED = "https://blocked.zenrows.com/api/agent/signup"; const calls: Array<{ method: string; url: string }> = []; const fakeFetch = (async (url: string, init?: RequestInit) => { const method = init?.method ?? "GET"; @@ -161,16 +161,16 @@ test("resolveSignupUrl: discovers via .well-known when no env/config", async () const fakeFetch = (async (url: string) => { calledUrl = url; return new Response( - JSON.stringify({ agent_auth: { signup_endpoint: "https://disco.example/api/agent/signup" } }), + JSON.stringify({ agent_auth: { signup_endpoint: "https://disco.zenrows.com/api/agent/signup" } }), { status: 200, headers: { "content-type": "application/json" } }, ); }) as unknown as typeof fetch; try { createWorkspace(root); - assert.equal(await resolveSignupUrl(root, { fetchImpl: fakeFetch }), "https://disco.example/api/agent/signup"); + assert.equal(await resolveSignupUrl(root, { fetchImpl: fakeFetch }), "https://disco.zenrows.com/api/agent/signup"); assert.ok(calledUrl?.endsWith("/.well-known/oauth-protected-resource")); // resolveAccountUrl derives /account from the discovered (cached) signup URL. - assert.equal(await resolveAccountUrl(root), "https://disco.example/api/agent/account"); + assert.equal(await resolveAccountUrl(root), "https://disco.zenrows.com/api/agent/account"); } finally { if (prevSignup !== undefined) process.env[SIGNUP_URL_ENV] = prevSignup; else delete process.env[SIGNUP_URL_ENV]; @@ -217,3 +217,36 @@ test("signupAgent throws SIGNUP_RATE_LIMITED on 429", async () => { (e: unknown) => e instanceof ToolkitError && e.code === "SIGNUP_RATE_LIMITED", ); }); + +test("discoverSignupUrl: a non-Zenrows or non-https endpoint is ignored, so the key never goes there", async () => { + for (const endpoint of [ + "https://evil.example/api/agent/signup", + "http://app.zenrows.com/api/agent/signup", + "https://zenrows.com.evil.example/api/agent/signup", + "https://user:pw@app.zenrows.com/api/agent/signup", + ]) { + const fakeFetch = (async () => + new Response(JSON.stringify({ agent_auth: { signup_endpoint: endpoint } }), { status: 200 })) as unknown as typeof fetch; + assert.equal(await discoverSignupUrl(undefined, { fetchImpl: fakeFetch }), null, endpoint); + } + const ok = (async () => + new Response(JSON.stringify({ agent_auth: { signup_endpoint: "https://api.zenrows.com/api/agent/signup" } }), { status: 200 })) as unknown as typeof fetch; + assert.equal(await discoverSignupUrl(undefined, { fetchImpl: ok }), "https://api.zenrows.com/api/agent/signup"); +}); + +test("siblingOfSignup: derives /account and /claim, else falls back to the built-in endpoint", () => { + assert.equal(siblingOfSignup("http://127.0.0.1:9/api/agent/signup", "claim"), "http://127.0.0.1:9/api/agent/claim"); + assert.equal(siblingOfSignup("https://x.zenrows.com/api/agent/signup/", "account"), "https://x.zenrows.com/api/agent/account"); + assert.equal(siblingOfSignup("https://x.zenrows.com/api/agent/register", "claim"), AGENT_SIGNUP_API_URL.replace(/\/signup$/, "/claim")); +}); + +test("requestClaimLink: a non-JSON 200 is reported as an unexpected response, not a network failure", async () => { + const fakeFetch = (async () => new Response("oops", { status: 200 })) as unknown as typeof fetch; + await assert.rejects(requestClaimLink("k", { url: "https://x.zenrows.com/api/agent/claim", fetchImpl: fakeFetch }), (e: unknown) => + e instanceof ToolkitError && e.code === "FETCH_FAILED" && /without a claim link/.test(e.message)); +}); + +test("requestClaimLink: 409 not_agent_account is final", async () => { + const fakeFetch = (async () => new Response(JSON.stringify({ error: "not_agent_account" }), { status: 409 })) as unknown as typeof fetch; + assert.deepEqual(await requestClaimLink("k", { url: "https://x.zenrows.com/api/agent/claim", fetchImpl: fakeFetch }), { status: "not_agent" }); +}); diff --git a/tests/claim-link.test.ts b/tests/claim-link.test.ts index 1f6ea59..5fe167a 100644 --- a/tests/claim-link.test.ts +++ b/tests/claim-link.test.ts @@ -1,6 +1,6 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { statSync } from "node:fs"; +import { existsSync, statSync, utimesSync, writeFileSync } from "node:fs"; import { tempRoot } from "./helpers.ts"; import { createWorkspace, workspacePaths } from "../src/core/workspace.ts"; import { saveApiKey } from "../src/core/auth.ts"; @@ -205,3 +205,62 @@ test("refreshClaimAdvice: swaps in a fresh link; leaves unrelated errors alone", const other = new ToolkitError({ code: "FETCH_FAILED", message: "m", likely_cause: "c", next_action: "n" }); assert.equal(await refreshClaimAdvice(other, { projectRoot: root, fetchImpl: s.fetchImpl, ...endpoints }), other); })); + +test("isTrustedClaimUrl: a URL with userinfo is rejected", () => { + assert.equal(isTrustedClaimUrl("https://user:pw@app.zenrows.com/claim/t"), false); + assert.equal(isTrustedClaimUrl("https://user@app.zenrows.com/claim/t"), false); +}); + +test("refreshClaimState: 409 not_agent_account is final (marked claimed, no retry)", () => + withWorkspace(acct({ createdAt: daysAgo(40) }), async (root) => { + const s = stub({ account: "throw", claim: 409 }); + // the stub's 409 says already_claimed; swap in not_agent_account + const fetchImpl = (async (url: string, init?: RequestInit) => + url === CLAIM_ENDPOINT + ? new Response(JSON.stringify({ error: "not_agent_account" }), { status: 409 }) + : s.fetchImpl(url, init)) as unknown as typeof fetch; + assert.deepEqual(await refreshClaimState({ projectRoot: root, force: true, fetchImpl, ...endpoints }), { status: "claimed" }); + assert.equal(readAccount(root)?.unclaimed, false); + })); + +test("refreshClaimState: parallel refreshes mint one link, not one each", () => + withWorkspace(acct({ createdAt: daysAgo(40) }), async (root) => { + const s = stub({ account: { claimed: false }, claim: { claimUrl: NEW_LINK } }); + const slow = (async (url: string, init?: RequestInit) => { + await new Promise((r) => setTimeout(r, 30)); + return s.fetchImpl(url, init); + }) as unknown as typeof fetch; + const results = await Promise.all( + [0, 1, 2, 3].map(() => refreshClaimState({ projectRoot: root, fetchImpl: slow, ...endpoints })), + ); + assert.equal(s.calls.filter((c) => c.method === "POST").length, 1); + assert.equal(results.filter((r) => r.status === "unclaimed" && r.refreshed).length, 1); + assert.equal(readAccount(root)?.claimUrl, NEW_LINK); + assert.equal(existsSync(workspacePaths(root).account + ".lock"), false, "lock released"); + })); + +test("refreshClaimState: skips the POST when another process stored a fresh link meanwhile", () => + withWorkspace(acct({ createdAt: daysAgo(40) }), async (root) => { + const s = stub({ account: { claimed: false }, claim: { claimUrl: NEW_LINK } }); + const racing = (async (url: string, init?: RequestInit) => { + // While our status check is in flight, another process writes a fresh link. + if (url === ACCOUNT_URL) writeAccount(acct({ createdAt: daysAgo(40), claimUrl: "https://app.zenrows.com/claim/other", claimUrlIssuedAt: daysAgo(0) }), root); + return s.fetchImpl(url, init); + }) as unknown as typeof fetch; + const st = await refreshClaimState({ projectRoot: root, force: true, fetchImpl: racing, ...endpoints }); + assert.deepEqual(st, { status: "unclaimed", claimUrl: "https://app.zenrows.com/claim/other", refreshed: false }); + assert.equal(s.calls.filter((c) => c.method === "POST").length, 0); + })); + +test("refreshClaimState: a live lock held elsewhere means no network; a stale one is taken over", () => + withWorkspace(acct({ createdAt: daysAgo(40) }), async (root) => { + const lock = workspacePaths(root).account + ".lock"; + writeFileSync(lock, "99999"); + const s = stub({ account: { claimed: false }, claim: { claimUrl: NEW_LINK } }); + await refreshClaimState({ projectRoot: root, fetchImpl: s.fetchImpl, ...endpoints }); + assert.equal(s.calls.length, 0); + const old = new Date(Date.now() - 5 * 60_000); + utimesSync(lock, old, old); + await refreshClaimState({ projectRoot: root, fetchImpl: s.fetchImpl, ...endpoints }); + assert.equal(s.calls.filter((c) => c.method === "POST").length, 1); + })); diff --git a/tests/workspace.test.ts b/tests/workspace.test.ts index b8a008c..d8faadd 100644 --- a/tests/workspace.test.ts +++ b/tests/workspace.test.ts @@ -1,8 +1,8 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { existsSync, readFileSync } from "node:fs"; +import { chmodSync, existsSync, readdirSync, readFileSync, statSync } from "node:fs"; import { join } from "node:path"; -import { createWorkspace, WORKSPACE_SUBDIRS, findWorkspace } from "../src/core/workspace.ts"; +import { createWorkspace, WORKSPACE_SUBDIRS, findWorkspace, writePrivateJson } from "../src/core/workspace.ts"; import { tempRoot } from "./helpers.ts"; test("createWorkspace builds the full .zenrows tree", () => { @@ -43,3 +43,21 @@ test("findWorkspace walks upward from a nested dir", () => { cleanup(); } }); + +test("writePrivateJson: file is 0600 from creation, a new dir is 0700, an existing 0644 file ends 0600", { skip: process.platform === "win32" }, () => { + const { root, cleanup } = tempRoot(); + try { + const dir = join(root, "private"); + const file = join(dir, "secrets.json"); + writePrivateJson(file, { a: 1 }); + assert.equal(statSync(file).mode & 0o777, 0o600); + assert.equal(statSync(dir).mode & 0o777, 0o700); + assert.deepEqual(readdirSync(dir), ["secrets.json"], "no temp file left behind"); + chmodSync(file, 0o644); + writePrivateJson(file, { a: 2 }); + assert.equal(statSync(file).mode & 0o777, 0o600); + assert.deepEqual(JSON.parse(readFileSync(file, "utf8")), { a: 2 }); + } finally { + cleanup(); + } +}); From f18abfef8d9cff70b18dc0d32202a836677ef0bd Mon Sep 17 00:00:00 2001 From: Ander Rodriguez Date: Sun, 18 Oct 2026 13:01:51 +0200 Subject: [PATCH 5/5] fix(account): a signup URL from the environment or a cloned project's config could receive the API key on the claim and status checks The account and claim endpoints are derived from the signup URL only when it is https on zenrows.com or http(s) on loopback, with no userinfo; anything else uses the built-in endpoint. Both requests now resolve against the caller's project and run URL discovery through the same 5-second fetch. The claim lock holds a random token: release deletes it only while the token is still ours, and a stale lock is renamed aside before takeover and put back if it turns out to be live. saveApiKey no longer chmods a file that is already written 0600. --- src/core/agent-account.ts | 34 +++++++++++++++++++++---- src/core/auth.ts | 7 +---- src/core/claim-link.ts | 50 ++++++++++++++++++++++++++---------- tests/agent-account.test.ts | 51 +++++++++++++++++++++++++++++++++++-- tests/claim-link.test.ts | 16 +++++++++++- 5 files changed, 131 insertions(+), 27 deletions(-) diff --git a/src/core/agent-account.ts b/src/core/agent-account.ts index 0cafa34..96c11b4 100644 --- a/src/core/agent-account.ts +++ b/src/core/agent-account.ts @@ -245,10 +245,34 @@ export async function resolveAccountUrl( */ export function siblingOfSignup(signupUrl: string, name: "account" | "claim"): string { const re = /\/signup\/?$/; - const base = re.test(signupUrl) ? signupUrl : AGENT_SIGNUP_API_URL; + // These two endpoints receive the API key. A signup URL from the environment + // or a project's config (which a cloned repository could ship) is only + // trusted when it is Zenrows or this machine; anything else gets the + // built-in endpoint. + const base = re.test(signupUrl) && isAgentKeyDestination(signupUrl) ? signupUrl : AGENT_SIGNUP_API_URL; return base.replace(re, `/${name}`); } +/** + * Where the CLI may send the API key for the agent account endpoints: a + * Zenrows https URL, or http(s) on loopback (local testing). No userinfo. + */ +export function isAgentKeyDestination(value: string): boolean { + if (isZenrowsHttpsUrl(value)) return true; + try { + const u = new URL(value); + const host = u.hostname.toLowerCase(); + return ( + (u.protocol === "https:" || u.protocol === "http:") && + u.username === "" && + u.password === "" && + (host === "127.0.0.1" || host === "localhost" || host === "[::1]") + ); + } catch { + return false; + } +} + /** True for an https URL on zenrows.com or a subdomain, with no userinfo. */ export function isZenrowsHttpsUrl(value: unknown): value is string { if (typeof value !== "string") return false; @@ -289,9 +313,9 @@ export type ClaimLinkResponse = */ export async function requestClaimLink( apiKey: string, - opts: { url?: string; fetchImpl?: typeof fetch } = {}, + opts: { url?: string; fetchImpl?: typeof fetch; projectRoot?: string } = {}, ): Promise { - const url = opts.url ?? (await resolveClaimUrl()); + const url = opts.url ?? (await resolveClaimUrl(opts.projectRoot, { fetchImpl: opts.fetchImpl })); const doFetch = opts.fetchImpl ?? fetch; const res = await doFetch(url, { method: "POST", @@ -336,9 +360,9 @@ export async function requestClaimLink( */ export async function fetchAccountStatus( apiKey: string, - opts: { url?: string; fetchImpl?: typeof fetch } = {}, + opts: { url?: string; fetchImpl?: typeof fetch; projectRoot?: string } = {}, ): Promise { - const url = opts.url ?? (await resolveAccountUrl()); + const url = opts.url ?? (await resolveAccountUrl(opts.projectRoot, { fetchImpl: opts.fetchImpl })); const doFetch = opts.fetchImpl ?? fetch; const res = await doFetch(url, { method: "GET", diff --git a/src/core/auth.ts b/src/core/auth.ts index 612aa71..1224135 100644 --- a/src/core/auth.ts +++ b/src/core/auth.ts @@ -4,7 +4,7 @@ * Resolution order: explicit secrets file → ZENROWS_API_KEY env var. * The key is never printed in full and never written into run artifacts. */ -import { chmodSync, existsSync } from "node:fs"; +import { existsSync } from "node:fs"; import type { AuthState, Secrets } from "../types/index.ts"; import { ToolkitError } from "./errors.ts"; import { registerSecret } from "./logger.ts"; @@ -56,11 +56,6 @@ export function saveApiKey(apiKey: string, projectRoot?: string): void { } const file = secretsPath(projectRoot); writePrivateJson(file, { apiKey: trimmed } satisfies Secrets); - try { - chmodSync(file, 0o600); - } catch { - // best-effort on platforms without POSIX permissions - } registerSecret(trimmed); } diff --git a/src/core/claim-link.ts b/src/core/claim-link.ts index 7d22e31..64e05fb 100644 --- a/src/core/claim-link.ts +++ b/src/core/claim-link.ts @@ -8,7 +8,8 @@ * `zenrows account claim`", or nothing because the account is already claimed. */ import type { AgentAccount } from "../types/index.ts"; -import { statSync, unlinkSync, writeFileSync } from "node:fs"; +import { randomBytes } from "node:crypto"; +import { linkSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs"; import { accountPath, fetchAccountStatus, isZenrowsHttpsUrl, readAccount, requestClaimLink, writeAccount } from "./agent-account.ts"; import { resolveApiKey } from "./auth.ts"; import { CLAIM_LINK_REFRESH, CLAIM_NEXT_ACTION_PREFIX, ToolkitError, quotaNextAction, type ClaimHint } from "./errors.ts"; @@ -112,7 +113,7 @@ async function refreshLocked(key: string, opts: RefreshOptions, now: Date): Prom base(url, { ...init, signal: AbortSignal.timeout(CLAIM_REQUEST_TIMEOUT_MS) })) as typeof fetch; try { - const remote = await fetchAccountStatus(key, { url: opts.accountUrl, fetchImpl }); + const remote = await fetchAccountStatus(key, { url: opts.accountUrl, fetchImpl, projectRoot }); if (remote.claimed === true) return markClaimed(acct, projectRoot); } catch { // Status unknown: carry on and judge the held link on its own. @@ -125,7 +126,7 @@ async function refreshLocked(key: string, opts: RefreshOptions, now: Date): Prom let fresh; try { - fresh = await requestClaimLink(key, { url: opts.claimUrl, fetchImpl }); + fresh = await requestClaimLink(key, { url: opts.claimUrl, fetchImpl, projectRoot }); } catch (err) { if (opts.force && !usableClaimUrl(acct, now)) { if (err instanceof ToolkitError) throw err; @@ -176,25 +177,43 @@ function offlineState(acct: AgentAccount, now: Date): ClaimState { const CLAIM_LOCK_STALE_MS = 60 * 1000; /** - * Create `account.json.lock` exclusively. Returns a release function, or null - * when another live process holds the lock. + * Create `account.json.lock` exclusively, holding a random token. Returns a + * release function, or null when another live process holds the lock. + * + * A lock older than CLAIM_LOCK_STALE_MS is taken over by renaming it aside + * first (only one process can rename it away). If what was renamed turns out + * to be live, it is put back and this process gives up. Release deletes the + * lock only while it still holds this process's token. */ function acquireClaimLock(projectRoot: string | undefined): (() => void) | null { const lock = `${accountPath(projectRoot)}.lock`; + const token = randomBytes(16).toString("hex"); + const release = () => { + try { + if (readFileSync(lock, "utf8") === token) unlinkSync(lock); + } catch { + } + }; for (let attempt = 0; attempt < 2; attempt++) { try { - writeFileSync(lock, String(process.pid), { flag: "wx", mode: 0o600 }); - return () => { - try { - unlinkSync(lock); - } catch { - } - }; + writeFileSync(lock, token, { flag: "wx", mode: 0o600 }); + return readFileSync(lock, "utf8") === token ? release : null; } catch (err) { if ((err as NodeJS.ErrnoException).code !== "EEXIST") return null; try { if (Date.now() - statSync(lock).mtimeMs < CLAIM_LOCK_STALE_MS) return null; - unlinkSync(lock); + const aside = `${lock}.${token}.stale`; + renameSync(lock, aside); + if (Date.now() - statSync(aside).mtimeMs < CLAIM_LOCK_STALE_MS) { + // Another process replaced the stale lock between our checks: restore it. + try { + linkSync(aside, lock); + } catch { + } + unlinkSync(aside); + return null; + } + unlinkSync(aside); } catch { return null; } @@ -203,6 +222,11 @@ function acquireClaimLock(projectRoot: string | undefined): (() => void) | null return null; } +/** Test-only: the lock path, so tests can simulate other processes. */ +export function _claimLockPath(projectRoot?: string): string { + return `${accountPath(projectRoot)}.lock`; +} + function markClaimed(acct: AgentAccount, projectRoot?: string): ClaimState { writeAccount({ ...acct, unclaimed: false }, projectRoot); return { status: "claimed" }; diff --git a/tests/agent-account.test.ts b/tests/agent-account.test.ts index 2ff1807..e4c4ffa 100644 --- a/tests/agent-account.test.ts +++ b/tests/agent-account.test.ts @@ -184,10 +184,10 @@ test("resolveSignupUrl: discovers via .well-known when no env/config", async () test("resolveAccountUrl derives /account from the signup URL", async () => { const { root, cleanup } = tempRoot(); const prev = process.env[SIGNUP_URL_ENV]; - process.env[SIGNUP_URL_ENV] = "http://localhost.com:8000/api/agent/signup"; + process.env[SIGNUP_URL_ENV] = "http://localhost:8000/api/agent/signup"; try { createWorkspace(root); - assert.equal(await resolveAccountUrl(root), "http://localhost.com:8000/api/agent/account"); + assert.equal(await resolveAccountUrl(root), "http://localhost:8000/api/agent/account"); } finally { if (prev !== undefined) process.env[SIGNUP_URL_ENV] = prev; else delete process.env[SIGNUP_URL_ENV]; @@ -250,3 +250,50 @@ test("requestClaimLink: 409 not_agent_account is final", async () => { const fakeFetch = (async () => new Response(JSON.stringify({ error: "not_agent_account" }), { status: 409 })) as unknown as typeof fetch; assert.deepEqual(await requestClaimLink("k", { url: "https://x.zenrows.com/api/agent/claim", fetchImpl: fakeFetch }), { status: "not_agent" }); }); + +test("siblingOfSignup: an override that is not Zenrows or loopback never receives the key", () => { + const builtIn = AGENT_SIGNUP_API_URL.replace(/\/signup$/, "/account"); + for (const override of [ + "https://evil.example/api/agent/signup", + "http://app.zenrows.com/api/agent/signup", + "https://u:p@app.zenrows.com/api/agent/signup", + "http://u@127.0.0.1:9/api/agent/signup", + "http://10.0.0.5/api/agent/signup", + ]) { + assert.equal(siblingOfSignup(override, "account"), builtIn, override); + } + for (const ok of ["http://localhost:8000/api/agent/signup", "https://[::1]:8443/api/agent/signup", "https://staging.zenrows.com/api/agent/signup"]) { + assert.equal(siblingOfSignup(ok, "account"), ok.replace(/\/signup$/, "/account"), ok); + } +}); + +test("fetchAccountStatus: a project config's untrusted signupUrl is not where the key goes; the project root is honoured", async () => { + const { root, cleanup } = tempRoot(); + const prevSignup = process.env[SIGNUP_URL_ENV]; + delete process.env[SIGNUP_URL_ENV]; + const calls: string[] = []; + const fakeFetch = (async (url: string) => { + calls.push(String(url)); + return new Response(JSON.stringify({ accountId: "u1", claimed: false, isAgent: true }), { status: 200 }); + }) as unknown as typeof fetch; + try { + createWorkspace(root); + const cfg = { apiBase: "https://api.zenrows.com/v1/", defaultMode: "auto" as const, telemetry: "off" as const, version: "0.1.0" }; + saveConfig({ ...cfg, signupUrl: "https://attacker.example/api/agent/signup" }, root); + await fetchAccountStatus("k", { projectRoot: root, fetchImpl: fakeFetch }); + assert.deepEqual(calls, [AGENT_SIGNUP_API_URL.replace(/\/signup$/, "/account")]); + calls.length = 0; + saveConfig({ ...cfg, signupUrl: "http://127.0.0.1:9/api/agent/signup" }, root); + await requestClaimLink("k", { projectRoot: root, fetchImpl: (async (url: string) => { + calls.push(String(url)); + return new Response(JSON.stringify({ claimUrl: "https://app.zenrows.com/claim/t" }), { status: 200 }); + }) as unknown as typeof fetch }); + assert.deepEqual(calls, ["http://127.0.0.1:9/api/agent/claim"]); + calls.length = 0; + await fetchAccountStatus("k", { projectRoot: root, fetchImpl: fakeFetch }); + assert.deepEqual(calls, ["http://127.0.0.1:9/api/agent/account"]); + } finally { + if (prevSignup !== undefined) process.env[SIGNUP_URL_ENV] = prevSignup; + cleanup(); + } +}); diff --git a/tests/claim-link.test.ts b/tests/claim-link.test.ts index 5fe167a..db690c0 100644 --- a/tests/claim-link.test.ts +++ b/tests/claim-link.test.ts @@ -1,11 +1,12 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { existsSync, statSync, utimesSync, writeFileSync } from "node:fs"; +import { existsSync, readFileSync, statSync, utimesSync, writeFileSync } from "node:fs"; import { tempRoot } from "./helpers.ts"; import { createWorkspace, workspacePaths } from "../src/core/workspace.ts"; import { saveApiKey } from "../src/core/auth.ts"; import { readAccount, writeAccount, requestClaimLink } from "../src/core/agent-account.ts"; import { + _claimLockPath, isTrustedClaimUrl, refreshClaimAdvice, refreshClaimState, @@ -264,3 +265,16 @@ test("refreshClaimState: a live lock held elsewhere means no network; a stale on await refreshClaimState({ projectRoot: root, fetchImpl: s.fetchImpl, ...endpoints }); assert.equal(s.calls.filter((c) => c.method === "POST").length, 1); })); + +test("claim lock: release never deletes a lock another process now holds", () => + withWorkspace(acct({ createdAt: daysAgo(40) }), async (root) => { + const lock = _claimLockPath(root); + const s = stub({ account: { claimed: false }, claim: { claimUrl: NEW_LINK } }); + const stealing = (async (url: string, init?: RequestInit) => { + // Simulate our lock being taken over (we looked stale) by another process. + if (url === ACCOUNT_URL) writeFileSync(lock, "someone-else"); + return s.fetchImpl(url, init); + }) as unknown as typeof fetch; + await refreshClaimState({ projectRoot: root, force: true, fetchImpl: stealing, ...endpoints }); + assert.equal(readFileSync(lock, "utf8"), "someone-else"); + }));