@unotest/web 0.33.0 → 0.35.0

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