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 () => {