@unotest/grounder-client 0.33.0 → 0.35.1

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,181 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.35.1] - 2026-09-16
4
+
5
+ ## [0.35.0] - 2026-09-15
6
+
7
+ ### Minor Changes
8
+
9
+ - 86da16e: A remote grounder can now say "this account has spent its month", and a client
10
+ can tell that apart from a bad token.
11
+
12
+ A grounder running on an external model provider meters what each account
13
+ costs and stops at a ceiling. At the ceiling the two routes that call a model —
14
+ indexing a page and resolving an intent — answer HTTP **402** with
15
+ `grounder-budget-exhausted`. Money, not permission: the token is perfectly
16
+ good, nothing is re-minted over this, and a client that treats it as an auth
17
+ failure would retry forever. The body carries no amount and nothing about any
18
+ other account; the figure is an estimate of a bill we do not issue, and a
19
+ customer would rightly check it against the provider's own.
20
+
21
+ What a ceiling stops is **writing** tests, not running them, and every text
22
+ about it says so: only the MCP server asks the grounder — `ground_element`,
23
+ and an intent locator inside `explore_step` / `find_element`. A scenario
24
+ carries the locator it was written with, so suites, their runs on a box and
25
+ the viewer are untouched by a cap.
26
+
27
+ `ErrorResponse.code` names it, and gains `revocation-list-unavailable` at the
28
+ same time: a grounder has been answering with that code since signed tokens
29
+ arrived, and the union never learned about it — a client written from the type
30
+ could not discriminate a refusal it was already receiving. `retryAfterSeconds`
31
+ joins the shape for it, matching the `Retry-After` header that was already
32
+ being sent.
33
+
34
+ Nothing changes for a grounder running on our own GPU: nothing is metered
35
+ there, so nothing can be refused for money.
36
+
37
+ - 7ffa4ae: `box picker on|off`: the Picker, switched on a box you already have
38
+
39
+ The Picker is an hourly surcharge on top of the box, and until now it could
40
+ only be chosen when the box was rented — with `box create --picker`, a flag
41
+ most people met for the first time in `--help`.
42
+
43
+ - **`unotest-web box picker on|off [name]`** switches it on a live box, with
44
+ no re-creating and no re-pushing. `on` mints the box's tokens the way
45
+ `box access` does and writes the Picker's three keys —
46
+ `UNOTEST_GROUNDER_MODE=remote` and `UNOTEST_GROUNDER_REMOTE_URL` into
47
+ `unotest/.env`, `UNOTEST_GROUNDER_REMOTE_TOKEN` into `unotest/.secrets`.
48
+ `off` clears the three again, but only when they name that box's grounder:
49
+ a project pointing at a grounder of its own, or at another box's, keeps it
50
+ and is told so. Either way the charge follows at the next hourly tick,
51
+ because a started hour is charged at the rate it started with.
52
+ - **`box create` asks** about the Picker on a terminal when `--picker` was not
53
+ given, quoting the hourly surcharge from the cloud's own price list rather
54
+ than a number kept in the CLI. `--yes`, `--json` and a run with no terminal
55
+ answer no without asking.
56
+ - **`box status`** says whether the Picker is on.
57
+ - **`run_test` offers it once.** A project that has a box but grounds intents
58
+ nowhere remote gets `pickerSuggestion` (`{state: "no-picker", next, why}`)
59
+ after the first run that passes — exactly once per MCP server session, on
60
+ the same terms as `boxSuggestion`, and never both: with no box there is
61
+ nothing to put a Picker on.
62
+ - **A refusal for money reads as one.** A remote grounder answering
63
+ `grounder-budget-exhausted` (HTTP 402) means the account's estimated spend
64
+ for the month has reached its ceiling, not that the token went bad: nothing
65
+ is re-minted over it, and the CLI and MCP replies name both ways out — top
66
+ up the balance, or `box picker off` to stop the Picker and ground locally
67
+ again. No amount is quoted: the figure estimates a bill we do not issue.
68
+
69
+ ## [0.34.0] - 2026-09-07
70
+
71
+ ### Patch Changes
72
+
73
+ - c6b91bb: From `init` to a paid box without copying a token: `login`, `box create`,
74
+ and the tokens that keep themselves fresh.
75
+
76
+ Three new commands sign this machine in to unotest cloud — the account
77
+ that rents boxes — and rent one:
78
+
79
+ - `login` runs the device flow (RFC 8628): it prints a link and a code,
80
+ opens the browser when there is one, and waits for the approval. The
81
+ key is kept in `~/.config/unotest/credentials.json` (mode 0600,
82
+ `XDG_CONFIG_HOME` honoured) and is never printed, not even with
83
+ `--json`. `UNOTEST_CLOUD_TOKEN` in the environment wins over the file,
84
+ which is how a CI job signs in. `logout` revokes the key on the server
85
+ when the cloud can be reached and removes it locally always; `whoami`
86
+ says who, where the key came from, and the balance (exit 77 when not
87
+ signed in).
88
+ - `box create [name] [--size s|m|l] [--topup <eur>]` creates a box named
89
+ after the project (its package name, as a slug), with the environments
90
+ the project runs locally (`unotest/.env` and every `unotest/.env.<name>`,
91
+ their `APP_BASE_URL` or the config's `baseUrl` as the target). When the
92
+ balance will not cover the first hour it prints a Stripe Checkout link,
93
+ opens it when it can, and waits for the payment. Then it waits for the
94
+ box, mints its cloud-signed push and read tokens, writes
95
+ `UNOTEST_BOX_URL` to `unotest/.env` and `UNOTEST_BOX_TOKEN` +
96
+ `UNOTEST_BOX_READ_TOKEN` to `unotest/.secrets` (through the same
97
+ structure-preserving writer the viewer's Variables panel uses, with
98
+ `.gitignore` guarded the way `init` guards it), pushes the suite —
99
+ retrying with backoff while a fresh box has no revocation list yet —
100
+ and prints the viewer's address. Exit codes for an agent driving it
101
+ without a terminal: 75 the payment was not confirmed in time (nothing
102
+ created, nothing charged), 76 the terms of service are not accepted
103
+ (a person must, in a browser), 77 not signed in. No prompt is ever
104
+ shown without a TTY.
105
+ `--picker` rents the box with the Picker; `UNOTEST_GROUNDER_MODE=remote`
106
+ and `UNOTEST_GROUNDER_REMOTE_URL` then go to `unotest/.env` and
107
+ `UNOTEST_GROUNDER_REMOTE_TOKEN` to `unotest/.secrets` — the names the MCP
108
+ server reads — so intents ground on the box with no further setup. A
109
+ box without the Picker leaves those keys alone. The last line of
110
+ `create` is the next step: the complete `bundle push --run --env …
111
+ --collection …` when the project has exactly one environment and one
112
+ collection, the viewer's address plus the command with placeholders
113
+ otherwise.
114
+ - `box access [name]` re-mints the pair and rewrites the two files;
115
+ `box status` and `box destroy` (asks on a terminal, `--yes` elsewhere)
116
+ are thin wrappers. The name defaults to the box `UNOTEST_BOX_URL`
117
+ points at (`acme.box.unotest.com` → `acme`).
118
+
119
+ Cloud-signed box tokens live thirty days. When a box answers that one
120
+ has expired, `bundle push` and the `box …` read commands re-mint the
121
+ pair through the cloud and retry once, provided a cloud login is at
122
+ hand; without one they say to run `login && box access`. The box a
123
+ re-mint is for is the one the project points at, named from its address
124
+ or, when the address spells no name (a stand behind an IP), by the same
125
+ default `create` used — so a lab box re-mints like any other. A `--box`
126
+ naming somewhere else is said to be somewhere else rather than blamed on
127
+ a missing login. A box that has
128
+ not yet received its revocation list is told apart from a real refusal
129
+ and retried; a token the box calls revoked is never re-minted quietly.
130
+ `bundle push` now also reads the push token from `unotest/.secrets`,
131
+ where `box create` puts it (then `unotest/.env`, for suites set up by
132
+ hand), so nothing has to be exported after the setup.
133
+
134
+ The MCP server knows the state too. While the project points at no box,
135
+ `box_envs`, `box_runs`, `box_run` and the other box tools answer
136
+ `{state: "no-box", next, why}` instead of an error, and `run_test`
137
+ carries the same `boxSuggestion` exactly once per server — after the
138
+ first local run that passes — so an agent offers the box at the moment
139
+ it is worth something and never nags. The box tools re-mint an expired
140
+ cloud-signed read token the way the CLI does, through the composition
141
+ root, and `unotest/.secrets` is read on every call: a pair written by
142
+ `box create` while the server runs is used by the next call. On a
143
+ Picker box, `ground_element` (and intent locators) whose picker token
144
+ the grounder refuses re-mint it once through the cloud and hand the new
145
+ token to the running client; a refusal that cannot be mended answers
146
+ `{state: "picker-token-refused", next: "npx @unotest/web box access",
147
+ why}`, and a grounder that has not read its revocation list yet answers
148
+ `{state: "retry-shortly", retryAfterSeconds}`. Box tokens are checked
149
+ before the network: cloud-signed ones by their whole shape (`unos_…`), a
150
+ push token in the read variable (and the reverse) is named as the wrong
151
+ kind, anything else as not a token.
152
+
153
+ A renewal the cloud itself refuses is its own answer, not the cloud's.
154
+ When a box or the grounder says a cloud-signed token is stale and the
155
+ `/access` call that would replace it is refused in turn — no such box,
156
+ the box not ready, too many live pairs, the cloud unreachable — every
157
+ surface says that the credentials could not be renewed and keeps the
158
+ cloud's refusal as the cause. The CLI prints one line and exits with the
159
+ code that refusal earns; the grounding tools answer
160
+ `{state: "picker-token-refused", next, why}` as before, so an intent is
161
+ never answered with a sentence about boxes.
162
+
163
+ `init` ends with one line saying how to run the suite on a box; the
164
+ "mint a read token in the guard" advice in the box commands' refusals
165
+ now points at `login` and `box access` instead. `UNOTEST_CLOUD_URL`
166
+ points the CLI at a stand or a fake cloud; nobody sets it otherwise.
167
+
168
+ `@unotest/protocol`: `replaceEnvVar` (an in-place value replacement the
169
+ new upsert builds on), `BOX_PUSH_TOKEN_PREFIX` / `BOX_SIGNED_TOKEN_PREFIX`
170
+ / `BOX_SIGNED_TOKEN`, and `UNOTEST_BOX_URL`, `UNOTEST_GROUNDER_MODE`,
171
+ `UNOTEST_GROUNDER_REMOTE_URL` listed among the runner-config keys so they
172
+ file under the Runner section of `unotest/.env`.
173
+
174
+ `@unotest/grounder-client`: `GrounderHttpClientOptions.token` may be a
175
+ function yielding the current bearer; `GrounderRemoteError` carries the
176
+ grounder's `retryAfterSeconds`, and the error codes gain
177
+ `revocation-list-unavailable`.
178
+
3
179
  ## [0.33.0] - 2026-09-06
4
180
 
5
181
  ## [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.1",
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": {