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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,18 @@ unclaimed Zenrows Free plan account**, stores the key in `.zenrows/secrets.json`
(0600, gitignored), and records the account in `.zenrows/account.json`
(no secret — just the accountId, Free-period info, and a claim link).

Zenrows may first ask the agent one short question before it creates the
account. The command then fails with `SIGNUP_CHALLENGE` and prints the
question; answer it and re-run the original command:

```bash
zenrows signup --answer "<your answer>"
```

A wrong or late answer gets a new question. Too many questions or wrong
answers from one network pause signups there for an hour
(`SIGNUP_CHALLENGE_LOCKED`).

When you (or your agent) want to keep the usage and history, **claim** the
account — this confirms an email (Google, GitHub or a 6-digit code); the same
key keeps working:
Expand All @@ -98,6 +110,7 @@ Opt out of auto-provisioning at any time:
```bash
zenrows fetch <url> --no-signup # do not auto-create an account for this call
zenrows signup --agent # explicitly provision a Free plan account, headless
zenrows signup --answer "<text>" # answer the signup question (SIGNUP_CHALLENGE)
zenrows login --api-key <key> # use an existing key (stored 0600, gitignored)
zenrows login --env # use the ZENROWS_API_KEY environment variable
```
Expand Down
5 changes: 4 additions & 1 deletion agent-plugin/skills/zenrows/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,10 @@ 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
key locally. Zenrows may first ask one short question (which word is a
bird, which word does not belong): the tool call
returns it; answer it yourself with the `account_signup_answer` tool, then
retry the call. When the Free plan nears its limit (or the user asks), surface
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 —
Expand Down
7 changes: 7 additions & 0 deletions skills/zenrows/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,13 @@ the toolkit automatically creates a **free, unclaimed Zenrows Free plan account*
key locally — no human step required. To opt out, pass `--no-signup` or set
`auto_signup: false` in `.zenrows/policy.json`, then use `zenrows login --api-key <key>`.

Before it creates the account, Zenrows may ask you one short question (error code
`SIGNUP_CHALLENGE`, with the question in the message and in `error.challenge.question`).
Answer it yourself, do not ask the human, then run `zenrows signup --answer "<your answer>"`
and re-run your original command. A wrong answer returns a new question the same way.
After too many questions or wrong answers from one network (`SIGNUP_CHALLENGE_LOCKED`), wait an hour or use
`zenrows login --api-key <key>`.

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). It prints a valid link, requesting a fresh one when the stored link is
Expand Down
56 changes: 49 additions & 7 deletions src/cli/commands/signup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,13 @@
* There is no documented programmatic signup API, so we never fake account
* creation: we explain what is needed, open the signup URL, and hand off to
* `login`.
*
* `--agent` provisions a Free plan account headless, and `--answer` answers the
* one short question signup may ask an agent first (SIGNUP_CHALLENGE).
*/
import { saveApiKey } from "../../core/auth.ts";
import { readAccount, signupAgent, writeAccount } from "../../core/agent-account.ts";
import { answerSignupChallenge, readAccount, signupAgent } from "../../core/agent-account.ts";
import { storeSignup } from "../../core/ensure-key.ts";
import { ToolkitError } from "../../core/errors.ts";
import { attributionEnabled } from "../../core/config.ts";
import { log } from "../../core/logger.ts";
import { openUrl, SIGNUP_URL, withParams } from "../../core/open-url.ts";
Expand All @@ -16,17 +20,55 @@ import { parse, type Command, type RunContext } from "../command.ts";
export const signup: Command = {
name: "signup",
summary: "Create a Zenrows account / obtain an API key (opens the browser).",
usage: "zenrows signup [--agent] [--no-open]",
usage: 'zenrows signup [--agent] [--no-open] [--answer "<text>"]',
help: [
"Without flags, opens the Zenrows signup page in the browser.",
"",
" --agent Create a Free plan account headless and store its key.",
" --no-open Print the signup URL instead of opening it.",
' --answer "<text>" Answer the question Zenrows asked before creating an account',
" (error SIGNUP_CHALLENGE). Answer it yourself, then re-run",
" your original command.",
].join("\n"),
async run(argv: string[], ctx: RunContext): Promise<number> {
const { values } = parse(argv, { "no-open": { type: "boolean" }, agent: { type: "boolean" } });
const { values } = parse(argv, {
"no-open": { type: "boolean" },
agent: { type: "boolean" },
answer: { type: "string" },
});

if (values.answer !== undefined) {
const answer = String(values.answer).trim();
if (!answer) {
throw new ToolkitError({
code: "INVALID_USAGE",
message: "--answer needs the answer text.",
likely_cause: "An empty value was passed to --answer.",
next_action: 'Run: zenrows signup --answer "<your answer>"',
});
}
const res = await answerSignupChallenge(answer);
const account = storeSignup(res);
log.success("Right answer. Created a Zenrows Free plan account and stored its API key.");
log.dim(`Claim it anytime (keeps your usage): ${account.claimUrl}`);
log.info("Now re-run your original command.");
if (ctx.json) {
log.out(
JSON.stringify(
{ ok: true, accountId: res.accountId, claimUrl: res.claimUrl, next_action: "Re-run your original command." },
null,
2,
),
);
}
return 0;
}

if (values.agent) {
const account = readAccount();
if (account) { log.info("An agent account already exists. Claim it with: zenrows account claim"); return 0; }
const res = await signupAgent();
saveApiKey(res.apiKey);
const now = new Date().toISOString();
writeAccount({ accountId: res.accountId, unclaimed: true, claimUrl: res.claimUrl, createdAt: now, claimUrlIssuedAt: now });
storeSignup(res);
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));
Expand Down
74 changes: 67 additions & 7 deletions src/core/agent-account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,18 @@ 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, writePrivateJson } from "./workspace.ts";
import {
challengeError,
challengeExpired,
challengeLockedError,
clearChallenge,
isChallengeLocked,
noChallengeError,
parseChallenge,
readChallenge,
wasWrongAnswer,
writeChallenge,
} from "./signup-challenge.ts";

/** Env var to override the signup endpoint (highest priority; for local testing). */
export const SIGNUP_URL_ENV = "ZENROWS_AGENT_SIGNUP_URL";
Expand Down Expand Up @@ -137,19 +149,39 @@ export interface SignupResponse {
}

export async function signupAgent(
opts: { url?: string; fetchImpl?: typeof fetch } = {},
opts: { url?: string; fetchImpl?: typeof fetch; projectRoot?: string } = {},
): Promise<SignupResponse> {
const urls = opts.url ? [opts.url] : await signupCandidates(undefined, { fetchImpl: opts.fetchImpl });
const doFetch = opts.fetchImpl ?? fetch;
const urls = opts.url ? [opts.url] : await signupCandidates(opts.projectRoot, { fetchImpl: opts.fetchImpl });
return postSignup(urls, undefined, opts);
}

/**
* Answer the pending signup question (see signup-challenge.ts). Posts it to
* the signup URL that asked it; a right answer creates the account like a
* plain signup does. A wrong answer stores and reports the new question.
*/
export async function answerSignupChallenge(
answer: string,
opts: { fetchImpl?: typeof fetch; projectRoot?: string } = {},
): Promise<SignupResponse> {
const pending = readChallenge(opts.projectRoot);
if (!pending) throw noChallengeError(false);
if (challengeExpired(pending)) {
clearChallenge(opts.projectRoot);
throw noChallengeError(true);
}
return postSignup([pending.signupUrl], { challenge_id: pending.id, answer }, opts);
}

function signupHeaders(projectRoot?: string): Record<string, string> {
// Signup is the one functional request the CLI must make — no separate
// telemetry beacon exists. When attribution is enabled we ride anonymous
// provenance on it (a random id + coarse host/runtime brand). No PII, no URLs.
// `telemetry:"off"` / ZENROWS_TELEMETRY=off suppresses every X-ZR-* header.
const headers: Record<string, string> = {
"content-type": "application/json",
"User-Agent": CLI_USER_AGENT,
...agentClientHeader(),
...agentClientHeader({ projectRoot }),
};
if (attributionEnabled()) {
const p = detectClient();
Expand All @@ -161,15 +193,27 @@ export async function signupAgent(
headers["X-ZR-Node"] = p.node;
headers["X-ZR-CI"] = p.ci ? "1" : "0";
}
return headers;
}

async function postSignup(
urls: string[],
body: { challenge_id: string; answer: string } | undefined,
opts: { fetchImpl?: typeof fetch; projectRoot?: string },
): Promise<SignupResponse> {
const doFetch = opts.fetchImpl ?? fetch;
const headers = signupHeaders(opts.projectRoot);

// Try each candidate in order; on any failure fall back to the next (the last
// is always the built-in default). This is the safety net: a wrong/blocked
// discovered endpoint can never leave provisioning worse off than no discovery.
// A signup question or the wrong-answer lock is a real answer from a working
// endpoint, so it ends the loop.
let lastErr: ToolkitError | undefined;
for (const url of urls) {
let res: Response;
try {
res = await doFetch(url, { method: "POST", headers });
res = await doFetch(url, { method: "POST", headers, ...(body ? { body: JSON.stringify(body) } : {}) });
} catch (err) {
lastErr = new ToolkitError({
code: "BACKEND_UNAVAILABLE",
Expand All @@ -179,8 +223,24 @@ export async function signupAgent(
});
continue;
}
if (res.status === 201) return (await res.json()) as SignupResponse;
lastErr = signupError(res.status, await res.text());
if (res.status === 201) {
clearChallenge(opts.projectRoot);
return (await res.json()) as SignupResponse;
}
const text = await res.text();
if (res.status === 428) {
const challenge = parseChallenge(text, url);
if (challenge) {
writeChallenge(challenge, opts.projectRoot);
throw challengeError(challenge, { wrong: body !== undefined || wasWrongAnswer(text) });
}
}
if (res.status === 429 && isChallengeLocked(text)) {
clearChallenge(opts.projectRoot);
const ra = Number(res.headers.get("retry-after"));
throw challengeLockedError(Number.isFinite(ra) && ra > 0 ? ra : undefined);
}
lastErr = signupError(res.status, text);
}
throw (
lastErr ??
Expand Down
23 changes: 16 additions & 7 deletions src/core/ensure-key.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@
* When no key is configured and `policy.auto_signup` is enabled, this creates a
* free unclaimed Free plan account (via the public signup endpoint), persists the
* key (0600) and the account metadata, and returns the key. Otherwise it defers
* to `requireApiKey`, which throws AUTH_MISSING.
* to `requireApiKey`, which throws AUTH_MISSING. Signup may first ask one
* short question (SIGNUP_CHALLENGE), which the agent answers with
* `zenrows signup --answer`.
*/
import type { AgentAccount, Policy } from "../types/index.ts";
import { requireApiKey, resolveApiKey, saveApiKey } from "./auth.ts";
import { signupAgent, writeAccount } from "./agent-account.ts";
import { signupAgent, writeAccount, type SignupResponse } from "./agent-account.ts";

export async function ensureApiKey(
policy: Policy,
Expand All @@ -23,16 +25,23 @@ export async function ensureApiKey(
if (!policy.auto_signup) return requireApiKey(opts.projectRoot); // throws AUTH_MISSING

const doSignup = opts.signupImpl ?? signupAgent;
const res = await doSignup();
saveApiKey(res.apiKey, opts.projectRoot);
// May throw SIGNUP_CHALLENGE: the agent answers with `zenrows signup --answer`.
const res = await doSignup({ projectRoot: opts.projectRoot });
const account = storeSignup(res, opts.projectRoot);
opts.onProvision?.(account);
return res.apiKey;
}

/** Persist a fresh agent signup: the key (0600) and the account metadata. */
export function storeSignup(res: SignupResponse, projectRoot?: string): AgentAccount {
saveApiKey(res.apiKey, projectRoot);
const account: AgentAccount = {
accountId: res.accountId,
unclaimed: true,
claimUrl: res.claimUrl,
createdAt: new Date().toISOString(),
};
account.claimUrlIssuedAt = account.createdAt;
writeAccount(account, opts.projectRoot);
opts.onProvision?.(account);
return res.apiKey;
writeAccount(account, projectRoot);
return account;
}
8 changes: 8 additions & 0 deletions src/core/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ export type ErrorCode =
| "POLICY_BROWSER_DISABLED"
| "SIGNUP_RATE_LIMITED"
| "SIGNUP_FAILED"
| "SIGNUP_CHALLENGE"
| "SIGNUP_CHALLENGE_LOCKED"
| "SIGNUP_CHALLENGE_MISSING"
| "UNKNOWN_FLAG"
| "DOMAIN_FORBIDDEN"
| "FETCH_FAILED"
Expand Down Expand Up @@ -90,6 +93,8 @@ export interface ToolkitErrorShape {
idempotency_key?: string;
/** Seconds to wait before a retry (the `Retry-After` header). */
retry_after?: number;
/** The question signup asks before it creates an account (SIGNUP_CHALLENGE). */
challenge?: { id: string; question: string; expires_at: string };
}

export class ToolkitError extends Error {
Expand All @@ -102,6 +107,7 @@ export class ToolkitError extends Error {
readonly job_id?: string;
readonly idempotency_key?: string;
readonly retry_after?: number;
readonly challenge?: { id: string; question: string; expires_at: string };

constructor(shape: ToolkitErrorShape) {
super(shape.message);
Expand All @@ -115,6 +121,7 @@ export class ToolkitError extends Error {
this.job_id = shape.job_id;
this.idempotency_key = shape.idempotency_key;
this.retry_after = shape.retry_after;
this.challenge = shape.challenge;
}

toJSON(): ToolkitErrorShape {
Expand All @@ -129,6 +136,7 @@ export class ToolkitError extends Error {
...(this.job_id ? { job_id: this.job_id } : {}),
...(this.idempotency_key ? { idempotency_key: this.idempotency_key } : {}),
...(this.retry_after !== undefined ? { retry_after: this.retry_after } : {}),
...(this.challenge ? { challenge: this.challenge } : {}),
};
}
}
Expand Down
Loading
Loading