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..96c11b4 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,122 @@ 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\/?$/; + // 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; + 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. */ +export async function resolveClaimUrl( + projectRoot?: string, + opts: { fetchImpl?: typeof fetch } = {}, +): Promise { + return siblingOfSignup(await resolveSignupUrl(projectRoot, opts), "claim"); +} + +export type ClaimLinkResponse = + | { status: "unclaimed"; claimUrl: string; expiresAt?: string } + | { 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 + * 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; projectRoot?: string } = {}, +): Promise { + const url = opts.url ?? (await resolveClaimUrl(opts.projectRoot, { fetchImpl: opts.fetchImpl })); + 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) { + 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 endpoint answered HTTP 200 without a claim link (unexpected response body).", + 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" }; + 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}).`, + 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.", + }); } /** @@ -248,9 +360,9 @@ export async function resolveAccountUrl( */ 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 a9fa240..1224135 100644 --- a/src/core/auth.ts +++ b/src/core/auth.ts @@ -4,12 +4,12 @@ * 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"; 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,19 +55,14 @@ export function saveApiKey(apiKey: string, projectRoot?: string): void { }); } const file = secretsPath(projectRoot); - writeJson(file, { apiKey: trimmed } satisfies Secrets); - try { - chmodSync(file, 0o600); - } catch { - // best-effort on platforms without POSIX permissions - } + writePrivateJson(file, { apiKey: trimmed } satisfies Secrets); registerSecret(trimmed); } 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/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..64e05fb --- /dev/null +++ b/src/core/claim-link.ts @@ -0,0 +1,257 @@ +/** + * 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 { 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"; + +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), with no userinfo, is shown or opened. */ +export function isTrustedClaimUrl(value: unknown): value is string { + return isZenrowsHttpsUrl(value); +} + +/** 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(); + 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 key = opts.apiKey ?? resolveApiKey(projectRoot).key; + if (!key) return offlineState(first, now); + + // 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(); + } +} + +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; + + try { + 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. + } + + // 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 { + fresh = await requestClaimLink(key, { url: opts.claimUrl, fetchImpl, projectRoot }); + } 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 offlineState(acct, now); + } + // Both 409s are final: a person owns the account, so there is nothing to claim. + if (fresh.status === "claimed" || fresh.status === "not_agent") 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 offlineState(acct, now); + } + const latest = readAccount(projectRoot) ?? acct; + writeAccount({ ...latest, claimUrl: fresh.claimUrl, claimUrlIssuedAt: now.toISOString() }, projectRoot); + return { status: "unclaimed", claimUrl: fresh.claimUrl, refreshed: true }; +} + +function throttled(acct: AgentAccount, now: Date): boolean { + if (!acct.lastClaimCheckAt) return false; + const last = new Date(acct.lastClaimCheckAt).getTime(); + return !Number.isNaN(last) && now.getTime() - last < CLAIM_CHECK_THROTTLE_MS; +} + +function needsNewLink(acct: AgentAccount, now: Date): boolean { + return !isTrustedClaimUrl(acct.claimUrl) || linkAgeMs(acct, now) >= 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, 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, 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; + 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; + } + } + } + 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" }; +} + +/** 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/provenance.ts b/src/core/provenance.ts index 2d8ccf0..bdb69a7 100644 --- a/src/core/provenance.ts +++ b/src/core/provenance.ts @@ -25,16 +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, "OPENAI_") || hasPrefix(env, "CODEX_")) { + } else if (CODEX_VARS.some((v) => env[v])) { client = "openai-codex"; } else if (hasPrefix(env, "WINDSURF")) { client = "windsurf"; 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/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/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/agent-account.test.ts b/tests/agent-account.test.ts index f333266..e4c4ffa 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]; @@ -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]; @@ -217,3 +217,83 @@ 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" }); +}); + +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 new file mode 100644 index 0000000..db690c0 --- /dev/null +++ b/tests/claim-link.test.ts @@ -0,0 +1,280 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +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, + 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); + })); + +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); + })); + +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"); + })); 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 { diff --git a/tests/provenance.test.ts b/tests/provenance.test.ts index fb7da85..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", () => { @@ -18,9 +25,19 @@ 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"); - assert.equal(detectClient({ CODEX_HOME: "x" }).client, "openai-codex"); +test("detectClient: openai-codex from Codex's own variables", () => { + 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"); }); test("detectClient: windsurf", () => { 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(); + } +});