@unotest/grounder-client 0.33.0 → 0.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,179 @@
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
+ ## [0.34.0] - 2026-09-07
68
+
69
+ ### Patch Changes
70
+
71
+ - c6b91bb: From `init` to a paid box without copying a token: `login`, `box create`,
72
+ and the tokens that keep themselves fresh.
73
+
74
+ Three new commands sign this machine in to unotest cloud — the account
75
+ that rents boxes — and rent one:
76
+
77
+ - `login` runs the device flow (RFC 8628): it prints a link and a code,
78
+ opens the browser when there is one, and waits for the approval. The
79
+ key is kept in `~/.config/unotest/credentials.json` (mode 0600,
80
+ `XDG_CONFIG_HOME` honoured) and is never printed, not even with
81
+ `--json`. `UNOTEST_CLOUD_TOKEN` in the environment wins over the file,
82
+ which is how a CI job signs in. `logout` revokes the key on the server
83
+ when the cloud can be reached and removes it locally always; `whoami`
84
+ says who, where the key came from, and the balance (exit 77 when not
85
+ signed in).
86
+ - `box create [name] [--size s|m|l] [--topup <eur>]` creates a box named
87
+ after the project (its package name, as a slug), with the environments
88
+ the project runs locally (`unotest/.env` and every `unotest/.env.<name>`,
89
+ their `APP_BASE_URL` or the config's `baseUrl` as the target). When the
90
+ balance will not cover the first hour it prints a Stripe Checkout link,
91
+ opens it when it can, and waits for the payment. Then it waits for the
92
+ box, mints its cloud-signed push and read tokens, writes
93
+ `UNOTEST_BOX_URL` to `unotest/.env` and `UNOTEST_BOX_TOKEN` +
94
+ `UNOTEST_BOX_READ_TOKEN` to `unotest/.secrets` (through the same
95
+ structure-preserving writer the viewer's Variables panel uses, with
96
+ `.gitignore` guarded the way `init` guards it), pushes the suite —
97
+ retrying with backoff while a fresh box has no revocation list yet —
98
+ and prints the viewer's address. Exit codes for an agent driving it
99
+ without a terminal: 75 the payment was not confirmed in time (nothing
100
+ created, nothing charged), 76 the terms of service are not accepted
101
+ (a person must, in a browser), 77 not signed in. No prompt is ever
102
+ shown without a TTY.
103
+ `--picker` rents the box with the Picker; `UNOTEST_GROUNDER_MODE=remote`
104
+ and `UNOTEST_GROUNDER_REMOTE_URL` then go to `unotest/.env` and
105
+ `UNOTEST_GROUNDER_REMOTE_TOKEN` to `unotest/.secrets` — the names the MCP
106
+ server reads — so intents ground on the box with no further setup. A
107
+ box without the Picker leaves those keys alone. The last line of
108
+ `create` is the next step: the complete `bundle push --run --env …
109
+ --collection …` when the project has exactly one environment and one
110
+ collection, the viewer's address plus the command with placeholders
111
+ otherwise.
112
+ - `box access [name]` re-mints the pair and rewrites the two files;
113
+ `box status` and `box destroy` (asks on a terminal, `--yes` elsewhere)
114
+ are thin wrappers. The name defaults to the box `UNOTEST_BOX_URL`
115
+ points at (`acme.box.unotest.com` → `acme`).
116
+
117
+ Cloud-signed box tokens live thirty days. When a box answers that one
118
+ has expired, `bundle push` and the `box …` read commands re-mint the
119
+ pair through the cloud and retry once, provided a cloud login is at
120
+ hand; without one they say to run `login && box access`. The box a
121
+ re-mint is for is the one the project points at, named from its address
122
+ or, when the address spells no name (a stand behind an IP), by the same
123
+ default `create` used — so a lab box re-mints like any other. A `--box`
124
+ naming somewhere else is said to be somewhere else rather than blamed on
125
+ a missing login. A box that has
126
+ not yet received its revocation list is told apart from a real refusal
127
+ and retried; a token the box calls revoked is never re-minted quietly.
128
+ `bundle push` now also reads the push token from `unotest/.secrets`,
129
+ where `box create` puts it (then `unotest/.env`, for suites set up by
130
+ hand), so nothing has to be exported after the setup.
131
+
132
+ The MCP server knows the state too. While the project points at no box,
133
+ `box_envs`, `box_runs`, `box_run` and the other box tools answer
134
+ `{state: "no-box", next, why}` instead of an error, and `run_test`
135
+ carries the same `boxSuggestion` exactly once per server — after the
136
+ first local run that passes — so an agent offers the box at the moment
137
+ it is worth something and never nags. The box tools re-mint an expired
138
+ cloud-signed read token the way the CLI does, through the composition
139
+ root, and `unotest/.secrets` is read on every call: a pair written by
140
+ `box create` while the server runs is used by the next call. On a
141
+ Picker box, `ground_element` (and intent locators) whose picker token
142
+ the grounder refuses re-mint it once through the cloud and hand the new
143
+ token to the running client; a refusal that cannot be mended answers
144
+ `{state: "picker-token-refused", next: "npx @unotest/web box access",
145
+ why}`, and a grounder that has not read its revocation list yet answers
146
+ `{state: "retry-shortly", retryAfterSeconds}`. Box tokens are checked
147
+ before the network: cloud-signed ones by their whole shape (`unos_…`), a
148
+ push token in the read variable (and the reverse) is named as the wrong
149
+ kind, anything else as not a token.
150
+
151
+ A renewal the cloud itself refuses is its own answer, not the cloud's.
152
+ When a box or the grounder says a cloud-signed token is stale and the
153
+ `/access` call that would replace it is refused in turn — no such box,
154
+ the box not ready, too many live pairs, the cloud unreachable — every
155
+ surface says that the credentials could not be renewed and keeps the
156
+ cloud's refusal as the cause. The CLI prints one line and exits with the
157
+ code that refusal earns; the grounding tools answer
158
+ `{state: "picker-token-refused", next, why}` as before, so an intent is
159
+ never answered with a sentence about boxes.
160
+
161
+ `init` ends with one line saying how to run the suite on a box; the
162
+ "mint a read token in the guard" advice in the box commands' refusals
163
+ now points at `login` and `box access` instead. `UNOTEST_CLOUD_URL`
164
+ points the CLI at a stand or a fake cloud; nobody sets it otherwise.
165
+
166
+ `@unotest/protocol`: `replaceEnvVar` (an in-place value replacement the
167
+ new upsert builds on), `BOX_PUSH_TOKEN_PREFIX` / `BOX_SIGNED_TOKEN_PREFIX`
168
+ / `BOX_SIGNED_TOKEN`, and `UNOTEST_BOX_URL`, `UNOTEST_GROUNDER_MODE`,
169
+ `UNOTEST_GROUNDER_REMOTE_URL` listed among the runner-config keys so they
170
+ file under the Runner section of `unotest/.env`.
171
+
172
+ `@unotest/grounder-client`: `GrounderHttpClientOptions.token` may be a
173
+ function yielding the current bearer; `GrounderRemoteError` carries the
174
+ grounder's `retryAfterSeconds`, and the error codes gain
175
+ `revocation-list-unavailable`.
176
+
3
177
  ## [0.33.0] - 2026-09-06
4
178
 
5
179
  ## [0.32.0] - 2026-09-05
package/dist/index.d.ts CHANGED
@@ -138,8 +138,20 @@ interface ResolveResponse extends GroundLines {
138
138
  }
139
139
  interface ErrorResponse {
140
140
  error: string;
141
- /** Machine code for control flow. `no-index` → client must re-`/index`. */
142
- code?: "no-index" | "unauthorized" | "bad-request" | "internal";
141
+ /** Machine code for control flow. `no-index` → client must re-`/index`;
142
+ * `unauthorized` → the bearer was refused, whatever the reason (one
143
+ * body for all of them, so nothing here is an oracle) — in remote
144
+ * mode that is a cloud-signed picker token to re-mint;
145
+ * `revocation-list-unavailable` → the server cannot judge any token
146
+ * yet, retry after `retryAfterSeconds`;
147
+ * `grounder-budget-exhausted` → the account's estimated spend for the
148
+ * month has reached its ceiling. Answered with **402**, never 401: the
149
+ * token is perfectly good, and re-minting it changes nothing. Carries no
150
+ * amount and nothing about any other account — the number is not one we
151
+ * own, and the customer would check it against their provider's bill. */
152
+ code?: "no-index" | "unauthorized" | "revocation-list-unavailable" | "grounder-budget-exhausted" | "bad-request" | "internal";
153
+ /** With `revocation-list-unavailable`: how long to wait. */
154
+ retryAfterSeconds?: number;
143
155
  }
144
156
  declare const ROUTES: {
145
157
  readonly index: "/index";
@@ -151,11 +163,17 @@ declare const ROUTES: {
151
163
  declare class GrounderRemoteError extends Error {
152
164
  readonly status: number;
153
165
  readonly code: ErrorResponse["code"] | undefined;
154
- constructor(message: string, status: number, code: ErrorResponse["code"] | undefined);
166
+ /** Only with `revocation-list-unavailable`. */
167
+ readonly retryAfterSeconds?: number | undefined;
168
+ constructor(message: string, status: number, code: ErrorResponse["code"] | undefined,
169
+ /** Only with `revocation-list-unavailable`. */
170
+ retryAfterSeconds?: number | undefined);
155
171
  }
156
172
  interface GrounderHttpClientOptions {
157
173
  baseUrl: string;
158
- token: string;
174
+ /** The bearer, or a function that yields the CURRENT one — for a
175
+ * long-lived client whose token is re-minted while it runs. */
176
+ token: string | (() => string);
159
177
  /** Override the generated session id (tests). */
160
178
  sessionId?: string;
161
179
  /** Injected fetch (tests). Defaults to global fetch. */
package/dist/index.js CHANGED
@@ -47,14 +47,16 @@ var ROUTES = {
47
47
  // src/http-client.ts
48
48
  import { randomUUID } from "crypto";
49
49
  var GrounderRemoteError = class extends Error {
50
- constructor(message, status, code) {
50
+ constructor(message, status, code, retryAfterSeconds) {
51
51
  super(message);
52
52
  this.status = status;
53
53
  this.code = code;
54
+ this.retryAfterSeconds = retryAfterSeconds;
54
55
  this.name = "GrounderRemoteError";
55
56
  }
56
57
  status;
57
58
  code;
59
+ retryAfterSeconds;
58
60
  };
59
61
  var GrounderHttpClient = class {
60
62
  sessionId;
@@ -63,7 +65,7 @@ var GrounderHttpClient = class {
63
65
  fetchImpl;
64
66
  constructor(opts) {
65
67
  this.baseUrl = opts.baseUrl.replace(/\/+$/, "");
66
- this.token = opts.token;
68
+ this.token = typeof opts.token === "function" ? opts.token : () => opts.token;
67
69
  this.sessionId = opts.sessionId ?? randomUUID();
68
70
  this.fetchImpl = opts.fetchImpl ?? fetch;
69
71
  }
@@ -83,7 +85,7 @@ var GrounderHttpClient = class {
83
85
  method: "POST",
84
86
  headers: {
85
87
  "content-type": "application/json",
86
- authorization: `Bearer ${this.token}`,
88
+ authorization: `Bearer ${this.token()}`,
87
89
  [SESSION_HEADER]: this.sessionId
88
90
  },
89
91
  body: JSON.stringify(body)
@@ -97,7 +99,12 @@ var GrounderHttpClient = class {
97
99
  }
98
100
  const json = await res.json().catch(() => ({}));
99
101
  if (!res.ok) {
100
- throw new GrounderRemoteError(json.error ?? `${path} \u2192 ${res.status}`, res.status, json.code);
102
+ throw new GrounderRemoteError(
103
+ json.error ?? `${path} \u2192 ${res.status}`,
104
+ res.status,
105
+ json.code,
106
+ typeof json.retryAfterSeconds === "number" ? json.retryAfterSeconds : void 0
107
+ );
101
108
  }
102
109
  return json;
103
110
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unotest/grounder-client",
3
- "version": "0.33.0",
3
+ "version": "0.35.0",
4
4
  "description": "Client contract for the unotest semantic UI grounder: typed intent/resolution types, the injectable DOM walker (RawCapture), the content hash, and the HTTP client + wire protocol of the grounding service. The grounding engine itself is a private service; this package is everything a consumer needs to capture a page and talk to it.",
5
5
  "license": "MIT",
6
6
  "publishConfig": {