@unotest/web 0.33.0 → 0.35.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,539 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.35.1] - 2026-09-16
4
+
5
+ ### Patch Changes
6
+
7
+ - a420ce5: `unotest-web viewer --help` now prints the usage and exits instead of
8
+ starting a viewer and scaffolding a `unotest/` project into the current
9
+ directory. Asking any subcommand for help is answered before anything is
10
+ read from or written to disk.
11
+ - fd979ac: `lint` and a pushed bundle now agree on what "declared" means
12
+
13
+ A bundle has always read `unotest/.env.example` and `.secrets.example` as
14
+ the suite's declared externals; the linter read only `unotest/.env` and
15
+ `.secrets`, which are gitignored. A project was therefore complete for one
16
+ and broken for the other: green on a machine that had the real files, red
17
+ on a fresh checkout — a CI runner, a colleague who had just cloned — over
18
+ names the repository does declare, with a message pointing at files that
19
+ checkout does not have.
20
+
21
+ Both now read the same list. A name written in an example twin counts as
22
+ declared, and the "searched:" line of a refusal names the twins alongside
23
+ the real files.
24
+
25
+ Values are never read from an example: it holds placeholders, and a
26
+ scenario that reads such a variable still fails at that step — "no value",
27
+ which is a different problem from "no such name" and has a different fix.
28
+ A name declared nowhere is the error it always was.
29
+
30
+ - 4284099: `unotest-web --help` now lists `box picker on|off` alongside the other box
31
+ commands.
32
+ - @unotest/core@0.35.1
33
+ - @unotest/dsl@0.35.1
34
+ - @unotest/grounder-client@0.35.1
35
+ - @unotest/protocol@0.35.1
36
+ - @unotest/viewer@0.35.1
37
+
38
+ ## [0.35.0] - 2026-09-15
39
+
40
+ ### Minor Changes
41
+
42
+ - 86da16e: A remote grounder can now say "this account has spent its month", and a client
43
+ can tell that apart from a bad token.
44
+
45
+ A grounder running on an external model provider meters what each account
46
+ costs and stops at a ceiling. At the ceiling the two routes that call a model —
47
+ indexing a page and resolving an intent — answer HTTP **402** with
48
+ `grounder-budget-exhausted`. Money, not permission: the token is perfectly
49
+ good, nothing is re-minted over this, and a client that treats it as an auth
50
+ failure would retry forever. The body carries no amount and nothing about any
51
+ other account; the figure is an estimate of a bill we do not issue, and a
52
+ customer would rightly check it against the provider's own.
53
+
54
+ What a ceiling stops is **writing** tests, not running them, and every text
55
+ about it says so: only the MCP server asks the grounder — `ground_element`,
56
+ and an intent locator inside `explore_step` / `find_element`. A scenario
57
+ carries the locator it was written with, so suites, their runs on a box and
58
+ the viewer are untouched by a cap.
59
+
60
+ `ErrorResponse.code` names it, and gains `revocation-list-unavailable` at the
61
+ same time: a grounder has been answering with that code since signed tokens
62
+ arrived, and the union never learned about it — a client written from the type
63
+ could not discriminate a refusal it was already receiving. `retryAfterSeconds`
64
+ joins the shape for it, matching the `Retry-After` header that was already
65
+ being sent.
66
+
67
+ Nothing changes for a grounder running on our own GPU: nothing is metered
68
+ there, so nothing can be refused for money.
69
+
70
+ - 7ffa4ae: `box picker on|off`: the Picker, switched on a box you already have
71
+
72
+ The Picker is an hourly surcharge on top of the box, and until now it could
73
+ only be chosen when the box was rented — with `box create --picker`, a flag
74
+ most people met for the first time in `--help`.
75
+
76
+ - **`unotest-web box picker on|off [name]`** switches it on a live box, with
77
+ no re-creating and no re-pushing. `on` mints the box's tokens the way
78
+ `box access` does and writes the Picker's three keys —
79
+ `UNOTEST_GROUNDER_MODE=remote` and `UNOTEST_GROUNDER_REMOTE_URL` into
80
+ `unotest/.env`, `UNOTEST_GROUNDER_REMOTE_TOKEN` into `unotest/.secrets`.
81
+ `off` clears the three again, but only when they name that box's grounder:
82
+ a project pointing at a grounder of its own, or at another box's, keeps it
83
+ and is told so. Either way the charge follows at the next hourly tick,
84
+ because a started hour is charged at the rate it started with.
85
+ - **`box create` asks** about the Picker on a terminal when `--picker` was not
86
+ given, quoting the hourly surcharge from the cloud's own price list rather
87
+ than a number kept in the CLI. `--yes`, `--json` and a run with no terminal
88
+ answer no without asking.
89
+ - **`box status`** says whether the Picker is on.
90
+ - **`run_test` offers it once.** A project that has a box but grounds intents
91
+ nowhere remote gets `pickerSuggestion` (`{state: "no-picker", next, why}`)
92
+ after the first run that passes — exactly once per MCP server session, on
93
+ the same terms as `boxSuggestion`, and never both: with no box there is
94
+ nothing to put a Picker on.
95
+ - **A refusal for money reads as one.** A remote grounder answering
96
+ `grounder-budget-exhausted` (HTTP 402) means the account's estimated spend
97
+ for the month has reached its ceiling, not that the token went bad: nothing
98
+ is re-minted over it, and the CLI and MCP replies name both ways out — top
99
+ up the balance, or `box picker off` to stop the Picker and ground locally
100
+ again. No amount is quoted: the figure estimates a bill we do not issue.
101
+
102
+ ### Patch Changes
103
+
104
+ - 79c99ab: A box can now say "I cannot judge this token yet" instead of "your token is
105
+ wrong".
106
+
107
+ A box rented from the fleet accepts tokens the cloud signed for it, and it
108
+ checks every one against the operator's revocation list. Until that list has
109
+ reached the box — the minutes between it coming up and its first update tick
110
+ — there is nothing to check against, so such a token is refused. That is not
111
+ the same refusal as a bad credential: the answer is to send the SAME token
112
+ again shortly, never to mint another.
113
+
114
+ `BoxReadErrorCode` gains `revocation-list-unavailable` for it, and a refusal
115
+ body may carry `retryAfterSeconds` (the box sends `Retry-After` with the same
116
+ number). The push and run answers gain the same code, plus `ambiguous-project`
117
+ for a cloud-minted push token that names no project on a box that serves
118
+ several. Nothing changes for the tokens a person or a CI job mints on the box
119
+ itself.
120
+
121
+ - 1584ce2: `box status` says where the Picker grounds
122
+
123
+ A box whose Picker is on now prints one more line: `Grounding: Our GPU` or
124
+ `Grounding: External provider (Gemini)` — which models the cloud resolves
125
+ intent locators with. It is the answer to a question a customer could not
126
+ check before, only read on a web page.
127
+
128
+ The line appears only while the Picker is on. With it off, intents are
129
+ resolved on the machine the tests run from, and naming our models under that
130
+ box would describe somebody else's. A cloud that has not heard from its own
131
+ grounder sends nothing and the CLI prints nothing: a mode we have not been
132
+ told is not a mode to invent.
133
+
134
+ On the wire the field is `grounding`, not `grounderMode`. `UNOTEST_GROUNDER_MODE`
135
+ already means something else and belongs to the reader — whether THEIR machine
136
+ grounds locally, remotely or not at all — and in `box status` the two would
137
+ have met a line apart. It is optional, so an older cloud simply says nothing.
138
+
139
+ - dee96a1: A box can now say "this token has expired" instead of "your token is wrong".
140
+
141
+ Tokens the cloud signs for a rented box live thirty days. Until now a box
142
+ refused an expired one with the same blanket `unauthorized` it gives a
143
+ forgery, so the CLI could not tell "ask the cloud for another pair and
144
+ retry" from "this credential will never work", and an expired pair looked
145
+ revoked.
146
+
147
+ The read, push, run and values answers gain the code `expired` for it. A box
148
+ sends it only for a token its operator key really signed, of the right kind,
149
+ for that box — the signature, the kind and the box are all checked before
150
+ the expiry is — so nothing a guesser sends can produce it. It is also
151
+ decided before the revocation list is consulted: a box that has not received
152
+ a list yet still answers `expired` rather than asking for the same dead
153
+ token again.
154
+
155
+ Two codes that a box could already send are now accepted when parsing a run
156
+ or values answer, which had been left out of those two readers:
157
+ `revocation-list-unavailable` and `ambiguous-project`.
158
+
159
+ - 220bf37: Fake cloud: two access pairs that arrive without a `client_name` no longer
160
+ replace each other. A machine whose hostname the contract's charset refuses
161
+ sends no name at all, so two such machines were evicting one another from a
162
+ box — the fake now replaces a pair only when a name actually matches. Its
163
+ refusal for a box that is not ready is worded like the rest of the service
164
+ ("That box is deleted."), so a client sees the same sentence from the fake
165
+ and from the real cloud.
166
+ - 067cb3d: The Picker comes with the box, and the CLI says so
167
+
168
+ The Picker used to be priced as a factor on the box's own rate, which made
169
+ its surcharge a different number for every size and made "what does it cost"
170
+ a sum rather than a price. It is now a fixed hourly price per size — and, for
171
+ now, one nobody pays: it is included in the price of the box.
172
+
173
+ `box create`'s question, `box picker on|off` and `box status` all say it the
174
+ same way, with the number the cloud sends and never one of this CLI's own:
175
+ "included in the price of the box (normally €0.05/h)". The list price stays
176
+ visible on purpose. "Included", alone, reads as "cheap now, billed later" —
177
+ and what is actually charged is the number a client should quote, so
178
+ `GET /rates` sends both: `picker` is what will be added to the bill (zero
179
+ while it is included) and the new `pickerList` is what it lists at, beside
180
+ `pickerIncluded`.
181
+
182
+ A cloud that does not send the two new fields is quoted the old way, from
183
+ `picker` alone — nothing to strike through, and no invented number.
184
+
185
+ - 417369a: `box picker off` now takes the token away, not just the surcharge
186
+
187
+ Until now `off` stopped the hourly charge and cleared the Picker's keys from
188
+ this project's files. A copy of the token already on another machine — a
189
+ teammate's laptop, a CI secret — went on working until the box's licence ran
190
+ out, grounding intents on our grounder that nobody was paying for.
191
+
192
+ Switching the Picker off now revokes that box's Picker tokens, all of them:
193
+ the cloud writes their ids into the operator's signed revocation list, and
194
+ the grounder stops accepting them once it has re-read
195
+ that list. The command says so, and says the minute rather than promising an
196
+ instant. Clearing the files is now the convenience it always was; the
197
+ revocation is what ends the access.
198
+
199
+ Two smaller things follow from it. `off` asks the cloud to revoke even when
200
+ the Picker was already off, so repeating the command is how you finish the
201
+ job if it failed once. And a revocation the cloud cannot write is a refusal
202
+ you see — the surcharge stops either way, but a token still in the wild is
203
+ not something to leave in a log.
204
+
205
+ - Updated dependencies [79c99ab]
206
+ - Updated dependencies [dee96a1]
207
+ - Updated dependencies [86da16e]
208
+ - Updated dependencies [7ffa4ae]
209
+ - @unotest/protocol@0.35.0
210
+ - @unotest/grounder-client@0.35.0
211
+ - @unotest/viewer@0.35.0
212
+ - @unotest/core@0.35.0
213
+ - @unotest/dsl@0.35.0
214
+
215
+ ## [0.34.0] - 2026-09-07
216
+
217
+ _Never published to npm on its own: the registry went from 0.33.0
218
+ straight to 0.35.0, which carries everything listed here._
219
+
220
+ ### Minor Changes
221
+
222
+ - c6b91bb: From `init` to a paid box without copying a token: `login`, `box create`,
223
+ and the tokens that keep themselves fresh.
224
+
225
+ Three new commands sign this machine in to unotest cloud — the account
226
+ that rents boxes — and rent one:
227
+
228
+ - `login` runs the device flow (RFC 8628): it prints a link and a code,
229
+ opens the browser when there is one, and waits for the approval. The
230
+ key is kept in `~/.config/unotest/credentials.json` (mode 0600,
231
+ `XDG_CONFIG_HOME` honoured) and is never printed, not even with
232
+ `--json`. `UNOTEST_CLOUD_TOKEN` in the environment wins over the file,
233
+ which is how a CI job signs in. `logout` revokes the key on the server
234
+ when the cloud can be reached and removes it locally always; `whoami`
235
+ says who, where the key came from, and the balance (exit 77 when not
236
+ signed in).
237
+ - `box create [name] [--size s|m|l] [--topup <eur>]` creates a box named
238
+ after the project (its package name, as a slug), with the environments
239
+ the project runs locally (`unotest/.env` and every `unotest/.env.<name>`,
240
+ their `APP_BASE_URL` or the config's `baseUrl` as the target). When the
241
+ balance will not cover the first hour it prints a Stripe Checkout link,
242
+ opens it when it can, and waits for the payment. Then it waits for the
243
+ box, mints its cloud-signed push and read tokens, writes
244
+ `UNOTEST_BOX_URL` to `unotest/.env` and `UNOTEST_BOX_TOKEN` +
245
+ `UNOTEST_BOX_READ_TOKEN` to `unotest/.secrets` (through the same
246
+ structure-preserving writer the viewer's Variables panel uses, with
247
+ `.gitignore` guarded the way `init` guards it), pushes the suite —
248
+ retrying with backoff while a fresh box has no revocation list yet —
249
+ and prints the viewer's address. Exit codes for an agent driving it
250
+ without a terminal: 75 the payment was not confirmed in time (nothing
251
+ created, nothing charged), 76 the terms of service are not accepted
252
+ (a person must, in a browser), 77 not signed in. No prompt is ever
253
+ shown without a TTY.
254
+ `--picker` rents the box with the Picker; `UNOTEST_GROUNDER_MODE=remote`
255
+ and `UNOTEST_GROUNDER_REMOTE_URL` then go to `unotest/.env` and
256
+ `UNOTEST_GROUNDER_REMOTE_TOKEN` to `unotest/.secrets` — the names the MCP
257
+ server reads — so intents ground on the box with no further setup. A
258
+ box without the Picker leaves those keys alone. The last line of
259
+ `create` is the next step: the complete `bundle push --run --env …
260
+ --collection …` when the project has exactly one environment and one
261
+ collection, the viewer's address plus the command with placeholders
262
+ otherwise.
263
+ - `box access [name]` re-mints the pair and rewrites the two files;
264
+ `box status` and `box destroy` (asks on a terminal, `--yes` elsewhere)
265
+ are thin wrappers. The name defaults to the box `UNOTEST_BOX_URL`
266
+ points at (`acme.box.unotest.com` → `acme`).
267
+
268
+ Cloud-signed box tokens live thirty days. When a box answers that one
269
+ has expired, `bundle push` and the `box …` read commands re-mint the
270
+ pair through the cloud and retry once, provided a cloud login is at
271
+ hand; without one they say to run `login && box access`. The box a
272
+ re-mint is for is the one the project points at, named from its address
273
+ or, when the address spells no name (a stand behind an IP), by the same
274
+ default `create` used — so a lab box re-mints like any other. A `--box`
275
+ naming somewhere else is said to be somewhere else rather than blamed on
276
+ a missing login. A box that has
277
+ not yet received its revocation list is told apart from a real refusal
278
+ and retried; a token the box calls revoked is never re-minted quietly.
279
+ `bundle push` now also reads the push token from `unotest/.secrets`,
280
+ where `box create` puts it (then `unotest/.env`, for suites set up by
281
+ hand), so nothing has to be exported after the setup.
282
+
283
+ The MCP server knows the state too. While the project points at no box,
284
+ `box_envs`, `box_runs`, `box_run` and the other box tools answer
285
+ `{state: "no-box", next, why}` instead of an error, and `run_test`
286
+ carries the same `boxSuggestion` exactly once per server — after the
287
+ first local run that passes — so an agent offers the box at the moment
288
+ it is worth something and never nags. The box tools re-mint an expired
289
+ cloud-signed read token the way the CLI does, through the composition
290
+ root, and `unotest/.secrets` is read on every call: a pair written by
291
+ `box create` while the server runs is used by the next call. On a
292
+ Picker box, `ground_element` (and intent locators) whose picker token
293
+ the grounder refuses re-mint it once through the cloud and hand the new
294
+ token to the running client; a refusal that cannot be mended answers
295
+ `{state: "picker-token-refused", next: "npx @unotest/web box access",
296
+ why}`, and a grounder that has not read its revocation list yet answers
297
+ `{state: "retry-shortly", retryAfterSeconds}`. Box tokens are checked
298
+ before the network: cloud-signed ones by their whole shape (`unos_…`), a
299
+ push token in the read variable (and the reverse) is named as the wrong
300
+ kind, anything else as not a token.
301
+
302
+ A renewal the cloud itself refuses is its own answer, not the cloud's.
303
+ When a box or the grounder says a cloud-signed token is stale and the
304
+ `/access` call that would replace it is refused in turn — no such box,
305
+ the box not ready, too many live pairs, the cloud unreachable — every
306
+ surface says that the credentials could not be renewed and keeps the
307
+ cloud's refusal as the cause. The CLI prints one line and exits with the
308
+ code that refusal earns; the grounding tools answer
309
+ `{state: "picker-token-refused", next, why}` as before, so an intent is
310
+ never answered with a sentence about boxes.
311
+
312
+ `init` ends with one line saying how to run the suite on a box; the
313
+ "mint a read token in the guard" advice in the box commands' refusals
314
+ now points at `login` and `box access` instead. `UNOTEST_CLOUD_URL`
315
+ points the CLI at a stand or a fake cloud; nobody sets it otherwise.
316
+
317
+ `@unotest/protocol`: `replaceEnvVar` (an in-place value replacement the
318
+ new upsert builds on), `BOX_PUSH_TOKEN_PREFIX` / `BOX_SIGNED_TOKEN_PREFIX`
319
+ / `BOX_SIGNED_TOKEN`, and `UNOTEST_BOX_URL`, `UNOTEST_GROUNDER_MODE`,
320
+ `UNOTEST_GROUNDER_REMOTE_URL` listed among the runner-config keys so they
321
+ file under the Runner section of `unotest/.env`.
322
+
323
+ `@unotest/grounder-client`: `GrounderHttpClientOptions.token` may be a
324
+ function yielding the current bearer; `GrounderRemoteError` carries the
325
+ grounder's `retryAfterSeconds`, and the error codes gain
326
+ `revocation-list-unavailable`.
327
+
328
+ ### Patch Changes
329
+
330
+ - d68538f: A box refusing a token now answers with the words the box actually
331
+ sends, and with the step that fits the token you presented.
332
+
333
+ The CLI knew five refusal codes; a box sends three. `expired` still
334
+ re-mints through the cloud and retries once, `revocation-list-unavailable`
335
+ still means the same token, later. Everything a retry cannot fix —
336
+ revoked, bad signature, wrong door — arrives as one `unauthorized`, and
337
+ that is deliberate on the box side: a refusal that names WHICH check
338
+ failed is an oracle. The three codes written ahead of the box and never
339
+ sent by anything are gone.
340
+
341
+ What you see changes in two ways:
342
+
343
+ - the sentence is the box's own reason, never a stock phrase of ours.
344
+ A code this CLI does not know (a box newer than the CLI) reaches you
345
+ with the box's message too, instead of a generic line about a token.
346
+ - the suggested fix follows the KIND of token: a cloud-signed pair is
347
+ re-minted with `npx @unotest/web box access`; a token you minted
348
+ yourself on a box you run is re-minted in that box's guard, because
349
+ there is no cloud behind it to point at.
350
+
351
+ The MCP `box_*` tools return this as a state rather than an error, the
352
+ way they already do for a project with no box: `{state:
353
+ "box-token-refused", next, why}` — `why` is what the box said, `next`
354
+ is the command that fixes it.
355
+
356
+ `pnpm fake:cloud --box-refusal <code>` makes the fake box refuse with
357
+ any code, including one the CLI does not know, so both paths can be
358
+ exercised without a real box.
359
+
360
+ - 565d1b3: Secrets no longer travel with the page capture that grounding reads.
361
+
362
+ `UNOTEST_BOX_SECRET_NAMES` and `unotest/.secrets` masked what the runner
363
+ said out loud — logs, the debug log, the recorded session — but not the
364
+ capture `ground_element` and intent locators send to the grounder, and in
365
+ the hosted mode on to a model provider. Only the field TYPE was filtered
366
+ there: a `password` or `hidden` input was dropped by the page walker,
367
+ while an API token in a plain text input, a one-time code, a card number,
368
+ a whole textarea, a login, or a key the application printed on the page
369
+ went out verbatim.
370
+
371
+ Both modes come through one place now, and it cleans the capture twice
372
+ over before the content hash is taken:
373
+
374
+ - every registered secret value is replaced by `‹secret:NAME›` wherever it
375
+ occurs — a control's value, a text node, an accessible name, the page
376
+ title. Masking is by EXACT value: the same secret written in another
377
+ case is not recognised, deliberately, because matching loosely would
378
+ mask ordinary words and the noise would land in what grounding reads.
379
+ - the page URL and every link keep scheme, host and path; query, fragment
380
+ and `user:pass@` are dropped. That is where a password-reset token, a
381
+ signed link or a session id lives, and no list of known secrets can
382
+ cover those. A link made of nothing BUT a fragment (`#main`) keeps it —
383
+ that fragment is the whole label, and there is no path to name instead;
384
+ a fragment hanging off a real address (`/invite#SECRET`) is dropped like
385
+ any other. `data:`, `blob:` and `javascript:` keep their scheme alone:
386
+ there the document, or the code of a call with its arguments, sits
387
+ where the path would be. `mailto:` and `tel:` stay whole — there the
388
+ path is the addressee, which is a label like any other.
389
+
390
+ The masking is what the grounder is given, so it is also what the content
391
+ hash is taken of — two visits that differ only in a link's one-time token
392
+ are correctly one page to the index cache, not two.
393
+
394
+ The graph's own vocabulary is untouched: `kind`, `tag`, `role`, `ref` and
395
+ `testId` are structure, not content, and a secret whose value happens to
396
+ be `button` no longer has anywhere to do damage. Everything else in a
397
+ capture is masked, including a field added to it later.
398
+
399
+ Loading a secret shorter than six characters now warns, once, naming the
400
+ variable and its length and never its value. Nothing changes about how it
401
+ is masked — the warning exists because masking by value has no notion of
402
+ length, so `admin` as a password also masks the word wherever a page
403
+ writes it, and grounding can get worse on a page that has nothing to do
404
+ with the secret.
405
+
406
+ Still not covered, and worth saying plainly: a value the run was never
407
+ told is a secret. A key the application itself renders on the page — the
408
+ "copy it now" screen — is in no registry, and goes out with the capture.
409
+
410
+ - 32a495e: Failure artifacts survive a run that is isolated from its own artifact root.
411
+
412
+ A run executed inside a container sees only what is mounted into it. The
413
+ failure bundle's directory is derived from the artifact root, which on such
414
+ a run is not one of those mounts — so it could not be created, and a failed
415
+ test produced no screenshot, no trace and no page HTML at all. Worse, that
416
+ one error took the always-on copies in the run directory down with it: both
417
+ sinks shared a single try, so the artifact that does not depend on the
418
+ bundle was lost too.
419
+
420
+ Two changes. `UNOTEST_FAILURES_ROOT` now says where bundles live, for
421
+ whoever spawns the run to point at a directory it can actually write
422
+ (unset, everything behaves exactly as before). And the two sinks are
423
+ independent: an unusable bundle root no longer costs you the run
424
+ directory's screenshot and page HTML.
425
+
426
+ The viewer serves failure assets from either layout, so a bundle written
427
+ inside the environment's runs tree renders like a local one.
428
+
429
+ - 2416abf: `init` no longer rewrites a config the project had already edited, and
430
+ `--help` no longer runs the command it is asking about.
431
+
432
+ Two defects, one outcome. `init` wrote the channel into
433
+ `unotest/unotest.config.mjs` unconditionally at the end of every run, on
434
+ the assumption that the file was the placeholder it had written seconds
435
+ earlier. In a project that had run `init` before, that file is the
436
+ project's own: it was replaced by the template with no `--force`, no
437
+ warning, and an `already present` line printed about it. And because the
438
+ option loop ignored anything it did not recognise, `init --help` — the
439
+ first thing a person types when a command is unfamiliar — fell through
440
+ into a real run in the current directory, scaffolding the layout and
441
+ taking the config with it.
442
+
443
+ What changes:
444
+
445
+ - an existing file stays exactly as it is; the channel is patched only
446
+ into a config this run created itself. A re-run reports `already
447
+ present` and changes nothing.
448
+ - `--force` still takes the templates, and now keeps what it replaced
449
+ next to it as `<file>.bak`. Being overruled is not the same as losing
450
+ the work.
451
+ - the package-owned copies `init` keeps in sync — the test-authoring
452
+ skill, the agent-trace hook — are still synced without `--force`, but a
453
+ copy that diverged is now kept as `<file>.bak` too. "Nobody edits
454
+ these" is an assumption about a file in your repository, and it costs
455
+ nothing to be wrong about it safely.
456
+ - `--help` / `-h` works on every command, prints the usage, and does
457
+ nothing else — answered before the workspace is even resolved, so a
458
+ question touches no files anywhere.
459
+ - an option a command does not recognise is refused with its usage and
460
+ exit **2**, instead of being dropped silently. `init` reads its options
461
+ against the list of what it knows, so the next flag added cannot fall
462
+ through the same hole.
463
+
464
+ - 9daaaf7: `init` writes files that pass the project's own `prettier --check`.
465
+
466
+ The block `init` maintains in `CLAUDE.md` now has a blank line between
467
+ each marker and the text inside, and the JSON it writes — `.mcp.json`,
468
+ `.claude/settings.json` — keeps a short array on one line (`"allow":
469
+ ["mcp__unotest-web"]`) instead of one element per line. Both are what
470
+ prettier produces, so a repository whose CI runs `format:check` no longer
471
+ goes red on files it did not write itself. A file `init` merges into is
472
+ re-emitted by the same rules, including the entries the user had there.
473
+
474
+ `AGENTS.md` too: its DSL examples now sit in fences prettier does not
475
+ reformat, and every list has the blank line before it that prettier's
476
+ Markdown rules want.
477
+
478
+ - 50b1d4d: The search text sent for grounding is masked the same way the page
479
+ capture already is.
480
+
481
+ Masking the capture cleaned what the PAGE said. The other half of a
482
+ grounding call — the intent, the thing being searched for — went out
483
+ untouched: to the grounding service, into the picker's prompt and the
484
+ embedder's query, and in the hosted mode on to a model provider. A
485
+ scenario that puts a variable into the search (`intent("the row for " +
486
+ LOGIN)`) sent its value verbatim, while the same value was masked
487
+ everywhere else in the run.
488
+
489
+ Now every registered secret is replaced by `‹secret:NAME›` across the
490
+ whole intent, not only its text: the predicate's field description and
491
+ its value too, which is where a value is most literally the thing being
492
+ searched for. Only the structural keys are kept — the comparison
493
+ operator, the kind of unit wanted back, the kind of ordinal — so a field
494
+ added to the contract later is masked by default rather than forgotten.
495
+
496
+ A numeric secret is masked as well, and only on an exact match: a
497
+ one-time code arrives as a number, and a number has no text for a value
498
+ to hide inside, so `447213` masks while `4472130` and every ordinary
499
+ quantity are left alone.
500
+
501
+ This happens in one place for both grounding modes — local and remote —
502
+ so a value cannot escape through whichever mode a project runs.
503
+
504
+ The honest cost, because it is real: masking is by exact value. Where the
505
+ page rendered the same secret in another form — a different case, a
506
+ percent-encoded path, a value split by an invisible character — the mask
507
+ now lands on the intent alone, and the correspondence between what you
508
+ searched for and what the page shows is lost for that one case.
509
+ Grounding can get worse there. It is the same boundary already stated
510
+ about masking generally: the exact value is what is recognised, not the
511
+ secret "in any form".
512
+
513
+ - Updated dependencies [32a495e]
514
+ - Updated dependencies [c6b91bb]
515
+
516
+ - @unotest/protocol@0.34.0
517
+ - @unotest/viewer@0.34.0
518
+ - @unotest/grounder-client@0.34.0
519
+ - @unotest/core@0.34.0
520
+ - @unotest/dsl@0.34.0
521
+
522
+ - Named late, and it should have been named in 0.33.0: on a box the pushed
523
+ suite is mounted **read-only**. Since run isolation (0.33.0) a run sees
524
+ its bundle at `/box/sources` and cannot write next to its own files —
525
+ a `prepare` script that seeded a database beside the fixtures, or a
526
+ helper that kept a scratch file under `unotest/.tmp`, now fails with
527
+ `EROFS` / `ENOENT` before a single scenario runs. The places a run may
528
+ write are `/tmp` (the container's own, gone with the run) and the run's
529
+ artifact directory under `UNOTEST_ARTIFACTS_ROOT`; `os.tmpdir()` in
530
+ Node and `$TMPDIR` in a shell both land in the former. Migration for a
531
+ suite that wrote beside its sources: pick the scratch directory by
532
+ whether `UNOTEST_ARTIFACTS_ROOT` is set — `unotest/.tmp` locally,
533
+ `/tmp/<suite>` on a box — and point the seed, the fixture and the
534
+ scenario at the same helper. Our own dogfood suite failed on exactly
535
+ this for a day.
536
+
3
537
  ## [0.33.0] - 2026-09-06
4
538
 
5
539
  ### Minor Changes
package/README.md CHANGED
@@ -134,6 +134,56 @@ bundle, so if your config computes `schedules` from its environment, the
134
134
  box notices the difference and says so instead of silently arming
135
135
  another set.
136
136
 
137
+ ### Run on a box
138
+
139
+ Three commands, no token to copy:
140
+
141
+ ```sh
142
+ npx @unotest/web login # a link and a code; approve in the browser
143
+ npx @unotest/web box create # rents a box named after the project, size S
144
+ ```
145
+
146
+ `login` signs this machine in to unotest cloud — the account that rents
147
+ boxes — and keeps the key in `~/.config/unotest/credentials.json`
148
+ (mode 0600). `box create` creates the box with the environments the
149
+ suite already runs locally (each `unotest/.env.<name>` and its
150
+ `APP_BASE_URL`), asks for a top-up when the balance is short (it prints
151
+ the Checkout link and waits for the payment), waits for the box, writes
152
+ `UNOTEST_BOX_URL` to `unotest/.env` and the push and read tokens to
153
+ `unotest/.secrets`, pushes the suite and prints the viewer's address.
154
+ The tokens live thirty days; `bundle push` and `box …` re-mint them
155
+ through the cloud when a box says they have expired, and `box access`
156
+ does it on demand. `box status` and `box destroy` do what they say;
157
+ `whoami` and `logout` complete the set. In CI, export
158
+ `UNOTEST_CLOUD_TOKEN` instead of signing in.
159
+
160
+ The **Picker** — grounding intent locators on the box's grounder — comes
161
+ with the box today: it lists at €0.05–€0.13 an hour depending on size,
162
+ and that is struck through, nothing is added to the bill. It is still
163
+ never switched on quietly, because that will not always be true.
164
+ `box create --picker` rents the box with it; without the flag a terminal
165
+ is asked once, with the price read from the cloud's own list — the CLI
166
+ has no price of its own — and the answer defaults to no. On a box you already have, `box picker on` and
167
+ `box picker off` switch it without re-creating anything: `on` writes
168
+ `UNOTEST_GROUNDER_MODE=remote` and `UNOTEST_GROUNDER_REMOTE_URL` to
169
+ `unotest/.env` and `UNOTEST_GROUNDER_REMOTE_TOKEN` to `unotest/.secrets`,
170
+ `off` clears the three again — unless they name a grounder that is not
171
+ this box's, which is left alone and reported. `off` also revokes the
172
+ box's Picker token, so a copy of it on another machine stops working
173
+ too: the grounder refuses it once it has re-read the operator's
174
+ revocation list — how often it does that is the operator's own setting. Either way the charge follows at the
175
+ next hourly tick, because a started hour is charged at the rate it
176
+ started with. `box status` says which state a box is in, and — while the
177
+ Picker is on — which models the cloud grounds with: `Our GPU` or
178
+ `External provider (Gemini)`. That is our grounder, not your
179
+ `UNOTEST_GROUNDER_MODE`; a cloud that has not heard from its grounder
180
+ prints nothing rather than a guess.
181
+
182
+ An agent can drive all of it without a terminal: no prompt is shown
183
+ without a TTY, and the exit codes say what happened — 75 the payment was
184
+ not confirmed in time, 76 the terms of service need accepting (a person
185
+ must, in a browser), 77 not signed in.
186
+
137
187
  ### Sending the suite to a box
138
188
 
139
189
  If your team runs a box (a machine that keeps the environments, the
@@ -247,11 +297,13 @@ started by your editor, so a token that only exists in your shell is a
247
297
  token the agent never sees. A token added while the server is running is
248
298
  picked up on the next call — nothing to restart.
249
299
 
250
- You mint it for yourself on the box's guard ("Read tokens"), it is shown
251
- once, and it is revocable. It is read-only whatever your role on the box it cannot start
252
- a run or change a value. `UNOTEST_BOX_TOKEN` is the project's push
253
- credential and cannot read runs. `box envs` lists the environments a token
254
- may look at.
300
+ With a box rented through `box create`, the token is minted for you
301
+ `box access` re-mints it and lives thirty days. On a box set up by
302
+ hand you mint it for yourself on the box's guard, where it is shown once
303
+ and can be revoked. Either way it is read-only whatever your role on the
304
+ box it cannot start a run or change a value. `UNOTEST_BOX_TOKEN` is the
305
+ project's push credential and cannot read runs. `box envs` lists the
306
+ environments a token may look at.
255
307
 
256
308
  ## 5. Watch it run — the viewer
257
309
 
@@ -45,12 +45,23 @@ function resolveTsxCli() {
45
45
 
46
46
  const args = process.argv.slice(2);
47
47
  const sub = args[0];
48
+ /** A question, not a command — see `dispatch`. */
49
+ const asksForHelp = args.some((a) => a === "--help" || a === "-h");
48
50
 
49
51
  // Each route declares its compiled entry under dist/ AND its source entry
50
52
  // under src/. In published packages dist/ exists (built by prepublishOnly);
51
53
  // in a fresh clone we fall back to running TS sources via tsx so that
52
54
  // `unotest-web --help` works without an explicit `pnpm build` first.
53
55
  function dispatch(sub) {
56
+ // A question is not a command. Every `--help` goes to the cli dispatcher
57
+ // with the arguments untouched, whatever the route for that subcommand
58
+ // would otherwise be: it answers help before it resolves a workspace or
59
+ // touches disk. Without this, a route that substitutes its own arguments
60
+ // drops the flag and runs the command — `viewer --help` started a viewer
61
+ // and scaffolded `unotest/` into the current directory.
62
+ if (asksForHelp) {
63
+ return { dist: "dist/runner/cli.js", src: "src/runner/cli.ts", forwardArgs: args };
64
+ }
54
65
  switch (sub) {
55
66
  case "mcp":
56
67
  return { dist: "dist/mcp/server.js", src: "src/mcp/server.ts", forwardArgs: [] };
@@ -108,7 +119,7 @@ const distPath = resolve(root, dist);
108
119
  // `runInit` directly so we can await its promise — the module's own
109
120
  // self-invoke `if (invokedDirectly)` block fires only when bin spawns
110
121
  // a child, which we explicitly skip here.
111
- if (sub === "init" && existsSync(distPath)) {
122
+ if (sub === "init" && !asksForHelp && existsSync(distPath)) {
112
123
  const initModule = await import(pathToFileURL(distPath).href);
113
124
  try {
114
125
  const { exitCode } = await initModule.runInit(forwardArgs);