@unotest/web 0.39.0 → 0.41.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,590 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.41.0] - 2026-09-21
4
+
5
+ ### Minor Changes
6
+
7
+ - e793fa2: `env push | set | rm`: the box and the token come from the project's own files
8
+
9
+ The values commands now resolve the box address and the project token
10
+ exactly the way `bundle push` does — a flag, then `UNOTEST_BOX_URL` /
11
+ `UNOTEST_BOX_TOKEN`, then `unotest/.env` and `unotest/.secrets`, where
12
+ `box create` and `box access` wrote them. A project set up with either of
13
+ those commands no longer answers "no box address" to `env push` while
14
+ `bundle push` works, and the two exports stop being typed in front of
15
+ every line.
16
+
17
+ `env set <env> <NAME> <value>` takes the value as an argument. Stdin
18
+ still works when there is none, and stays the way to pass a credential:
19
+ an argument is in the shell's history.
20
+
21
+ `env push --dry-run` now prints what the overlays resolved to, quoted, so
22
+ a value that kept its quotes or picked up a trailing space is visible
23
+ before it reaches a box. `--dry-run --json` carries the same under
24
+ `values`. A secret's value is still never printed: where it would be, the
25
+ mask used everywhere else (`‹secret:NAME›`) stands instead.
26
+
27
+ A push whose box lacks some of the suite's declared values no longer
28
+ claims the run "will stop at the first step that reads" one of them: the
29
+ names are what the suite DECLARES, and a declared name no scenario reads
30
+ costs a run nothing.
31
+
32
+ - 41b0369: A box rented from the dashboard now says how to send it a suite
33
+
34
+ A box rented from the CLI arrives with the suite already on it: `box create`
35
+ pushes it and prints the command that runs it. A box rented from the dashboard
36
+ arrives empty, and the card said nothing about that. It showed the address, the
37
+ login and the password, and left the customer with a machine that answers, an
38
+ empty viewer, and no idea that anything else was needed. Everything they had to
39
+ do next lived in an email and on the box's own empty screen.
40
+
41
+ The card of a ready box now carries the three commands that take a project from
42
+ here to a first run: sign in, take the box's tokens, push the suite. Each is a
43
+ field that can be copied in one click, with the box's own name already in it.
44
+ The environment name is filled in when the box has exactly one, and left as
45
+ `<env>` when it has several — picking one for the customer would send the run
46
+ to a site they did not mean, and nothing would refuse it, because the name is
47
+ perfectly valid. The collection is named too: a box runs a collection, not a
48
+ single scenario, and a push that names none is turned away.
49
+
50
+ Those commands are now written in one place and printed by both the card and
51
+ the "your box is ready" letter. They used to be the letter's alone, and a
52
+ second copy on the card would have been a second piece of advice the first time
53
+ either was edited.
54
+
55
+ Once a project has taken the box's tokens, the card stops repeating the first
56
+ two commands and asks only for the push. That is the one thing the cloud can
57
+ honestly know about the far end of a box: the suite travels from the customer's
58
+ machine straight to it, and nothing reports back. So the card says nothing at
59
+ all about runs — not "no runs yet", not "0 runs". It cannot tell a box that has
60
+ been testing for a week from one nobody has touched, and a page that says
61
+ otherwise is inventing it.
62
+
63
+ ### Patch Changes
64
+
65
+ - 36bc4b3: An empty list of environments says which kind of empty it is
66
+
67
+ `box envs` on a box that listed nothing said "This box serves no environments
68
+ you can read." — a sentence about the token, on a list that is not filtered by
69
+ the token at all. People whose box was simply new went looking for a stronger
70
+ credential, and the box's own journal had nothing to say either way.
71
+
72
+ The list now talks about the box:
73
+
74
+ This box serves no environments yet.
75
+ An environment appears once a suite has been pushed to it.
76
+
77
+ A token that really is not let in still answers in its own words, with its own
78
+ exit code, so the two are told apart by reading them.
79
+
80
+ Behind the list, the box now records which of five things happened when it
81
+ looked for its route table: there is no table yet, the table cannot be read,
82
+ it is not JSON, its shape is wrong, or it is honestly empty. Each is one line
83
+ in the box's journal, naming the file and never its contents — the table holds
84
+ internal addresses. A table that loaded says how many environments it found,
85
+ because "none" and "three" were otherwise the same silence.
86
+
87
+ Two fixes come with it. A line belongs to a state rather than to a request, so
88
+ a busy box writes one line and not one per proxied asset. And a table that
89
+ failed to parse is read again a second later instead of being remembered as an
90
+ empty box until its timestamp changes — which is why "I tried again and it was
91
+ the same" used to be true.
92
+
93
+ An empty list stays a successful answer, `200` with no entries: a box with
94
+ nothing on it yet is a normal box, not a service that is down.
95
+
96
+ - 7bac819: A box installs a suite whose `npm ci` is slow, and stops retrying in a loop
97
+
98
+ A box could not install a suite whose dependency install went quiet for
99
+ more than five minutes — which is what a heavy lockfile does. The install
100
+ died on the 301st second, reported the runner as unavailable, and the box
101
+ started the whole install again in the second it failed. That repeated for
102
+ as long as the box existed: nothing ever ran on it, and the message blamed
103
+ the network rather than naming the timeout that caused it.
104
+
105
+ Three things change. The daemon no longer puts a ceiling of its own on
106
+ reading a run's output, so silence is not a failure; the run's deadline
107
+ remains the only clock that ends a run, and a backstop behind it covers a
108
+ connection that stops answering altogether. A run whose output stops
109
+ without an exit now takes its container with it — abandoned containers
110
+ used to fill the box's capacity while healthy environments waited for a
111
+ slot. And a failed install is followed by a pause that grows: 1, 2, 5, 15
112
+ and 30 minutes, capped there, never "never". Asking for that bundle
113
+ yourself — a switch or a repair — is tried at once and clears the count,
114
+ as does an install that succeeds, and the log says when the box will try
115
+ next by itself.
116
+
117
+ The pause is the box's own, never yours: a run you order by hand, and a run
118
+ a push to your repository orders, install the bundle at once and clear the
119
+ count. Only the box's unattended retry waits.
120
+
121
+ - 7880b7d: Installs on a box share one npm cache, and a suite can never reach it
122
+
123
+ Until now every install on a box started with an empty npm cache: the
124
+ container that runs `npm ci` was thrown away with everything it had
125
+ downloaded, so the second box of the same account — and the same box
126
+ after a restart — paid the full download again for dependencies it had
127
+ already had twice.
128
+
129
+ Boxes now keep one npm cache and every install uses it. The second
130
+ install of a dependency is taken from disk instead of the network; how
131
+ much time that saves depends on your lockfile and your registry, and on a
132
+ fast registry it can be none at all — the promise here is only that
133
+ what has already been downloaded is not downloaded again.
134
+
135
+ The cache is mounted into the container that installs, and into no other.
136
+ A container running your suite gets no access to it under any name: the
137
+ cache is the directory that every later install of every project on that
138
+ box unpacks, and a test that could write into it would be writing into
139
+ the next install. That boundary is asserted as a pair — the install has
140
+ it, the run does not — rather than as two separate claims.
141
+
142
+ A substituted cache entry is never installed. npm verifies an entry's
143
+ checksum when it READS from the cache, not only when it writes one: an
144
+ entry whose content does not match is reported as corrupted and fetched
145
+ again (verified on the box's own image, npm 11.16.0 — with a registry
146
+ reachable the correct package is installed; without one the install fails
147
+ rather than using the substitute).
148
+
149
+ The box keeps the cache's size in hand. It is measured on each
150
+ housekeeping sweep and, over the ceiling (1 GB by default, and an
151
+ operator can raise it or turn it off), emptied whole with a line in the
152
+ log — losing a cache costs a download and nothing else.
153
+
154
+ - d456001: Ordering a run in an environment now decides what that environment serves
155
+
156
+ Pushing a suite and asking for a run in a named environment is a
157
+ statement about that environment: it should serve the bundle you just
158
+ sent. Until now the box decided that separately, by matching the
159
+ bundle's branch — so a suite packed outside a git repository, which is
160
+ what `init` produces in a clean directory, matched nothing. The run
161
+ installed, executed and passed, while the environment kept pointing at
162
+ nothing: its viewer was never started, the guard was given no route for
163
+ it, `box envs` listed nothing, and `box runs --env <name>` answered that
164
+ the box does not serve that environment. Four different answers to one
165
+ question, after the client had been told the suite was accepted and
166
+ queued.
167
+
168
+ An explicit run request now selects the bundle for the environment,
169
+ through the same path a selection made in the viewer takes, recorded
170
+ against the token that pushed — so it survives a restart and the box
171
+ does not silently move the environment back on its next sweep. Branch
172
+ matching keeps deciding exactly one thing, the one it is right for:
173
+ which bundle the environment's SCHEDULES run.
174
+
175
+ An environment that may not be switched (`allowSwitch: false`) keeps
176
+ serving what its own branch deploys, and the answer now says so instead
177
+ of leaving it to be found out: the run goes ahead there, and the
178
+ acceptance names the branch the environment stays on — so it is clear
179
+ the result belongs to that environment's journal rather than to the
180
+ screen the environment shows.
181
+
182
+ The one case with nothing to show is refused out loud. An environment
183
+ that may not be switched and serves nothing yet has no viewer at all, so
184
+ a run ordered there could only end where this bug ended: executed, green
185
+ and invisible. The refusal names the branch to deploy to instead.
186
+
187
+ Migration note: that refusal is a new error code on the run endpoint.
188
+ A CLI older than this release does not know it and will report the
189
+ box's answer as "not a box" rather than as a refusal — the request is
190
+ still refused, and nothing runs. Upgrade the CLI to see the message.
191
+ The acceptance note is a new field on an otherwise unchanged answer, and
192
+ an older CLI ignores it.
193
+
194
+ - 436b683: The box's own screen gives the same first-run command as everything else
195
+
196
+ A freshly rented box shows two commands: one that points the project at the
197
+ box, one that sends the suite and starts it running. The second was printed
198
+ without `--collection`, and a box runs a collection or a schedule — never a
199
+ scenario by name. Copied exactly as shown, into a project straight out of
200
+ `init`, it came back refused with nothing to run: an answer about a project
201
+ that had done nothing wrong, from the first screen a new box owner reads.
202
+
203
+ The screen now prints the whole command, and says where collections live:
204
+
205
+ npx @unotest/web bundle push --run --env ENVIRONMENT --collection examples
206
+
207
+ The command is no longer written out on each surface that shows it. The box's
208
+ screen, the last line of `box create`, and the collection `init` writes all
209
+ come from one place, so the next flag to change changes in one edit rather
210
+ than in three. `examples` is the collection `init` creates, and the screen
211
+ says so — it is an example of a name, not a claim about your project.
212
+
213
+ The cabinet half is unchanged in behaviour: with exactly one environment and
214
+ one collection it still prints a command that can be pasted as is, and
215
+ placeholders where it cannot know.
216
+
217
+ - 3d8e45d: `unotest/.secrets` is now created owner-only (`0600`)
218
+
219
+ The file holds the box push and read tokens, and was created with the
220
+ default umask — on a shared machine any other user could read it. It is
221
+ now narrow from the moment it is created, and a file left wider by an
222
+ earlier version is narrowed the next time a command writes to it, with one
223
+ line saying what changed. `unotest/.env` is configuration rather than
224
+ secrets and keeps the width your project gave it.
225
+
226
+ - 8a436b9: Exploration tools refuse an unknown `{{NAME}}` instead of typing it
227
+
228
+ A `{{NAME}}` the session could not resolve was passed through as text, so
229
+ `explore_run_flow {args:["admin","{{PASSWORD}}"]}` typed the placeholder
230
+ into the password field and the page answered with a normal login failure.
231
+ It now fails with a message that names the variable, says where to declare
232
+ it, and lists the names the session does have — so the value never has to
233
+ be pasted into the tool call by hand.
234
+
235
+ A bare `SHOUTY_NAME` is unchanged: a reference where it is registered, a
236
+ literal otherwise. A variable declared with an empty value still
237
+ substitutes as empty and is reported in `variableWarnings`; a name
238
+ declared as a secret with an empty value is refused with its own message.
239
+
240
+ - a1b9eac: Editing a secret in the viewer's variables panel keeps `unotest/.secrets` owner-only
241
+
242
+ The panel writes a file by creating a temporary one beside it and renaming
243
+ it into place, and a rename replaces the target together with its
244
+ permissions. So a secrets file that was owner-only became world-readable
245
+ again after a single edit in the panel, with the box tokens inside it, and
246
+ adding the first secret to a project created the file wide in the first
247
+ place.
248
+
249
+ The temporary file is now created owner-only when the layer being written
250
+ holds secrets, which also narrows a file an earlier version left wide. A
251
+ leftover temporary file from an interrupted write is removed rather than
252
+ reused, so it cannot pass its own permissions on. `unotest/.env` is
253
+ configuration rather than secrets and keeps the width your project gave it.
254
+ On Windows there are no POSIX permission bits and nothing is narrowed
255
+ there.
256
+
257
+ - Updated dependencies [436b683]
258
+ - Updated dependencies [a1b9eac]
259
+ - @unotest/protocol@0.41.0
260
+ - @unotest/viewer@0.41.0
261
+ - @unotest/core@0.41.0
262
+ - @unotest/dsl@0.41.0
263
+ - @unotest/grounder-client@0.41.0
264
+
265
+ ## [0.40.0] - 2026-09-19
266
+
267
+ ### Minor Changes
268
+
269
+ - fa848e9: A box that finishes coming up tells the person who rented it
270
+
271
+ Renting a box takes minutes, and until now it ended in silence. Somebody who
272
+ closed the tab while the machine was being built learned nothing: not that it
273
+ was up, not where it answers, not what to do next. A rental that says nothing
274
+ looks like a rental that did not work.
275
+
276
+ The service now sends one message, at the moment the box becomes ready, to the
277
+ address on the account. It names the box's address, points at the card on the
278
+ dashboard where the login and password stay readable for as long as the box
279
+ lives, and gives the three commands that put a suite on it:
280
+
281
+ npx @unotest/web login
282
+ npx @unotest/web box access <box>
283
+ npx @unotest/web bundle push --run --env <environment> --collection examples
284
+
285
+ The last one names a collection because a box runs a collection or a schedule,
286
+ never a scenario by name, and a project straight out of `init` has no schedule:
287
+ without the flag the run is refused before it starts. `examples` is what `init`
288
+ writes, and the message says to name your own instead where you have one.
289
+
290
+ The environment is filled in only when the box has one. A box can have several
291
+ — `box create` sends every environment the project has — and a command naming
292
+ the first of them reads as a command naming the only one, which would send a
293
+ run somewhere the reader did not choose. With several, the command carries
294
+ `<env>` and the message says where the names are written. Same rule as the
295
+ CLI's own next step, so the two do not answer one question differently.
296
+
297
+ The message carries no password. The break-glass password of a box is on its
298
+ card, which can be looked at again; a copy in a mailbox is one nobody can
299
+ withdraw. The type the message is built from has no field for it, so it cannot
300
+ be added back by accident.
301
+
302
+ It is sent once. The guard is the transition itself, taken inside the write
303
+ lock that makes a box ready, so a resumed build, the billing tick and a read
304
+ of the card all find the box already ready and announce nothing. A mail server
305
+ that is down is logged and does not touch the rental: the customer paid for a
306
+ machine, not for mail.
307
+
308
+ - 42548f7: A box deleted by hand tells its owner too, and the letter says which deletion happened
309
+
310
+ A box could go two ways, and only one of them said anything. When the balance
311
+ stopped covering a box's next hour, the billing tick deleted it and wrote to
312
+ the owner. When a person deleted one — from the dashboard, the CLI or the REST
313
+ API — the machine was released, the record was settled, and nobody was told.
314
+
315
+ That silence is only harmless while the person who presses delete and the
316
+ person who pays for the account are the same person. An account can have
317
+ several people on it, an API key, and a dashboard open where it should not be.
318
+ The one deletion a customer most needs to hear about is the one they did not
319
+ make, and it was the one that arrived as nothing at all.
320
+
321
+ Every deletion now ends in the same letter, from the same place, and the
322
+ letter's first paragraph says how the box went: the balance could not cover
323
+ the next hour, or somebody with access to the account asked for it. The second
324
+ version names no sum — what the balance happened to be when a person pressed
325
+ delete explains nothing about why the box is gone, and a figure printed beside
326
+ a deletion reads as its cause. It points at the dashboard instead, for a reader
327
+ whose next question is who else has access. Everything after that first
328
+ paragraph is one text for both: the machine and what was stored on it are gone,
329
+ the scenarios in the customer's own repository are not, and the name is never
330
+ reused.
331
+
332
+ One letter per deletion, not per caller. The transition to deleted is claimed
333
+ under the same write lock that settles the record, and only the caller that
334
+ made it writes. A person and the billing tick arriving at one box in the same
335
+ moment therefore send one letter between them — previously they could each
336
+ send their own.
337
+
338
+ A box the fleet has no record of is now settled rather than retried forever.
339
+ The tick used to treat "no such box" from the fleet as a failure to delete,
340
+ leaving the record live and billable and trying again every hour; it now ends
341
+ the way it does when a person deletes such a box, which is that there is
342
+ nothing to release and the record is closed.
343
+
344
+ The journal says which way a box went, on both paths. Until now a deletion by
345
+ hand left no line at all, and the only way to tell one from an eviction was the
346
+ absence of the tick's own lines.
347
+
348
+ A mail server that is down still never holds a deletion up: the machine is
349
+ released and the record settled first, and a failed send is a line in the log.
350
+
351
+ - cb4a7e5: `box access` without a name lists the boxes the account already has, instead of advising a second one
352
+
353
+ Run in a directory that does not know a box — a fresh checkout, a second
354
+ project — `box access`, `box status`, `box destroy` and `box picker`
355
+ refused with three things to try, and the only one anybody can follow
356
+ without thinking was `box create`. For somebody who already rents a box,
357
+ following it rents and pays for another.
358
+
359
+ The refusal now asks the account first. With one box it names it and gives
360
+ the command back ready to run:
361
+
362
+ no box named and none configured — this account has one box,
363
+ "larch": run `box access larch`
364
+
365
+ With several it lists them and asks which. Only an account with no boxes
366
+ at all is still told to create one, and a cloud that refused or could not
367
+ be reached is told the same as before — a cloud that did not answer is
368
+ never read as "you have no boxes".
369
+
370
+ - 1cb7ba5: Reading a box takes the same environment name that ordered the run
371
+
372
+ `bundle push --run --env prod` has always taken the bare environment
373
+ name and let the box fill the project in from the token. The commands
374
+ and MCP tools that read the results back wanted the full
375
+ `acme/prod`, and a bare name came back as a 404 that talked about the
376
+ environment rather than about its form:
377
+
378
+ ✗ this box does not serve 'prod'
379
+
380
+ So the name that worked in one command failed in the next, and nothing
381
+ on screen said why.
382
+
383
+ Both halves now take both spellings. `box runs`, `box run`, `box queue`
384
+ and `box screenshot` — and `box_runs`, `box_run`, `box_run_download`,
385
+ `box_queue`, `box_screenshot` — translate the name against the box's own
386
+ list before the request, exactly the list `box envs` prints. A bare name
387
+ only one project on the box has is sent as that project's full name; a
388
+ bare name several projects share is refused before anything is asked,
389
+ naming them:
390
+
391
+ ✗ box runs: more than one project on this box has an environment
392
+ called "test" (acme/test, beta/test) — name the one you mean in
393
+ full.
394
+
395
+ A name the box serves in neither form is refused the same way, listing
396
+ what it does serve. Where the list cannot be had — a box that did not
397
+ answer, a refused token, an empty list, which does not prove there are
398
+ no environments — the name goes as it was typed and the box decides,
399
+ exactly as before; its refusal now names the form as well as the
400
+ command that lists the names.
401
+
402
+ ### Patch Changes
403
+
404
+ - 9878745: `bundle push` that refuses leaves nothing behind in the project
405
+
406
+ The environment check now stands in front of the packing step, not only in
407
+ front of the upload. Packing writes: a suite that has no `package-lock.json`
408
+ yet gets one, in your repository, next to your scenarios. So a push refused
409
+ for naming an environment the box does not have still ended with a file that
410
+ was not there before the command — the refusal cost something after all, and
411
+ the next `git status` showed it.
412
+
413
+ The order is now: the flags, the box address, the token, the environment, and
414
+ only then the suite is packed. Nothing that can be refused happens after the
415
+ first write.
416
+
417
+ What was already right stays: `--dry-run` still packs and still prints its
418
+ bundle id, without asking for a box. The refusals about a missing address and
419
+ a missing token answer in the same words and with the same exit codes as
420
+ before — they simply answer sooner, before the lockfile is touched. A push
421
+ that goes through writes the lockfile exactly as it always did.
422
+
423
+ - 8cc5b20: `bundle push --run` checks the environment name before uploading, and names the environments itself
424
+
425
+ Pushing with an environment name the box does not have uploaded the whole
426
+ bundle first, and only then came back with a refusal — so the last thing
427
+ on screen was "uploaded", the wasted upload looked like success, and the
428
+ refusal read like a problem with the box. The advice that followed was to
429
+ ask the operator which environments exist; on a rented box the customer
430
+ is the operator.
431
+
432
+ The name is now checked against the box's own list first, with the read
433
+ token `box envs` uses. An unknown name is refused before anything is
434
+ uploaded, and the refusal names what the box does serve:
435
+
436
+ ✗ no environment named "staging" on https://larch.box.unotest.com
437
+ — nothing was uploaded.
438
+ it serves acme/test, acme/prod — push into one of them with
439
+ --env test
440
+
441
+ Both spellings the box itself gives are accepted, `acme/test` and the
442
+ bare `test`. Where the list cannot be had — no read token, a box that did
443
+ not answer, an empty list, which does not prove there are none — the push
444
+ goes ahead exactly as before and the box decides. Refusals that still
445
+ come from the box now point at `box envs` instead of an operator.
446
+
447
+ - 8a9c112: A fresh project can start a remote run: `init` writes a collection, and the refusal says what a box runs
448
+
449
+ A box runs a collection or a schedule; the local runner takes a scenario by
450
+ name. Both are true, and nothing said they differ — so a project created by
451
+ `init`, which wrote neither a collection nor a schedule, could not start a
452
+ remote run at all. The command the box's own screen offers came back with
453
+ "nothing to run", and the refusal named the state without naming the
454
+ difference or anything to try.
455
+
456
+ `init` now writes `unotest/e2e/_collections/examples.yaml`, naming the
457
+ examples it already creates. They hit the public playground, so the first
458
+ remote run passes before the box can reach the customer's own application:
459
+
460
+ npx @unotest/web bundle push --run --env <environment> --collection examples
461
+
462
+ A run asked for without a collection is still refused — that has not
463
+ changed — but the refusal now says that a box runs a collection or a
464
+ schedule rather than a scenario by name, and what to pass instead.
465
+
466
+ - 3f9bcb3: The box's screens fit a phone: the header wraps instead of running off the side
467
+
468
+ Four things sit in the header of every guard page — the box's own address,
469
+ who is looking, the link to the other half of the guard, and the way out. On
470
+ a screen 320 px wide they did not fit on one line, and what did not fit was
471
+ pushed past the right edge, taking the page's width with it. The whole screen
472
+ then scrolled sideways, and the control left outside was the one that signs a
473
+ viewer out: a box could not be left from a phone.
474
+
475
+ The header now wraps. On a narrow screen it takes a second row, and every
476
+ control stays reachable; a long box address or a long directory name wraps
477
+ with it instead of widening the row. Nothing changes on a wide screen — the
478
+ header is one row of the same height it always was.
479
+
480
+ - 7131b87: New box keeps the environment you named, so a box can be rented again
481
+
482
+ Renting a box from the cabinet was impossible for anyone who used the
483
+ Environment field the way it asks to be used. Typing `prod` and pressing Enter
484
+ turned the name into a tag, as it should; submitting the form then answered
485
+ "Name what runs at this address — prod, staging, pr-42." and came back with
486
+ the field empty and the tag gone. The form was refusing the very name it had
487
+ just been given, and there was no way through it: the only path to a first box
488
+ through the cabinet was closed.
489
+
490
+ The tag the browser makes now travels under the name the server reads
491
+ environments back from. It had been travelling under the name of the text box
492
+ it was typed in, so a row with one tag arrived with an address and no names at
493
+ all, and the refusal about names was the server honestly reporting what it
494
+ received.
495
+
496
+ Two things beside the fix, so this class of failure is harder to repeat. The
497
+ name a tag posts under is stated once, in the form, and the browser reads it
498
+ from there rather than spelling it out a second time. And the reader on the
499
+ server now accepts either half of the field repeated — two tags on one address
500
+ have always arrived as a repeated field, and a reader that expects a single
501
+ value drops a repeated one whole rather than in part.
502
+
503
+ A form submitted with scripting off is unchanged: the typed name still posts on
504
+ its own, one name per submit.
505
+
506
+ - e1db36f: `picker off` says how long the withdrawal takes, as one number
507
+
508
+ Switching the Picker off — a box's with `box picker off`, a project's with
509
+ `picker off` — withdraws its token through a revocation list that two
510
+ services read on their own schedules: the cloud rewrites the list, the
511
+ grounding service re-reads it. The command used to say only that the token
512
+ stops working "once the list is refreshed", because the CLI knew neither
513
+ interval and half a figure read as the whole would have been wrong.
514
+
515
+ The cloud now adds both halves and hands the sum over, and the command
516
+ says it:
517
+
518
+ ✓ The Picker token for this box is revoked — the grounder stops
519
+ accepting it within about 2 minutes, once it refreshes its
520
+ revocation list
521
+
522
+ The dashboard's own notices after switching off say the same figure. When
523
+ the cloud cannot name it — the grounding service has not answered yet —
524
+ the sentence stays as it was, with no number: a default would be a
525
+ promise nothing keeps.
526
+
527
+ - 36d1dba: `bundle push --run --env` now names an environment the box will accept, and refuses one it cannot.
528
+
529
+ A box lists its environments as `<project>/<environment>`, and that is the
530
+ form its viewer API and every `box` subcommand take. The door that orders
531
+ runs takes the bare environment instead and fills the project in from the
532
+ token that pushed. The pre-upload check accepted both forms but carried
533
+ whichever was typed straight through, and a refusal advised the listed one
534
+ — so a push that followed our own advice was refused by the box after the
535
+ bundle had already been uploaded.
536
+
537
+ Both forms are still accepted, with one exception named below. Whichever is
538
+ given, the run is now ordered under the name the box runs, and a refusal
539
+ advises that same name.
540
+
541
+ On a box that serves several projects, a listed name is translated only
542
+ while the bare name it becomes belongs to one of them. Where two projects
543
+ share an environment name, the run would be ordered inside whichever
544
+ project your token belongs to — not necessarily the one you named — so the
545
+ push is refused before the upload instead, and the refusal names both:
546
+
547
+ ✗ "beta/prod" cannot be ordered by that name on
548
+ https://larch.box.unotest.com — nothing was uploaded.
549
+ acme/prod and beta/prod are both called "prod", and a run is
550
+ ordered by the bare name inside the project your token belongs
551
+ to — push with --env prod if that project is yours
552
+
553
+ A bare name several projects share is left as it was typed: that is the
554
+ form the box expects, and the token is what says which project it means.
555
+
556
+ None of this reaches a box that has not named an environment yet. A box
557
+ with nothing to list answers with an empty list, which does not prove it
558
+ serves nothing, so the push goes ahead and the box decides — exactly as
559
+ before. Until a suite has been pushed to it once, a wrong name is still
560
+ answered by the box after the upload, not by this check.
561
+
562
+ - 5193108: The New box dialog closes again after the form has refused something
563
+
564
+ A dialog in the dashboard could be opened two ways and closed only one. The
565
+ Create box button opens it with a link to `#new-box`, and the × and the veil
566
+ close it by dropping that target again. A form the server refused comes back
567
+ open too — the refusal has to be visible — but that copy is opened by a class
568
+ in the served markup, and a link to `#` cannot take a class away. So anyone
569
+ who filled the form in, got a refusal, and reached for the × found the dialog
570
+ nailed open until they reloaded the page.
571
+
572
+ Closing now undoes whatever opened it. The dialog the button opened still
573
+ closes on the spot, with no request. The dialog a refusal opened closes by
574
+ going back to the boxes page — which has no refusal to keep it open — and the
575
+ × and the veil both carry that address. No script is involved in either case:
576
+ the dashboard is where boxes are bought, and it works with scripting off.
577
+
578
+ Closing after a refusal drops what was typed. That is the intended reading of
579
+ closing a form, and a draft that reappeared at the next click would be the
580
+ bigger surprise.
581
+
582
+ - @unotest/viewer@0.40.0
583
+ - @unotest/core@0.40.0
584
+ - @unotest/dsl@0.40.0
585
+ - @unotest/grounder-client@0.40.0
586
+ - @unotest/protocol@0.40.0
587
+
3
588
  ## [0.39.0] - 2026-09-18
4
589
 
5
590
  ### Minor Changes
@@ -66,7 +651,7 @@ picker on` fetches this project's own access and writes the three grounder
66
651
 
67
652
  That screen named one command:
68
653
 
69
- npx @unotest/web bundle push --run --env ENVIRONMENT
654
+ npx @unotest/web bundle push --run --env ENVIRONMENT
70
655
 
71
656
  In a project that has just been created, the command refuses with `no box
72
657
  address` before it does anything: the project holds neither the box's address
@@ -75,8 +660,8 @@ address` before it does anything: the project holds neither the box's address
75
660
 
76
661
  The screen now names both steps, in the order they have to happen:
77
662
 
78
- npx @unotest/web box access larch
79
- npx @unotest/web bundle push --run --env ENVIRONMENT
663
+ npx @unotest/web box access larch
664
+ npx @unotest/web bundle push --run --env ENVIRONMENT
80
665
 
81
666
  The box's own name is filled in when its licence carries one. `box access`
82
667
  with no name refuses, and its refusal offers to create a box — to somebody who
package/README.md CHANGED
@@ -171,7 +171,10 @@ has no price of its own — and the answer defaults to no. On a box you already
171
171
  this box's, which is left alone and reported. `off` also revokes the
172
172
  box's Picker token, so a copy of it on another machine stops working
173
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
174
+ revocation list, and `off` says how long that takes — "within about 2
175
+ minutes" — whenever the cloud knows both halves of the wait. Where it
176
+ does not, the sentence stays true and carries no figure rather than
177
+ half of one. Either way the charge follows at the
175
178
  next hourly tick, because a started hour is charged at the rate it
176
179
  started with. `box status` says which state a box is in, and — while the
177
180
  Picker is on — which models the cloud grounds with: `Our GPU` or
@@ -250,13 +253,18 @@ run reads:
250
253
 
251
254
  ```sh
252
255
  npx @unotest/web env push dev # .env + .env.dev + .secrets + .secrets.dev
256
+ npx @unotest/web env set dev REGION eu-west
253
257
  printf '%s' "$KEY" | npx @unotest/web env set dev API_KEY --secret
254
258
  ```
255
259
 
256
260
  `.env*` become the environment's variables, `.secrets*` its secrets,
257
- `APP_BASE_URL` its target; `UNOTEST_*` stay home. A value never prints.
258
- The token must be minted with `--values` on the box. Admins also see and
259
- change them on the box's admin page. Manual: [An environment's values on a
261
+ `APP_BASE_URL` its target; `UNOTEST_*` stay home. A secret's value never
262
+ prints; `env push --dry-run` shows what the other values resolved to. The
263
+ box and the token come from the same places `bundle push` reads them
264
+ from — flags, the environment, then `unotest/.env` and
265
+ `unotest/.secrets` — and the token must be minted with `--values` on the
266
+ box. Admins also see and change them on the box's admin page. Manual: [An
267
+ environment's values on a
260
268
  box](https://docs.unotest.com/box/environments-and-values/).
261
269
 
262
270
  A run on the box happens inside the box's own container: nothing on your
@@ -306,6 +314,16 @@ file; `--box` and `--token` win over both.
306
314
  The token is never read from `unotest/.env` — that file travels inside a
307
315
  pushed bundle, and `.secrets` does not.
308
316
 
317
+ `unotest/.secrets` is created owner-only (`0600`), so the tokens in it are
318
+ not readable by other users of a shared machine. A file left wider by an
319
+ earlier version is narrowed the next time a command writes to it, and the
320
+ command says so once. Editing a secret in the viewer's variables panel
321
+ keeps the same width. `unotest/.env` is configuration, not secrets, and
322
+ keeps whatever width your project gave it.
323
+
324
+ On Windows there are no POSIX permission bits, so nothing is narrowed there
325
+ and no width is promised: protect the file with the tools your system has.
326
+
309
327
  Keeping it in the file is what lets your agent read runs: an MCP server is
310
328
  started by your editor, so a token that only exists in your shell is a
311
329
  token the agent never sees. A token added while the server is running is
@@ -317,7 +335,8 @@ hand you mint it for yourself on the box's guard, where it is shown once
317
335
  and can be revoked. Either way it is read-only whatever your role on the
318
336
  box — it cannot start a run or change a value. `UNOTEST_BOX_TOKEN` is the
319
337
  project's push credential and cannot read runs. `box envs` lists the
320
- environments a token may look at.
338
+ environments the box serves — an empty list means the box has none yet,
339
+ not that the token is too weak to see them.
321
340
 
322
341
  ## 5. Watch it run — the viewer
323
342