@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 +876 -0
- package/README.md +62 -7
- package/dist/config/schema.d.ts +18 -18
- package/dist/config/schema.js +1 -1
- package/dist/driver/index.js +1 -1
- package/dist/dsl/index.d.ts +9 -9
- package/dist/dsl/index.js +1 -1
- package/dist/dsl/web-dsl-language-service.js +1 -1
- package/dist/mcp/server.js +1 -1
- package/dist/runner/cli.js +1 -1
- package/dist/runner/init/chrome-probe.js +1 -1
- package/dist/runner/init.d.ts +42 -2
- package/dist/runner/init.js +1 -1
- package/dist/runner/install-chromium.js +1 -1
- package/dist/runner/prepare-fix.js +1 -1
- package/dist/runner/scaffold-workspace.js +1 -1
- package/dist/runner/serve-fixture.js +1 -1
- package/dist/runner/web-runner-adapter.js +1 -1
- package/package.json +9 -9
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
|