diff --git a/README.md b/README.md index 7ee30bc98..ea8bfadb2 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ Requires **Node ≥ 22** and ~1.5 GB of free disk on macOS, Linux, or Windows. B - **More on the way** — the supported list keeps growing, and a PR to add your agent is welcome; see [CONTRIBUTING.md](CONTRIBUTING.md). - **Interactive setup** — `--interactive` is a plain-text flow; `--wizard` is the full terminal TUI. - **Defer downloads** — `--no-warmup` waits until first use. A failed component download never fails setup; init reports what's not ready with the exact fix and still completes. -- **One free account** — the tools need an activated install, so `init` closes by pointing at `wigolo register`. `wigolo login` signs in a machine you've already got an account for. Diagnostics work without either. See [account & telemetry](#account--telemetry). +- **No account required** — every tool runs on a fresh install with no sign-up at all. `init` closes by naming what a free account would *add* (sync, marketplace, higher pacing and watch limits); `wigolo register` takes it when you want it. See [account & telemetry](#account--telemetry). `init` is unattended by default, so it's safe in scripts and CI, and any setup problem surfaces right here in the per-component report, before your agent's first call. **Search, fetch, crawl, extract, cache, and find-similar work with no API key.** Check it's healthy anytime: @@ -117,7 +117,7 @@ wigolo isn't a free stand-in for the paid tools — it's built to match them. It - **Built for agents.** One MCP call fans out many queries across many engines in parallel, which a serial host tool-loop can't replicate. Every result carries transparent per-result scoring, and output is budget-aware. - **Honest output.** Stale cache, failed fetches, degraded backends, and truncation are surfaced in the result. When a bot-protected page can't be read, you get a labeled `blocked_by_challenge` failure, not a challenge shell returned as content. - **$0 per query, free to re-query.** Default search talks to public engines through direct adapters; the reranker and embeddings run on-device. Every response is cached, so asking again is instant and costs nothing. -- **Private by default.** Your queries and target URLs reach the engines and sites you're asking about — that's the product working. Nothing else about your work leaves: cache, embeddings, models, and config stay under `~/.wigolo/`, and no third party sees them unless you explicitly opt into an LLM for synthesis. [Full egress list](docs/privacy-security.md#network-egress). +- **Private by default.** Your queries and target URLs reach the engines and sites you're asking about — that's the product working. Beyond that: no page content, URLs, or credentials leave your machine; usage stats do, off with one flag. Cache, embeddings, models, and config stay under `~/.wigolo/`, and no third party sees them unless you explicitly opt into an LLM for synthesis. [Full egress list](docs/privacy-security.md#network-egress). Here's what one real result looks like, dissected. It includes the failed engine and the weak result, because those are part of the answer too: @@ -301,10 +301,17 @@ flowchart TD ## Account & telemetry -The ten tools need an activated install. `npx wigolo register` creates a free account from -an email address and a mailed sign-in code — no password, no card, nothing to buy. Five -verbs own it, separate from `wigolo auth`, which is about signing in to *websites* through -the browser engine: +**wigolo runs fully without an account — registering only adds to it.** All ten tools work +on a fresh install, on every surface, with no sign-up. What an account unlocks: + +- sync — your cache, settings and watches across machines +- marketplace — publish and install skills and plugins +- higher pacing and watch limits +- managed cloud runs, when they land + +`npx wigolo register` creates one from an email address and a mailed sign-in code — no +password, no card, nothing to buy. Five verbs own it, separate from `wigolo auth`, which is +about signing in to *websites* through the browser engine: ```bash npx wigolo register # create the account and activate this machine @@ -314,12 +321,16 @@ npx wigolo account # summary, grants, telemetry state, export, delete npx wigolo logout # clear the local credential only ``` -Activation is verified offline against a signed token on disk, so ordinary runs never call -the service and a network outage cannot de-activate you. Diagnostics are never gated: -`doctor`, `verify` and `warmup` run on a machine that has never registered. +Once you have registered, the sign-in is verified offline against a signed token on disk, so +ordinary runs never call the service and a network outage cannot cost you the unlocks. +`register --headless` is the agent-assisted path: an agent mails itself the code and +finishes with `wigolo register --code`, so nothing ever waits on a prompt. + +The honest one-liner, and the same words the CLI and your agent see: **no page content, +URLs, or credentials leave your machine; usage stats do, off with one flag.** -**Usage and reliability telemetry is on by default**, which is a change in 0.3.0 — earlier -releases sent nothing. It is six counters and no seventh: a tool ran (which one, which +**Usage and reliability telemetry is on by default** for a registered install, which is a +change in 0.3.0 — earlier releases sent nothing. It is six counters and no seventh: a tool ran (which one, which surface, whether it worked, how long as a coarse bucket), a tool failed (its error *class*), a fetch was blocked (the registrable domain and why), a fetch escalated a tier, a search engine failed (its error *class*), and a daemon's uptime as a bucket. Every field @@ -392,7 +403,7 @@ The full guide covers per-symptom fixes, a "what still works when X fails" map,
Free? What's the catch? -No catch by design. The expensive parts (ranking, embeddings, the browser engine) run on *your* hardware, so there's no per-query cost to recover and no reason for a meter. It's sustained by donations, and the AGPL license legally prevents a switch into a closed hosted product. Since 0.3.0 the tools do need a free account — an email address and a mailed code, no card — which is what makes [usage and reliability telemetry](#account--telemetry) attributable; there is still nothing to buy. +No catch by design. The expensive parts (ranking, embeddings, the browser engine) run on *your* hardware, so there's no per-query cost to recover and no reason for a meter. It's sustained by donations, and the AGPL license legally prevents a switch into a closed hosted product. You don't need an account either — every tool runs on a fresh install. A free account (an email address and a mailed code, no card) unlocks sync, the marketplace and higher limits, and is what makes [usage and reliability telemetry](#account--telemetry) attributable; there is still nothing to buy.
diff --git a/docs/cli.md b/docs/cli.md index 6e98cd991..4d67bc562 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -162,28 +162,44 @@ Five verbs, separate from the management commands above because they concern you rather than this machine's setup. Not to be confused with [`wigolo auth`](#auth), which manages site sign-ins for the browser engine. -All ten tools are gated on an activated install. Diagnostics are not: `doctor`, `verify` -and `warmup` run on a machine that has never registered, so a broken install can always be -diagnosed. Everything that reaches a tool — the MCP server, the REST daemon, the -interactive shell, a one-shot tool command — refuses with the same line until you activate: +**No tool is gated on an account.** All ten run on a machine that has never registered, on +every surface — the MCP server, the REST daemon, the interactive shell, a one-shot tool +command — and so do `doctor`, `verify` and `warmup`. Registering *unlocks* things instead: +sync across machines, the marketplace, higher pacing and watch limits, and managed cloud +runs when they land. An unregistered install is told that exactly once, in a footer under a +tool result that already succeeded, and never again. -```text -wigolo needs an account — run `wigolo register` to create one (already have one? `wigolo login`). -``` +Telemetry, in the words every surface uses: no page content, URLs, or credentials leave +your machine; usage stats do, off with one flag (`WIGOLO_TELEMETRY=off`). ### register ```text -wigolo register [--email E] [--json] +wigolo register [--email E] [--code C] [--headless] [--marketing-consent] [--json] ``` -Creates your account and activates this install. It asks for your email address, mails a +Creates your account and unlocks it on this install. It asks for your email address, mails a sign-in code and waits for you to type it back; then — still before the account exists — shows what usage and reliability telemetry covers and asks whether you want occasional -product-update emails. No password at any point. If the account service is unreachable -when the disclosure is fetched, registration stops and nothing is created: the wording -being agreed to is served, never bundled into the client, so there is no offline -substitute to show you. +product-update emails. That last question is **unticked by default**: consent is an +affirmative act, so anything other than an explicit yes is a no. No password at any point. +If the account service is unreachable when the disclosure is fetched, registration stops and +nothing is created: the wording being agreed to is served, never bundled into the client, so +there is no offline substitute to show you. + +**`--headless` is the agent-assisted path**, and it asks nothing — there is no prompt to +hang on, which is what makes it safe inside an agent loop. It runs in two stages: + +```bash +wigolo register --headless --email you@example.com # mails the code; creates nothing +wigolo register --headless --email you@example.com --code 123456 # the human relays the code +``` + +Stage one creates no account and carries no consent. The human reads the code out of their +own inbox and hands it back, so the person who owns the address is the person who claims the +account. `--marketing-consent` is the only way to say yes to product-update email on this +path; omitting it — and the explicit `--no-marketing-consent` — both mean no. `wigolo login` +takes the same two flags for signing an existing account in. ### login diff --git a/docs/getting-started.md b/docs/getting-started.md index 5629053dd..823cb0ebe 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -25,16 +25,27 @@ Useful variants: - `npx wigolo init --wizard` — the rich guided setup TUI. - `npx wigolo init --json` — machine-readable summary on stdout. -When setup finishes on a machine that has no wigolo account yet, `init` closes with the -next step: +When setup finishes on a machine that has no wigolo account yet, `init` closes by saying so +— and by saying it changes nothing about whether the tools work: ```text - Next step: run `wigolo register` to activate this install (already have an account? `wigolo login`). + wigolo runs fully without an account — registering only adds to it. + Optional — `wigolo register` unlocks: + · sync — your cache, settings and watches across machines + · marketplace — publish and install skills and plugins + · higher pacing and watch limits + · managed cloud runs, when they land + Telemetry: no page content, URLs, or credentials leave your machine; usage stats do, off with one flag (WIGOLO_TELEMETRY=off). ``` -## 2. Activate this install +## 2. An account, if and when you want one — optional -The ten tools need an account. Create one — it takes an email address and a sign-in code, +**Skip this section and everything still works.** All ten tools, on every surface, run on a +machine that has never registered, and so do `doctor`, `verify` and `warmup`. Nothing is +gated. + +A free account unlocks sync across machines, the marketplace, higher pacing and watch +limits, and managed cloud runs when they land. It takes an email address and a sign-in code, no password: ```bash @@ -43,19 +54,16 @@ npx wigolo register `register` asks for your email, mails a sign-in code, and waits for you to type it back. Before the account is actually created it shows what usage and reliability telemetry -covers and asks whether you want occasional product-update emails — then activates this -machine. Already have an account? `npx wigolo login` signs this machine in instead. - -Until then every tool refuses with the same line, whichever surface it was called from: - -```text -wigolo needs an account — run `wigolo register` to create one (already have one? `wigolo login`). -``` - -Diagnostics stay available while unactivated — `doctor`, `verify` and `warmup` run on a -machine that has never registered, so a broken install can still be diagnosed. See -[Account & telemetry](../README.md#account--telemetry) for what is collected and how to -turn telemetry off. +covers and asks whether you want occasional product-update emails — that question is +unticked by default. Already have an account? `npx wigolo login` signs this machine in +instead. Inside an agent loop, `npx wigolo register --headless --email you@example.com` +mails the code without ever waiting on a prompt; the human reads it from their own inbox and +finishes with `--code`. + +An unregistered install says all of this exactly once — in a footer under a tool result that +already worked — and then never again. See +[Account & telemetry](../README.md#account--telemetry) for what telemetry collects and how +to turn it off. ## 3. First search — through your agent diff --git a/docs/privacy-security.md b/docs/privacy-security.md index 4a72e29a4..9a7f2aaf1 100644 --- a/docs/privacy-security.md +++ b/docs/privacy-security.md @@ -4,10 +4,18 @@ wigolo's privacy model is structural, not a policy promise: the software runs on machine, stores on your disk, and the only thing it can report is a closed list of counters that page content, queries and URLs are not representable in. -As of 0.3.0 there is one vendor backend — the account service that activates your install -and receives usage and reliability telemetry. What it can receive is bounded by the code, -not by a promise, and the telemetry half is a single switch away from silent. Both are -below. +As of 0.3.0 there is one vendor backend — the account service that registers you and +receives usage and reliability telemetry. What it can receive is bounded by the code, not by +a promise, and the telemetry half is a single switch away from silent. Both are below. + +The claim in one sentence, and it is the same sentence the CLI, the first-run output and +your agent are shown: **no page content, URLs, or credentials leave your machine; usage +stats do, off with one flag.** Earlier copy said nothing left your machine at all. That was +never true of the counters and the wording is retired; what follows is the exact list. + +No tool is gated on an account — a machine that has never registered runs all ten — and an +install with no account reports nothing at all, because counters are attributed to an +account or not collected ([below](#usage-and-reliability-telemetry)). ## Everything stays local @@ -103,7 +111,7 @@ wigolo config --set WIGOLO_TELEMETRY=off # permanently ``` `off`, `no`, `false` and `0` all mean off. Off means nothing is queued, nothing is written -to `telemetry/`, and nothing leaves the machine — the switch is read before an event is +to `telemetry/`, and no counter reaches the wire — the switch is read before an event is built, not before a batch is sent. Nothing is queued or sent on an install that has never registered either, because there is no account to attribute counters to. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 207de5f40..166b20693 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -15,10 +15,9 @@ wigolo doctor --fix # repairs the known failure classes automatically | Browser engine won't launch on Linux | `wigolo warmup --browser` installs the OS system libraries the browser engine needs (escalating with sudo where required); when it can't, the error prints the exact install command to run yourself, then re-run `wigolo warmup`. | | `wigolo serve` exits: port in use | The daemon deliberately does not auto-rebind. The error names a free port to retry with, e.g. `wigolo serve --port 3334`. | | `wigolo serve` refuses to start on a non-loopback host | Working as designed (fail-closed). Set `WIGOLO_API_TOKEN` / `WIGOLO_API_TOKEN_FILE`, or explicitly pass `--allow-unauthenticated`. See [self-hosting](./self-hosting.md#binding-beyond-loopback). | -| Every tool refuses: "wigolo needs an account" | This install is not activated. `wigolo register` creates a free account and activates it; `wigolo login` signs in an existing one. Diagnostics (`doctor`, `verify`, `warmup`) keep working while unactivated. See [below](#every-tool-says-wigolo-needs-an-account). | -| A tool refuses: "your wigolo sign-in has expired" | `wigolo login` reconnects this machine. Do **not** run `register` — that creates a second account. | -| A tool refuses: "wigolo needs an update to verify your sign-in" | Update wigolo, then `wigolo login`. The account service signs activation with a key this build does not hold, and re-registering cannot change that. | -| `wigolo serve` exits immediately with an account message | `serve` refuses at start rather than starting and failing every request. Activate the install first. | +| A tool refused because of your account | It didn't. No tool, and no surface, is gated on an account — see [below](#no-tool-is-gated-on-an-account) if something told you otherwise. | +| `wigolo whoami` says this machine was never activated | Expected on an install that has never registered, and it costs you nothing but the [unlocks](./cli.md#your-wigolo-account). `wigolo register` takes them; `wigolo login` signs in an account you already have. | +| An account you *do* have stopped working | `wigolo login` reconnects this machine when a sign-in expires; update wigolo first if it says it cannot verify the sign-in. Do **not** run `register` — that creates a second account against the same email. Your tools keep running throughout. | | Fetch result says `blocked_by_challenge` | See [below](#blocked_by_challenge). | | Search results feel thin / an engine seems dead | Degraded engines are *reported*, not hidden — check `engine_warnings`, `engine_telemetry`, and `engine_pool` in the response, and `wigolo doctor`'s per-engine table (it names the env var when an engine just wants a key, e.g. `WIGOLO_GITHUB_TOKEN`, `BRAVE_API_KEY`). | | Results are stale | Pass `force_refresh: true` (news, prices, changelogs), or clear scoped entries: `wigolo cache clear --url-pattern="*example.com*"`. Lifetimes are tunable: `CACHE_TTL_SEARCH`, `CACHE_TTL_CONTENT`. | @@ -45,15 +44,16 @@ No. `init` exits 0 even when a download fails, and the **core** (search, HTTP fe Re-run `wigolo warmup --all` any time to retry the downloads, or just let each component lazy-load on first use. -## Every tool says "wigolo needs an account" +## No tool is gated on an account -```text -wigolo needs an account — run `wigolo register` to create one (already have one? `wigolo login`). -``` +wigolo runs fully without one. All ten tools, on every surface — the MCP server, the REST +daemon, the interactive shell, one-shot tool commands — plus `doctor`, `verify` and +`warmup`, work on a machine that has never registered, and `wigolo serve` starts normally. +Earlier 0.3.x builds did refuse with `wigolo needs an account`; that gate is gone from core. +If you are seeing that line, you are running an older build — check `wigolo --version`. -Not an error — the install has not been activated. Since 0.3.0 the ten tools need a free -account, and the refusal is identical on every surface: the MCP server, the REST daemon, -the interactive shell, and one-shot tool commands. +Registering *unlocks* things instead: sync across machines, the marketplace, higher pacing +and watch limits, and managed cloud runs when they land. ```bash wigolo register # new account: email + a mailed sign-in code, no password @@ -61,15 +61,14 @@ wigolo login # existing account, new machine wigolo whoami # what this machine currently thinks (fully offline) ``` -Diagnostics are deliberately not gated. `wigolo doctor`, `wigolo verify` and `wigolo -warmup` run on a machine that has never registered, so a broken install can always be -diagnosed — `doctor` prints an Account section with the activation state. +An unregistered install mentions this exactly once, in a footer under a tool result that +already succeeded, and never again. If you want the footer back, the flag lives at +`~/.wigolo/account/nudge.json` — delete the file. -Activation is checked offline against a signed token on disk, so a network outage does not -de-activate you. Three things do, and each has its own line: never having registered -(`register`), a sign-in that expired (`login`), and a build too old to verify the service's -current signing key (update, then `login`). Read which line you got before acting — running -`register` on an expired sign-in creates a second account against the same email. +An account you already have can still lapse, and that is worth reading the line for: a +sign-in expires (`login`), or a build is too old to verify the service's current signing key +(update, then `login`). Neither stops a tool from running. Running `register` on an expired +sign-in creates a second account against the same email, so read which one you got. ## blocked_by_challenge diff --git a/llms.txt b/llms.txt index 8ede1e06e..f22f3ccee 100644 --- a/llms.txt +++ b/llms.txt @@ -17,7 +17,7 @@ Repository: https://github.com/KnockOutEZ/wigolo - [Docs index](docs/README.md): map of all documentation pages - [Getting started](docs/getting-started.md): npx wigolo init, first search, doctor/verify - [Installation](docs/installation.md): npm, Docker (ghcr.io/knockoutez/wigolo), MCP bundle/registries, agent auto-wire matrix, uninstall -- [Configuration](docs/configuration.md): env vars and config.json — search backends (core|searxng|hybrid), fetch/browser knobs, on-device models, optional LLM providers, cache TTLs, serve policy, telemetry (off by default) +- [Configuration](docs/configuration.md): env vars and config.json — search backends (core|searxng|hybrid), fetch/browser knobs, on-device models, optional LLM providers, cache TTLs, serve policy, telemetry (on by default for a registered install, one flag to turn off) - [Tools](docs/tools.md): the 10 tools — search, fetch, crawl, cache, extract, find_similar, research, agent, diff, watch — with params and response fields - [CLI](docs/cli.md): management commands, one-shot tools, interactive shell, --json contract - [REST API](docs/rest-api.md): wigolo serve, POST /v1/{tool}, /openapi.json, remote MCP endpoints, fail-closed auth, resource limits diff --git a/site/src/components/HowItWorks.tsx b/site/src/components/HowItWorks.tsx index aa42bcc7d..1f84d3dae 100644 --- a/site/src/components/HowItWorks.tsx +++ b/site/src/components/HowItWorks.tsx @@ -183,8 +183,8 @@ export default function HowItWorks() { Models and cache live under ~/.wigolo on your machine — - no keys, nothing metered, and nothing leaves unless you opt into an - LLM. + no keys, no account, nothing metered. No page content, URLs, or + credentials leave your machine; usage stats do, off with one flag. diff --git a/src/account/nudge.ts b/src/account/nudge.ts new file mode 100644 index 000000000..d4a4dc356 --- /dev/null +++ b/src/account/nudge.ts @@ -0,0 +1,147 @@ +/** + * `/account/nudge.json` — the counter behind the ONE registration + * nudge (PX brief §0a.2: "one nudge after N successful runs, never repeated"). + * + * WHY IT IS A FILE AND NOT A PROCESS COUNTER. The surfaces that produce a + * successful run are mostly short-lived — a one-shot `wigolo search`, an MCP + * server the harness restarts every session. A counter in memory would reset + * before it ever reached N on the CLI, and would reach N once per session on + * MCP, i.e. it would either never fire or fire forever. "Never repeated" is a + * property of the disk or it is not a property at all. + * + * WHY COUNTING AND CLAIMING ARE TWO CALLS. `recordSuccessfulRun` is called by + * every tool-dispatch seam, including the daemon's REST surface — which has no + * channel to print a nudge into. `takeRegistrationNudge` is called only by the + * surfaces that can actually render one, and it is the call that burns the + * once-only flag. Splitting them is what keeps a REST-heavy user's runs + * counting toward a nudge they will see on their next CLI or MCP call, instead + * of silently spending the single nudge on a surface with nowhere to put it. + * + * WHY THE FLAG IS WRITTEN BEFORE THE CALLER RENDERS. `takeRegistrationNudge` + * persists `nudged: true` and THEN returns true. If the render fails the user + * loses one nudge; if the order were reversed a crash between render and write + * would repeat it, and §0a.2's word is "never". Losing a nudge is a nudge; the + * other way round is a nag. + * + * NOTHING HERE IS SECRET, and unlike `state.json` this file carries no email — + * but it is written 0600 into the same 0700 directory anyway, because the + * directory is already that and a second mode in one place is a question + * somebody has to answer later. + */ + +import { readFileSync, writeFileSync, mkdirSync, renameSync, unlinkSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { randomBytes } from 'node:crypto'; +import { createLogger } from '../logger.js'; + +const log = createLogger('account'); + +const FILE_MODE = 0o600; +const DIR_MODE = 0o700; + +/** + * How many successful runs before the single nudge. + * + * Five, chosen so it lands after the user has seen wigolo work rather than + * during their first look at it — a nudge on run one is an install prompt + * wearing a footer, and a nudge at fifty is one nobody reaches. Recorded with + * its reversal condition in DECISIONS-AUTO (A-336-2). + */ +export const NUDGE_AFTER_RUNS = 5; + +export interface NudgeState { + /** Successful tool runs seen on this install, across every surface. */ + successful_runs: number; + /** True once the single nudge has been handed to a surface to render. */ + nudged: boolean; +} + +export const EMPTY_NUDGE_STATE: Readonly = Object.freeze({ + successful_runs: 0, + nudged: false, +}); + +export function nudgeStatePath(dataDir: string): string { + return join(dataDir, 'account', 'nudge.json'); +} + +/** + * The store. Every method is TOTAL: a corrupt file, an unwritable disk or a + * read-only data dir degrades to "no nudge", never to a thrown error — this + * sits on the tail of every successful tool call, and a footer is not allowed + * to be the reason a result never reaches its caller. + */ +export class NudgeStore { + private readonly path: string; + + constructor(dataDir: string) { + this.path = nudgeStatePath(dataDir); + } + + read(): NudgeState { + try { + const raw = JSON.parse(readFileSync(this.path, 'utf8')) as Partial; + const runs = typeof raw.successful_runs === 'number' && Number.isFinite(raw.successful_runs) + ? Math.max(0, Math.floor(raw.successful_runs)) + : 0; + return { successful_runs: runs, nudged: raw.nudged === true }; + } catch { + return { ...EMPTY_NUDGE_STATE }; + } + } + + write(next: NudgeState): boolean { + try { + const dir = dirname(this.path); + mkdirSync(dir, { recursive: true, mode: DIR_MODE }); + const tmp = `${this.path}.${randomBytes(6).toString('hex')}.tmp`; + try { + writeFileSync(tmp, JSON.stringify(next, null, 2), { encoding: 'utf8', mode: FILE_MODE }); + renameSync(tmp, this.path); + } catch (err) { + try { unlinkSync(tmp); } catch { /* the temp file may never have been created */ } + throw err; + } + return true; + } catch (err) { + log.debug('nudge state write failed', { error: String(err) }); + return false; + } + } +} + +/** Count one successful run. Never renders anything; never throws. */ +export function recordSuccessfulRun(dataDir: string): void { + try { + const store = new NudgeStore(dataDir); + const state = store.read(); + // Once the nudge is spent the counter has no reader, so stop writing to disk + // on the tail of every tool call for the rest of the install's life. + if (state.nudged) return; + store.write({ ...state, successful_runs: state.successful_runs + 1 }); + } catch (err) { + log.debug('nudge count failed', { error: String(err) }); + } +} + +/** + * Claim the single nudge, if it is due. Returns true AT MOST ONCE per install. + * + * The caller has already established that this install is unregistered — the + * nudge has no meaning otherwise, and asking the gate from in here would put a + * second activation read on the tail of every call. + */ +export function takeRegistrationNudge(dataDir: string): boolean { + try { + const store = new NudgeStore(dataDir); + const state = store.read(); + if (state.nudged) return false; + if (state.successful_runs < NUDGE_AFTER_RUNS) return false; + // Write first, return second — see the header note on ordering. + if (!store.write({ ...state, nudged: true })) return false; + return true; + } catch (err) { + log.debug('nudge claim failed', { error: String(err) }); + return false; + } +} diff --git a/src/account/unlocks.ts b/src/account/unlocks.ts new file mode 100644 index 000000000..30392f3a8 --- /dev/null +++ b/src/account/unlocks.ts @@ -0,0 +1,79 @@ +/** + * What registration UNLOCKS, and the one nudge that says so (PX brief §0a.1–3, + * CEO consulting pass 2026-09-03). + * + * PX2 shipped a hard gate: an unregistered core install could not execute a tool + * on any surface. The amendment made that gate Studio-only, which leaves core + * with the opposite problem — an account now buys something rather than lifting + * a wall, and nothing in the product said what. This file is that answer, and it + * is deliberately the ONLY place the answer is written: the MCP footer, the + * per-session instructions notice and both `init` paths render this list, so a + * new unlock is one edit and the three surfaces cannot drift apart. + * + * CAPABILITY LANGUAGE, NOT PRODUCT NAMES. Each line names what the user gets to + * do, never the mechanism that does it — the same rule the tool descriptions + * follow, for the same reason: the mechanism is ours to change and the + * capability is what was promised. + * + * NO TIER ADJECTIVES. There is no "free", "pro" or "premium" here. Which grants + * an account carries is a server-side row the entitlement schema exists to let + * the CEO change without shipping code (PX brief §3), so compiling a tier name + * into a string would be publishing a decision this file does not own. + */ + +/** + * The unlock list, in the order it renders everywhere. + * + * Kept short on purpose: this is a footer on somebody else's result, not a + * pricing page. Four lines is what fits under a tool result without becoming + * the thing the reader is looking at. + */ +export const REGISTRATION_UNLOCKS: readonly string[] = Object.freeze([ + 'sync — your cache, settings and watches across machines', + 'marketplace — publish and install skills and plugins', + 'higher pacing and watch limits', + 'managed cloud runs, when they land', +]); + +/** The one sentence that must be true of core after §0a.1: nothing is walled. */ +export const UNREGISTERED_RUNS_LINE = + 'wigolo runs fully without an account — registering only adds to it.'; + +/** + * The telemetry claim, verbatim per PX brief §0a.4. + * + * It lives here rather than being retyped per surface because §0a.4 pins the + * WORDING, not the gist: "nothing leaves your machine" was retired precisely + * because six surfaces each said the privacy story slightly differently and one + * of them was false. A single exported constant is what makes "the claim is the + * same everywhere" checkable by a test instead of by reading. + */ +export const TELEMETRY_CLAIM = + 'no page content, URLs, or credentials leave your machine; usage stats do, off with one flag'; + +/** Bulleted unlock lines, ready to print under a heading. */ +export function unlockLines(bullet = '·'): string[] { + return REGISTRATION_UNLOCKS.map((u) => `${bullet} ${u}`); +} + +/** + * The registration nudge, as the block every surface renders. + * + * One block, one call to action, and the first line says the install already + * works — because the reader is looking at a successful result when they see + * it, and a prompt that implies otherwise reads as a wall being announced late. + */ +export function registrationNudgeLines(): string[] { + return [ + UNREGISTERED_RUNS_LINE, + '`wigolo register` unlocks:', + ...unlockLines().map((l) => ` ${l}`), + `Telemetry: ${TELEMETRY_CLAIM} (WIGOLO_TELEMETRY=off).`, + 'Shown once. It will not appear again.', + ]; +} + +/** The nudge as one text block — the MCP footer and the CLI line share it. */ +export function registrationNudgeText(): string { + return registrationNudgeLines().join('\n'); +} diff --git a/src/cli/account.ts b/src/cli/account.ts index 22c9b8afa..1cd822da8 100644 --- a/src/cli/account.ts +++ b/src/cli/account.ts @@ -113,6 +113,25 @@ function flagValue(args: readonly string[], name: string): string | null { * and a client-side pattern that rejects a deliverable address is a worse bug * than one that forwards an undeliverable one. This only catches "empty" and * "obviously not an address" before spending a round trip. */ +/** Presence-only flag, matching `--flag` exactly (never `--flag=…`). */ +function hasFlag(args: readonly string[], name: string): boolean { + return args.includes(name); +} + +/** + * Marketing consent from flags — the headless answer to the interactive prompt. + * + * DEFAULT FALSE, and that is a legal position rather than a preference (PX brief + * §0a.5): consent must be an affirmative act, so an omitted flag is a NO. The + * explicit `--no-marketing-consent` exists anyway, because an agent writing the + * command line should be able to say "the human declined" and have it read as a + * decision in the transcript instead of as an omission. + */ +function marketingConsentFromFlags(args: readonly string[]): boolean { + if (hasFlag(args, '--no-marketing-consent')) return false; + return hasFlag(args, '--marketing-consent'); +} + function looksLikeEmail(value: string): boolean { const v = value.trim(); return v.length >= 3 && v.includes('@') && !v.startsWith('@') && !v.endsWith('@') && !/\s/.test(v); @@ -244,6 +263,12 @@ async function runSignIn( const json = args.includes('--json'); const write = (line: string): void => { stderr.write(`${line}\n`); }; + // HEADLESS / AGENT-ASSISTED (PX brief §0a.2). `--headless` asks nothing: every + // answer arrives as a flag, so an unattended agent can drive the half of + // registration that is machine work while the human does the half that is not. + const headless = hasFlag(args, '--headless') || hasFlag(args, '--no-input'); + const codeFlag = flagValue(args, '--code'); + const store = new AccountStateStore(dataDir); // Read BEFORE verify: "account created" vs "signed in" is keyed on local // prior state only. The verify response does not flag creation and we do not @@ -251,6 +276,10 @@ async function runSignIn( const priorState = store.read(); let email = flagValue(args, '--email'); + if (!email && headless) { + write('`--email` is required with `--headless` — there is no prompt to ask on.'); + return 1; + } if (!email) email = await prompter.ask('Email: '); if (email === null) { write('No email address given.'); @@ -262,40 +291,84 @@ async function runSignIn( return 1; } - const requested = await client.requestCode(email); - if (!requested.ok) { - write(`Could not request a sign-in code: ${describeFailure(requested.code, requested.message)}`); - return 1; - } - write('Check your email for the sign-in code.'); - - const code = await prompter.ask('Sign-in code: '); - if (code === null || code.trim().length === 0) { - write('No sign-in code given.'); - return 1; - } - - let marketingConsent: boolean | undefined; + // The disclosure is SERVED, never client-bundled (PX1 §9). If we cannot fetch + // it we cannot show it, and creating an account without showing the wording + // the service is publishing is not a thing this command may do — so the flow + // stops rather than degrading to a summary of our own. Headless is held to the + // SAME rule: the agent relays it to the human, who is the one consenting. let disclosureVersion: string | null = priorState.disclosure_version; - - if (options.withConsent) { - // The disclosure is SERVED, never client-bundled (PX1 §9). If we cannot - // fetch it we cannot show it, and creating an account without showing the - // wording the service is publishing is not a thing this command may do — - // so the flow stops rather than degrading to a summary of our own. + const showDisclosure = async (): Promise => { const disclosure = await client.telemetryDisclosure(); if (!disclosure.ok) { write('Could not load the telemetry disclosure from the accounts service.'); write('Registration stopped — nothing was created. Try again when the service is reachable.'); - return 1; + return false; } write(''); write(disclosure.data.text); write(''); disclosureVersion = disclosure.data.version; + return true; + }; + + if (headless && codeFlag === null) { + // STAGE ONE of the headless flow: the agent asks the service to mail the + // human, then STOPS. Nothing is created here — `request-code` is idempotent + // and carries no consent — so an agent that runs this without the human's + // say-so has done nothing but send them an email they can ignore. + if (options.withConsent && !(await showDisclosure())) return 1; + const requested = await client.requestCode(email); + if (!requested.ok) { + write(`Could not request a sign-in code: ${describeFailure(requested.code, requested.message)}`); + return 1; + } + const finish = `wigolo ${verb} --headless --email ${email} --code `; + if (json) { + stdout.write(`${JSON.stringify({ + status: 'ok', + action: 'claim_pending', + email, + finish_command: finish, + ...(disclosureVersion === null ? {} : { disclosure_version: disclosureVersion }), + })}\n`); + } + write(`A sign-in code is on its way to ${email}.`); + write('That mailbox owns this account: nothing exists until its owner hands over the code.'); + write(`Finish with: ${finish}`); + write('Add `--marketing-consent` only if they said yes to product-update email.'); + return 0; + } + + let code: string | null = codeFlag; + if (code === null) { + const requested = await client.requestCode(email); + if (!requested.ok) { + write(`Could not request a sign-in code: ${describeFailure(requested.code, requested.message)}`); + return 1; + } + write('Check your email for the sign-in code.'); + code = await prompter.ask('Sign-in code: '); + } + if (code === null || code.trim().length === 0) { + write('No sign-in code given.'); + return 1; + } + + let marketingConsent: boolean | undefined; - const answer = await prompter.ask('Send me occasional product updates by email? [Y/n] '); - marketingConsent = parseYesNo(answer, true); + if (options.withConsent) { + if (!(await showDisclosure())) return 1; + if (headless) { + // No prompt exists, so the flag IS the answer — and an absent flag is a no. + marketingConsent = marketingConsentFromFlags(args); + } else { + // UNTICKED BY DEFAULT (§0a.5, reversing §5 pin 8's "default ON"). Consent + // has to be an affirmative act to be valid, which makes the bare-Enter + // answer NO and puts the capital letter on the `N`. Product and security + // email is transactional and unaffected — this toggle is marketing only. + const answer = await prompter.ask('Send me occasional product updates by email? [y/N] '); + marketingConsent = parseYesNo(answer, false); + } } const verified = await client.verify({ diff --git a/src/cli/daemon.ts b/src/cli/daemon.ts index 19637cb9b..cdfac5f84 100644 --- a/src/cli/daemon.ts +++ b/src/cli/daemon.ts @@ -5,7 +5,6 @@ import { DaemonHttpServer } from '../daemon/http-server.js'; import { checkBindHost } from '../companion/bind.js'; import { closeDaemonBrowser } from '../fetch/playwright-tier.js'; import { resolveApiToken, evaluateBindGate } from '../daemon/rest/auth.js'; -import { checkActivation } from '../server/activation.js'; const logger = createLogger('cli'); @@ -123,23 +122,6 @@ export function checkServeBindGate(args: DaemonArgs): ServeBindGateResult { export function runDaemon(args: string[]): void { const parsed = parseDaemonArgs(args); - // THE ACTIVATION GATE for `serve` (PX2 mini-spec §3, A-212-2). This surface is - // human-invoked at a terminal, so unlike MCP it refuses to START rather than - // starting and refusing each call: the operator is standing right there and the - // single line names the command that fixes it. A daemon that came up and 403'd - // everything would be strictly worse — the same outcome, discovered later, from - // a different machine. - // - // It refuses on ANY refusal, not only "never activated" (A-222-2): an expired - // sign-in cannot execute a tool either, and the gate's own line already says - // which of the three situations this is. - const activation = checkActivation(); - if (!activation.ok) { - log(activation.message); - process.exit(1); - return; - } - // Two fail-closed checks before the server starts, in order: // 1. INTENT — a non-loopback bind requires an explicit `--allow-remote`. // 2. AUTH — a non-loopback bind additionally needs a bearer token, or an explicit diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts index 233716843..4b36d5f65 100644 --- a/src/cli/doctor.ts +++ b/src/cli/doctor.ts @@ -23,6 +23,7 @@ import { } from '../search/core/engine-health.js'; import type { EngineEntry } from '../search/core/engine-base.js'; import { telemetryStatus } from '../telemetry/index.js'; +import { TELEMETRY_CLAIM } from '../account/unlocks.js'; import { readPersistedConfig } from '../persisted-config.js'; import { authenticatedOriginCount } from '../companion/auth-origin-store.js'; import { readEscalationCounters, formatEscalationCounterLines } from '../companion/escalation-counters.js'; @@ -1451,7 +1452,9 @@ function checkTelemetryStatus(): void { // account`, which already says "Telemetry: on" / "Telemetry: off". switch (telemetryStatus()) { case 'enabled': - out('[wigolo doctor] Telemetry: on — usage and reliability counters are sent to your account (set WIGOLO_TELEMETRY=off to turn it off)'); + // §0a.4 pins the WORDING of the claim, not its gist, so it is imported rather + // than re-typed in the ninth voice — see `account/unlocks.ts`. + out(`[wigolo doctor] Telemetry: on — ${TELEMETRY_CLAIM} (set WIGOLO_TELEMETRY=off to turn it off)`); break; case 'disabled': out('[wigolo doctor] Telemetry: off — nothing is queued and nothing is sent'); diff --git a/src/cli/help.ts b/src/cli/help.ts index 8418ea64c..ad4c1217a 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -258,11 +258,14 @@ Subcommands: Write the cached corpus out as dated Markdown + a manifest studio setup Install the browser companion and pair it with this machine -Your wigolo account (\`wigolo auth\` is a different thing — site sign-ins for -the browser engine): - register [--email E] [--json] - Create your wigolo account and activate this install - login [--email E] [--json] +Your wigolo account is OPTIONAL — every tool above runs without one. It unlocks +sync, the marketplace, higher pacing and watch limits (\`wigolo auth\` is a +different thing — site sign-ins for the browser engine): + register [--email E] [--code C] [--headless] [--marketing-consent] [--json] + Create your wigolo account and unlock the extras. + --headless asks nothing: run it once to mail the + code, then again with --code to finish. + login [--email E] [--code C] [--headless] [--json] Sign in to your wigolo account on this machine logout [--json] Sign out on this machine (local credential only) whoami [--json] Show the signed-in account and its activation state diff --git a/src/cli/init.ts b/src/cli/init.ts index 318748578..22cc27e8f 100644 --- a/src/cli/init.ts +++ b/src/cli/init.ts @@ -222,33 +222,44 @@ async function reportSetupAndDoctor( * * Fail-safe: any read/parse failure returns `null`. A hint is never worth failing setup. */ -export async function activationNextStepLine( +export async function activationNextStepLines( dataDir: string, env: NodeJS.ProcessEnv = process.env, nowMs: number = Date.now(), -): Promise { +): Promise { try { const { AccountStateStore } = await import('../account/state.js'); const { resolvePinnedKeys } = await import('../account/pinned-keys.js'); const { evaluateActivation } = await import('../account/gate.js'); + const { unlockLines, UNREGISTERED_RUNS_LINE, TELEMETRY_CLAIM } = await import('../account/unlocks.js'); const state = new AccountStateStore(dataDir).read(); const keys = resolvePinnedKeys(env); const decision = evaluateActivation({ state, keys: keys.keys }, nowMs); - if (decision.ok) return null; + if (decision.ok) return []; switch (decision.reason) { case 'never_activated': - return 'Next step: run `wigolo register` to activate this install' - + ' (already have an account? `wigolo login`).'; + // NOT a next step, and the wording is the whole point (§0a.1/3). Setup has + // just finished; the install is complete and every tool works. Registering + // is an offer, so the block leads with what already works and never uses + // the imperative the other two arms have earned. + return [ + UNREGISTERED_RUNS_LINE, + 'Optional — `wigolo register` unlocks:', + ...unlockLines().map((l) => ` ${l}`), + `Telemetry: ${TELEMETRY_CLAIM} (WIGOLO_TELEMETRY=off).`, + ]; case 'expired': - return 'Next step: run `wigolo login` — the sign-in on this machine has expired.'; + // These two arms DO stay imperative: the user already has an account, so + // something they were promised has stopped working and only they can fix it. + return ['Next step: run `wigolo login` — the sign-in on this machine has expired.']; case 'update_required': - return 'Next step: update wigolo, then run `wigolo login` — this build cannot verify' - + ' your sign-in.'; + return ['Next step: update wigolo, then run `wigolo login` — this build cannot verify' + + ' your sign-in.']; } } catch { // Best-effort: a hint failure never affects setup or the exit code. } - return null; + return []; } /** @@ -375,7 +386,7 @@ interface InitJsonSummary { components?: ComponentSummary; doctor?: DoctorSummaryCheck[]; /** The first-run activation hint, when this install is not activated yet. Absent when it is. */ - nextStep?: string; + nextSteps?: string[]; readyCount?: number; total?: number; requiredFailed?: boolean; @@ -466,10 +477,10 @@ async function runInitWizard(flags: InitFlagsResolved): Promise { } const doctor = await reportSetupAndDoctor(components, dataDir, print); - const nextStep = await activationNextStepLine(dataDir); - if (nextStep !== null) { + const nextSteps = await activationNextStepLines(dataDir); + if (nextSteps.length > 0) { print(''); - print(` ${nextStep}`); + for (const line of nextSteps) print(` ${line}`); } if (flags.json) { @@ -481,7 +492,7 @@ async function runInitWizard(flags: InitFlagsResolved): Promise { configPersisted: true, components, doctor, - ...(nextStep !== null ? { nextStep } : {}), + ...(nextSteps.length > 0 ? { nextSteps: [...nextSteps] } : {}), }); } return 0; @@ -815,10 +826,10 @@ async function runInitPlain(flags: InitFlagsResolved): Promise { // requiredFailed. The exit code stays driven by the honest setup summary: a // genuinely-failed REQUESTED agent registration is still an exit-1 failure. const doctor = await reportSetupAndDoctor(components, config.dataDir, print); - const nextStep = await activationNextStepLine(config.dataDir); - if (nextStep !== null) { + const nextSteps = await activationNextStepLines(config.dataDir); + if (nextSteps.length > 0) { print(''); - print(` ${nextStep}`); + for (const line of nextSteps) print(` ${line}`); } if (flags.json) { @@ -833,7 +844,7 @@ async function runInitPlain(flags: InitFlagsResolved): Promise { readyCount: summary.readyCount, total: summary.total, requiredFailed: summary.requiredFailed, - ...(nextStep !== null ? { nextStep } : {}), + ...(nextSteps.length > 0 ? { nextSteps: [...nextSteps] } : {}), }); } return summary.exitCode; diff --git a/src/cli/tool-run.ts b/src/cli/tool-run.ts index f7dac751c..e0effe5ab 100644 --- a/src/cli/tool-run.ts +++ b/src/cli/tool-run.ts @@ -7,8 +7,8 @@ import { DuckDuckGoEngine } from '../search/engines/duckduckgo.js'; import { BingEngine } from '../search/engines/bing.js'; import { initDatabase, closeDatabase } from '../cache/db.js'; import { BackendStatus } from '../server/backend-status.js'; -import { checkActivation } from '../server/activation.js'; import { recordToolTelemetry } from '../telemetry/instrumentation.js'; +import { noteSuccessfulToolRun, claimRegistrationNudge } from '../server/activation.js'; import { getConfig } from '../config.js'; import { createLogger } from '../logger.js'; import { parseArgs, type ParsedArgs } from '../repl/parser.js'; @@ -186,17 +186,6 @@ export async function runTool(command: string, rawArgs: string[]): Promise a !== '--json'); // `parseArgs` expects the command token at index 0. The boolean-flag set @@ -225,6 +214,13 @@ export async function runTool(command: string, rawArgs: string[]): Promise 0; recordToolTelemetry(command, 'cli', !failed, Date.now() - startedAt, failed ? result.error : undefined); + if (!failed) { + // §0a.2: count the run, and render the single nudge if this is the one it + // falls due on. On stderr, so a `--json` pipeline's stdout stays parseable. + noteSuccessfulToolRun(); + const nudge = claimRegistrationNudge(); + if (nudge !== null) process.stderr.write(`\n${nudge}\n`); + } if (useJson && failed) { // Emit a JSON error object on stdout — the whole result already carries diff --git a/src/cli/tui/schema/advanced.ts b/src/cli/tui/schema/advanced.ts index 1228eaf23..d0b014538 100644 --- a/src/cli/tui/schema/advanced.ts +++ b/src/cli/tui/schema/advanced.ts @@ -78,7 +78,7 @@ export const advancedCategory: CategoryDef = { // NOT "anonymous": every batch is authorised as your account, so the counters are // attributed to it. Claiming anonymity in the same sentence that says "to your // account" was the shipped wording and it contradicted itself. - help: 'Send usage and reliability counters to your account: which tools ran, how long they took as coarse buckets, error classes, and the registrable domain of a blocked site. Never page content, queries, full URLs, credentials or file paths. Turn it off here, or set WIGOLO_TELEMETRY=off for a single run — off means nothing is queued and nothing leaves the machine.', + help: 'Send usage and reliability counters to your account: which tools ran, how long they took as coarse buckets, error classes, and the registrable domain of a blocked site. Never page content, queries, full URLs, credentials or file paths. Turn it off here, or set WIGOLO_TELEMETRY=off for a single run — off means nothing is queued and no counter reaches the wire.', }, { key: 'WIGOLO_DAEMON_HOST', diff --git a/src/daemon/http-server.ts b/src/daemon/http-server.ts index 0fd8a7c27..d9cd27453 100644 --- a/src/daemon/http-server.ts +++ b/src/daemon/http-server.ts @@ -27,30 +27,7 @@ import { createLogger } from '../logger.js'; import { ensureAdminToken, readAdminToken, tokenMatches } from './admin-token.js'; import { resetBreakers, getBreakerSnapshot } from '../search/core/engine-base.js'; import { resolveApiToken } from './rest/auth.js'; -import { checkActivation } from '../server/activation.js'; -/** - * REST paths inside the `/v1` family that the activation gate does NOT cover, - * because the gate's predicate is "can this reach one of the ten tool handlers" - * (A-212-1) and these cannot (A-222-3). - * - * DISCOVERY — `/openapi.json`, `/v1/openapi.json`, `/v1/tools` describe the - * surface and execute nothing. They are this transport's `initialize` and - * `tools/list`, which mini-spec §3 keeps open on MCP; a REST client must be - * able to learn what a server offers before it has an account. - * - * The runs surface used to be the second group, ungated for the same predicate. It left core with - * the companion extraction, and the group left with it rather than being kept warm for it. - */ -const REST_UNGATED_EXACT: ReadonlySet = new Set([ - '/openapi.json', - '/v1/openapi.json', - '/v1/tools', -]); - -function restPathIsUngated(pathname: string): boolean { - return REST_UNGATED_EXACT.has(pathname); -} import type { RestRouter } from './rest/router.js'; export type UpgradeHandler = (req: IncomingMessage, socket: Duplex, head: Buffer) => void; @@ -388,20 +365,12 @@ export class DaemonHttpServer { pathname === '/compat/firecrawl' || pathname.startsWith('/compat/firecrawl/') ) { - // THE ACTIVATION GATE for the REST families (PX2 mini-spec §3, A-212-2). - // Route-level IS tool-level here, and this is the one seam that sits above - // BOTH dispatchers: `/v1` goes through `rest/dispatch.ts`, but the - // firecrawl-compat handlers call `handleFetch`/`handleSearch`/`handleCrawl` - // directly and would walk straight past a check placed inside dispatch. - // - // See `REST_UNGATED_EXACT` for which paths inside this family are exempt - // and why. `/health`, `/sse` and every non-tool route never reach here. - if (!restPathIsUngated(pathname)) { - const activation = checkActivation(); - if (!activation.ok) { - return this.writeRequestError(res, 403, 'not_activated', activation.message); - } - } + // NO ACTIVATION GATE HERE (PX brief §0a.1, 2026-09-03). PX2 refused every + // `/v1` and firecrawl-compat request from an unregistered install; the CEO + // consulting pass amended the hard gate to Studio-only, so core's REST + // surface runs unregistered. The `requireActivation` seam itself stays — + // Studio and the unlock list still ask it the same question — but no core + // route turns its answer into a refusal. const router = await this.getRestRouter(); return router.handle(req, res); } diff --git a/src/daemon/rest/dispatch.ts b/src/daemon/rest/dispatch.ts index 462f6fe48..e1f6afcee 100644 --- a/src/daemon/rest/dispatch.ts +++ b/src/daemon/rest/dispatch.ts @@ -23,6 +23,7 @@ import { handleDiff, type DiffInput } from '../../tools/diff.js'; import { handleWatch } from '../../tools/watch.js'; import { scheduleOverdueCheck } from '../../watch/scheduler.js'; import { recordToolTelemetry } from '../../telemetry/instrumentation.js'; +import { noteSuccessfulToolRun } from '../../server/activation.js'; import { guardServeTarget } from './target-guard.js'; import { guardResolvedServeTarget, type SsrfResult, type SsrfRejection } from '../../watch/ssrf.js'; import { getConfig } from '../../config.js'; @@ -406,12 +407,16 @@ function shapeUntrusted(tool: string, input: unknown, body: unknown, mode: Untru export async function dispatchTool(tool: string, input: unknown, ctx: DispatchContext): Promise { const startedAt = Date.now(); const result = await dispatchToolInner(tool, input, ctx); - // Reported here rather than in `routeRequest`, for the same reason the MCP seam reports - // from the audit block: this is the wrapper every REST tool call passes through, and it - // sits BELOW the activation gate (`routeRequest`), so a refused request returns without - // ever reaching this function and emits nothing at all. + // Reported here rather than in `routeRequest`: this is the wrapper every REST tool + // call passes through. Since §0a.1 removed the route-level activation gate there is + // no longer anything above it to filter what arrives — telemetry's own activation + // check (`telemetry/client.ts`) is what keeps an unregistered install silent. const ok = result.status >= 200 && result.status < 300; recordToolTelemetry(tool, 'rest', ok, Date.now() - startedAt, ok ? undefined : restFailure(result)); + // COUNT ONLY, never claim (PX brief §0a.2). A REST response has no channel for a + // prose nudge — the body is a typed envelope somebody parses — so these runs push + // the counter and the nudge is rendered by whichever CLI or MCP call crosses N. + if (ok) noteSuccessfulToolRun(); if (result.status !== 200) return result; return { ...result, body: shapeUntrusted(tool, input, result.body, ctx.untrustedMode) }; } diff --git a/src/instructions.ts b/src/instructions.ts index 25070212f..fab7706af 100644 --- a/src/instructions.ts +++ b/src/instructions.ts @@ -65,32 +65,40 @@ Wigolo returns structured evidence — YOU write the final answer from it. Full usage detail: read resource \`wigolo://docs/usage\`.`; /** - * The one-line activation notice prepended to the per-session instructions when - * the install has no account yet (PX2 mini-spec §3). + * The one-line unlock notice prepended to the per-session instructions when the + * install has no account yet (PX brief §0a.1-3, CEO consulting pass 2026-09-03). * - * It exists because the refusal a harness gets back from `tools/call` arrives - * AFTER the model has already decided to call a tool. Saying it once, up front, - * is the difference between an agent that reports "wigolo needs an account" and - * one that keeps retrying a tool it can never run. It is deliberately not a - * second copy of the refusal line — the refusal is the gate's to word (see - * `src/account/gate.ts`); this only tells the model the surface is inert. + * WHAT IT USED TO BE, AND WHY THAT MATTERS TO ITS WORDING. Under PX2 this line + * said the surface was INERT — every `tools/call` was refused until an account + * existed — because a harness only learns about a refusal after the model has + * already committed to a tool call. §0a.1 made the hard gate Studio-only, so + * the notice's whole premise is gone: nothing here is refused. What replaced it + * has the opposite failure mode to avoid. A model that reads "not registered" + * and infers "so this will not work" would stop calling tools that work + * perfectly, which is exactly the outcome the old line was written to cause. So + * the first clause is the capability, the rest is the offer, and the word + * "optional" is doing real work. + * + * It carries no per-unlock detail: the four-line list is the footer's job + * (`src/account/unlocks.ts`), and every character here is charged against the + * per-session instruction budget on every single session. */ -export const ACTIVATION_NOTICE = - 'NOT ACTIVATED: this wigolo install has no account, so every tool call is refused until `wigolo register` completes (already have one? `wigolo login`). Registering takes effect on the next call — no restart.'; +export const UNLOCK_NOTICE = + 'ACCOUNT: none on this install. Every tool below works anyway — registration is optional and only ADDS to wigolo (sync, marketplace, higher pacing and watch limits). `wigolo register` when the user wants those; never block a tool call on it.'; /** - * The per-session instructions for a server, with the activation notice when the - * install is un-activated. + * The per-session instructions for a server, with the unlock notice when the + * install has no account. * - * HONEST LIMITATION, stated in the mini-spec rather than papered over: this - * string is composed once at server construction — per session on the daemon, - * per PROCESS on stdio — so after registering, the notice lingers until the - * harness restarts the server. Harmless, because tool calls re-check per - * dispatch and start working immediately; the notice's own last sentence says - * exactly that, so a model reading a stale notice is not misled. + * HONEST LIMITATION, unchanged from PX2 and still worth stating: this string is + * composed once at server construction — per session on the daemon, per PROCESS + * on stdio — so after registering, the notice lingers until the harness restarts + * the server. It was harmless then because tool calls re-checked per dispatch; + * it is more harmless now, because a stale copy of this notice describes an + * install that has strictly more capability than the notice claims. */ export function serverInstructions(activated: boolean): string { - return activated ? WIGOLO_INSTRUCTIONS : `${ACTIVATION_NOTICE}\n\n${WIGOLO_INSTRUCTIONS}`; + return activated ? WIGOLO_INSTRUCTIONS : `${UNLOCK_NOTICE}\n\n${WIGOLO_INSTRUCTIONS}`; } // Full usage guide. Surfaced via the wigolo://docs/usage resource so MCP diff --git a/src/repl/shell.ts b/src/repl/shell.ts index 70ff94942..9ba9aa8cb 100644 --- a/src/repl/shell.ts +++ b/src/repl/shell.ts @@ -6,8 +6,8 @@ import { getConfig } from '../config.js'; import { parseArgs, tokenize, type ParsedArgs } from './parser.js'; import { booleanFlagsFor } from '../cli/flag-bridge.js'; import { complete } from './completer.js'; -import { checkActivation } from '../server/activation.js'; import { recordToolTelemetry } from '../telemetry/instrumentation.js'; +import { noteSuccessfulToolRun, claimRegistrationNudge } from '../server/activation.js'; import { formatSearchResults, formatFetchResult, @@ -121,16 +121,6 @@ function appendHistory(historyPath: string, line: string): void { } export async function startShell(deps: ReplDeps, options: ShellOptions = {}): Promise { - // THE ACTIVATION GATE for the REPL (PX2 mini-spec §3). Checked once at entry, - // before the readline interface exists: a shell whose every command is refused - // is worse than no shell, and the single line names the command that fixes it. - // `failures: 1` so a piped `wigolo shell` exits non-zero like any failed run. - const activation = checkActivation(); - if (!activation.ok) { - (options.errorOutput ?? process.stderr).write(`${activation.message}\n`); - return { failures: 1 }; - } - const config = getConfig(); const historyPath = config.shellHistoryPath; let jsonMode = options.jsonMode ?? false; @@ -326,6 +316,12 @@ export async function startShell(deps: ReplDeps, options: ShellOptions = {}): Pr const ok = failures === failuresBefore; recordToolTelemetry(parsed.command, 'repl', ok, Date.now() - startedAt, thrownError ?? inBandError); + if (ok) { + // §0a.2, same seam as the CLI one-shot: count, then render at most once. + noteSuccessfulToolRun(); + const nudge = claimRegistrationNudge(); + if (nudge !== null) say(`\n${nudge}`); + } rl.prompt(); } diff --git a/src/server.ts b/src/server.ts index de2e9830f..7c4430cd4 100644 --- a/src/server.ts +++ b/src/server.ts @@ -58,7 +58,7 @@ import { TOOL_DESCRIPTIONS, serverInstructions, } from './instructions.js'; -import { checkActivation, activationToolError } from './server/activation.js'; +import { checkActivation, appendRegistrationFooter } from './server/activation.js'; import { startTelemetry } from './telemetry/index.js'; import { recordToolTelemetry } from './telemetry/instrumentation.js'; import { @@ -487,22 +487,12 @@ export function createMcpServer(subsystems: Subsystems): Server { server.setRequestHandler(CallToolRequestSchema, async (request, extra) => { const { name, arguments: args } = request.params; - // THE ACTIVATION GATE (PX2 mini-spec §3, A-212-2). It is the FIRST statement in - // this handler and that position is load-bearing, not tidiness: the watch - // scheduler below re-fetches overdue URLs and posts webhooks, so a gate placed - // under it would refuse the call and still egress on behalf of an install that - // has no account. One check covers stdio, the daemon's per-session HTTP MCP - // (both build their servers from this factory) and any hosted surface, because - // every one of them arrives here. - // - // `initialize` and `tools/list` are untouched — the protocol still works, and - // the refusal is a designed tool error rather than a dead connection. - const activation = checkActivation(); - if (!activation.ok) { - log.info('tool call refused — install not activated', { tool: name, step: activation.step }); - return activationToolError(activation); - } - + // NO ACTIVATION GATE HERE (PX brief §0a.1, 2026-09-03). PX2 refused every + // `tools/call` from an unregistered install at exactly this line; the CEO + // consulting pass made the hard gate Studio-only, so core dispatches all ten + // tools whether or not an account exists. Registration became an UNLOCK, and + // the only thing this surface now derives from activation is the footer that + // says so — appended once, below, after the result is computed. // Lazy-execution hook for the `watch` tool. Every non-watch tool call // gives us a chance to run overdue watch jobs in the background. This // is intentional: wigolo has no daemon — checks only fire when the @@ -744,12 +734,17 @@ export function createMcpServer(subsystems: Subsystems): Server { ts: Date.now(), durationMs: Date.now() - auditStartedAt, }); - // Telemetry rides the same seam as the audit, and deliberately BELOW the gate: a - // refused call returned above and never reaches here, so an unactivated install - // produces no account, no queue write and no event — the absence is structural, - // not a condition anyone has to remember to write. + // Telemetry rides the same seam as the audit. Before §0a.1 this line sat below a + // gate that returned first, so an unregistered install structurally emitted + // nothing; now every install reaches here and the only thing standing between an + // unregistered run and an event is the off switch itself (A-336-5). That is the + // claim §0a.4 makes out loud rather than the silence PX2 could imply. recordToolTelemetry(name, 'mcp', !result.isError, Date.now() - auditStartedAt, errorReason); - return result; + // §0a.2/3: registration is an unlock, so the ONE thing an unregistered install + // is told is what an account would add — once, in a footer, on a call that + // already succeeded. Product law 9: the text we return IS the interface, so it + // rides the result rather than a channel a terminal user cannot see. + return appendRegistrationFooter(result); }); return server; diff --git a/src/server/activation.ts b/src/server/activation.ts index 8aa763ce6..8fc7b2c57 100644 --- a/src/server/activation.ts +++ b/src/server/activation.ts @@ -34,6 +34,8 @@ import { getConfig } from '../config.js'; import { AccountStateStore, type AccountState } from '../account/state.js'; import { resolvePinnedKeys, type PinnedKey } from '../account/pinned-keys.js'; import { requireActivation, type ActivationDecision } from '../account/gate.js'; +import { recordSuccessfulRun, takeRegistrationNudge } from '../account/nudge.js'; +import { registrationNudgeText } from '../account/unlocks.js'; /** Mini-spec §3: in-memory activation state is re-read from disk at most this often. */ export const ACTIVATION_RELOAD_MS = 60_000; @@ -136,14 +138,68 @@ export function checkActivation(): ActivationDecision { return activationChecker().check(); } +// --------------------------------------------------------------------------- +// The unlock footer (PX brief §0a.1-3, CEO consulting pass 2026-09-03) +// --------------------------------------------------------------------------- +// +// PX2 turned a refusal into the only thing an unregistered install ever heard +// from this file. The amendment deleted the refusal from every core surface, so +// what is left is the opposite job: an unregistered install runs everything, and +// exactly once — after it has seen wigolo work N times — it is told what an +// account would ADD. Nothing below can refuse anything; the widest failure any +// of it has is printing nothing. + +/** Count one successful tool run toward the single nudge. Never throws. */ +export function noteSuccessfulToolRun(): void { + try { + // A registered install has nothing to be nudged about, and asking here keeps + // the disk counter from growing for the rest of an activated install's life. + if (checkActivation().ok) return; + recordSuccessfulRun(getConfig().dataDir); + } catch { + // Best-effort by contract: a footer never fails a result. + } +} + /** - * The MCP rendering of a refusal: a designed tool error, not a transport failure - * (product law 9 — the text we return IS the interface). `isError: true` so a - * harness renders it as a failed call rather than as a result. + * Claim the one registration nudge if it is due, as rendered text. + * + * Returns non-null AT MOST ONCE per install, across every surface and every + * process — the once-only flag lives on disk (`account/nudge.ts`), not here. */ -export function activationToolError(decision: Extract): { +export function claimRegistrationNudge(): string | null { + try { + if (checkActivation().ok) return null; + if (!takeRegistrationNudge(getConfig().dataDir)) return null; + return registrationNudgeText(); + } catch { + return null; + } +} + +interface ToolResultShape { content: { type: 'text'; text: string }[]; - isError: true; -} { - return { content: [{ type: 'text', text: decision.message }], isError: true }; + isError: boolean; +} + +/** + * The MCP seam: count the run, and append the nudge as its own text block when + * this is the call it is due on. + * + * A FAILED call counts for nothing and is never footed. Two reasons, both about + * honesty rather than tidiness: a footer under an error reads as part of the + * error ("did registering fail?"), and "after N successful runs" is the brief's + * wording — a user whose five calls all errored has not seen wigolo work and is + * owed a working tool, not a sign-up prompt. + * + * The nudge is a SEPARATE content block, never appended into the result's own + * text: every core tool returns JSON in that first block, and concatenating + * prose onto it would break every caller that parses it. + */ +export function appendRegistrationFooter(result: T): T { + if (result.isError) return result; + noteSuccessfulToolRun(); + const nudge = claimRegistrationNudge(); + if (nudge === null) return result; + return { ...result, content: [...result.content, { type: 'text' as const, text: nudge }] }; } diff --git a/tests/integration/activation-cli.test.ts b/tests/integration/activation-cli.test.ts index 76b2e3884..1827f0b27 100644 --- a/tests/integration/activation-cli.test.ts +++ b/tests/integration/activation-cli.test.ts @@ -1,20 +1,22 @@ /** - * The activation gate at the three process-entry surfaces (PX2 mini-spec §3): - * one-shot tool runs, the REPL, and `serve`. + * The three process-entry surfaces on an UNREGISTERED install (PX brief §0a.1, + * issue #336): one-shot tool runs, the REPL, and `serve`. * - * These three are short-lived or human-invoked, so unlike MCP they do not serve - * and then refuse per call — mini-spec §3 pins a single check at entry. What the - * arms below actually protect: + * WHAT THIS FILE USED TO PIN. Under PX2 each of these three checked activation + * once at process entry and stopped: a one-shot exited 1 with the refusal on + * stderr, the REPL returned `failures: 1` before readline ever attached, and + * `serve` called `process.exit(1)` before binding. §0a.1 made the hard gate + * Studio-only, so all three now start and run with no account, and every arm + * below is the inverse of the one it replaced. * - * - `--help` stays open. Help is not a tool call, and an install that cannot - * tell you what a command does cannot tell you how to fix itself either. - * - The REPL refuses before readline attaches, so an un-activated shell is - * never a prompt that rejects everything typed into it. - * - `serve` refuses to START. The operator is at a terminal; a daemon that came - * up and 403'd every request would be the same outcome, discovered later, - * from a different machine. + * WHY EACH ARM IS STILL WORTH ITS SECONDS. The three surfaces had three DIFFERENT + * refusal shapes — an exit code, a returned failure count, and a `process.exit` + * — so a gate left behind in any one of them fails differently and would be + * invisible to a sweep over the other two. `serve` is the sharpest: its refusal + * was a real `process.exit(1)` before the listener, which is indistinguishable + * from a crash unless something asserts the bind path is reached. * - * The un-activated condition is real: a fresh temp data dir with no account + * The unregistered condition is real: a fresh temp data dir with no account * state, evaluated by the shipped disk-backed checker. The suite as a whole runs * activated (tests/setup.ts), which is why each arm resets the checker first. */ @@ -33,8 +35,6 @@ import { startShell } from '../../src/repl/shell.js'; import { runDaemon } from '../../src/cli/daemon.js'; import type { ReplDeps } from '../../src/repl/commands/types.js'; -const REFUSAL = ACTIVATION_REFUSALS.never_activated; - function sink(): { stream: NodeJS.WritableStream; text: () => string } { const chunks: string[] = []; const stream = new Writable({ @@ -57,7 +57,7 @@ function captureStderr(): { text: () => string; restore: () => void } { return { text: () => chunks.join(''), restore: () => { process.stderr.write = original; } }; } -describe('activation gate — CLI one-shots, REPL and serve', () => { +describe('CLI one-shots, REPL and serve — unregistered', () => { let dataDir: string; let savedDataDir: string | undefined; @@ -78,18 +78,31 @@ describe('activation gate — CLI one-shots, REPL and serve', () => { try { rmSync(dataDir, { recursive: true, force: true }); } catch { /* ignore */ } }); - it('a one-shot tool run exits 1 with the pinned line on stderr', async () => { + it('a one-shot tool run executes with no account and exits 0', async () => { + // `cache stats` is the one of the ten that reaches a real handler and answers + // entirely from local state, so this arm can assert the STRONG thing — exit 0, + // a real result — instead of merely "no refusal was printed". Under PX2 this + // exact call exited 1 with the refusal and never reached the handler: `cache` + // was gated like every other tool, because locality is irrelevant to a + // predicate about which handlers exist. + const out: string[] = []; + const originalOut = process.stdout.write.bind(process.stdout); + process.stdout.write = ((c: string | Uint8Array) => { out.push(c.toString()); return true; }) as typeof process.stdout.write; const err = captureStderr(); try { - const code = await runTool('search', ['anything']); - expect(code).toBe(1); - expect(err.text()).toContain(REFUSAL); + const code = await runTool('cache', ['stats']); + expect(code).toBe(0); + for (const line of Object.values(ACTIVATION_REFUSALS)) { + expect(err.text()).not.toContain(line); + } + expect(out.join('').length).toBeGreaterThan(0); } finally { err.restore(); + process.stdout.write = originalOut; } }); - it('`--help` still works un-activated — help is not a tool call', async () => { + it('`--help` still works unregistered — it always did, and still must', async () => { const outChunks: string[] = []; const originalOut = process.stdout.write.bind(process.stdout); process.stdout.write = ((c: string | Uint8Array) => { outChunks.push(c.toString()); return true; }) as typeof process.stdout.write; @@ -97,15 +110,20 @@ describe('activation gate — CLI one-shots, REPL and serve', () => { try { const code = await runTool('search', ['--help']); expect(code).toBe(0); - expect(err.text()).not.toContain(REFUSAL); - expect(outChunks.join('')).not.toContain(REFUSAL); + for (const line of Object.values(ACTIVATION_REFUSALS)) { + expect(err.text()).not.toContain(line); + expect(outChunks.join('')).not.toContain(line); + } } finally { err.restore(); process.stdout.write = originalOut; } }); - it('the REPL refuses before readline attaches — no prompt that rejects everything', async () => { + it('the REPL attaches readline and prints its banner with no account', async () => { + // The banner is printed by the readline path, which PX2's check returned + // ABOVE. Its presence is the proof the shell was really built, and + // `failures: 0` is the proof nothing counted the startup as a failed run. const err = sink(); const out = sink(); const result = await startShell({} as ReplDeps, { @@ -113,13 +131,17 @@ describe('activation gate — CLI one-shots, REPL and serve', () => { output: out.stream, errorOutput: err.stream, }); - expect(result.failures).toBe(1); - expect(err.text()).toContain(REFUSAL); - // The banner is printed by the readline path, which must never be reached. - expect(err.text()).not.toContain('wigolo interactive shell'); + expect(result.failures).toBe(0); + expect(err.text()).toContain('wigolo interactive shell'); + for (const line of Object.values(ACTIVATION_REFUSALS)) { + expect(err.text()).not.toContain(line); + } }); - it('an activated REPL starts normally — the refusal above is the gate, not a broken shell', async () => { + it('a registered REPL starts IDENTICALLY — the arm above is about the gate', async () => { + // THE OUTSIDE SIGNAL. "The unregistered shell started" is only interesting + // next to a registered one that starts the same way; without this arm a shell + // that had stopped gating because it had stopped working would read as a pass. const restore = installActivated(); const err = sink(); const out = sink(); @@ -131,21 +153,26 @@ describe('activation gate — CLI one-shots, REPL and serve', () => { }); expect(result.failures).toBe(0); expect(err.text()).toContain('wigolo interactive shell'); - expect(err.text()).not.toContain(REFUSAL); } finally { restore(); } }); - it('`serve` refuses to start: one line on stderr, exit 1, no listener', () => { + it('`serve` starts with no account — it never calls process.exit on activation', () => { + // THE SHARPEST OF THE THREE. PX2's refusal here was a real `process.exit(1)` + // before the listener existed. `runDaemon` is left to run into its own bind + // path, and the assertions are that the exit spy was not called with 1 and + // that the bind-gate banner — the line printed strictly BELOW where the check + // used to sit — actually reached stderr. const err = captureStderr(); const exit = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never); try { runDaemon([]); - expect(exit).toHaveBeenCalledWith(1); - expect(err.text()).toContain(REFUSAL); - // It refuses BEFORE the bind-gate banner — nothing was started to be torn down. - expect(err.text()).not.toContain('Starting daemon on'); + expect(exit).not.toHaveBeenCalledWith(1); + expect(err.text()).toContain('Starting daemon on'); + for (const line of Object.values(ACTIVATION_REFUSALS)) { + expect(err.text()).not.toContain(line); + } } finally { exit.mockRestore(); err.restore(); diff --git a/tests/integration/px2-rc/rc-exit-gate.test.ts b/tests/integration/px2-rc/rc-exit-gate.test.ts index be5cd2940..1441b1b4a 100644 --- a/tests/integration/px2-rc/rc-exit-gate.test.ts +++ b/tests/integration/px2-rc/rc-exit-gate.test.ts @@ -1,15 +1,27 @@ /** - * PX2 RC exit gate, every arm (mini-spec §13): a fresh install demands - * registration, completes it against a locally-run accounts service, runs all - * ten tools with nothing leaving this machine, sends zero telemetry when - * telemetry is off, and refuses once a non-perpetual entitlement falls out of - * both its own validity window and the fourteen-day grace. + * PX2-R RC exit gate, every arm (mini-spec §13, amended by PX brief §0a.1-5): + * a fresh install RUNS ALL TEN TOOLS UNREGISTERED, nudges once and never again, + * completes registration against a locally-run accounts service both + * interactively and headlessly, runs all ten tools with nothing leaving this + * machine, sends zero telemetry when telemetry is off, and KEEPS RUNNING once a + * non-perpetual entitlement falls out of both its own validity window and the + * fourteen-day grace. + * + * WHAT §0a INVERTED, AND WHY THE ARMS DID NOT SIMPLY GO AWAY. PX2's gate arms + * asserted a refusal at every one of those points. The CEO consulting pass of + * 2026-09-03 made the hard gate Studio-only, so each refusal became its + * opposite — but the FIXTURE is what was expensive and what was load-bearing, + * not the assertion. A packed tarball installed from disk, a real Postgres + * cluster, a real accounts service on a back-dated clock: that apparatus is the + * only thing in the tree that can say "an actually-shipped install, with an + * actually-expired entitlement, still runs". So the arms keep their fixtures and + * flip their claims. * * The whole file is one sequence on purpose. Each arm's precondition is the - * previous arm's outcome — an install that has not refused has not proven it was - * fresh, and tools that run before registration would prove the opposite of the - * gate — so splitting them across files would mean re-paying a multi-minute - * install to assert something the previous file already established. + * previous arm's outcome — the unregistered arms must run before registration or + * they are testing a registered install — so splitting them across files would + * mean re-paying a multi-minute install to assert something the previous file + * already established. * * ORDER IS LOAD-BEARING AT THE TAIL. The telemetry arm needs a healthy activated * install, and the grace arm ENDS with one that is deliberately expired and a @@ -18,7 +30,7 @@ * activation had already been taken away from it. */ -import { readFile, writeFile } from 'node:fs/promises'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; @@ -56,10 +68,41 @@ import { startMcpSession, TEN_TOOLS, type McpSession } from './rc-mcp-client.js' if (RC_GATE_DISABLED) console.warn(RC_GATE_SKIP_NOTICE); -/** The refusal a never-activated install must give, verbatim (`src/account/gate.ts`). */ +/** The refusal a never-activated install must NEVER give in core, verbatim + * (`src/account/gate.ts`). Restated rather than imported for the same reason as + * `GRACE_MS` below: these arms drive an INSTALLED tarball. */ const NEVER_ACTIVATED_LINE = 'wigolo needs an account — run `wigolo register` to create one (already have one? `wigolo login`).'; +/** + * The sentence §0a.1 turns on (`src/account/unlocks.ts`). + * + * It leads BOTH surfaces that carry the offer — the single nudge and the closing + * block of first-run setup — which is why it has one name here and two aliases + * below: an arm asserting "the nudge has not fired yet" and an arm asserting + * "setup said the install works" are reading the same string for opposite reasons, + * and the local name is what says which. + */ +const UNREGISTERED_RUNS_LINE = 'wigolo runs fully without an account — registering only adds to it.'; + +/** The first line of the single registration nudge. */ +const NUDGE_LEAD_LINE = UNREGISTERED_RUNS_LINE; + +/** The unlock list the footer and first-run output must carry (`src/account/unlocks.ts`). */ +const UNLOCK_LINES = [ + 'sync — your cache, settings and watches across machines', + 'marketplace — publish and install skills and plugins', + 'higher pacing and watch limits', + 'managed cloud runs, when they land', +]; + +/** The telemetry claim, verbatim per PX brief §0a.4. */ +const TELEMETRY_CLAIM_LINE = + 'no page content, URLs, or credentials leave your machine; usage stats do, off with one flag'; + +/** `NUDGE_AFTER_RUNS` in `src/account/nudge.ts`, restated for the same reason. */ +const NUDGE_AFTER_RUNS = 5; + /** The changelog fixture's two bodies. `diff` is handed both, so the change is real. */ const CHANGELOG_V1 = 'Version one of this page.'; const CHANGELOG_V2 = @@ -117,6 +160,7 @@ describe.skipIf(RC_GATE_DISABLED)('PX2 RC exit gate — fresh install, registrat let tarball: PackedTarball; let full: FreshInstall; let omitOptional: FreshInstall; + let headless: FreshInstall; /** Set on every arm, so a forgotten variable reds instead of reaching a real host. */ let env: Record; @@ -172,36 +216,200 @@ describe.skipIf(RC_GATE_DISABLED)('PX2 RC exit gate — fresh install, registrat } }, 300_000); - it('refuses the first tool run before registration, naming `wigolo register`', async () => { + /** + * Put the single nudge back to "not yet due, not yet spent". + * + * The nudge is an install-lifetime resource and the arms share one install, so + * an arm that wants to OBSERVE it has to be the arm that spends it. Writing the + * file is how: it is the whole of the state (`src/account/nudge.ts`), it lives + * in the install's throwaway data dir under the system temp root, and the + * alternative — ordering the arms so the nudge happens to land where a test is + * looking — makes every future arm's placement load-bearing for a reason nobody + * reading it would guess. + */ + async function resetNudgeState(): Promise { + // `mkdir -p` because the directory is created by whichever surface writes the + // account state first, and an arm run in isolation (`-t`) has not run them. + // Measured: without it the arm dies on ENOENT instead of asserting anything, + // which is exactly the shape that makes a forced-condition check unreadable. + await mkdir(join(full.dataDir, 'account'), { recursive: true }); + await writeFile( + join(full.dataDir, 'account', 'nudge.json'), + `${JSON.stringify({ successful_runs: 0, nudged: false }, null, 2)}\n`, + 'utf8', + ); + } + + /** A tool result's FIRST content block — the tool's own JSON, never the footer. */ + function firstTextBlock(raw: unknown): string { + return (raw as { content?: Array<{ text?: string }> }).content?.[0]?.text ?? ''; + } + + it('closes first-run setup by naming the unlocks, not by demanding an account', async () => { + // §0a.3 on the OTHER surface the unlock list has to reach. The unit suite + // covers `activationNextStepLines`, which is the function that composes these + // lines — but composing them and PRINTING them are two different claims, and + // only one of them is what a person installing wigolo actually meets. So this + // arm drives the installed binary's real setup path and reads its real stdout. + // + // `--no-warmup` because the arm is about the closing block, not the component + // downloads; the RC install has no network to fetch models over anyway. + const result = await runCli(full, ['init', '--no-warmup'], { env, timeoutMs: 600_000 }); + + expect(result.code, `init failed on a fresh install:\n${result.combined}`).toBe(0); + // The premise first: setup must not tell the user their install is inert. + expect(result.combined).not.toContain(NEVER_ACTIVATED_LINE); + expect(result.combined).not.toContain('Next step: run `wigolo register`'); + // Then the offer, in full — the same four lines the MCP footer renders. + expect(result.combined).toContain(UNREGISTERED_RUNS_LINE); + for (const unlock of UNLOCK_LINES) { + expect(result.combined, `first-run output omitted the unlock "${unlock}"`).toContain(unlock); + } + expect(result.combined).toContain(TELEMETRY_CLAIM_LINE); + record('arm 1b — first-run setup output, unregistered', result.combined.slice(-1200)); + }, 900_000); + + it('runs the first tool on a fresh install with no account at all', async () => { + // THE SENTENCE §0a.1 TURNS ON, measured on a real installed tarball. PX2's + // version of this arm asserted exit 1 and the refusal line at exactly this + // point in the sequence. const result = await runCli(full, ['cache', '--stats'], { env }); - expect(result.code).toBe(1); - expect(result.combined).toContain(NEVER_ACTIVATED_LINE); + expect(result.code, `a fresh install refused its first tool run:\n${result.combined}`).toBe(0); + expect(result.combined).not.toContain(NEVER_ACTIVATED_LINE); + // Run one of five: far too early for the nudge, which is asserted below. + expect(result.combined).not.toContain(NUDGE_LEAD_LINE); record('arm 2 — first tool run, unregistered (CLI)', `$ wigolo cache --stats\n${result.combined}`); }, 300_000); - it('refuses every one of the ten tools over MCP before registration', async () => { + it('runs every one of the ten tools over MCP before registration', async () => { const session = await startMcpSession(full, env); try { - // The server serves the protocol and refuses per call (A-212-2), so a - // successful handshake here is part of the assertion, not a precondition. const listed = await session.listTools(); for (const tool of TEN_TOOLS) expect(listed).toContain(tool); - const refusals: string[] = []; + const outcomes: string[] = []; for (const tool of TEN_TOOLS) { const outcome = await session.call(tool, minimalArgs(tool, site.url)); - expect(outcome.text, `${tool} must refuse before registration`).toContain( + // The exit gate's clause is that all ten RUN unregistered. Whether each + // one's own answer is a result or an input complaint is arm 4's business + // — here the claim is only that no account was asked for. + expect(outcome.text, `${tool} refused before registration`).not.toContain( NEVER_ACTIVATED_LINE, ); - refusals.push(`${tool}: ${outcome.text.split('\n')[0]}`); + expect(outcome.text.length, `${tool} returned nothing`).toBeGreaterThan(0); + outcomes.push(`${tool}: ${outcome.text.split('\n')[0]}`); } - record('arm 2 — all ten tools refused over MCP, unregistered', refusals.join('\n')); + record('arm 2 — all ten tools RUN over MCP, unregistered', outcomes.join('\n')); } finally { await session.stop(); } }, 600_000); + it('nudges ONCE about registration, with the unlock list, and never again', async () => { + // §0a.2/3 on a real install. `cache --stats` is used because it succeeds from + // local state alone, so "N successful runs" is reached deterministically and + // the arm is not measuring the fixture site or the stub engine. + // + // THE COUNTER IS RESET FIRST, AND THAT IS NOT TIDINESS. The single nudge is an + // install-lifetime resource: whichever surface crosses N spends it, and the + // arms above cross N over MCP, where nothing is reading for a footer. Measured + // on this fixture — after the ten-tool arm the install sits at exactly + // `successful_runs: 5, nudged: true`, so a loop that merely drives "a generous + // margin past N" observes zero nudges and reds on a product that is behaving + // correctly. Resetting makes THIS arm the one that spends the nudge, which is + // also what lets it assert the stronger claim: not just "once across many runs" + // but quiet for N-1, loud on N, quiet forever after. + await resetNudgeState(); + + const quietBefore: string[] = []; + for (let i = 0; i < NUDGE_AFTER_RUNS - 1; i += 1) { + const r = await runCli(full, ['cache', '--stats'], { env }); + expect(r.code, `run ${i + 1} failed:\n${r.combined}`).toBe(0); + if (r.combined.includes(NUDGE_LEAD_LINE)) quietBefore.push(`run ${i + 1}`); + } + expect( + quietBefore, + `the nudge fired early, on ${quietBefore.join(', ')} — N is ${NUDGE_AFTER_RUNS}`, + ).toEqual([]); + + const loud = await runCli(full, ['cache', '--stats'], { env }); + expect(loud.code, `run ${NUDGE_AFTER_RUNS} failed:\n${loud.combined}`).toBe(0); + expect( + loud.combined.includes(NUDGE_LEAD_LINE), + `run ${NUDGE_AFTER_RUNS} did not nudge:\n${loud.combined}`, + ).toBe(true); + expect(loud.combined).toContain('wigolo register'); + // §0a.3: the unlock LIST, not merely an invitation to register. + for (const unlock of UNLOCK_LINES) expect(loud.combined).toContain(unlock); + // §0a.4: the claim, in the pinned wording, where the user is deciding. + expect(loud.combined).toContain(TELEMETRY_CLAIM_LINE); + + // "Never repeated" is the half a single observation cannot establish, and it + // is the half that fails loudest in the product — a nag. + const quietAfter: string[] = []; + for (let i = 0; i < NUDGE_AFTER_RUNS; i += 1) { + const r = await runCli(full, ['cache', '--stats'], { env }); + expect(r.code, `run ${NUDGE_AFTER_RUNS + i + 1} failed:\n${r.combined}`).toBe(0); + if (r.combined.includes(NUDGE_LEAD_LINE)) quietAfter.push(`run ${NUDGE_AFTER_RUNS + i + 1}`); + } + expect(quietAfter, `the nudge repeated on ${quietAfter.join(', ')}`).toEqual([]); + + record('arm 2b — the single registration nudge', loud.combined); + }, 900_000); + + it('renders the unlock footer on an MCP tool result, once, without breaking its JSON', async () => { + // §0a.3 on the surface product law 9 is about: for a terminal user with no + // plugin, the text the tool returns IS the interface, so the unlock list has to + // arrive INSIDE a result rather than on a channel only a CLI has. + // + // Reset for the same reason the arm above does, then drive N successful calls + // through the protocol. `cache` is the tool that answers from local state, so + // the count is the arm's own and not the fixture site's. + await resetNudgeState(); + + const session = await startMcpSession(full, env); + try { + const footed: string[] = []; + let lastJson = ''; + let footerText = ''; + for (let i = 0; i < NUDGE_AFTER_RUNS; i += 1) { + const outcome = await session.call('cache', { stats: true }); + expect(outcome.isError, `cache call ${i + 1} errored:\n${outcome.text}`).toBe(false); + if (outcome.text.includes(UNREGISTERED_RUNS_LINE)) { + footed.push(`call ${i + 1}`); + footerText = outcome.text; + } + lastJson = firstTextBlock(outcome.raw); + } + + expect(footed.length, `the footer appeared on ${footed.join(', ')}`).toBe(1); + expect(footed[0]).toBe(`call ${NUDGE_AFTER_RUNS}`); + // §0a.3: the footer's job is to say what an account ADDS, so the list is the + // assertion — a footer that only invited the reader to register would be the + // wall being announced late, which is the thing §0a.1 removed. + for (const unlock of UNLOCK_LINES) { + expect(footerText, `the MCP footer omitted the unlock "${unlock}"`).toContain(unlock); + } + expect(footerText).toContain(TELEMETRY_CLAIM_LINE); + const footedText = lastJson; + // The footer is a SEPARATE content block. Every core tool returns JSON in the + // first one, so prose concatenated onto it would break every caller that + // parses a result — which is most of them. + expect(() => JSON.parse(footedText) as unknown).not.toThrow(); + expect(footedText).not.toContain(UNREGISTERED_RUNS_LINE); + + const after = await session.call('cache', { stats: true }); + expect( + after.text.includes(UNREGISTERED_RUNS_LINE), + 'the footer repeated on the call after the one it was due on', + ).toBe(false); + record('arm 2c — the unlock footer on an MCP result', `footed on ${footed[0]} of ${NUDGE_AFTER_RUNS}`); + } finally { + await session.stop(); + } + }, 900_000); + it('completes registration through the installed binary, with the code from the dev outbox', async () => { const result = await runCli(full, ['register', '--email', EMAIL], { env, @@ -212,8 +420,10 @@ describe.skipIf(RC_GATE_DISABLED)('PX2 RC exit gate — fresh install, registrat onStarted: async (_child, write) => { const code = await readOutboxCode(service.dataDir, EMAIL); write(code); - // The consent prompt defaults Y; answering it explicitly keeps the arm - // independent of that default. + // §0a.5 reversed the marketing-consent default to unticked, so this + // prompt is now `[y/N]`. The arm answers it EXPLICITLY, which is what + // keeps it independent of the default in either direction — and the + // default itself gets its own arm below, driven with a bare newline. write('y'); }, }); @@ -236,6 +446,52 @@ describe.skipIf(RC_GATE_DISABLED)('PX2 RC exit gate — fresh install, registrat ); }, 600_000); + it('registers headlessly — the agent-assisted path, with no prompt anywhere', async () => { + // §0a.2's second half, on a separate install so the sequence's own account is + // untouched. STAGE ONE creates nothing: it asks the service to mail the human, + // prints the exact command that finishes the job, and exits 0 with no prompt. + headless = await installTarball(tarball.path); + const email = 'px2-rc-headless@example.test'; + + const started = await runCli(headless, ['register', '--headless', '--email', email], { env }); + expect(started.code, `headless stage one failed:\n${started.combined}`).toBe(0); + expect(started.combined).toContain(email); + expect(started.combined).toContain('--code'); + // Nothing exists yet: no account state was written by asking for a code. + expect(await readStateOrNull(headless)).toBeNull(); + + // STAGE TWO: the human hands over the code, the agent finishes. No stdin is + // written at any point in this arm and `runCli` ends the pipe immediately, so + // any surviving prompt reads EOF, takes the "nothing given" branch and exits + // 1 — which is what makes `code === 0` a real assertion that nothing asked. + const code = await readOutboxCode(service.dataDir, email); + const finished = await runCli( + headless, + ['register', '--headless', '--email', email, '--code', code], + { env }, + ); + expect(finished.code, `headless stage two failed:\n${finished.combined}`).toBe(0); + expect(finished.combined).toContain('Account created.'); + + const state = await readState(headless); + expect(state.email).toBe(email); + expect(state.entitlement_token).toMatch(/^v1\./); + // §0a.5: no `--marketing-consent` was passed, so consent is NO. An omitted + // flag defaulting to yes is exactly the GDPR-invalid shape §0a.5 reversed. + expect(state['marketing_consent']).toBe(false); + + // And the unlocked install runs, which is the point of unlocking anything. + const ran = await runCli(headless, ['cache', '--stats'], { env }); + expect(ran.code).toBe(0); + + record( + 'arm 3b — headless registration', + `$ wigolo register --headless --email ${email}\n${started.combined}\n` + + `$ wigolo register --headless --email ${email} --code \n${finished.combined}\n` + + `marketing_consent persisted as: ${String(state['marketing_consent'])}`, + ); + }, 900_000); + it('records which credential-custody tier actually ran on the full install', async () => { const result = await runCli(full, ['whoami'], { env }); @@ -303,7 +559,10 @@ describe.skipIf(RC_GATE_DISABLED)('PX2 RC exit gate — fresh install, registrat url: `${site.url}/changelog`, force_refresh: true, }); - expect(refreshed.isError, 're-reading the changed page failed').toBe(false); + expect( + refreshed.isError, + `re-reading the changed page failed:\n${refreshed.text}`, + ).toBe(false); expect(refreshed.text, 'force_refresh did not move the cache to version two').toContain( CHANGELOG_V2, ); @@ -387,9 +646,9 @@ describe.skipIf(RC_GATE_DISABLED)('PX2 RC exit gate — fresh install, registrat it('forces the encrypted-file custody tier on an --omit=optional install and still registers and runs a tool', async () => { omitOptional = await installTarball(tarball.path, { omitOptional: true }); - const refused = await runCli(omitOptional, ['cache', '--stats'], { env }); - expect(refused.code).toBe(1); - expect(refused.combined).toContain(NEVER_ACTIVATED_LINE); + const unregistered = await runCli(omitOptional, ['cache', '--stats'], { env }); + expect(unregistered.code, `an --omit=optional install refused unregistered:\n${unregistered.combined}`).toBe(0); + expect(unregistered.combined).not.toContain(NEVER_ACTIVATED_LINE); const email = 'px2-rc-omit@example.test'; const registered = await runCli(omitOptional, ['register', '--email', email], { @@ -416,7 +675,7 @@ describe.skipIf(RC_GATE_DISABLED)('PX2 RC exit gate — fresh install, registrat 'arm 1b — --omit=optional install', `custody tier: ${custody.tier} (keychainAvailable()=${custody.keychainAvailable}, ` + `readRefreshToken().location=${String(custody.location)})\n\n` + - `$ wigolo cache --stats (unregistered)\n${refused.combined}\n` + + `$ wigolo cache --stats (unregistered)\n${unregistered.combined}\n` + `\n$ wigolo register --email ${email}\n${registered.combined}` + `\n$ wigolo cache --stats (registered) → exit ${ran.code}`, ); @@ -493,7 +752,7 @@ describe.skipIf(RC_GATE_DISABLED)('PX2 RC exit gate — fresh install, registrat ); }, 1_800_000); - it('refuses when a non-perpetual entitlement is out of BOTH its validity window and grace, while a perpetual one survives the identical clock', async () => { + it('KEEPS RUNNING when a non-perpetual entitlement is out of BOTH its validity window and grace, while the gate seam still calls it expired', async () => { // ---- move the service's clock, not the assertion ---------------------------------- // // Revoking the grant and ageing `last_refresh_at` is NOT sufficient on its own: the @@ -582,16 +841,30 @@ describe.skipIf(RC_GATE_DISABLED)('PX2 RC exit gate — fresh install, registrat expect(Date.parse(expiredPayload.valid_until)).toBeLessThan(Date.now()); expect(Date.now() - Date.parse(expiredState.last_refresh_at ?? '')).toBeGreaterThan(GRACE_MS); - const refusedRun = await runCli(full, ['cache', '--stats'], { env }); - expect(refusedRun.code).toBe(1); - expect(refusedRun.combined).toContain(EXPIRED_LINE); - // WHICH refusal fired is the whole arm. `never_activated` is step 1/2 and would mean the - // state or the signature broke — an earlier clause answering in step 6's place. + // ---- the two halves of §0a.1, on the sharpest fixture the suite can build ---------- + // + // (a) CORE DOES NOT REFUSE. Under PX2 this exact command exited 1 with EXPIRED_LINE. + const expiredRun = await runCli(full, ['cache', '--stats'], { env }); + expect( + expiredRun.code, + `an expired entitlement stopped core from running:\n${expiredRun.combined}`, + ).toBe(0); + expect(expiredRun.combined).not.toContain(EXPIRED_LINE); + expect(expiredRun.combined).not.toContain(NEVER_ACTIVATED_LINE); + + // (b) THE SEAM STILL WORKS, AND STILL SAYS `expired`. This is the half that would + // otherwise rot silently: §0a.1 keeps `requireActivation` because Studio and the + // unlock story consume it, so a core that stopped refusing must NOT be a core whose + // gate stopped evaluating. `whoami` renders the decision, and WHICH answer it gives + // is the whole point — "not activated" would mean the state or the pinned key broke + // rather than the entitlement expiring, an earlier clause answering in step 6's place. + const seam = await runCli(full, ['whoami'], { env }); + expect(seam.code).toBe(0); expect( - refusedRun.combined, - 'the install refused as never-activated, so the state or the pinned key broke rather ' + - 'than the entitlement expiring', - ).not.toContain(NEVER_ACTIVATED_LINE); + seam.combined, + `the gate seam did not report an expired activation:\n${seam.combined}`, + ).toContain('expired'); + expect(seam.combined).not.toContain('not activated'); // ---- restore: the same binary, the same clock, the perpetual state back ------------- await writeState(full, perpetualState); @@ -610,7 +883,8 @@ describe.skipIf(RC_GATE_DISABLED)('PX2 RC exit gate — fresh install, registrat ` $ wigolo cache --stats → exit ${perpetualRun.code} (PASSES, brief §3)\n\n` + `after raw SQL revoke + subscription insert — live grants ${JSON.stringify(liveGrants)}\n` + ` grants=${JSON.stringify(expiredPayload.grants)}, valid_until ${expiredPayload.valid_until}\n` + - ` $ wigolo cache --stats → exit ${refusedRun.code}\n${refusedRun.combined.trim()}\n\n` + + ` $ wigolo cache --stats → exit ${expiredRun.code} (RUNS, §0a.1)\n${expiredRun.combined.trim()}\n` + + ` $ wigolo whoami → activation reported as expired by the surviving seam\n${seam.combined.trim()}\n\n` + `restore (perpetual state written back) → exit ${restoredRun.code}`, ); }, 1_800_000); diff --git a/tests/unit/cli/account.test.ts b/tests/unit/cli/account.test.ts index 1e26520c1..3b4f789e3 100644 --- a/tests/unit/cli/account.test.ts +++ b/tests/unit/cli/account.test.ts @@ -153,9 +153,11 @@ describe('wigolo register', () => { const code = await runAccountCommand('register', ['--json'], { dataDir, client: new AccountsClient({ baseUrl: BASE, fetchImpl }), - // email, code, then an EMPTY line for the consent toggle: the default is Y - // (§5 pin 8), and "just pressed enter" is the answer that exercises it. - input: pipedStdin(['user@example.com', '654321', '']), + // email, code, then an explicit `y` for the consent toggle. It cannot be a + // bare newline any more: §0a.5 reversed the default to unticked, so Enter + // now means NO and this arm asserts `marketing_consent: true` below. The + // default itself gets its own arm. + input: pipedStdin(['user@example.com', '654321', 'y']), stderr: err.stream, stdout: out.stream, nowMs: now, @@ -174,7 +176,7 @@ describe('wigolo register', () => { const text = err.text(); expect(text).toContain('Check your email for the sign-in code.'); expect(text).toContain(DISCLOSURE_TEXT); - expect(text).toContain('Send me occasional product updates by email? [Y/n]'); + expect(text).toContain('Send me occasional product updates by email? [y/N]'); expect(text).toContain('Account created.'); expect(text).toContain('WIGOLO_TELEMETRY=off'); @@ -192,6 +194,32 @@ describe('wigolo register', () => { expect(doc).toMatchObject({ status: 'ok', action: 'created', account_id: 'acct_221', marketing_consent: true }); }); + it('treats a BARE ENTER on the consent toggle as NO (§0a.5, GDPR-valid)', async () => { + // WHY THIS ARM IS THE ONE THAT MATTERS. §5 pin 8 shipped this default as ON, + // and the consulting pass reversed it: consent has to be an affirmative act, + // so the answer nobody types is a refusal. A bare newline is exactly the + // "user pressed Enter to get past it" case, and it must reach the service as + // `false` — asserted on the WIRE, not just in local state, because the + // account row is what a marketing send would read. + const { fetchImpl, hits } = transport(okRoutes('v1.abcd1234.payload.sig')); + const err = sink(); + + const code = await runAccountCommand('register', [], { + dataDir, + client: new AccountsClient({ baseUrl: BASE, fetchImpl }), + input: pipedStdin(['user@example.com', '654321', '']), + stderr: err.stream, + stdout: sink().stream, + nowMs: now, + }); + + expect(code).toBe(0); + expect(err.text()).toContain('[y/N]'); + const verify = hits.find((h) => h.path === '/auth/verify'); + expect(verify?.body).toMatchObject({ marketing_consent: false }); + expect(new AccountStateStore(dataDir).read().marketing_consent).toBe(false); + }); + it('carries the toggle ANSWER, not the default, when the user declines', async () => { const { fetchImpl, hits } = transport(okRoutes('v1.abcd1234.payload.sig')); const err = sink(); @@ -327,6 +355,118 @@ describe('wigolo register', () => { // login // --------------------------------------------------------------------------- +describe('wigolo register --headless (§0a.2)', () => { + it('stage one mails a code, prints the finishing command, and CREATES NOTHING', async () => { + // The half an unattended agent can do on its own. It must not create an + // account: the mailbox owner has not consented to anything yet, and + // `request-code` is the only call in the flow that carries no consent. + const { fetchImpl, hits } = transport(okRoutes('v1.abcd1234.payload.sig')); + const err = sink(); + const out = sink(); + + const code = await runAccountCommand('register', ['--headless', '--email', 'agent@example.com', '--json'], { + dataDir, + client: new AccountsClient({ baseUrl: BASE, fetchImpl }), + // NOTHING on stdin. A surviving prompt reads EOF and takes a failure + // branch, so `code === 0` is a real assertion that nothing asked. + input: pipedStdin([]), + stderr: err.stream, + stdout: out.stream, + nowMs: now, + }); + + expect(code).toBe(0); + // The disclosure is fetched and shown BEFORE the mail goes out, so the agent + // can relay the wording to the human who is about to be asked to consent. + expect(hits.map((h) => h.path)).toEqual(['/legal/telemetry-disclosure', '/auth/request-code']); + expect(err.text()).toContain(DISCLOSURE_TEXT); + // The exact command that finishes the job — an agent cannot guess a flag set. + expect(err.text()).toContain('wigolo register --headless --email agent@example.com --code '); + + // NOTHING WAS CREATED. No verify, no entitlement, no state on disk. + expect(hits.some((h) => h.path === '/auth/verify')).toBe(false); + expect(new AccountStateStore(dataDir).read().account_id).toBeNull(); + + const doc = JSON.parse(out.text().trim()) as Record; + expect(doc).toMatchObject({ status: 'ok', action: 'claim_pending', email: 'agent@example.com' }); + }); + + it('stage two finishes with --code, asking nothing, and defaults consent to NO', async () => { + const { fetchImpl, hits } = transport(okRoutes('v1.abcd1234.payload.sig')); + const err = sink(); + + const code = await runAccountCommand( + 'register', + ['--headless', '--email', 'user@example.com', '--code', '654321'], + { + dataDir, + client: new AccountsClient({ baseUrl: BASE, fetchImpl }), + input: pipedStdin([]), + stderr: err.stream, + stdout: sink().stream, + nowMs: now, + }, + ); + + expect(code).toBe(0); + expect(err.text()).toContain('Account created.'); + // The code came from a flag, so no second `request-code` was spent on it. + expect(hits.map((h) => h.path)).toEqual([ + '/legal/telemetry-disclosure', + '/auth/verify', + '/entitlements/token', + ]); + // §0a.5 in its headless form: an OMITTED flag is a refusal, not an omission. + expect(hits.find((h) => h.path === '/auth/verify')?.body).toMatchObject({ + marketing_consent: false, + }); + expect(new AccountStateStore(dataDir).read().marketing_consent).toBe(false); + // And it really did activate, rather than exiting 0 having done half a job. + expect(new AccountStateStore(dataDir).read().entitlement_token).toBe('v1.abcd1234.payload.sig'); + }); + + it('sends consent only when --marketing-consent is passed explicitly', async () => { + const { fetchImpl, hits } = transport(okRoutes('v1.abcd1234.payload.sig')); + const code = await runAccountCommand( + 'register', + ['--headless', '--email', 'user@example.com', '--code', '654321', '--marketing-consent'], + { + dataDir, + client: new AccountsClient({ baseUrl: BASE, fetchImpl }), + input: pipedStdin([]), + stderr: sink().stream, + stdout: sink().stream, + nowMs: now, + }, + ); + expect(code).toBe(0); + expect(hits.find((h) => h.path === '/auth/verify')?.body).toMatchObject({ + marketing_consent: true, + }); + }); + + it('refuses --headless without --email rather than blocking on a prompt', async () => { + // WHY: the whole promise of the flag is that nothing asks. With no address and + // no prompt there is no flow to run, and the failure has to name the missing + // flag — an agent reading "No email address given" would retry the same + // command forever. + const { fetchImpl, hits } = transport(okRoutes('v1.abcd1234.payload.sig')); + const err = sink(); + const code = await runAccountCommand('register', ['--headless'], { + dataDir, + client: new AccountsClient({ baseUrl: BASE, fetchImpl }), + input: pipedStdin([]), + stderr: err.stream, + stdout: sink().stream, + nowMs: now, + }); + expect(code).toBe(1); + expect(err.text()).toContain('--email'); + expect(err.text()).toContain('--headless'); + expect(hits).toEqual([]); + }); +}); + describe('wigolo login', () => { it('NEVER sends marketing_consent, and never fetches the full disclosure', async () => { // Creation-only default: a `false` on sign-in would silently overwrite a diff --git a/tests/unit/cli/capability-language-copy.test.ts b/tests/unit/cli/capability-language-copy.test.ts index 047ec5126..419d28c00 100644 --- a/tests/unit/cli/capability-language-copy.test.ts +++ b/tests/unit/cli/capability-language-copy.test.ts @@ -7,7 +7,7 @@ import { join } from 'node:path'; import { runAccountCommand } from '../../../src/cli/account.js'; import { ACTIVATION_REFUSALS, type ActivationRefusalReason } from '../../../src/account/gate.js'; -import { activationNextStepLine } from '../../../src/cli/init.js'; +import { activationNextStepLines } from '../../../src/cli/init.js'; import { advancedCategory } from '../../../src/cli/tui/schema/advanced.js'; import { runStudioSetup } from '../../../src/cli/studio-setup.js'; import type { AccountsClient } from '../../../src/account/client.js'; @@ -120,7 +120,7 @@ describe('capability language — the copy PX2 added', () => { expect(ACTIVATION_REFUSALS.never_activated).toContain('wigolo register'); }); - it("init's next step names no implementation, for every reason it can fire on", async () => { + it("init's first-run block names no implementation, for every reason it can fire on", async () => { const actual = await vi.importActual( '../../../src/account/gate.js', ); @@ -132,19 +132,20 @@ describe('capability language — the copy PX2 added', () => { evaluateActivation: () => ({ ok: false, step: 'no_token', reason, message: '' }), })); vi.resetModules(); - const { activationNextStepLine: fresh } = await import('../../../src/cli/init.js'); - const line = await fresh(mkdtempSync(join(tmpdir(), 'wigolo-caplang-init-')), {}, Date.now()); - expect(line, `no line for ${reason}`).not.toBeNull(); - assertCapabilityLanguage(`init next step (${reason})`, line as string); - seen.push(line as string); + const { activationNextStepLines: fresh } = await import('../../../src/cli/init.js'); + const lines = await fresh(mkdtempSync(join(tmpdir(), 'wigolo-caplang-init-')), {}, Date.now()); + expect(lines.length, `no block for ${reason}`).toBeGreaterThan(0); + const block = lines.join('\n'); + assertCapabilityLanguage(`init next step (${reason})`, block); + seen.push(block); vi.doUnmock('../../../src/account/gate.js'); vi.resetModules(); } - // Three reasons, three DIFFERENT lines — a single shared line would make the sweep - // above cover one string while claiming three. + // Three reasons, three DIFFERENT blocks — a single shared block would make the + // sweep above cover one string while claiming three. expect(new Set(seen).size).toBe(3); // And the real export still works unmocked. - expect(typeof activationNextStepLine).toBe('function'); + expect(typeof activationNextStepLines).toBe('function'); }); it.each([ diff --git a/tests/unit/cli/doctor.test.ts b/tests/unit/cli/doctor.test.ts index 7d5ca0374..98cabf495 100644 --- a/tests/unit/cli/doctor.test.ts +++ b/tests/unit/cli/doctor.test.ts @@ -534,7 +534,14 @@ describe('runDoctor', () => { activateAccount(); resetTelemetryForTest(); await runDoctor('/tmp/.wigolo'); - expect(outBuffer).toMatch(/Telemetry: on — usage and reliability counters are sent to your account/); + // §0a.4 pins the WORDING, so the literal is spelled out here rather than + // imported from `account/unlocks.ts` — importing the constant would make this + // assertion agree with any edit to the constant, including a wrong one, and + // the point of the clause is that all six surfaces say the SAME sentence. + expect(outBuffer).toContain( + 'Telemetry: on — no page content, URLs, or credentials leave your machine;' + + ' usage stats do, off with one flag', + ); expect(outBuffer).toContain('WIGOLO_TELEMETRY=off'); // Anti-inversion: an activated install with telemetry ON must never be described // with the word "off" ahead of the switch hint. diff --git a/tests/unit/cli/init-activation-hint.test.ts b/tests/unit/cli/init-activation-hint.test.ts index dd93bfe90..51638e5c3 100644 --- a/tests/unit/cli/init-activation-hint.test.ts +++ b/tests/unit/cli/init-activation-hint.test.ts @@ -4,12 +4,19 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; /** - * `activationNextStepLine` is the first-run affordance shared by BOTH init paths - * (PX2 mini-spec §8). Without it setup reports success and the user's first tool call - * is refused with no clue what to do. + * `activationNextStepLines` is the first-run block shared by BOTH init paths + * (PX2 mini-spec §8, rewritten for PX brief §0a.1-3). + * + * WHAT IT USED TO BE. A single "Next step: run `wigolo register` to activate this + * install" line, whose justification was that without it setup reported success and + * the user's first tool call was refused with no clue what to do. §0a.1 removed the + * refusal, so that justification is gone and the line would now be a lie: nothing + * is pending. The never-registered arm returns an OFFER instead — what an account + * would add — and the two arms that still have an imperative are the ones where the + * user already has an account and something they were promised stopped working. * * The gate is mocked so each refusal reason can be driven without minting a signed - * entitlement token; the never-activated arm runs the REAL gate against a real empty + * entitlement token; the never-registered arm runs the REAL gate against a real empty * data directory, which is the shape a fresh install actually has. */ const { evaluateActivationMock } = vi.hoisted(() => ({ evaluateActivationMock: vi.fn() })); @@ -21,7 +28,8 @@ vi.mock('../../../src/account/gate.js', async () => { return { ...actual, evaluateActivation: evaluateActivationMock }; }); -import { activationNextStepLine } from '../../../src/cli/init.js'; +import { activationNextStepLines } from '../../../src/cli/init.js'; +import { REGISTRATION_UNLOCKS, UNREGISTERED_RUNS_LINE, TELEMETRY_CLAIM } from '../../../src/account/unlocks.js'; import { ACTIVATION_REFUSALS, type ActivationRefusalReason } from '../../../src/account/gate.js'; function refusal(reason: ActivationRefusalReason): unknown { @@ -29,71 +37,85 @@ function refusal(reason: ActivationRefusalReason): unknown { return { ok: false, step, reason, message: ACTIVATION_REFUSALS[reason] }; } -describe('activationNextStepLine — init\'s first-run next step', () => { +describe('activationNextStepLines — init\'s first-run block', () => { beforeEach(() => { vi.clearAllMocks(); }); - it('points a fresh install at `wigolo register`, through the real gate', async () => { - // WHY: the whole point of the line. An empty data dir has no entitlement token, so - // the real gate refuses at step 1 and the user must be sent to register. + it('offers a fresh install the unlocks, and never implies it must register', async () => { + // WHY: setup has just succeeded and every tool works. The block has to lead with + // that and read as an offer — an imperative here ("Next step: register") tells a + // user something is pending when nothing is, which is the exact claim §0a.1 + // retired. The real gate runs: an empty data dir has no token, so this is the + // shape a fresh install actually produces. const actual = await vi.importActual( '../../../src/account/gate.js', ); evaluateActivationMock.mockImplementation(actual.evaluateActivation); const dataDir = mkdtempSync(join(tmpdir(), 'wigolo-init-hint-')); - const line = await activationNextStepLine(dataDir, {}, Date.now()); - expect(line).toContain('wigolo register'); - expect(line).toContain('wigolo login'); + const lines = await activationNextStepLines(dataDir, {}, Date.now()); + const block = lines.join('\n'); + expect(block).toContain(UNREGISTERED_RUNS_LINE); + expect(block).toContain('wigolo register'); + // The unlock LIST, not just the verb — that is what §0a.3 asks first-run to carry. + for (const unlock of REGISTRATION_UNLOCKS) expect(block).toContain(unlock); + // And the telemetry claim, in the §0a.4 wording, at the one moment the user is + // deciding whether to hand over an email address. + expect(block).toContain(TELEMETRY_CLAIM); + // No imperative: this arm is the difference between an offer and a wall. + expect(block).not.toMatch(/^Next step: /m); }); - it('says nothing at all on an activated install', async () => { - // WHY: a hint that keeps printing after it has been acted on is a nag, and init - // already prints a long report. `null` is how the caller suppresses the block. + it('says nothing at all on a registered install', async () => { + // WHY: an offer that keeps printing after it has been accepted is a nag, and init + // already prints a long report. An empty array is how the caller suppresses it. evaluateActivationMock.mockReturnValue({ ok: true, step: 'perpetual' }); - expect(await activationNextStepLine('/nonexistent', {}, 0)).toBeNull(); + expect(await activationNextStepLines('/nonexistent', {}, 0)).toEqual([]); }); it('sends an EXPIRED sign-in to `login`, never to `register`', async () => { // WHY: the regression this arm exists for — telling someone whose sign-in expired to - // register would have them create a SECOND account against the same email. + // register would have them create a SECOND account against the same email. This arm + // survives §0a.1 unchanged: the user HAS an account, and unlocks they were promised + // have stopped working, so an imperative is the honest register here. evaluateActivationMock.mockReturnValue(refusal('expired')); - const line = await activationNextStepLine('/nonexistent', {}, 0); - expect(line).toContain('wigolo login'); - expect(line).not.toContain('wigolo register'); + const block = (await activationNextStepLines('/nonexistent', {}, 0)).join('\n'); + expect(block).toContain('wigolo login'); + expect(block).not.toContain('wigolo register'); }); it('sends an UPDATE-REQUIRED install to update, never to `register`', async () => { // WHY: same class as above. Re-registering cannot fix a signing key this build does // not hold, so the line must not offer it as a remedy. evaluateActivationMock.mockReturnValue(refusal('update_required')); - const line = await activationNextStepLine('/nonexistent', {}, 0); - expect(line).toContain('update wigolo'); - expect(line).toContain('wigolo login'); - expect(line).not.toContain('wigolo register'); + const block = (await activationNextStepLines('/nonexistent', {}, 0)).join('\n'); + expect(block).toContain('update wigolo'); + expect(block).toContain('wigolo login'); + expect(block).not.toContain('wigolo register'); }); - it('has a line for EVERY refusal reason the gate can return', async () => { + it('has a block for EVERY refusal reason the gate can return', async () => { // WHY: exhaustiveness against the gate, not against this file's own list. A reason - // added to `ACTIVATION_REFUSALS` with no branch here would silently print nothing — - // a refused install told setup was complete. + // added to `ACTIVATION_REFUSALS` with no branch here would silently print nothing. + // The `Next step:` shape is asserted only for the reasons that still carry an + // imperative — never_activated deliberately does not, which is checked above. for (const reason of Object.keys(ACTIVATION_REFUSALS) as ActivationRefusalReason[]) { evaluateActivationMock.mockReturnValue(refusal(reason)); - const line = await activationNextStepLine('/nonexistent', {}, 0); - expect(line, `no next-step line for refusal reason "${reason}"`).toBeTruthy(); - expect(line).toMatch(/^Next step: /); + const lines = await activationNextStepLines('/nonexistent', {}, 0); + expect(lines.length, `no block for refusal reason "${reason}"`).toBeGreaterThan(0); + if (reason !== 'never_activated') expect(lines[0]).toMatch(/^Next step: /); } }); - it('returns null rather than throwing when the gate blows up', async () => { + it('returns an empty block rather than throwing when the gate blows up', async () => { // WHY: a discoverability hint must never be able to fail setup or change its exit code. evaluateActivationMock.mockImplementation(() => { throw new Error('boom'); }); - expect(await activationNextStepLine('/nonexistent', {}, 0)).toBeNull(); + expect(await activationNextStepLines('/nonexistent', {}, 0)).toEqual([]); }); - it('keeps the line in capability language — no implementation names', async () => { + it('keeps the block in capability language — no implementation names', async () => { evaluateActivationMock.mockReturnValue(refusal('never_activated')); - const line = await activationNextStepLine('/nonexistent', {}, 0); - expect(line).not.toMatch(/playwright|chromium|searxng|electron|postgres|ed25519|jwt/i); + const block = (await activationNextStepLines('/nonexistent', {}, 0)).join('\n'); + expect(block).not.toMatch(/playwright|chromium|searxng|electron|postgres|ed25519|jwt/i); }); }); diff --git a/tests/unit/daemon/activation-routes.test.ts b/tests/unit/daemon/activation-routes.test.ts index 79e72955d..5d82d994b 100644 --- a/tests/unit/daemon/activation-routes.test.ts +++ b/tests/unit/daemon/activation-routes.test.ts @@ -1,19 +1,24 @@ /** - * The activation gate on the daemon's HTTP routes (PX2 mini-spec §3, issue #222). + * The daemon's HTTP routes on an UNREGISTERED install (PX brief §0a.1, issue #336). * - * WHY THE CHECK IS IN `routeRequest` AND NOT IN `rest/dispatch.ts`. The - * firecrawl-compat handlers do not go through `dispatchTool` — they call - * `handleFetch` / `handleSearch` / `handleCrawl` directly. A gate inside dispatch - * would therefore cover `/v1/{tool}` and leave `/compat/firecrawl/*` wide open, - * with nothing in the diff to show for it. `routeRequest` is the one seam above - * both, which is why the compat arm below is the load-bearing one: it is the - * path a check placed one layer lower would silently miss. + * WHAT THIS FILE USED TO PIN. PX2 put an activation check in `routeRequest`, above + * both REST dispatchers, and every arm here asserted a structured 403 carrying the + * `never_activated` line — for `/v1/{tool}`, for the firecrawl-compat family that + * bypasses `dispatchTool`, and for `/v1/runs`. §0a.1 made the hard gate + * Studio-only, so the check is gone and each of those arms is inverted. * - * The complement matters just as much. `/health` is a liveness probe, and - * `/openapi.json`, `/v1/openapi.json` and `/v1/tools` execute no tool — gating - * them would make an un-activated install unable to describe itself, which is the - * REST equivalent of refusing `tools/list`. `/v1/runs*` was in that column for the - * same reason until the run surface left core with the companion extraction. + * WHY THE COMPAT ARM IS STILL THE LOAD-BEARING ONE, JUST POINTING THE OTHER WAY. + * `/compat/firecrawl/*` calls `handleFetch` / `handleSearch` / `handleCrawl` + * directly and never passes through `rest/dispatch.ts`. A gate reintroduced in + * `routeRequest` — the only seam above both — would be invisible to an arm that + * only exercises `/v1/{tool}`, and equally invisible the other way round. Both + * families are swept, and the assertion is "not 403, and no refusal text + * anywhere in the body". + * + * The complement is unchanged and still asserted: `/health` is a liveness probe + * and the discovery routes describe the surface, so they were open under PX2 and + * are open now. Their arms exist to prove the sweep above is about the gate + * rather than about the whole server being broken. */ import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest'; @@ -86,31 +91,37 @@ afterAll(async () => { try { rmSync(dataDir, { recursive: true, force: true }); } catch { /* ignore */ } }, 30000); -describe('daemon routes — un-activated install', () => { +/** No refusal line, and no `not_activated` code, anywhere in a response. */ +function expectUngated(r: { status: number; body: unknown }, what: string): void { + const whole = JSON.stringify(r.body); + expect({ what, status: r.status }).not.toEqual({ what, status: 403 }); + expect(whole, `${what} carried the not_activated code`).not.toContain('not_activated'); + for (const line of Object.values(ACTIVATION_REFUSALS)) { + expect(whole, `${what} rendered a refusal line`).not.toContain(line); + } +} + +describe('daemon routes — unregistered install', () => { beforeEach(() => { // The temp data dir carries no account state, so the shipped disk-backed - // checker is the un-activated condition. Dropping any checker a sibling + // checker is the unregistered condition. Dropping any checker a sibling // file installed is what makes that true rather than assumed. setActivationChecker(null); }); - it('refuses POST /v1/{tool} with the pinned line and a structured 403', async () => { - const r = await request('POST', '/v1/search', { query: 'anything' }); - expect(r.status).toBe(403); - const body = r.body as { ok: boolean; error: string; error_reason: string }; - expect(body.ok).toBe(false); - expect(body.error).toBe('not_activated'); - expect(body.error_reason).toBe(ACTIVATION_REFUSALS.never_activated); + it('lets POST /v1/{tool} through to the tool with no account', async () => { + // Reaching the tool's OWN answer — not a 403 — is the whole of §0a.1 on this + // transport. Whether that answer is a result or a validation error is the + // tool's business; what matters is that the route stopped arbitrating. + const r = await request('POST', '/v1/search', {}); + expectUngated(r, 'POST /v1/search'); }); - it('refuses the firecrawl-compat family — the handlers that bypass dispatchTool', async () => { - // This is the arm a gate inside `rest/dispatch.ts` would fail: /compat - // reaches handleScrape/handleSearch directly and never passes through it. - const r = await request('POST', '/compat/firecrawl/v1/scrape', { - url: 'https://example.invalid/', - }); - expect(r.status).toBe(403); - expect((r.body as { error: string }).error).toBe('not_activated'); + it('lets the firecrawl-compat family through — the handlers that bypass dispatchTool', async () => { + // The arm a gate reintroduced in `routeRequest` would fail even if `/v1` were + // somehow left alone: /compat reaches handleScrape/handleSearch directly. + const r = await request('POST', '/compat/firecrawl/v1/scrape', {}); + expectUngated(r, 'POST /compat/firecrawl/v1/scrape'); }); it('leaves /health open — a liveness probe exposes no tool surface', async () => { @@ -125,25 +136,32 @@ describe('daemon routes — un-activated install', () => { } }); - it('refuses /v1/runs like any other unknown path — the run surface is not core\'s any more', async () => { - // It used to be the second ungated group, exempt because the run store reached no tool handler. - // The surface left core with the run layer, so the exemption left with it: what a client gets - // is the ordinary un-activated refusal, not a route that half-answers. + it('answers /v1/runs as an unknown path, not as a refusal', async () => { + // Under PX2 this path returned the activation 403, which made "the run surface + // left core" and "you have no account" indistinguishable to a client. With the + // gate gone it is simply a route that does not exist, which is the truth. const r = await request('POST', '/v1/runs', {}); - expect(r.status).toBe(403); + expectUngated(r, 'POST /v1/runs'); }); }); -describe('daemon routes — activated install', () => { +describe('daemon routes — registered install', () => { let restore: () => void; beforeEach(() => { restore = installActivated(); }); afterEach(() => { restore(); }); - it('lets a tool route through to its own validation instead of the refusal', async () => { - // The proof that the 403s above are the GATE and not the route being broken: - // the identical request now reaches the tool's input validation. - const r = await request('POST', '/v1/search', {}); - expect(r.status).not.toBe(403); - expect(JSON.stringify(r.body)).not.toContain(ACTIVATION_REFUSALS.never_activated); + it('answers a tool route IDENTICALLY to the unregistered one', async () => { + // THE OUTSIDE SIGNAL. On its own, "the unregistered call was not a 403" could + // mean the route is broken for everybody. Running the identical request with a + // real activated fixture and getting the same status is what makes the arms + // above a statement about the gate: registration changed nothing here, which + // is exactly §0a.1's claim. + const registered = await request('POST', '/v1/search', {}); + expect(registered.status).not.toBe(403); + expect(JSON.stringify(registered.body)).not.toContain('not_activated'); + + setActivationChecker(null); + const unregistered = await request('POST', '/v1/search', {}); + expect(unregistered.status).toBe(registered.status); }); }); diff --git a/tests/unit/repl/shell-history-mode.test.ts b/tests/unit/repl/shell-history-mode.test.ts index 03b9c561d..fce5a9d14 100644 --- a/tests/unit/repl/shell-history-mode.test.ts +++ b/tests/unit/repl/shell-history-mode.test.ts @@ -25,12 +25,17 @@ const fsMock = vi.hoisted(() => ({ vi.mock('node:fs', () => fsMock); -// This file replaces node:fs WHOLESALE, so the activation gate at the top of -// `startShell` cannot read the account state the suite seeds on the real disk -// (tests/setup.ts): it would refuse before readline ever attaches and take every -// history-mode assertion below with it. The gate is not what this file is about, -// and its own arms — driven against a real un-activated data dir with a real -// signed token — live in tests/unit/server/activation-gate.test.ts and +// This file replaces node:fs WHOLESALE, so anything `startShell` reads from the +// real disk has to be stubbed here or it reads the mock's empty world instead. +// The account state the suite seeds (tests/setup.ts) is one of those things. +// +// It mattered more before PX2-R: an activation gate stood at the top of +// `startShell` and would have refused before readline ever attached, taking every +// history-mode assertion below with it. §0a.1 deleted that gate, so what is left +// on this path is the registration nudge — which reads and WRITES the counter +// file, i.e. the mocked fs. Both nudge seams are stubbed to no-ops for the same +// reason the gate was: neither is what this file is about, and their own arms +// live in tests/unit/server/activation-gate.test.ts and // tests/integration/activation-cli.test.ts. vi.mock('../../../src/server/activation.js', () => ({ checkActivation: () => ({ @@ -45,6 +50,8 @@ vi.mock('../../../src/server/activation.js', () => ({ ], }, }), + noteSuccessfulToolRun: () => {}, + claimRegistrationNudge: () => null, })); vi.mock('../../../src/repl/commands/fetch.js', () => ({ executeFetch: vi.fn() })); diff --git a/tests/unit/server/activation-gate.test.ts b/tests/unit/server/activation-gate.test.ts index 5eed2327b..fc511f5af 100644 --- a/tests/unit/server/activation-gate.test.ts +++ b/tests/unit/server/activation-gate.test.ts @@ -1,32 +1,40 @@ /** - * The activation gate at MCP dispatch (PX2 mini-spec §3, issue #222). + * MCP dispatch on an UNREGISTERED install (PX brief §0a.1-3, issue #336). * - * WHAT THESE ARMS ARE ACTUALLY ABOUT. It is easy to write a gate test that only - * proves a string comes back. The properties that can actually break here are: + * THIS FILE USED TO PIN THE OPPOSITE. Under PX2 every arm here asserted that an + * install with no account was refused at `tools/call` with one of three pinned + * lines. The CEO consulting pass of 2026-09-03 made the hard gate Studio-only, + * so each of those arms is now inverted: the same fixtures, the same fresh empty + * data dir, and the assertion that the call goes THROUGH. * - * 1. WHERE the gate sits. It is the first statement in the `tools/call` - * handler, ABOVE `scheduleOverdueCheck`. A gate one line lower would return - * the same refusal text and still re-fetch every overdue watch URL on - * behalf of an install with no account. So the egress arm below does not - * assert on the refusal at all — it asserts that the watch path did not - * run, and it proves the recorder can fire by running the same call - * activated and watching it fire. - * 2. WHAT stays open. `initialize` and `tools/list` must keep working, or the - * server is a dead connection instead of a designed refusal. - * 3. THAT IT RE-EVALUATES. Registering in another terminal has to take effect - * on the next call of a server that is already running, and a subscription - * crossing its grace boundary has to start refusing without a restart. - * Both are driven here — one by writing the real `state.json` mid-flight, - * the other by an injected clock. + * WHAT THESE ARMS ARE ACTUALLY ABOUT — three properties that can really break: * - * The data dir is repointed at an empty directory for the un-activated arms, so - * "un-activated" is the real condition (no state file) rather than a stubbed - * decision. The suite as a whole runs activated (see `tests/setup.ts`), which is - * exactly why these arms have to build their own. + * 1. NOTHING IS WALLED. Every one of the ten tools dispatches with no account, + * and no refusal line reaches any result. Asserting one successful call + * would not catch a gate reintroduced on one tool, so the sweep is over all + * ten and it greps the refusal text out of the whole result. + * 2. THE WATCH PATH RUNS. PX2's gate sat deliberately ABOVE + * `scheduleOverdueCheck` so a refused call could not egress. With the gate + * gone the scheduler is reachable unregistered, and the arm proves it fires + * — the same recorder, the same overdue job, now expected to be touched. + * 3. THE NUDGE FIRES ONCE. Not on run N-1, once on run N, never again, never + * on a failed call, never on a registered install. That is the whole of + * §0a.2's "never repeated", and it is a property of the disk, so the arms + * drive real successive calls against a real data dir. + * + * The gate SEAM itself is untouched and still unit-tested in + * `tests/unit/account/gate.test.ts` — Studio and the unlock story both consume + * it. What no longer exists is a core call site that turns its answer into a + * refusal, and the sweep in arm 1 is what would catch one coming back. + * + * The data dir is repointed at an empty directory, so "unregistered" is the real + * condition (no state file) rather than a stubbed decision. The suite as a whole + * runs activated (see `tests/setup.ts`), which is exactly why these arms have to + * build their own. */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from 'node:fs'; +import { mkdtempSync, rmSync, mkdirSync, writeFileSync, existsSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; @@ -35,7 +43,9 @@ import { resetConfig } from '../../../src/config.js'; import { _resetMigrationGuard } from '../../../src/cache/migrations/runner.js'; import { ACTIVATION_REFUSALS } from '../../../src/account/gate.js'; import { setActivationChecker } from '../../../src/server/activation.js'; -import { ACTIVATION_NOTICE, WIGOLO_INSTRUCTIONS, serverInstructions } from '../../../src/instructions.js'; +import { UNLOCK_NOTICE, WIGOLO_INSTRUCTIONS, serverInstructions } from '../../../src/instructions.js'; +import { NUDGE_AFTER_RUNS, nudgeStatePath } from '../../../src/account/nudge.js'; +import { REGISTRATION_UNLOCKS, UNREGISTERED_RUNS_LINE } from '../../../src/account/unlocks.js'; import { generateMintKeyPair, mintToken, grant, payload } from '../account/mint-entitlement.js'; import { installChecker, sourceFor, subscriptionAccount } from './activation-fixture.js'; @@ -113,7 +123,31 @@ vi.mock('../../../src/tools/fetch.js', () => ({ }, })); -const NEVER_ACTIVATED_LINE = ACTIVATION_REFUSALS.never_activated; +// THE REST OF THE TEN, STUBBED AT THE HANDLER. The ten-tool sweep below asserts +// something about DISPATCH, not about any tool's behaviour, and six of the ten +// reach the network on the way to their own answer — which the suite's net fence +// correctly refuses. Stubbing the handlers is what lets the sweep be over all ten +// rather than over the four that happen to be local: the assertion is that the +// call reaches a handler at all and comes back without a refusal, and a stub that +// returns a plain result proves exactly that. +vi.mock('../../../src/tools/search.js', () => ({ + handleSearch: () => Promise.resolve({ ok: true, data: { results: [], query: 'stub' } }), +})); +vi.mock('../../../src/tools/crawl.js', () => ({ + handleCrawl: () => Promise.resolve({ pages: [], total_found: 0, crawled: 0 }), +})); +vi.mock('../../../src/tools/extract.js', () => ({ + handleExtract: () => Promise.resolve({ ok: true, data: { url: 'https://example.invalid/x' } }), +})); +vi.mock('../../../src/tools/find-similar.js', () => ({ + handleFindSimilar: () => Promise.resolve({ ok: true, data: { results: [] } }), +})); +vi.mock('../../../src/tools/research.js', () => ({ + handleResearch: () => Promise.resolve({ ok: true, data: { brief: { topics: [] } } }), +})); +vi.mock('../../../src/tools/agent.js', () => ({ + handleAgent: () => Promise.resolve({ ok: true, data: { steps: [] } }), +})); async function connectClient() { const { initSubsystems, createMcpServer } = await import('../../../src/server.js'); @@ -146,7 +180,7 @@ async function flushImmediates(): Promise { await new Promise((r) => setImmediate(r)); } -describe('activation gate — MCP tools/call', () => { +describe('MCP tools/call on an unregistered install', () => { let tmpDataDir: string; let savedPubkey: string | undefined; const mintKeys = generateMintKeyPair(); @@ -204,12 +238,10 @@ describe('activation gate — MCP tools/call', () => { ); } - it('serves the protocol unactivated: initialize and tools/list still work', async () => { + it('serves the protocol unregistered: initialize and tools/list still work', async () => { const { client, teardown } = await connectClient(); try { const res = await client.listTools(); - // The full surface is still described — a harness must be able to see what - // it would get, which is what makes the refusal legible rather than opaque. expect(res.tools.length).toBeGreaterThanOrEqual(10); expect(res.tools.map((t) => t.name)).toContain('search'); } finally { @@ -217,37 +249,64 @@ describe('activation gate — MCP tools/call', () => { } }); - it('refuses tools/call with the pinned line as a designed tool error', async () => { + it('dispatches a tool with no account at all, and returns the real result', async () => { + // WHY: the single sentence §0a.1 turns on. PX2 answered this exact call with + // `ACTIVATION_REFUSALS.never_activated` and `isError: true`. const { client, teardown } = await connectClient(); try { const res = await client.callTool({ name: 'diff', arguments: { old: { markdown: 'a\n' }, new: { markdown: 'b\n' }, output: 'unified' }, }); - expect(textOf(res)).toBe(NEVER_ACTIVATED_LINE); - expect(res.isError).toBe(true); + expect(res.isError).toBeFalsy(); + expect(JSON.parse(textOf(res)).changed).toBe(true); } finally { await teardown(); } }); - it('refuses the hosted studio_* pass-through on the same seam', async () => { + it('lets NO refusal line reach ANY of the ten tools unregistered', async () => { + // WHY THE SWEEP RATHER THAN ONE CALL: a gate reintroduced on a single tool — + // the shape of the regression this file exists to catch — is invisible to an + // arm that only exercises `diff`. Every tool is called with arguments that + // reach the handler, and the assertion is over the WHOLE result text, so a + // refusal smuggled into a second content block would still red this. + const args: Record> = { + diff: { old: { markdown: 'a\n' }, new: { markdown: 'b\n' }, output: 'unified' }, + fetch: { url: 'https://example.invalid/x' }, + search: { query: 'anything' }, + crawl: { url: 'https://example.invalid/x', max_pages: 1 }, + cache: { stats: true }, + extract: { url: 'https://example.invalid/x', mode: 'metadata' }, + find_similar: { concept: 'anything' }, + research: { question: 'anything?', depth: 'quick' }, + agent: { prompt: 'anything', max_time_ms: 1 }, + watch: { action: 'list' }, + }; + const refusalLines = Object.values(ACTIVATION_REFUSALS); const { client, teardown } = await connectClient(); try { - const res = await client.callTool({ name: 'studio_list', arguments: {} }); - expect(textOf(res)).toBe(NEVER_ACTIVATED_LINE); - expect(res.isError).toBe(true); + for (const [name, argv] of Object.entries(args)) { + const res = await client.callTool({ name, arguments: argv }); + const whole = JSON.stringify(res); + for (const line of refusalLines) { + expect(whole, `${name} rendered a refusal line`).not.toContain(line); + } + } } finally { await teardown(); } }); - it('a refused call produces ZERO watch-scheduler egress — and the recorder can fire', async () => { + it('runs the overdue watch check unregistered — the path PX2 gated above', async () => { + // WHY: the inverse of PX2's load-bearing arm. Its gate sat above + // `scheduleOverdueCheck` precisely so an accountless install could not + // egress; with no gate the scheduler is reachable, and this pins that the + // fixture really is overdue so the zero in any future gate arm would mean + // something. const { createJob, recordCheck, getJob } = await import('../../../src/watch/store.js'); const { client, teardown } = await connectClient(); try { - // An overdue job: created, then stamped with a check an hour ago against a - // 60-second interval. `scheduleOverdueCheck` would re-fetch it. const job = createJob({ url: 'https://example.invalid/watched', intervalSeconds: 60, @@ -256,26 +315,12 @@ describe('activation gate — MCP tools/call', () => { recordCheck(job.id, Date.now() - 3_600_000, 'hash-before'); const before = getJob(job.id)?.last_check_at ?? null; - // ARM 1 — un-activated. The refusal must arrive with the watch path untouched. - const refused = await client.callTool({ - name: 'diff', - arguments: { old: { markdown: 'a\n' }, new: { markdown: 'b\n' }, output: 'unified' }, - }); - await flushImmediates(); - expect(textOf(refused)).toBe(NEVER_ACTIVATED_LINE); - expect(fetchCalls.spy).not.toHaveBeenCalled(); - expect(getJob(job.id)?.last_check_at).toBe(before); - - // ARM 2 — the same call, activated. This is the outside signal: it proves - // the job really is overdue and the recorder really does fire, so ARM 1's - // zero is a fact about the gate and not about a mis-built fixture. - registerOnDisk(); - const allowed = await client.callTool({ + const res = await client.callTool({ name: 'diff', arguments: { old: { markdown: 'a\n' }, new: { markdown: 'b\n' }, output: 'unified' }, }); await flushImmediates(); - expect(textOf(allowed)).not.toBe(NEVER_ACTIVATED_LINE); + expect(res.isError).toBeFalsy(); expect(fetchCalls.spy).toHaveBeenCalled(); expect(getJob(job.id)?.last_check_at).not.toBe(before); } finally { @@ -283,33 +328,11 @@ describe('activation gate — MCP tools/call', () => { } }); - it('registering mid-flight makes the very next call succeed — no server restart', async () => { - const { client, teardown } = await connectClient(); - try { - const first = await client.callTool({ - name: 'diff', - arguments: { old: { markdown: 'a\n' }, new: { markdown: 'b\n' }, output: 'unified' }, - }); - expect(textOf(first)).toBe(NEVER_ACTIVATED_LINE); - - registerOnDisk(); - - // Same client, same server, same session. If the refusal were cached for - // the ≤1/min reload window this would still be the refusal line. - const second = await client.callTool({ - name: 'diff', - arguments: { old: { markdown: 'a\n' }, new: { markdown: 'b\n' }, output: 'unified' }, - }); - expect(textOf(second)).not.toBe(NEVER_ACTIVATED_LINE); - expect(JSON.parse(textOf(second)).changed).toBe(true); - } finally { - await teardown(); - } - }); - - it('crossing the grace boundary mid-flight flips a live server to refusing', async () => { - // A subscription grant: no perpetual arm, so `valid_until` and the 14-day - // rolling grace are what decide — the only shape whose activation expires. + it('keeps dispatching after an expired grant crosses its grace boundary', async () => { + // WHY: PX2 flipped a LIVE server to refusing at exactly this boundary, with + // no restart, and that arm was correct then. §0a.1 removed the wall from core + // entirely — including the expired arm, which is the one people would most + // expect to survive as a wall. The clock still moves; the behaviour does not. const lastRefresh = Date.parse('2026-01-01T00:00:00.000Z'); const account = subscriptionAccount({ validUntil: '2026-01-02T00:00:00.000Z', @@ -325,7 +348,7 @@ describe('activation gate — MCP tools/call', () => { name: 'diff', arguments: { old: { markdown: 'a\n' }, new: { markdown: 'b\n' }, output: 'unified' }, }); - expect(textOf(inside)).not.toBe(ACTIVATION_REFUSALS.expired); + expect(JSON.parse(textOf(inside)).changed).toBe(true); // Nothing about the process changes except the clock. clock = lastRefresh + GRACE_MS + 1_000; @@ -333,31 +356,171 @@ describe('activation gate — MCP tools/call', () => { name: 'diff', arguments: { old: { markdown: 'a\n' }, new: { markdown: 'b\n' }, output: 'unified' }, }); - expect(textOf(outside)).toBe(ACTIVATION_REFUSALS.expired); - expect(outside.isError).toBe(true); + expect(outside.isError).toBeFalsy(); + expect(JSON.parse(textOf(outside)).changed).toBe(true); + expect(JSON.stringify(outside)).not.toContain(ACTIVATION_REFUSALS.expired); } finally { await teardown(); restore(); } }); + + // ------------------------------------------------------------------------- + // The single registration nudge (§0a.2) + // ------------------------------------------------------------------------- + + /** Every text block of a result, joined — the footer is never block zero. */ + function allText(res: unknown): string { + const blocks = (res as { content?: Array<{ text?: string }> }).content ?? []; + return blocks.map((b) => b.text ?? '').join('\n'); + } + + /** Block zero — every core tool's own JSON. The footer is never here. */ + function jsonBlockOf(res: unknown): string { + return (res as { content?: Array<{ text?: string }> }).content?.[0]?.text ?? ''; + } + + // Typed as the real `Client` rather than a structural `{ callTool }`: the SDK's + // signature is generic over the request schema, so a hand-written shape is not + // assignable to it and every call site paid a type error for the convenience. + async function callDiff(client: Client): Promise { + return client.callTool({ + name: 'diff', + arguments: { old: { markdown: 'a\n' }, new: { markdown: 'b\n' }, output: 'unified' }, + }); + } + + async function runDiff(client: Client): Promise { + return allText(await callDiff(client)); + } + + it('fires the registration nudge EXACTLY once, on run N, and never again', async () => { + // WHY THE WHOLE SEQUENCE IS DRIVEN: "never repeated" is the requirement, and + // the two ways to get it wrong are opposite — a counter that resets (never + // fires) and a flag that is never burned (fires forever). Neither is visible + // from a single call, so the arm walks N-1 quiet runs, the one loud run, and + // three more quiet ones. + // THE BOUND IS DERIVED FROM THE CONSTANT, SO THE CONSTANT NEEDS ITS OWN PIN. + // Every loop below counts to `NUDGE_AFTER_RUNS`, which means lowering it to 1 + // moves this whole arm with it and stays green. A nudge on the very first run + // is a different product — an install prompt wearing a footer — so the first + // run's silence is asserted against a LITERAL, and the band is asserted + // directly. `< 2` is the regression; the upper bound catches a value nobody + // reaches, which is the same nudge as no nudge. + expect(NUDGE_AFTER_RUNS).toBeGreaterThanOrEqual(2); + expect(NUDGE_AFTER_RUNS).toBeLessThanOrEqual(20); + + const { client, teardown } = await connectClient(); + try { + expect(await runDiff(client), 'nudged on the first run').not.toContain(UNREGISTERED_RUNS_LINE); + for (let i = 2; i < NUDGE_AFTER_RUNS; i += 1) { + expect(await runDiff(client), `run ${i} nudged early`).not.toContain(UNREGISTERED_RUNS_LINE); + } + + const onNResult = await callDiff(client); + const onN = allText(onNResult); + expect(onN).toContain(UNREGISTERED_RUNS_LINE); + expect(onN).toContain('wigolo register'); + // The unlock LIST is the payload §0a.3 asks for, not just a sign-up line. + for (const unlock of REGISTRATION_UNLOCKS) expect(onN).toContain(unlock); + // Still a real result: the nudge rides ALONGSIDE the JSON, never into it. + expect(JSON.parse(jsonBlockOf(onNResult)).changed).toBe(true); + + for (let i = 0; i < 3; i += 1) { + expect(await runDiff(client), 'nudged twice').not.toContain(UNREGISTERED_RUNS_LINE); + } + } finally { + await teardown(); + } + }); + + it('survives a restart: a new server does not re-nudge', async () => { + // WHY: the flag has to be on disk. A per-process counter passes the arm above + // and fails here — and a per-process counter is what an MCP install, whose + // server the harness restarts every session, would hit every single session. + const first = await connectClient(); + try { + for (let i = 0; i < NUDGE_AFTER_RUNS; i += 1) await runDiff(first.client); + } finally { + await first.teardown(); + } + expect(existsSync(nudgeStatePath(tmpDataDir))).toBe(true); + + setActivationChecker(null); + const second = await connectClient(); + try { + for (let i = 0; i < NUDGE_AFTER_RUNS + 1; i += 1) { + expect(await runDiff(second.client)).not.toContain(UNREGISTERED_RUNS_LINE); + } + } finally { + await second.teardown(); + } + }); + + it('never nudges a REGISTERED install, however many runs it makes', async () => { + // WHY: the nudge's entire premise is "you have no account". Offering unlocks + // to somebody who already bought them is the nag §0a.2 forbids. + registerOnDisk(); + const { client, teardown } = await connectClient(); + try { + for (let i = 0; i < NUDGE_AFTER_RUNS + 2; i += 1) { + expect(await runDiff(client)).not.toContain(UNREGISTERED_RUNS_LINE); + } + // And nothing was even counted — no state file to carry into a later life. + expect(existsSync(nudgeStatePath(tmpDataDir))).toBe(false); + } finally { + await teardown(); + } + }); + + it('does not count FAILED calls toward the nudge', async () => { + // WHY: §0a.2 says "after N SUCCESSFUL runs". A user whose calls all error has + // not seen wigolo work, and a sign-up prompt under an error reads as part of + // the error. `fetch` is stubbed to fail at the top of this file, so N failed + // calls followed by N-1 good ones must stay quiet. + const { client, teardown } = await connectClient(); + try { + for (let i = 0; i < NUDGE_AFTER_RUNS + 1; i += 1) { + const res = await client.callTool({ + name: 'fetch', + arguments: { url: 'https://example.invalid/x' }, + }); + expect(res.isError).toBe(true); + expect(allText(res)).not.toContain(UNREGISTERED_RUNS_LINE); + } + for (let i = 1; i < NUDGE_AFTER_RUNS; i += 1) { + expect(await runDiff(client)).not.toContain(UNREGISTERED_RUNS_LINE); + } + // The very next successful run is N, and only now is it due. + expect(await runDiff(client)).toContain(UNREGISTERED_RUNS_LINE); + } finally { + await teardown(); + } + }); }); -describe('activation notice in the per-session instructions', () => { - it('prepends exactly one line when un-activated and nothing when activated', () => { +describe('the unlock notice in the per-session instructions', () => { + it('prepends exactly one line when unregistered and nothing when registered', () => { expect(serverInstructions(true)).toBe(WIGOLO_INSTRUCTIONS); - const unactivated = serverInstructions(false); - expect(unactivated.startsWith(ACTIVATION_NOTICE)).toBe(true); - expect(unactivated).toContain(WIGOLO_INSTRUCTIONS); + const unregistered = serverInstructions(false); + expect(unregistered.startsWith(UNLOCK_NOTICE)).toBe(true); + expect(unregistered).toContain(WIGOLO_INSTRUCTIONS); // One line, not a paragraph: the budget for this string is a session prompt. - expect(ACTIVATION_NOTICE.includes('\n')).toBe(false); + expect(UNLOCK_NOTICE.includes('\n')).toBe(false); }); - it('names `wigolo register` and says a restart is not needed', () => { - // The honest limitation the mini-spec pins: the notice is composed once at - // construction, so it can outlive the state it describes. It is only - // harmless because it tells the reader that retrying is enough. - expect(ACTIVATION_NOTICE).toContain('wigolo register'); - expect(ACTIVATION_NOTICE).toContain('no restart'); + it('tells the model the tools WORK, and never to block a call on registering', () => { + // WHY THIS ARM EXISTS AT ALL. The old notice said every tool call was refused + // until registration — which was true then and is the exact failure mode now: + // a model that reads "no account" and infers "so this will not work" stops + // calling tools that work perfectly. The notice has to say the opposite + // loudly enough that a model acts on it. + expect(UNLOCK_NOTICE).toContain('work'); + expect(UNLOCK_NOTICE).toContain('optional'); + expect(UNLOCK_NOTICE).toContain('never block a tool call'); + expect(UNLOCK_NOTICE).toContain('wigolo register'); + // And it must not resurrect the old claim. + expect(UNLOCK_NOTICE).not.toContain('refused'); }); }); diff --git a/tests/unit/server/instructions.test.ts b/tests/unit/server/instructions.test.ts index 7a3a7b3db..fd9fc5d72 100644 --- a/tests/unit/server/instructions.test.ts +++ b/tests/unit/server/instructions.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from 'vitest'; import { encode } from 'gpt-tokenizer'; import { - ACTIVATION_NOTICE, + UNLOCK_NOTICE, WIGOLO_INSTRUCTIONS, WIGOLO_INSTRUCTIONS_FULL, TOOL_DESCRIPTIONS, @@ -14,15 +14,15 @@ function wordCount(s: string): number { } describe('WIGOLO_INSTRUCTIONS (Layer 1 — per-session strategy)', () => { - // PX2 #222: an un-activated install prepends a one-line activation notice to + // #336 (§0a.1): an unregistered install prepends a one-line UNLOCK notice to // this string. The notice is COMPOSED at server construction and must never be // baked into the constant — every word-budget assertion in this block is - // written against the activated form, and an install that has registered must - // receive exactly what it received before 0.3.0. - it('is exactly what an activated session receives — the notice is composed, not baked in', () => { + // written against the registered form, and neither an install with an account + // nor one without gets a different tool surface. + it('is exactly what a registered session receives — the notice is composed, not baked in', () => { expect(serverInstructions(true)).toBe(WIGOLO_INSTRUCTIONS); - expect(WIGOLO_INSTRUCTIONS).not.toContain(ACTIVATION_NOTICE); - expect(serverInstructions(false)).toBe(`${ACTIVATION_NOTICE}\n\n${WIGOLO_INSTRUCTIONS}`); + expect(WIGOLO_INSTRUCTIONS).not.toContain(UNLOCK_NOTICE); + expect(serverInstructions(false)).toBe(`${UNLOCK_NOTICE}\n\n${WIGOLO_INSTRUCTIONS}`); }); it('is a non-empty string', () => { diff --git a/tests/unit/server/tool-telemetry.test.ts b/tests/unit/server/tool-telemetry.test.ts index 6a84ee2ef..18582a578 100644 --- a/tests/unit/server/tool-telemetry.test.ts +++ b/tests/unit/server/tool-telemetry.test.ts @@ -29,7 +29,9 @@ import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; import { resetConfig } from '../../../src/config.js'; import { _resetMigrationGuard } from '../../../src/cache/migrations/runner.js'; import { setActivationChecker } from '../../../src/server/activation.js'; -import { ACTIVATION_REFUSALS } from '../../../src/account/gate.js'; +import { ACTIVATION_REFUSALS, evaluateActivation } from '../../../src/account/gate.js'; +import { AccountStateStore } from '../../../src/account/state.js'; +import { resolvePinnedKeys } from '../../../src/account/pinned-keys.js'; import { queuePath } from '../../../src/telemetry/queue.js'; import { _resetTelemetryForTest, telemetryStatus } from '../../../src/telemetry/index.js'; import { generateMintKeyPair, mintToken, grant, payload } from '../account/mint-entitlement.js'; @@ -265,13 +267,18 @@ describe('tool.run / tool.error at the MCP dispatch seam', () => { expect(bytes).not.toContain('/secret/'); }); - it('emits ZERO events for a never-activated call', async () => { - // No activate(): the data dir has no state file, so the gate refuses for real. + it('emits ZERO events for an unregistered call that SUCCEEDS', async () => { + // No activate(): the data dir has no state file, so there is no account id. + // Since §0a.1 the call itself goes through — which is what makes this arm say + // something. Under PX2 the call was refused, so "nothing was queued" was + // over-determined: a report placed anywhere would have had no call to report. + // Now the tool really runs, really succeeds, and STILL nothing is queued, + // because collection is keyed to an account that does not exist. fetchStub.impl.mockResolvedValue({ ok: true, data: { url: PLANTED_URL, markdown: 'x' } }); const { client, teardown } = await connectClient(); try { const res = await client.callTool({ name: 'fetch', arguments: { url: PLANTED_URL } }); - expect(res.isError).toBe(true); + expect(res.isError).toBeFalsy(); } finally { await teardown(); } @@ -280,7 +287,7 @@ describe('tool.run / tool.error at the MCP dispatch seam', () => { expect(existsSync(queuePath(dataDir))).toBe(false); // And the recorder is not simply broken in this file: the SAME call, on the SAME - // process, reports as soon as the install is activated. + // process, reports as soon as the install has an account. activate(); _resetTelemetryForTest(); const second = await connectClient(); @@ -293,23 +300,28 @@ describe('tool.run / tool.error at the MCP dispatch seam', () => { }); /** - * THE ARM THAT PINS THE GATE'S PLACEMENT. + * THE ARM THAT PINS WHICH LAYER DECIDES, NOW THAT THERE IS ONLY ONE. * - * The never-activated arm above cannot do it: the telemetry client independently - * declines to collect when there is no account id, so a report moved ABOVE the gate - * would still write nothing there and that arm would stay green. Measured — the - * mutation was run. + * Under PX2 there were two: the activation gate refused an expired install's tool + * calls, AND the telemetry client independently declines to collect without an + * account id. This arm existed to separate them — an EXPIRED install has a real + * `account_id`, so the client was collecting and only the gate's refusal kept the + * queue empty. §0a.1 deleted that gate from core, so the expired install now + * DISPATCHES, and with a collecting client it reports. * - * An EXPIRED install is the shape that separates the two layers. Its `state.json` - * carries a real `account_id`, so the client is collecting; the gate refuses anyway, - * because the token is out of validity and out of its 14-day grace. If the report ever - * moves above the refusal, this queue stops being empty. + * That inversion is the whole point of keeping the arm. The property that survives + * is that ACCOUNT IDENTITY, not activation, is what decides whether wigolo reports: + * an install with an account reports (this arm), an install without one reports + * nothing at all (the never-activated arm above), and the difference is made by + * `telemetry/client.ts` alone. If someone re-derives collection from the gate — the + * obvious "tidy-up" now that the gate has no other core consumer — an expired + * install stops reporting and this queue goes empty again. * * The condition is forced, not stubbed: a real subscription token is minted with a past - * `valid_until` and a `last_refresh_at` aged past the grace window, and the gate walks - * all six of its steps over it. + * `valid_until` and a `last_refresh_at` aged past the grace window, so the gate really + * does evaluate to `expired` while the account id really is present. */ - it('emits ZERO events for an EXPIRED install, whose account id would otherwise be collecting', async () => { + it('DISPATCHES and reports for an EXPIRED install — account identity decides, not activation', async () => { const past = new Date(Date.now() - 60 * 24 * 3600_000).toISOString(); const { token } = mintToken( mintKeys, @@ -333,20 +345,25 @@ describe('tool.run / tool.error at the MCP dispatch seam', () => { { mode: 0o600 }, ); - // The precondition this arm rests on: telemetry considers this install activated. + // Two preconditions this arm rests on, both asserted rather than assumed: + // telemetry considers this install collecting, and the GATE considers it expired. expect(telemetryStatus()).toBe('enabled'); + expect(evaluateActivation( + { state: new AccountStateStore(dataDir).read(), keys: resolvePinnedKeys().keys }, + Date.now(), + ).ok).toBe(false); fetchStub.impl.mockResolvedValue({ ok: true, data: { url: PLANTED_URL, markdown: 'x' } }); const { client, teardown } = await connectClient(); try { const res = await client.callTool({ name: 'fetch', arguments: { url: PLANTED_URL } }); - expect(res.isError).toBe(true); - expect(textOf(res)).toContain(ACTIVATION_REFUSALS.expired); + expect(res.isError).toBeFalsy(); + expect(JSON.stringify(res)).not.toContain(ACTIVATION_REFUSALS.expired); } finally { await teardown(); } - expect(queueBytes()).toBe(''); + expect(queuedEvents().map((e) => e.name)).toContain('tool.run'); }); it('reports nothing for a name outside the ten-tool enum', async () => {