@unotest/web 0.32.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,881 @@
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
+
499
+ ## [0.33.0] - 2026-09-06
500
+
501
+ ### Minor Changes
502
+
503
+ - 2abfd4d: Box: every run is announced — Slack, Telegram, or a webhook of your own — and the channels are managed from the guard.
504
+
505
+ A box now tells people how its runs went, whoever started them: the
506
+ schedule's ticks, a pull request's check, somebody pressing Run in the
507
+ viewer, a terminal inside the container. The daemon reads each run's own
508
+ journal, so the three producers are one source; the run's manifest says
509
+ what set it off — `trigger: { kind: "schedule" | "ci" | "manual" | "cli",
510
+ by? }`, written by the runner from `UNOTEST_RUN_TRIGGER` /
511
+ `UNOTEST_RUN_ACTOR` (a box sets them for its scheduled and CI runs, the
512
+ viewer for a run ordered from its UI; a terminal sets nothing and reads as
513
+ `cli`).
514
+
515
+ A scheduled series is announced on a **change of state**: `failed` after
516
+ `afterFailures` red ticks in a row, `recovered`, `still-failing` at a rule's
517
+ `repeatEvery`, `not-run` when the suite could not run at all (no bundle, a
518
+ bundle that does not install) — daily by default. A run somebody ordered is
519
+ announced on its own, `failed` or `passed`, every time. A withdrawn or
520
+ aborted run says nothing; a run whose journal stops and whose heartbeat goes
521
+ cold is `interrupted`.
522
+
523
+ Channels, rules, mutes and quiet hours live on the box and are managed from
524
+ the guard's `/_guard/notifications` page (or `boxd notify …`): a channel's
525
+ secret is set once and never shown again, a rule picks environments,
526
+ triggers, events and channels, a series or the whole project can be muted
527
+ until a time, and quiet hours hold reminders and `passed` back until the
528
+ morning. A generic webhook receives the event as JSON (`version: 1`, the
529
+ box named, the `trigger`) with `X-Unotest-Event` and, when the channel has
530
+ a secret, `X-Unotest-Signature: sha256=<hex>` — HMAC-SHA256 over the raw
531
+ body, the scheme GitHub uses — plus any headers the channel declares.
532
+
533
+ `@unotest/protocol`: `RunManifest.trigger` and the trigger helpers
534
+ (`runTriggerFromEnv`, `runTriggerEnv`, `manifestTrigger`); the session-door
535
+ paths, views and parsers of the notifications API (`boxNotifyProjectPath`,
536
+ `boxNotifyChannelPath`, `boxNotifyRulePath`, `boxNotifySeriesPath`,
537
+ `boxNotifyMutePath`, `boxNotifyQuietHoursPath`, `boxNotifyPreviewPath`,
538
+ `BoxNotifyProjectView`, …). The first draft of this feature (channels
539
+ declared in the box config, secrets by field) never shipped; this replaces
540
+ it whole.
541
+
542
+ ### Patch Changes
543
+
544
+ - 5d1bde3: `lint` and `e2e` no longer fail on a project tree they cannot write to.
545
+
546
+ Editor typings (`unotest/jsconfig.json`, `.unotest/types/env.d.ts`) are a
547
+ convenience for a human with an IDE open, and they were being written on
548
+ every implicit `lint` / `e2e`. On a box that runs a suite in an isolated
549
+ container the bundle is mounted read-only, so that write raised `EROFS` /
550
+ `ENOENT` and every scenario ended as `interrupted` — a suite failing
551
+ because a cache could not be refreshed.
552
+
553
+ The write is now skipped when the tree refuses it (matched on the errno
554
+ `code`, never on a message), both artifacts report `skipped`, the result
555
+ carries the reason, and the run carries on; any other failure still
556
+ throws. `UNOTEST_EDITOR_TYPES=0` turns the whole thing off up front — a
557
+ box sets it on every run it starts, because there is no editor in a
558
+ container and the attempt is only noise in the log.
559
+
560
+ - b3e1220: Judge: the bearer token on `/judge` is compared in constant time, and the
561
+ docs say what a verdict sends and what it is not.
562
+
563
+ `UNOTEST_JUDGE_TOKEN` was checked with a plain string comparison, which
564
+ returns as soon as one character differs; both sides are now hashed and
565
+ compared with `timingSafeEqual`. The judge guide and the package README
566
+ gain a section on what leaves the machine — the one element's rendered
567
+ text and the rubric, nothing else; the secret redactor does not act on
568
+ that body; CI defaults to `fake` — and on why a verdict, being a model's
569
+ reading of the page's own text, is a check on wording and never a
570
+ security gate.
571
+
572
+ - b3e1220: Box notifications: a webhook channel's header values are credentials.
573
+
574
+ The notifications API and the guard's page now see a webhook's headers
575
+ as names with `{ set: true }` — the values (the receiver's
576
+ `Authorization`, most of the time) are never read back, like the signing
577
+ secret. The edit form lists the names; typing a value after one replaces
578
+ the set, names alone keep it. `/api/box/notify` is the guard's own route:
579
+ it is no longer reachable through the session proxy for any role — a
580
+ readonly session could previously list every project's channels with
581
+ their headers.
582
+
583
+ - b3e1220: Box notifications: the webhook signature now covers a timestamp.
584
+
585
+ Every webhook delivery carries `X-Unotest-Timestamp` (Unix milliseconds,
586
+ when it was sent), and `X-Unotest-Signature` is HMAC-SHA256 over
587
+ `<timestamp>.<raw body>` rather than the body alone — GitHub's scheme
588
+ with a timestamp in front, so a delivery captured on the wire cannot be
589
+ replayed to the receiver once its window (five minutes, documented) has
590
+ passed. The GitHub webhook the box receives is unchanged: that is
591
+ GitHub's contract.
592
+
593
+ The `by` of a CI-triggered run (`push a1b2c3d (branch)`) is cut at 200
594
+ characters — a branch name is the pusher's input, and it must not balloon
595
+ the run's manifest and every notification the run produces.
596
+
597
+ - b5907cf: Pin playwright to the exact release the box image ships.
598
+
599
+ `@unotest/web` depended on `playwright ^1.62.0`, so a fresh install of a
600
+ suite could pick a newer playwright than the browsers inside the box's
601
+ `mcr.microsoft.com/playwright` image, and every run on the box failed
602
+ with "Browser 'chromium' is not installed" the day 1.63.0 shipped. The
603
+ dependency is now the exact version of the image (1.62.1); the two are
604
+ bumped together, and a guard in the monorepo's `verify` keeps them equal.
605
+
606
+ - 5d1bde3: Building the runner no longer writes into the project tree.
607
+
608
+ `ExplorationService` created `.unotest/explorations/` in its constructor,
609
+ and the composition root builds it for every entry point — including an
610
+ `e2e` run. Where the project tree is read-only (a box mounts a pushed
611
+ bundle that way) the run ended before a browser was asked for. Recording
612
+ is an MCP activity, so the folder is now created with the first record
613
+ written instead.
614
+
615
+ With the editor-typings fix in the same release, an ordinary `lint` /
616
+ `e2e` writes nothing under the sources: run artifacts go to
617
+ `UNOTEST_ARTIFACTS_ROOT` (which the box makes writable), and everything
618
+ else that writes into the project — recording, authoring, `init`, `env
619
+ set`, `bundle push` — happens on a developer's machine.
620
+
621
+ - 5d1bde3: Run isolation, stage 1: a box no longer executes a test bundle inside its
622
+ own daemon.
623
+
624
+ box-runner (new, private): the run sidecar — the only service on a box
625
+ holding the docker socket, and the only one that starts a container. Three
626
+ authenticated routes on the `box` network (`POST /runs`, `GET
627
+ /runs/:id/stream` NDJSON, `DELETE /runs/:id`), a shared secret compared in
628
+ constant time, and an API that cannot be told anything about how a
629
+ container is built: no path, image, network, user, mount or flag. A request
630
+ names a project, an environment, a bundle, a scenario and the environment's
631
+ values; the bind sources are derived from the first three, resolved with
632
+ `realpath` and re-checked against the projects root, and must already
633
+ exist. The container is created over the Docker Engine API (pinned
634
+ `v1.43`) straight over the socket — no `docker` binary in the image and no
635
+ client dependency, and a container that is a JSON document has no place
636
+ for a value to become a flag. A daemon that refuses or is not there comes
637
+ back as `502 {reason}` before the run is accepted, or as an `error` event
638
+ on the stream after — never as a run that "exited with code null". An
639
+ environment variable the box does not forward is named in the sidecar's
640
+ log rather than dropped silently. A run gets the bundle tree read-only as its working directory, the
641
+ environment's `unotest/.runs.<env>` read-write, a `noexec` tmpfs `/tmp`,
642
+ a read-only rootfs, all capabilities dropped, `no-new-privileges`, its own
643
+ uid in boxd's group, memory/pids/cpu limits clamped to both the operator's ceilings and the
644
+ host's own size (docker refuses a container bigger than the machine
645
+ instead of clamping it), a 512 MB `/dev/shm`
646
+ (docker's default 64 MB kills Chromium mid-page; the host's IPC namespace
647
+ is deliberately not borrowed), and the `runs` network only. The container engine is an interface, so every rule is asserted on
648
+ the container that would have reached docker — including the cases where
649
+ the answer is no container at all.
650
+
651
+ boxd: running the SUITE is its own contract (`IScenarioRunner`), separate
652
+ from running a command (`ICommandRunner`, still the daemon's own `npm ci`
653
+ and viewers). `ContainerScenarioRunner` talks to the sidecar,
654
+ `ProcessScenarioRunner` keeps the old child-process shape for a box without
655
+ docker; `BOXD_RUNNER_KIND=container|process` chooses, and half a container
656
+ configuration refuses to start. Reading a bundle's schedules
657
+ (`unotest-web schedules --json`) executes the project's config module, so it
658
+ goes the same way. A run's environment is built from named parts
659
+ (`run-environment.ts`) instead of inheriting `process.env`, and its debug
660
+ tree is pointed at the run's own directory rather than the read-only
661
+ sources. New metrics
662
+ `boxd_run_container_total{outcome}` and `boxd_run_container_start_seconds`.
663
+ An environment's runs directory is created by the daemon with `2775`, so
664
+ the run's uid may write into it and the daemon's group may read it back.
665
+
666
+ box-kit: `secretsMatch` (constant-time secret comparison, moved out of
667
+ dist-service), `splitLines`, and `envNumber` / `envPositiveNumber` — the
668
+ env-reading rule the box-side services share.
669
+
670
+ `npm ci` of a bundle is a container of its own (`kind: "install"`): the
671
+ bundle tree is its only mount and it is writable, there is no environment
672
+ and no artifacts directory in reach, and a dependency's install scripts
673
+ RUN — a native module builds or fetches its binary as usual. That is what
674
+ running them inside the daemon could never allow. The container's last
675
+ steps, only on success, check the tree against the box's size cap, set
676
+ the final modes on what the install created and write the install marker
677
+ — each with an exit code of its own, so the daemon's log names the step
678
+ that failed — so a killed or oversized
679
+ install leaves a tree the box will not mount; a failed install takes the
680
+ tree with it. The daemon no longer passes `--ignore-scripts` and no
681
+ longer walks the tree afterwards.
682
+
683
+ The daemon runs with `umask 002` and clears `node_modules` before each
684
+ install, so a tree it created is one the install container (another uid
685
+ in its group) can actually write.
686
+
687
+ An install that fails KEEPS the tree, unmarked: nothing mounts it and the
688
+ next attempt reinstalls in place (`npm ci` wipes `node_modules` itself).
689
+ And a bundle directory holding a manifest with neither an archive nor a
690
+ tree behind it no longer counts as "this box has it" — a push of the same
691
+ content brings it back instead of being answered `already had this exact
692
+ bundle`.
693
+
694
+ web: `bundle push` and the docs say that a dependency's install scripts
695
+ run on a box, in isolation — the earlier advice to vendor them is gone.
696
+
697
+ A run ordered in the viewer now goes through the daemon's queue instead
698
+ of being spawned by the viewer.
699
+
700
+ `POST/GET/DELETE /api/box/envs/<project>/<env>/runs[/<runId>]` (paths and
701
+ wire types in `@unotest/protocol`) takes a `manual` ticket like every
702
+ other producer. Two callers may use it, and neither is taken on its word:
703
+ the guard, proving it is the guard with a shared secret from `box-init`
704
+ (the actor header is read only next to it — a viewer container could
705
+ otherwise claim to be any admin), and a viewer, with a per-environment
706
+ token the daemon minted when it started that viewer plus a short-lived
707
+ single-use ticket the guard signed over WHO clicked. The guard gates the
708
+ route under `admin` and requires an `Origin` on a mutation; the daemon
709
+ checks the token's environment, the ticket's environment and its `jti`
710
+ against the path.
711
+
712
+ `@unotest/viewer` gets `BoxdRunner`: on a box the Run button orders and
713
+ the Stop button asks the daemon, and the progress still comes from the
714
+ run's journal on disk. A local viewer spawns as it always did — the
715
+ composition root picks by whether a box handed it credentials.
716
+
717
+ - 348aba7: Run isolation, stage 2b and 3: an environment's viewer runs in a
718
+ container of its own, and a box ships the sidecar in its compose stack.
719
+
720
+ The viewer was the last thing on a box that executed a bundle's code next
721
+ to the daemon's state: it runs the UI, the language service and the
722
+ linter out of the bundle's own `node_modules`. It is now a container the
723
+ run sidecar starts and the daemon asks for — `POST /viewers`, one per
724
+ environment, addressed by a name both services derive
725
+ (`unotest-viewer-<project>-<env>-<8 hex>`, port 7788).
726
+
727
+ Each viewer gets a NETWORK of its own with exactly two other containers
728
+ attached: the guard, which proxies people at it, and the daemon, which it
729
+ orders runs from. Not the sidecar's network, not the runs network, and
730
+ above all not another viewer's — a viewer has no authentication of its
731
+ own, and two on one network would be two containers of untrusted code
732
+ with a route to each other. That network is INTERNAL: a viewer's server
733
+ makes no outbound call, so it gets none, while the network a run joins
734
+ stays open because a test drives an application. The name of a viewer and
735
+ its network is a function of the project/environment PAIR rather than of
736
+ the string they join to, so two environments whose names concatenate the
737
+ same way cannot end up sharing one. Its mounts are the bundle tree read-only and
738
+ the environment's `unotest/` read-write (the run history it renders and
739
+ the queue it withdraws tickets from); the environment's secrets and its
740
+ `current` link sit in the directory above and are mounted by nobody. Its
741
+ environment is assembled by the daemon — the target, the environment's
742
+ variables and secrets, its own credential — and the daemon's own
743
+ `process.env` no longer travels.
744
+
745
+ **Editing a suite's files from the viewer on a box no longer works**: the
746
+ sources are read-only there. Such edits never survived the next `bundle
747
+ push` anyway, which replaced the tree. Everything else in the UI is
748
+ unchanged.
749
+
750
+ Readiness is the docker daemon's own healthcheck, probed inside the
751
+ container and read through the sidecar — the daemon no longer waits on a
752
+ lock file, and nothing dials a viewer from the process holding the docker
753
+ socket. A viewer's output is streamed into the daemon's log and
754
+ reattached when the stream drops. A container whose bundle and values
755
+ still match is ADOPTED across a restart of either service, keeping the
756
+ credential it was born with; one whose environment is gone is stopped,
757
+ with its network and its token. The sidecar recognises its own containers
758
+ by label, never by name alone, and enforces its own ceiling on how many
759
+ viewers may exist (`BOX_RUNNER_MAX_VIEWERS`) — boxd knows how many
760
+ environments there are right up until boxd is the thing that was
761
+ compromised.
762
+
763
+ A run somebody stopped now ENDS in the viewer instead of hanging.
764
+ Stopping a run stops its container gracefully (SIGTERM, then SIGKILL
765
+ after `BOX_RUNNER_STOP_GRACE_SECONDS`, default 10) so the suite writes
766
+ the terminal event of its own journal; if it could not — the grace ran
767
+ out, the machine died — the daemon appends it. The same grace applies to
768
+ a run killed on its timeout, so a run's budget is the timeout plus the
769
+ grace.
770
+
771
+ A viewer that dies is noticed in about a second and replaced: the log
772
+ stream the daemon holds open ends with the container, and the 404 that
773
+ answers its immediate reattach is the signal. The route comes out of the
774
+ table at once — the guard then says the environment has no viewer instead
775
+ of proxying at a dead address — and a fresh container is started, on the
776
+ same backoff the daemon uses for everything else it cannot reach.
777
+
778
+ A restart of the sidecar is no longer an outage. While it cannot be
779
+ reached the daemon keeps the routes it published (the viewer containers
780
+ are up, and the guard reaches them directly) and tries again with a short
781
+ backoff instead of waiting for its next five-minute sweep. Editing a file
782
+ in the viewer on a box now answers 403 with the reason — the sources are
783
+ read-only there — rather than a blank 500.
784
+
785
+ The viewer no longer collapses the daemon's refusals into a blank 500:
786
+ the box's own status and reason reach the browser, so "that click has
787
+ already been used" (409), "the box cannot verify the caller" (503) and
788
+ "the box daemon is not answering" (503) are told apart by the person who
789
+ clicked.
790
+
791
+ Deployment: `compose.yaml` gains the `runner` service (the only holder of
792
+ the docker socket, the release ships its bundled `box-runner.mjs` beside
793
+ `boxd.mjs`), fixed container names for the guard and the daemon, a
794
+ `box_secrets` volume whose secrets `box-init` generates, and a one-off
795
+ migration of the projects tree to the group layout an install container
796
+ needs. Two values an operator fills in: `BOX_PROJECTS_HOST_DIR` (a bind
797
+ source is resolved by the docker daemon on the host) and `BOX_DOCKER_GID`.
798
+ Step by step in the box release's cutover manual.
799
+
800
+ - 9158968: Viewer: a run that started before the page was reloaded is shown again.
801
+
802
+ The live sidebar was rebuilt from WebSocket events only, and those speak
803
+ from the moment the page connects — a collection already in progress
804
+ never re-sent its `started` event, so after a reload it was missing from
805
+ ACTIVE, then reappeared nameless with no scenario list once its next
806
+ scenario began, while its children showed up one by one. On load, and on
807
+ an environment or target switch, the viewer now asks the server which
808
+ runs are live and replays each one's journal into the same state the
809
+ live stream feeds, so a reload mid-run shows the collection with its
810
+ name, scenarios and progress.
811
+
812
+ - b3e1220: Viewer: a session that may not edit variables does not see credential-shaped
813
+ values, nor the project's absolute path.
814
+
815
+ Behind the guard, a readonly session could read `GET /api/variables` and
816
+ get every box variable's value — `PASSWORD=…` included when the operator
817
+ had pushed it as a variable rather than a secret. The viewer now reads the
818
+ capabilities the guard stamps on each request: when the caller cannot edit
819
+ variables, a value whose name matches `PASSWORD | PASSWD | SECRET | TOKEN |
820
+ KEY | CREDENTIAL` is sent empty and flagged `secret`, and `GET /api/targets`
821
+ leaves out `projectRoot`. Display-masking by capability, not authorization —
822
+ a viewer without a proxy in front is unchanged, and the guard still refuses
823
+ every write.
824
+
825
+ - bd6b7ba: Viewer: a collection run no longer shows up nameless and without scenarios.
826
+
827
+ The journal tailer moved its cursor to the end of the file even when the
828
+ last line was still being written, so the half already on disk was lost
829
+ and the other half was later read as garbage. The longest line in a
830
+ journal is `collection-run:started` with the scenario list, so that was
831
+ the one it usually caught: the run appeared under its id instead of its
832
+ collection name, with "collection has no scenarios" and a `2/0 done`
833
+ counter. The cursor now stops at the last complete line and the rest is
834
+ read on the next change.
835
+
836
+ - 8846a06: Viewer: a run started the moment the viewer came up is shown, not lost
837
+ until the next change.
838
+
839
+ Watching a directory becomes live a moment after the watcher reports it
840
+ is ready, and a run directory created inside that gap was not merely
841
+ late — it was invisible until something else changed in the same day,
842
+ which in a quiet environment is the next run. Measured at 1-3% on macOS
843
+ with a busy machine, and the window is exactly "open the viewer, start a
844
+ run". The viewer now proves the watch delivers before it declares itself
845
+ up: it creates a directory of its own and waits to hear about it, so
846
+ boot completes on evidence rather than on a promise. A tree it cannot
847
+ write to, or a probe that never comes back, costs a warning in the log
848
+ and boots as before.
849
+
850
+ A directory whose name starts with a dot is never reported as a run — an
851
+ editor's leftovers, or the watcher's own probe, can no longer appear in
852
+ the run list as a phantom.
853
+
854
+ Run queue: a waiter whose process paused is no longer mistaken for dead
855
+ — the queue stamps its own files with its own clock.
856
+
857
+ A run waiting for a slot proves it is alive by touching its ticket, and
858
+ what it is judged against was the modification time the filesystem
859
+ wrote: on a network share that is the server's clock, and against a
860
+ machine busy enough to keep a process off the CPU, a ticket written a
861
+ moment ago could look like it belonged to a process that died. The queue
862
+ now stamps every file it creates with the same clock it judges them by.
863
+
864
+ - Updated dependencies [2abfd4d]
865
+ - Updated dependencies [b3e1220]
866
+ - Updated dependencies [b3e1220]
867
+ - Updated dependencies [5d1bde3]
868
+ - Updated dependencies [348aba7]
869
+ - Updated dependencies [9158968]
870
+ - Updated dependencies [b3e1220]
871
+ - Updated dependencies [bd6b7ba]
872
+ - Updated dependencies [8846a06]
873
+ - @unotest/protocol@0.33.0
874
+ - @unotest/viewer@0.33.0
875
+ - @unotest/core@0.33.0
876
+ - @unotest/dsl@0.33.0
877
+ - @unotest/grounder-client@0.33.0
878
+
3
879
  ## [0.32.0] - 2026-09-05
4
880
 
5
881
  ### Minor Changes