From b5b081cbefb71e736eee385cf0f93741644c80b3 Mon Sep 17 00:00:00 2001 From: KnockOutEZ Date: Mon, 7 Sep 2026 13:31:07 +0600 Subject: [PATCH 01/11] fix(core)!: registration becomes an unlock, not a gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PX brief §0a.1-3 (CEO consulting pass 2026-09-03) amends PX2's hard gate to Studio-only. Core now dispatches all ten tools unregistered on every surface — MCP tools/call, the daemon's /v1 and firecrawl-compat routes, CLI one-shots, the REPL and serve start. The requireActivation seam and src/account/* stay put; only core call-site policy flips. In place of the refusal, an unregistered install is told once what an account adds: src/account/unlocks.ts owns the list, src/account/nudge.ts owns the persisted once-only counter, and the MCP result footer, the CLI/REPL seams and both init paths render it. REST counts runs but never renders — a typed envelope has no place for prose. --- src/account/nudge.ts | 147 ++++++++++++++++++++++++++++++++++++ src/account/unlocks.ts | 79 +++++++++++++++++++ src/cli/daemon.ts | 18 ----- src/cli/init.ts | 47 +++++++----- src/cli/tool-run.ts | 20 ++--- src/daemon/http-server.ts | 43 ++--------- src/daemon/rest/dispatch.ts | 13 +++- src/instructions.ts | 46 ++++++----- src/repl/shell.ts | 18 ++--- src/server.ts | 30 +++----- src/server/activation.ts | 70 +++++++++++++++-- 11 files changed, 387 insertions(+), 144 deletions(-) create mode 100644 src/account/nudge.ts create mode 100644 src/account/unlocks.ts diff --git a/src/account/nudge.ts b/src/account/nudge.ts new file mode 100644 index 00000000..d4a4dc35 --- /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 00000000..30392f3a --- /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/daemon.ts b/src/cli/daemon.ts index 19637cb9..cdfac5f8 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/init.ts b/src/cli/init.ts index 31874857..22cc27e8 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 f7dac751..e0effe5a 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/daemon/http-server.ts b/src/daemon/http-server.ts index 0fd8a7c2..d9cd2745 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 462f6fe4..e1f6afce 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 25070212..fab7706a 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 70ff9494..9ba9aa8c 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 de2e9830..f684e7b5 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 @@ -749,7 +739,11 @@ export function createMcpServer(subsystems: Subsystems): Server { // produces no account, no queue write and no event — the absence is structural, // not a condition anyone has to remember to write. 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 8aa763ce..8fc7b2c5 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 }] }; } From 6bb317375cc8a2c06ab792a6a7a427306e1b8ead Mon Sep 17 00:00:00 2001 From: KnockOutEZ Date: Mon, 7 Sep 2026 13:32:32 +0600 Subject: [PATCH 02/11] feat(cli): headless register path and marketing consent unticked by default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PX brief §0a.2 and §0a.5. `--headless` (alias `--no-input`) asks nothing: stage one mails the code to the address the human owns and stops without creating anything; stage two takes `--code` and `--marketing-consent` as flags. The served telemetry disclosure is still shown on both stages — headless does not exempt an agent from relaying the wording the human is consenting to. The interactive consent prompt reverses to `[y/N]` with a false default: consent has to be an affirmative act. Product and security email stays transactional. --- src/cli/account.ts | 119 ++++++++++++++++++++++++++++++++++++--------- src/cli/help.ts | 13 +++-- 2 files changed, 104 insertions(+), 28 deletions(-) diff --git a/src/cli/account.ts b/src/cli/account.ts index 22c9b8af..1cd822da 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/help.ts b/src/cli/help.ts index 8418ea64..ad4c1217 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 From d6abadf8bfec41df50d3bbb309d5ded63602bf70 Mon Sep 17 00:00:00 2001 From: KnockOutEZ Date: Mon, 7 Sep 2026 13:40:06 +0600 Subject: [PATCH 03/11] test: invert the PX2 gate suites onto unregistered core, and pin the nudge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every arm that asserted a refusal is now the inverse assertion at the same seam, keeping the property each one was written for: the compat family still carries the daemon sweep because it bypasses dispatchTool, `serve` still gets its own arm because its refusal was a process.exit, and the watch-scheduler arm still exists because PX2's gate sat above it deliberately. New arms pin the single nudge: quiet before N, once on N, never again, across a server restart, never on a registered install, and never counted from a failed call. Four mutations were run red and restored — flag never burned, threshold lowered to 1, failed calls counted, activation check dropped. --- tests/integration/activation-cli.test.ts | 97 +++-- .../unit/cli/capability-language-copy.test.ts | 21 +- tests/unit/cli/init-activation-hint.test.ts | 92 +++-- tests/unit/daemon/activation-routes.test.ts | 102 ++--- tests/unit/server/activation-gate.test.ts | 352 +++++++++++++----- tests/unit/server/instructions.test.ts | 14 +- tests/unit/server/tool-telemetry.test.ts | 59 +-- 7 files changed, 491 insertions(+), 246 deletions(-) diff --git a/tests/integration/activation-cli.test.ts b/tests/integration/activation-cli.test.ts index 76b2e388..1827f0b2 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/unit/cli/capability-language-copy.test.ts b/tests/unit/cli/capability-language-copy.test.ts index 047ec512..419d28c0 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/init-activation-hint.test.ts b/tests/unit/cli/init-activation-hint.test.ts index dd93bfe9..51638e5c 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 79e72955..5d82d994 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/server/activation-gate.test.ts b/tests/unit/server/activation-gate.test.ts index 5eed2327..e39ee82e 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,168 @@ 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 ?? ''; + } + + async function callDiff(client: { callTool: (r: unknown) => Promise }): Promise { + return client.callTool({ + name: 'diff', + arguments: { old: { markdown: 'a\n' }, new: { markdown: 'b\n' }, output: 'unified' }, + }); + } + + async function runDiff(client: { callTool: (r: unknown) => Promise }): 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 7a3a7b3d..fd9fc5d7 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 6a84ee2e..18582a57 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 () => { From a2789c5d464b8739ec5a8b87feff9b6c258908f9 Mon Sep 17 00:00:00 2001 From: KnockOutEZ Date: Mon, 7 Sep 2026 13:42:24 +0600 Subject: [PATCH 04/11] test(px2-rc): flip the RC exit gate onto unregistered core MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every refusal arm keeps its expensive fixture and inverts its claim: the fresh install runs its first tool and all ten over MCP with no account, the --omit=optional install runs before it registers, and the back-dated-clock arm now asserts BOTH halves of the amendment — core keeps running on an entitlement that is out of its window and out of grace, while whoami shows the surviving requireActivation seam still calls it expired. New arms cover the single nudge with its unlock list and the pinned telemetry claim, and the headless two-stage registration, including that an omitted --marketing-consent persists as false. --- tests/integration/px2-rc/rc-exit-gate.test.ts | 203 ++++++++++++++---- 1 file changed, 166 insertions(+), 37 deletions(-) diff --git a/tests/integration/px2-rc/rc-exit-gate.test.ts b/tests/integration/px2-rc/rc-exit-gate.test.ts index be5cd294..d611d072 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 @@ -56,10 +68,30 @@ 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 first line of the single registration nudge (`src/account/unlocks.ts`). */ +const NUDGE_LEAD_LINE = 'wigolo runs fully without an account — registering only adds to it.'; + +/** 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 +149,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 +205,69 @@ 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 () => { + 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 already carries the arms above, so the loop drives a generous + // margin past N rather than counting to it exactly — what is being asserted + // is "exactly one nudge across many runs", which is stronger than "on run N" + // and is the clause §0a.2 actually pins ("never repeated"). + const seen: string[] = []; + for (let i = 0; i < NUDGE_AFTER_RUNS * 2; 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)) seen.push(r.combined); + } + + expect(seen.length, `the nudge fired ${seen.length} times across ${NUDGE_AFTER_RUNS * 2} runs`).toBe(1); + const nudge = seen[0]; + expect(nudge).toContain('wigolo register'); + // §0a.3: the unlock LIST, not merely an invitation to register. + for (const unlock of UNLOCK_LINES) expect(nudge).toContain(unlock); + // §0a.4: the claim, in the pinned wording, where the user is deciding. + expect(nudge).toContain(TELEMETRY_CLAIM_LINE); + record('arm 2b — the single registration nudge', nudge); + }, 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 +278,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 +304,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 }); @@ -387,9 +501,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 +530,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 +607,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 +696,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 +738,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); From c841c4ad79cfe20c948ca89c0fb1b304292c950f Mon Sep 17 00:00:00 2001 From: KnockOutEZ Date: Mon, 7 Sep 2026 13:43:34 +0600 Subject: [PATCH 05/11] test(cli): pin the headless register stages and the unticked consent default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bare-Enter consent is asserted on the wire as false, not only in local state — the account row is what a marketing send reads. Headless stage one is pinned to create nothing: disclosure then request-code, no verify, no state file. Four mutations were run red and restored: consent default flipped back to true, headless flags defaulting to yes, stage one falling through to verify, and --headless accepting a missing --email. --- tests/unit/cli/account.test.ts | 148 ++++++++++++++++++++++++++++++++- 1 file changed, 144 insertions(+), 4 deletions(-) diff --git a/tests/unit/cli/account.test.ts b/tests/unit/cli/account.test.ts index 1e26520c..3b4f789e 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 From a92c29cf1b9d5bdcddb8a089edbfd3ec8a8befab Mon Sep 17 00:00:00 2001 From: KnockOutEZ Date: Tue, 8 Sep 2026 05:37:45 +0600 Subject: [PATCH 06/11] docs: retire the gate claim and pin the telemetry wording on every public surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit README, docs/cli.md and docs/privacy-security.md all still said the ten tools needed an activated install — false since the amendment made the hard gate Studio-only, and false in the two places a reader decides whether to install at all. They now lead with what an account ADDS. The telemetry sentence is pinned rather than paraphrased: doctor imports TELEMETRY_CLAIM instead of spelling a ninth variant, and the docs carry the same words the CLI and the first-run output show. "Nothing leaves your machine" is retired where it read as an absolute; the off-switch paragraph keeps its conditional claim in wording that cannot be quoted back as one. Also documents the headless register flow and the unticked marketing default, and corrects the telemetry comment in server.ts that still described a gate returning above it. --- README.md | 33 ++++++++++++++++++++----------- docs/cli.md | 42 +++++++++++++++++++++++++++------------- docs/privacy-security.md | 18 ++++++++++++----- src/cli/doctor.ts | 5 ++++- src/server.ts | 9 +++++---- 5 files changed, 73 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index 7ee30bc9..33c347eb 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: @@ -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 6e98cd99..4d67bc56 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/privacy-security.md b/docs/privacy-security.md index 4a72e29a..9a7f2aaf 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/src/cli/doctor.ts b/src/cli/doctor.ts index 74a84795..4a4bed53 100644 --- a/src/cli/doctor.ts +++ b/src/cli/doctor.ts @@ -22,6 +22,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'; @@ -1365,7 +1366,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/server.ts b/src/server.ts index f684e7b5..7c4430cd 100644 --- a/src/server.ts +++ b/src/server.ts @@ -734,10 +734,11 @@ 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); // §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 From a944d410a7e5aeb677e19db807362b98f9ea6dd1 Mon Sep 17 00:00:00 2001 From: KnockOutEZ Date: Tue, 8 Sep 2026 05:39:54 +0600 Subject: [PATCH 07/11] docs: finish the telemetry sweep and delete the refusal that no longer exists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getting-started still had a step called "Activate this install" and quoted an init hint the code no longer prints; troubleshooting had four rows and a whole section explaining a refusal core cannot emit. Both now say the opposite, and troubleshooting keeps a pointer for anyone on an older build who really is seeing that line. llms.txt said telemetry was off by default. It is on for a registered install and has been since 0.3.0 — that one was simply wrong. The two remaining absolutes (the site's "nothing leaves" closer and the config TUI's help text) now carry the pinned sentence or a claim scoped to the off state. doctor's test spells the sentence out as a literal rather than importing the constant: the clause being tested is that six surfaces say the SAME words, which an import cannot fail to satisfy. --- README.md | 2 +- docs/getting-started.md | 44 ++++++++++++++++++------------ docs/troubleshooting.md | 37 ++++++++++++------------- llms.txt | 2 +- site/src/components/HowItWorks.tsx | 4 +-- src/cli/tui/schema/advanced.ts | 2 +- tests/unit/cli/doctor.test.ts | 9 +++++- 7 files changed, 57 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index 33c347eb..ea8bfadb 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/docs/getting-started.md b/docs/getting-started.md index 5629053d..823cb0eb 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/troubleshooting.md b/docs/troubleshooting.md index 207de5f4..166b2069 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 8ede1e06..f22f3cce 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 aa42bcc7..1f84d3da 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/cli/tui/schema/advanced.ts b/src/cli/tui/schema/advanced.ts index 1228eaf2..d0b01453 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/tests/unit/cli/doctor.test.ts b/tests/unit/cli/doctor.test.ts index 7d5ca037..98cabf49 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. From c3b933aa2c90744a4ad628cad61cd027b1c836f0 Mon Sep 17 00:00:00 2001 From: KnockOutEZ Date: Tue, 8 Sep 2026 05:42:41 +0600 Subject: [PATCH 08/11] test(px2-rc): assert the unlock list on the surface a person actually meets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The unlock list was pinned at e2e level only on the CLI nudge; first-run setup was covered by a unit test that calls activationNextStepLines directly and never exercises the code path that prints it. Composing the lines and printing them are two claims and only one of them is what somebody installing wigolo sees, so this adds an arm that drives the installed binary's real setup and reads its stdout — including the negative half, that setup never quotes the deleted refusal or the old register next-step. Types the two diff helpers as the SDK Client instead of a hand-written { callTool } shape. The SDK signature is generic over the request schema so the structural type was never assignable, and it was costing nine entries on the tests/ type-check debt ratchet (350 against a baseline of 341). --- tests/integration/px2-rc/rc-exit-gate.test.ts | 39 ++++++++++++++++++- tests/unit/server/activation-gate.test.ts | 7 +++- 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/tests/integration/px2-rc/rc-exit-gate.test.ts b/tests/integration/px2-rc/rc-exit-gate.test.ts index d611d072..51f56fa0 100644 --- a/tests/integration/px2-rc/rc-exit-gate.test.ts +++ b/tests/integration/px2-rc/rc-exit-gate.test.ts @@ -74,8 +74,19 @@ if (RC_GATE_DISABLED) console.warn(RC_GATE_SKIP_NOTICE); const NEVER_ACTIVATED_LINE = 'wigolo needs an account — run `wigolo register` to create one (already have one? `wigolo login`).'; -/** The first line of the single registration nudge (`src/account/unlocks.ts`). */ -const NUDGE_LEAD_LINE = 'wigolo runs fully without an account — registering only adds to it.'; +/** + * 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 = [ @@ -205,6 +216,30 @@ describe.skipIf(RC_GATE_DISABLED)('PX2 RC exit gate — fresh install, registrat } }, 300_000); + 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 diff --git a/tests/unit/server/activation-gate.test.ts b/tests/unit/server/activation-gate.test.ts index e39ee82e..fc511f5a 100644 --- a/tests/unit/server/activation-gate.test.ts +++ b/tests/unit/server/activation-gate.test.ts @@ -380,14 +380,17 @@ describe('MCP tools/call on an unregistered install', () => { return (res as { content?: Array<{ text?: string }> }).content?.[0]?.text ?? ''; } - async function callDiff(client: { callTool: (r: unknown) => Promise }): Promise { + // 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: { callTool: (r: unknown) => Promise }): Promise { + async function runDiff(client: Client): Promise { return allText(await callDiff(client)); } From 9a05db7d16211037c77894222c24401a44178d41 Mon Sep 17 00:00:00 2001 From: KnockOutEZ Date: Tue, 8 Sep 2026 05:50:40 +0600 Subject: [PATCH 09/11] test(px2-rc): let the nudge arm spend the nudge it measures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The arm looped ten runs and asserted exactly one nudge, on the theory that a generous margin past N is stronger than counting to N. It is not, because the nudge is an install-lifetime resource and the arms share one install: the ten-tool MCP arm crosses N first, spends the nudge into a result nobody is reading for a footer, and the CLI arm then observes zero. Measured on the fixture — the install sits at successful_runs 5 / nudged true before this arm starts, so it red on a product behaving exactly as specified. The arm now resets the counter and drives the whole shape: quiet for N-1, loud on N with the unlock list and the pinned telemetry claim, quiet for N more. That is both halves of "once, never repeated", and neither half depends on how many of the previous arm's ten tools happened to succeed. Adds the matching MCP arm, because the footer is where product law 9 puts the interface for a terminal user with no plugin: N successful calls over the protocol, the footer on exactly the Nth, absent on the next, and the tool's own JSON still parseable in the first content block. Also prints the fetch error text on the registered-tools diff seeding, which asserted a boolean and told us nothing about why it was true. --- tests/integration/px2-rc/rc-exit-gate.test.ts | 122 ++++++++++++++++-- 1 file changed, 108 insertions(+), 14 deletions(-) diff --git a/tests/integration/px2-rc/rc-exit-gate.test.ts b/tests/integration/px2-rc/rc-exit-gate.test.ts index 51f56fa0..6e54f2ee 100644 --- a/tests/integration/px2-rc/rc-exit-gate.test.ts +++ b/tests/integration/px2-rc/rc-exit-gate.test.ts @@ -216,6 +216,30 @@ describe.skipIf(RC_GATE_DISABLED)('PX2 RC exit gate — fresh install, registrat } }, 300_000); + /** + * 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 { + 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 @@ -282,25 +306,92 @@ describe.skipIf(RC_GATE_DISABLED)('PX2 RC exit gate — fresh install, registrat // 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 already carries the arms above, so the loop drives a generous - // margin past N rather than counting to it exactly — what is being asserted - // is "exactly one nudge across many runs", which is stronger than "on run N" - // and is the clause §0a.2 actually pins ("never repeated"). - const seen: string[] = []; - for (let i = 0; i < NUDGE_AFTER_RUNS * 2; i += 1) { + // 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)) seen.push(r.combined); + 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([]); - expect(seen.length, `the nudge fired ${seen.length} times across ${NUDGE_AFTER_RUNS * 2} runs`).toBe(1); - const nudge = seen[0]; - expect(nudge).toContain('wigolo register'); + 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(nudge).toContain(unlock); + 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(nudge).toContain(TELEMETRY_CLAIM_LINE); - record('arm 2b — the single registration nudge', nudge); + 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 = ''; + 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}`); + lastJson = firstTextBlock(outcome.raw); + } + + expect(footed.length, `the footer appeared on ${footed.join(', ')}`).toBe(1); + expect(footed[0]).toBe(`call ${NUDGE_AFTER_RUNS}`); + 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 () => { @@ -452,7 +543,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, ); From dc5a72a9ac767163c3032d1e80d4f07936dbbef8 Mon Sep 17 00:00:00 2001 From: KnockOutEZ Date: Tue, 8 Sep 2026 05:56:05 +0600 Subject: [PATCH 10/11] test(px2-rc): install the browser engine before the arms, not during them The registered ten-tool arm red twice on browser_engine_unavailable while seeding the diff baseline, and the cause is a prerequisite the fixture never had. installTarball is npm alone, so no engine is present; the fetch router pins a host to the tier that last served it, the fixture's short pages escalate once, and a later force_refresh therefore STARTS at the browser tier with no lower-tier content to fall back to. The failure also kicked off a background download that raced everything after it. beforeAll now warms the engine and asserts it, which is what an ordinary install does at setup and the only fix that stops an arm's result depending on how far a download got. Asserted rather than best-effort for the reason rc-gate-env.ts already gives: once the gate says it runs, a missing prerequisite throws instead of reporting green about something it never exercised. --- tests/integration/px2-rc/rc-exit-gate.test.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/integration/px2-rc/rc-exit-gate.test.ts b/tests/integration/px2-rc/rc-exit-gate.test.ts index 6e54f2ee..84cdc7d5 100644 --- a/tests/integration/px2-rc/rc-exit-gate.test.ts +++ b/tests/integration/px2-rc/rc-exit-gate.test.ts @@ -194,6 +194,24 @@ describe.skipIf(RC_GATE_DISABLED)('PX2 RC exit gate — fresh install, registrat tarball = await packWigolo(); full = await installTarball(tarball.path, { omitOptional: false }); + // THE BROWSER ENGINE IS A PREREQUISITE, NOT AN ARM'S PROBLEM. + // + // `installTarball` is npm alone, so the engine is absent — and the fetch + // router pins a host to the tier that last served it. The fixture's short + // pages escalate once, the host stays pinned at the browser tier, and a later + // `force_refresh` therefore STARTS there with no lower-tier content to fall + // back to: `browser_engine_unavailable`, plus a background install racing the + // rest of the run. Measured twice on this fixture, red both times in the + // registered ten-tool arm's diff seeding. + // + // Warming it here is what an ordinary install does at setup, and it is the + // only fix that does not make an arm's result depend on how far a download + // got. Asserted rather than best-effort, for the reason `rc-gate-env.ts` + // gives: once the gate says it runs, a missing prerequisite throws instead of + // quietly reporting green about something it never exercised. + const warmed = await runCli(full, ['warmup', '--browser'], { env, timeoutMs: 900_000 }); + expect(warmed.code, `warming the browser engine failed:\n${warmed.combined}`).toBe(0); + record( 'service', `accounts service: ${service.url}\nkid: ${service.kid}\n` + From 5a686fcb944ae845e49498c11314367ebf001acb Mon Sep 17 00:00:00 2001 From: KnockOutEZ Date: Tue, 8 Sep 2026 06:29:46 +0600 Subject: [PATCH 11/11] test: teach the history-mode mock about the nudge seams, and pin the footer's list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The REPL suite replaces node:fs wholesale and stubs the activation module, which under PX2 existed to keep the gate from refusing before readline attached. §0a.1 deleted that gate; what runs on this path now is the nudge, which READS AND WRITES the counter file — i.e. the mocked fs — so the module needs its two new exports stubbed or the whole file dies on a missing mock export. Caught by the full suite, which is what it is for. The MCP footer arm now asserts the unlock LIST and the telemetry claim, not just that a footer appeared: a footer that only invited the reader to register would be the wall announced late, which is the thing the amendment removed. Both new arms proven able to fail by emptying REGISTRATION_UNLOCKS and watching each go red on the missing line, then restoring it byte-identical. resetNudgeState creates its directory, because an arm run in isolation has not run whichever surface would otherwise have created it and died on ENOENT before asserting anything. --- tests/integration/px2-rc/rc-exit-gate.test.ts | 38 +++++++++---------- tests/unit/repl/shell-history-mode.test.ts | 19 +++++++--- 2 files changed, 31 insertions(+), 26 deletions(-) diff --git a/tests/integration/px2-rc/rc-exit-gate.test.ts b/tests/integration/px2-rc/rc-exit-gate.test.ts index 84cdc7d5..1441b1b4 100644 --- a/tests/integration/px2-rc/rc-exit-gate.test.ts +++ b/tests/integration/px2-rc/rc-exit-gate.test.ts @@ -30,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'; @@ -194,24 +194,6 @@ describe.skipIf(RC_GATE_DISABLED)('PX2 RC exit gate — fresh install, registrat tarball = await packWigolo(); full = await installTarball(tarball.path, { omitOptional: false }); - // THE BROWSER ENGINE IS A PREREQUISITE, NOT AN ARM'S PROBLEM. - // - // `installTarball` is npm alone, so the engine is absent — and the fetch - // router pins a host to the tier that last served it. The fixture's short - // pages escalate once, the host stays pinned at the browser tier, and a later - // `force_refresh` therefore STARTS there with no lower-tier content to fall - // back to: `browser_engine_unavailable`, plus a background install racing the - // rest of the run. Measured twice on this fixture, red both times in the - // registered ten-tool arm's diff seeding. - // - // Warming it here is what an ordinary install does at setup, and it is the - // only fix that does not make an arm's result depend on how far a download - // got. Asserted rather than best-effort, for the reason `rc-gate-env.ts` - // gives: once the gate says it runs, a missing prerequisite throws instead of - // quietly reporting green about something it never exercised. - const warmed = await runCli(full, ['warmup', '--browser'], { env, timeoutMs: 900_000 }); - expect(warmed.code, `warming the browser engine failed:\n${warmed.combined}`).toBe(0); - record( 'service', `accounts service: ${service.url}\nkid: ${service.kid}\n` + @@ -246,6 +228,11 @@ describe.skipIf(RC_GATE_DISABLED)('PX2 RC exit gate — fresh install, registrat * 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`, @@ -385,15 +372,26 @@ describe.skipIf(RC_GATE_DISABLED)('PX2 RC exit gate — fresh install, registrat 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}`); + 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 diff --git a/tests/unit/repl/shell-history-mode.test.ts b/tests/unit/repl/shell-history-mode.test.ts index 03b9c561..fce5a9d1 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() }));