@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 +176 -0
- package/dist/index.d.ts +22 -4
- package/dist/index.js +11 -4
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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.
|
|
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": {
|