@unotest/protocol 0.32.0 → 0.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,341 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.35.0] - 2026-09-15
4
+
5
+ ### Patch Changes
6
+
7
+ - 79c99ab: A box can now say "I cannot judge this token yet" instead of "your token is
8
+ wrong".
9
+
10
+ A box rented from the fleet accepts tokens the cloud signed for it, and it
11
+ checks every one against the operator's revocation list. Until that list has
12
+ reached the box — the minutes between it coming up and its first update tick
13
+ — there is nothing to check against, so such a token is refused. That is not
14
+ the same refusal as a bad credential: the answer is to send the SAME token
15
+ again shortly, never to mint another.
16
+
17
+ `BoxReadErrorCode` gains `revocation-list-unavailable` for it, and a refusal
18
+ body may carry `retryAfterSeconds` (the box sends `Retry-After` with the same
19
+ number). The push and run answers gain the same code, plus `ambiguous-project`
20
+ for a cloud-minted push token that names no project on a box that serves
21
+ several. Nothing changes for the tokens a person or a CI job mints on the box
22
+ itself.
23
+
24
+ - dee96a1: A box can now say "this token has expired" instead of "your token is wrong".
25
+
26
+ Tokens the cloud signs for a rented box live thirty days. Until now a box
27
+ refused an expired one with the same blanket `unauthorized` it gives a
28
+ forgery, so the CLI could not tell "ask the cloud for another pair and
29
+ retry" from "this credential will never work", and an expired pair looked
30
+ revoked.
31
+
32
+ The read, push, run and values answers gain the code `expired` for it. A box
33
+ sends it only for a token its operator key really signed, of the right kind,
34
+ for that box — the signature, the kind and the box are all checked before
35
+ the expiry is — so nothing a guesser sends can produce it. It is also
36
+ decided before the revocation list is consulted: a box that has not received
37
+ a list yet still answers `expired` rather than asking for the same dead
38
+ token again.
39
+
40
+ Two codes that a box could already send are now accepted when parsing a run
41
+ or values answer, which had been left out of those two readers:
42
+ `revocation-list-unavailable` and `ambiguous-project`.
43
+
44
+ ## [0.34.0] - 2026-09-07
45
+
46
+ ### Patch Changes
47
+
48
+ - 32a495e: Failure artifacts survive a run that is isolated from its own artifact root.
49
+
50
+ A run executed inside a container sees only what is mounted into it. The
51
+ failure bundle's directory is derived from the artifact root, which on such
52
+ a run is not one of those mounts — so it could not be created, and a failed
53
+ test produced no screenshot, no trace and no page HTML at all. Worse, that
54
+ one error took the always-on copies in the run directory down with it: both
55
+ sinks shared a single try, so the artifact that does not depend on the
56
+ bundle was lost too.
57
+
58
+ Two changes. `UNOTEST_FAILURES_ROOT` now says where bundles live, for
59
+ whoever spawns the run to point at a directory it can actually write
60
+ (unset, everything behaves exactly as before). And the two sinks are
61
+ independent: an unusable bundle root no longer costs you the run
62
+ directory's screenshot and page HTML.
63
+
64
+ The viewer serves failure assets from either layout, so a bundle written
65
+ inside the environment's runs tree renders like a local one.
66
+
67
+ - c6b91bb: From `init` to a paid box without copying a token: `login`, `box create`,
68
+ and the tokens that keep themselves fresh.
69
+
70
+ Three new commands sign this machine in to unotest cloud — the account
71
+ that rents boxes — and rent one:
72
+
73
+ - `login` runs the device flow (RFC 8628): it prints a link and a code,
74
+ opens the browser when there is one, and waits for the approval. The
75
+ key is kept in `~/.config/unotest/credentials.json` (mode 0600,
76
+ `XDG_CONFIG_HOME` honoured) and is never printed, not even with
77
+ `--json`. `UNOTEST_CLOUD_TOKEN` in the environment wins over the file,
78
+ which is how a CI job signs in. `logout` revokes the key on the server
79
+ when the cloud can be reached and removes it locally always; `whoami`
80
+ says who, where the key came from, and the balance (exit 77 when not
81
+ signed in).
82
+ - `box create [name] [--size s|m|l] [--topup <eur>]` creates a box named
83
+ after the project (its package name, as a slug), with the environments
84
+ the project runs locally (`unotest/.env` and every `unotest/.env.<name>`,
85
+ their `APP_BASE_URL` or the config's `baseUrl` as the target). When the
86
+ balance will not cover the first hour it prints a Stripe Checkout link,
87
+ opens it when it can, and waits for the payment. Then it waits for the
88
+ box, mints its cloud-signed push and read tokens, writes
89
+ `UNOTEST_BOX_URL` to `unotest/.env` and `UNOTEST_BOX_TOKEN` +
90
+ `UNOTEST_BOX_READ_TOKEN` to `unotest/.secrets` (through the same
91
+ structure-preserving writer the viewer's Variables panel uses, with
92
+ `.gitignore` guarded the way `init` guards it), pushes the suite —
93
+ retrying with backoff while a fresh box has no revocation list yet —
94
+ and prints the viewer's address. Exit codes for an agent driving it
95
+ without a terminal: 75 the payment was not confirmed in time (nothing
96
+ created, nothing charged), 76 the terms of service are not accepted
97
+ (a person must, in a browser), 77 not signed in. No prompt is ever
98
+ shown without a TTY.
99
+ `--picker` rents the box with the Picker; `UNOTEST_GROUNDER_MODE=remote`
100
+ and `UNOTEST_GROUNDER_REMOTE_URL` then go to `unotest/.env` and
101
+ `UNOTEST_GROUNDER_REMOTE_TOKEN` to `unotest/.secrets` — the names the MCP
102
+ server reads — so intents ground on the box with no further setup. A
103
+ box without the Picker leaves those keys alone. The last line of
104
+ `create` is the next step: the complete `bundle push --run --env …
105
+ --collection …` when the project has exactly one environment and one
106
+ collection, the viewer's address plus the command with placeholders
107
+ otherwise.
108
+ - `box access [name]` re-mints the pair and rewrites the two files;
109
+ `box status` and `box destroy` (asks on a terminal, `--yes` elsewhere)
110
+ are thin wrappers. The name defaults to the box `UNOTEST_BOX_URL`
111
+ points at (`acme.box.unotest.com` → `acme`).
112
+
113
+ Cloud-signed box tokens live thirty days. When a box answers that one
114
+ has expired, `bundle push` and the `box …` read commands re-mint the
115
+ pair through the cloud and retry once, provided a cloud login is at
116
+ hand; without one they say to run `login && box access`. The box a
117
+ re-mint is for is the one the project points at, named from its address
118
+ or, when the address spells no name (a stand behind an IP), by the same
119
+ default `create` used — so a lab box re-mints like any other. A `--box`
120
+ naming somewhere else is said to be somewhere else rather than blamed on
121
+ a missing login. A box that has
122
+ not yet received its revocation list is told apart from a real refusal
123
+ and retried; a token the box calls revoked is never re-minted quietly.
124
+ `bundle push` now also reads the push token from `unotest/.secrets`,
125
+ where `box create` puts it (then `unotest/.env`, for suites set up by
126
+ hand), so nothing has to be exported after the setup.
127
+
128
+ The MCP server knows the state too. While the project points at no box,
129
+ `box_envs`, `box_runs`, `box_run` and the other box tools answer
130
+ `{state: "no-box", next, why}` instead of an error, and `run_test`
131
+ carries the same `boxSuggestion` exactly once per server — after the
132
+ first local run that passes — so an agent offers the box at the moment
133
+ it is worth something and never nags. The box tools re-mint an expired
134
+ cloud-signed read token the way the CLI does, through the composition
135
+ root, and `unotest/.secrets` is read on every call: a pair written by
136
+ `box create` while the server runs is used by the next call. On a
137
+ Picker box, `ground_element` (and intent locators) whose picker token
138
+ the grounder refuses re-mint it once through the cloud and hand the new
139
+ token to the running client; a refusal that cannot be mended answers
140
+ `{state: "picker-token-refused", next: "npx @unotest/web box access",
141
+ why}`, and a grounder that has not read its revocation list yet answers
142
+ `{state: "retry-shortly", retryAfterSeconds}`. Box tokens are checked
143
+ before the network: cloud-signed ones by their whole shape (`unos_…`), a
144
+ push token in the read variable (and the reverse) is named as the wrong
145
+ kind, anything else as not a token.
146
+
147
+ A renewal the cloud itself refuses is its own answer, not the cloud's.
148
+ When a box or the grounder says a cloud-signed token is stale and the
149
+ `/access` call that would replace it is refused in turn — no such box,
150
+ the box not ready, too many live pairs, the cloud unreachable — every
151
+ surface says that the credentials could not be renewed and keeps the
152
+ cloud's refusal as the cause. The CLI prints one line and exits with the
153
+ code that refusal earns; the grounding tools answer
154
+ `{state: "picker-token-refused", next, why}` as before, so an intent is
155
+ never answered with a sentence about boxes.
156
+
157
+ `init` ends with one line saying how to run the suite on a box; the
158
+ "mint a read token in the guard" advice in the box commands' refusals
159
+ now points at `login` and `box access` instead. `UNOTEST_CLOUD_URL`
160
+ points the CLI at a stand or a fake cloud; nobody sets it otherwise.
161
+
162
+ `@unotest/protocol`: `replaceEnvVar` (an in-place value replacement the
163
+ new upsert builds on), `BOX_PUSH_TOKEN_PREFIX` / `BOX_SIGNED_TOKEN_PREFIX`
164
+ / `BOX_SIGNED_TOKEN`, and `UNOTEST_BOX_URL`, `UNOTEST_GROUNDER_MODE`,
165
+ `UNOTEST_GROUNDER_REMOTE_URL` listed among the runner-config keys so they
166
+ file under the Runner section of `unotest/.env`.
167
+
168
+ `@unotest/grounder-client`: `GrounderHttpClientOptions.token` may be a
169
+ function yielding the current bearer; `GrounderRemoteError` carries the
170
+ grounder's `retryAfterSeconds`, and the error codes gain
171
+ `revocation-list-unavailable`.
172
+
173
+ ## [0.33.0] - 2026-09-06
174
+
175
+ ### Minor Changes
176
+
177
+ - 2abfd4d: Box: every run is announced — Slack, Telegram, or a webhook of your own — and the channels are managed from the guard.
178
+
179
+ A box now tells people how its runs went, whoever started them: the
180
+ schedule's ticks, a pull request's check, somebody pressing Run in the
181
+ viewer, a terminal inside the container. The daemon reads each run's own
182
+ journal, so the three producers are one source; the run's manifest says
183
+ what set it off — `trigger: { kind: "schedule" | "ci" | "manual" | "cli",
184
+ by? }`, written by the runner from `UNOTEST_RUN_TRIGGER` /
185
+ `UNOTEST_RUN_ACTOR` (a box sets them for its scheduled and CI runs, the
186
+ viewer for a run ordered from its UI; a terminal sets nothing and reads as
187
+ `cli`).
188
+
189
+ A scheduled series is announced on a **change of state**: `failed` after
190
+ `afterFailures` red ticks in a row, `recovered`, `still-failing` at a rule's
191
+ `repeatEvery`, `not-run` when the suite could not run at all (no bundle, a
192
+ bundle that does not install) — daily by default. A run somebody ordered is
193
+ announced on its own, `failed` or `passed`, every time. A withdrawn or
194
+ aborted run says nothing; a run whose journal stops and whose heartbeat goes
195
+ cold is `interrupted`.
196
+
197
+ Channels, rules, mutes and quiet hours live on the box and are managed from
198
+ the guard's `/_guard/notifications` page (or `boxd notify …`): a channel's
199
+ secret is set once and never shown again, a rule picks environments,
200
+ triggers, events and channels, a series or the whole project can be muted
201
+ until a time, and quiet hours hold reminders and `passed` back until the
202
+ morning. A generic webhook receives the event as JSON (`version: 1`, the
203
+ box named, the `trigger`) with `X-Unotest-Event` and, when the channel has
204
+ a secret, `X-Unotest-Signature: sha256=<hex>` — HMAC-SHA256 over the raw
205
+ body, the scheme GitHub uses — plus any headers the channel declares.
206
+
207
+ `@unotest/protocol`: `RunManifest.trigger` and the trigger helpers
208
+ (`runTriggerFromEnv`, `runTriggerEnv`, `manifestTrigger`); the session-door
209
+ paths, views and parsers of the notifications API (`boxNotifyProjectPath`,
210
+ `boxNotifyChannelPath`, `boxNotifyRulePath`, `boxNotifySeriesPath`,
211
+ `boxNotifyMutePath`, `boxNotifyQuietHoursPath`, `boxNotifyPreviewPath`,
212
+ `BoxNotifyProjectView`, …). The first draft of this feature (channels
213
+ declared in the box config, secrets by field) never shipped; this replaces
214
+ it whole.
215
+
216
+ - 5d1bde3: Run isolation, stage 1: a box no longer executes a test bundle inside its
217
+ own daemon.
218
+
219
+ box-runner (new, private): the run sidecar — the only service on a box
220
+ holding the docker socket, and the only one that starts a container. Three
221
+ authenticated routes on the `box` network (`POST /runs`, `GET
222
+ /runs/:id/stream` NDJSON, `DELETE /runs/:id`), a shared secret compared in
223
+ constant time, and an API that cannot be told anything about how a
224
+ container is built: no path, image, network, user, mount or flag. A request
225
+ names a project, an environment, a bundle, a scenario and the environment's
226
+ values; the bind sources are derived from the first three, resolved with
227
+ `realpath` and re-checked against the projects root, and must already
228
+ exist. The container is created over the Docker Engine API (pinned
229
+ `v1.43`) straight over the socket — no `docker` binary in the image and no
230
+ client dependency, and a container that is a JSON document has no place
231
+ for a value to become a flag. A daemon that refuses or is not there comes
232
+ back as `502 {reason}` before the run is accepted, or as an `error` event
233
+ on the stream after — never as a run that "exited with code null". An
234
+ environment variable the box does not forward is named in the sidecar's
235
+ log rather than dropped silently. A run gets the bundle tree read-only as its working directory, the
236
+ environment's `unotest/.runs.<env>` read-write, a `noexec` tmpfs `/tmp`,
237
+ a read-only rootfs, all capabilities dropped, `no-new-privileges`, its own
238
+ uid in boxd's group, memory/pids/cpu limits clamped to both the operator's ceilings and the
239
+ host's own size (docker refuses a container bigger than the machine
240
+ instead of clamping it), a 512 MB `/dev/shm`
241
+ (docker's default 64 MB kills Chromium mid-page; the host's IPC namespace
242
+ is deliberately not borrowed), and the `runs` network only. The container engine is an interface, so every rule is asserted on
243
+ the container that would have reached docker — including the cases where
244
+ the answer is no container at all.
245
+
246
+ boxd: running the SUITE is its own contract (`IScenarioRunner`), separate
247
+ from running a command (`ICommandRunner`, still the daemon's own `npm ci`
248
+ and viewers). `ContainerScenarioRunner` talks to the sidecar,
249
+ `ProcessScenarioRunner` keeps the old child-process shape for a box without
250
+ docker; `BOXD_RUNNER_KIND=container|process` chooses, and half a container
251
+ configuration refuses to start. Reading a bundle's schedules
252
+ (`unotest-web schedules --json`) executes the project's config module, so it
253
+ goes the same way. A run's environment is built from named parts
254
+ (`run-environment.ts`) instead of inheriting `process.env`, and its debug
255
+ tree is pointed at the run's own directory rather than the read-only
256
+ sources. New metrics
257
+ `boxd_run_container_total{outcome}` and `boxd_run_container_start_seconds`.
258
+ An environment's runs directory is created by the daemon with `2775`, so
259
+ the run's uid may write into it and the daemon's group may read it back.
260
+
261
+ box-kit: `secretsMatch` (constant-time secret comparison, moved out of
262
+ dist-service), `splitLines`, and `envNumber` / `envPositiveNumber` — the
263
+ env-reading rule the box-side services share.
264
+
265
+ `npm ci` of a bundle is a container of its own (`kind: "install"`): the
266
+ bundle tree is its only mount and it is writable, there is no environment
267
+ and no artifacts directory in reach, and a dependency's install scripts
268
+ RUN — a native module builds or fetches its binary as usual. That is what
269
+ running them inside the daemon could never allow. The container's last
270
+ steps, only on success, check the tree against the box's size cap, set
271
+ the final modes on what the install created and write the install marker
272
+ — each with an exit code of its own, so the daemon's log names the step
273
+ that failed — so a killed or oversized
274
+ install leaves a tree the box will not mount; a failed install takes the
275
+ tree with it. The daemon no longer passes `--ignore-scripts` and no
276
+ longer walks the tree afterwards.
277
+
278
+ The daemon runs with `umask 002` and clears `node_modules` before each
279
+ install, so a tree it created is one the install container (another uid
280
+ in its group) can actually write.
281
+
282
+ An install that fails KEEPS the tree, unmarked: nothing mounts it and the
283
+ next attempt reinstalls in place (`npm ci` wipes `node_modules` itself).
284
+ And a bundle directory holding a manifest with neither an archive nor a
285
+ tree behind it no longer counts as "this box has it" — a push of the same
286
+ content brings it back instead of being answered `already had this exact
287
+ bundle`.
288
+
289
+ web: `bundle push` and the docs say that a dependency's install scripts
290
+ run on a box, in isolation — the earlier advice to vendor them is gone.
291
+
292
+ A run ordered in the viewer now goes through the daemon's queue instead
293
+ of being spawned by the viewer.
294
+
295
+ `POST/GET/DELETE /api/box/envs/<project>/<env>/runs[/<runId>]` (paths and
296
+ wire types in `@unotest/protocol`) takes a `manual` ticket like every
297
+ other producer. Two callers may use it, and neither is taken on its word:
298
+ the guard, proving it is the guard with a shared secret from `box-init`
299
+ (the actor header is read only next to it — a viewer container could
300
+ otherwise claim to be any admin), and a viewer, with a per-environment
301
+ token the daemon minted when it started that viewer plus a short-lived
302
+ single-use ticket the guard signed over WHO clicked. The guard gates the
303
+ route under `admin` and requires an `Origin` on a mutation; the daemon
304
+ checks the token's environment, the ticket's environment and its `jti`
305
+ against the path.
306
+
307
+ `@unotest/viewer` gets `BoxdRunner`: on a box the Run button orders and
308
+ the Stop button asks the daemon, and the progress still comes from the
309
+ run's journal on disk. A local viewer spawns as it always did — the
310
+ composition root picks by whether a box handed it credentials.
311
+
312
+ ### Patch Changes
313
+
314
+ - b3e1220: Box notifications: a webhook channel's header values are credentials.
315
+
316
+ The notifications API and the guard's page now see a webhook's headers
317
+ as names with `{ set: true }` — the values (the receiver's
318
+ `Authorization`, most of the time) are never read back, like the signing
319
+ secret. The edit form lists the names; typing a value after one replaces
320
+ the set, names alone keep it. `/api/box/notify` is the guard's own route:
321
+ it is no longer reachable through the session proxy for any role — a
322
+ readonly session could previously list every project's channels with
323
+ their headers.
324
+
325
+ - b3e1220: Box notifications: the webhook signature now covers a timestamp.
326
+
327
+ Every webhook delivery carries `X-Unotest-Timestamp` (Unix milliseconds,
328
+ when it was sent), and `X-Unotest-Signature` is HMAC-SHA256 over
329
+ `<timestamp>.<raw body>` rather than the body alone — GitHub's scheme
330
+ with a timestamp in front, so a delivery captured on the wire cannot be
331
+ replayed to the receiver once its window (five minutes, documented) has
332
+ passed. The GitHub webhook the box receives is unchanged: that is
333
+ GitHub's contract.
334
+
335
+ The `by` of a CI-triggered run (`push a1b2c3d (branch)`) is cut at 200
336
+ characters — a branch name is the pusher's input, and it must not balloon
337
+ the run's manifest and every notification the run produces.
338
+
3
339
  ## [0.32.0] - 2026-09-05
4
340
 
5
341
  ### Minor Changes