warpmetal 0.8.13 → 0.9.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/README.md +98 -4
- package/docs/agent-insights-cli.md +123 -0
- package/docs/agent-work-cli.md +72 -0
- package/docs/session-handoff-transport.md +68 -0
- package/package.json +5 -3
- package/skills/warpmetal/SKILL.md +54 -1
- package/skills/warpmetal/references/cli-reference.md +150 -2
- package/skills/warpmetal/references/runtime.md +19 -0
- package/src/account-gateway.js +217 -0
- package/src/account-session.js +209 -0
- package/src/agent-insights.js +638 -0
- package/src/agent-management.js +146 -0
- package/src/agent-work.js +800 -0
- package/src/api.js +10 -4
- package/src/cli.js +398 -11
- package/src/contracts/agent-manager-control-v1.schema.json +563 -0
- package/src/contracts/http.js +3 -0
- package/src/contracts/insights.js +133 -0
- package/src/contracts/manager.js +86 -0
- package/src/contracts/session-handoff.js +49 -0
- package/src/contracts/sources.json +12 -0
- package/src/contracts/work-handoff.js +73 -0
- package/src/contracts/work.js +310 -0
- package/src/customer-auth.js +329 -0
- package/src/installer.js +5 -5
- package/src/models.js +85 -0
- package/src/runtime.js +76 -3
- package/src/session-handoff.js +915 -0
- package/src/state.js +20 -4
|
@@ -3,12 +3,14 @@
|
|
|
3
3
|
## Contents
|
|
4
4
|
|
|
5
5
|
- Discovery
|
|
6
|
+
- Account authentication
|
|
6
7
|
- Purchase and provisioning
|
|
7
8
|
- SSH identities
|
|
8
9
|
- Renewal and notifications
|
|
9
10
|
- Server management
|
|
10
11
|
- Agent Runtime and sandboxes
|
|
11
12
|
- Per-agent access
|
|
13
|
+
- Retained Work and Insights (source candidate)
|
|
12
14
|
- Skill installation and state
|
|
13
15
|
- Exit codes
|
|
14
16
|
|
|
@@ -17,11 +19,106 @@
|
|
|
17
19
|
```sh
|
|
18
20
|
warpmetal health --json
|
|
19
21
|
warpmetal catalog [--plan <planId>] --json
|
|
22
|
+
warpmetal models [--provider <id>] [--auth-mode <api_key|chatgpt_subscription>] [--json]
|
|
20
23
|
```
|
|
21
24
|
|
|
25
|
+
## Public model catalog
|
|
26
|
+
|
|
27
|
+
`warpmetal models` performs a read-only `GET /agent-team-model-catalog` request.
|
|
28
|
+
It preserves the published snapshot metadata, source provenance, freshness state
|
|
29
|
+
and authentication modes. `--provider` and `--auth-mode` filter entries and
|
|
30
|
+
remove recommendations whose published entry is no longer present. The command
|
|
31
|
+
does not infer eligibility or contact a model provider. `catalog` remains the
|
|
32
|
+
VPS plan catalog.
|
|
33
|
+
|
|
22
34
|
`health` exits with code 3 when the service responds but purchasing is paused.
|
|
23
35
|
The catalog remains useful for read-only discovery.
|
|
24
36
|
|
|
37
|
+
## Account authentication
|
|
38
|
+
|
|
39
|
+
```sh
|
|
40
|
+
warpmetal login [--no-browser] [--read-only] --json
|
|
41
|
+
warpmetal auth status --json
|
|
42
|
+
warpmetal logout --json
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
`login` uses the public `warpmetal-customer-cli` device client. It requests
|
|
46
|
+
`cli:read cli:write` by default or only `cli:read` with `--read-only`, prints the
|
|
47
|
+
bounded verification page and user code to stderr, and opens that page unless
|
|
48
|
+
`--no-browser` is present. Polling follows the server interval and treats
|
|
49
|
+
pending, slow-down, denial, expiry and cancellation as distinct outcomes. JSON
|
|
50
|
+
stdout contains only safe account identity and scope fields; access, refresh
|
|
51
|
+
and device credentials are never output.
|
|
52
|
+
|
|
53
|
+
The rotating CLI refresh family is independent of browser sessions, legacy
|
|
54
|
+
owner tokens and SSH credentials. It is stored in an owner-only, atomic session
|
|
55
|
+
file under the private WarpMetal state directory. Concurrent commands serialize
|
|
56
|
+
refresh. If refresh completion is ambiguous, the saved session is cleared and a
|
|
57
|
+
new login is required rather than replaying the old credential.
|
|
58
|
+
|
|
59
|
+
Sessions are bound to the exact Identity and account origins. Defaults are
|
|
60
|
+
`https://identity.warpmetal.com` and `https://warpmetal.com`; explicit
|
|
61
|
+
`--identity-url` and `--account-url` values must be HTTPS origins (localhost may
|
|
62
|
+
use HTTP). Redirects are refused, and credentials for one origin pair are never
|
|
63
|
+
sent to another. `logout` attempts remote family revocation and always clears
|
|
64
|
+
the matching local session, reporting whether revocation was confirmed.
|
|
65
|
+
|
|
66
|
+
Account login does not grant server SSH access. `warpmetal server login` remains
|
|
67
|
+
the separate, server-specific SSH challenge command.
|
|
68
|
+
|
|
69
|
+
## Account orders and devices
|
|
70
|
+
|
|
71
|
+
```sh
|
|
72
|
+
warpmetal account orders [--task <taskId>] --json
|
|
73
|
+
warpmetal account devices [--server <serverId>] --json
|
|
74
|
+
warpmetal order prepare --account --without-agent-boxes \
|
|
75
|
+
--plan <planId> --hostname <name> --os '<exact catalog OS>' \
|
|
76
|
+
--generate-ssh-key [--idempotency-key <key>] --json
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Reads require `cli:read`; unpaid preparation requires `cli:write`. Only these
|
|
80
|
+
allowlisted operations are granted. Account scope never authorizes a payment,
|
|
81
|
+
renewal, server deletion, credential change or runtime execution. Collections
|
|
82
|
+
return the server's bounded page and `nextCursor`; order history is distinct from
|
|
83
|
+
active devices and can include released or cancelled orders.
|
|
84
|
+
|
|
85
|
+
Account mode signs in before checking order fields. It derives contact and
|
|
86
|
+
ownership from the signed-in principal, rejects `--email`, uses the private
|
|
87
|
+
account gateway, and records `ownershipMode: account` plus principal/origin in
|
|
88
|
+
private local state without an owner token. Use the same idempotency key and
|
|
89
|
+
unchanged payload to retry an ambiguous preparation. Do not retry a charge.
|
|
90
|
+
Existing owner-token checkout commands do not become account payment commands.
|
|
91
|
+
|
|
92
|
+
Boxes/team are selected by default for new account preparation: provide
|
|
93
|
+
`--runtime-file` with your chosen team, `--without-team` for one persistent small
|
|
94
|
+
`main` box, or `--without-agent-boxes` for no runtime. Explicit runtime files keep
|
|
95
|
+
their box/team choices, and older scripts without `--account` retain their
|
|
96
|
+
defaults. Server model/runtime readiness gates remain binding.
|
|
97
|
+
|
|
98
|
+
The optional `teams` sibling of `sandboxes` uses the deployed team-v1 schema:
|
|
99
|
+
|
|
100
|
+
```json
|
|
101
|
+
{
|
|
102
|
+
"sandboxes": [{"name":"manager","size":"small"},{"name":"builder","size":"small"}],
|
|
103
|
+
"teams": {"version":1,"teams":[{
|
|
104
|
+
"name":"Build team",
|
|
105
|
+
"members":[
|
|
106
|
+
{"sandboxName":"manager","role":"manager","providerId":"openai"},
|
|
107
|
+
{"sandboxName":"builder","role":"worker","providerId":"anthropic"}
|
|
108
|
+
],
|
|
109
|
+
"links":[{"from":"manager","to":"builder","capability":"task.delegate"}],
|
|
110
|
+
"startPolicy":"manual"
|
|
111
|
+
}]}
|
|
112
|
+
}
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Provider names in this example are preferences, not qualification proof. Optional
|
|
116
|
+
`modelId` and `authMode` must reflect the selected published entry; omitted values
|
|
117
|
+
mean choose later, never automatic credentials. A team has one manager and 2–8
|
|
118
|
+
distinct declared boxes. Credentials, unknown fields, automatic start and
|
|
119
|
+
cross-team links are rejected. Team/setup files are for order preparation;
|
|
120
|
+
`sandbox create --file` accepts a sandbox batch only.
|
|
121
|
+
|
|
25
122
|
## Purchase and provisioning
|
|
26
123
|
|
|
27
124
|
```sh
|
|
@@ -246,8 +343,8 @@ warpmetal sandbox list --server <serverId> --json
|
|
|
246
343
|
warpmetal sandbox get --server <serverId> --sandbox <sandboxId> [--wait] --json
|
|
247
344
|
warpmetal sandbox action \
|
|
248
345
|
--server <serverId> --sandbox <sandboxId> \
|
|
249
|
-
--action <start|stop|restart|make_persistent|refresh_image> --confirm <same-action> \
|
|
250
|
-
[--wait] --json
|
|
346
|
+
--action <start|stop|restart|make_persistent|refresh_image|patch_image> --confirm <same-action> \
|
|
347
|
+
[--image-digest <image@sha256:digest>] [--wait] --json
|
|
251
348
|
warpmetal sandbox delete \
|
|
252
349
|
--server <serverId> --sandbox <sandboxId> --confirm DELETE [--wait] --json
|
|
253
350
|
|
|
@@ -315,6 +412,57 @@ Every selection must reference a sandbox name in the same file. Unknown fields,
|
|
|
315
412
|
including URL, shell, command, argv, environment, or artifact overrides, are
|
|
316
413
|
rejected before an API request.
|
|
317
414
|
|
|
415
|
+
## Retained Work and Insights (source candidate)
|
|
416
|
+
|
|
417
|
+
Discover command availability with `warpmetal --help`; these routes require
|
|
418
|
+
the corresponding candidate control plane, Runtime and Sandbox.
|
|
419
|
+
|
|
420
|
+
```sh
|
|
421
|
+
warpmetal work list|sources|policy SERVER BOX --json
|
|
422
|
+
warpmetal work show|content|targets SERVER BOX WORK --json
|
|
423
|
+
warpmetal work create SERVER BOX --file request.json --json
|
|
424
|
+
warpmetal work update|enable|checkpoint|continue|restore|handoff SERVER BOX WORK --file request.json --json
|
|
425
|
+
warpmetal work status SERVER BOX WORK --kind checkpoint|continue|restore|handoff \
|
|
426
|
+
(--request REQUEST | --operation OPERATION) --json
|
|
427
|
+
warpmetal work open SERVER BOX WORK --json
|
|
428
|
+
warpmetal insights summary SERVER --json
|
|
429
|
+
warpmetal insights status|list SERVER BOX --json
|
|
430
|
+
warpmetal insights enable|disable SERVER BOX --file request.json --json
|
|
431
|
+
warpmetal insights show SERVER BOX FINDING --json
|
|
432
|
+
warpmetal insights acknowledge|snooze|dismiss SERVER BOX FINDING --file request.json --json
|
|
433
|
+
warpmetal insights open SERVER BOX FINDING [--takeover OPERATION] --json
|
|
434
|
+
warpmetal insights manager settings SERVER BOX [--file request.json] --json
|
|
435
|
+
warpmetal insights manager activity SERVER BOX [--finding FINDING] --json
|
|
436
|
+
warpmetal insights manager run SERVER BOX RUN --json
|
|
437
|
+
warpmetal insights manager target SERVER BOX FINDING --json
|
|
438
|
+
warpmetal insights manager recheck SERVER BOX FINDING --file request.json --json
|
|
439
|
+
warpmetal insights manager status SERVER BOX FINDING --request REQUEST --json
|
|
440
|
+
warpmetal insights takeover list SERVER BOX FINDING --json
|
|
441
|
+
warpmetal insights takeover SERVER BOX FINDING --file request.json --json
|
|
442
|
+
warpmetal insights takeover status SERVER BOX FINDING --operation OPERATION --json
|
|
443
|
+
warpmetal insights takeover resume SERVER BOX FINDING --operation OPERATION --file request.json --json
|
|
444
|
+
warpmetal insights review SERVER BOX RUN --json
|
|
445
|
+
```
|
|
446
|
+
|
|
447
|
+
Use the server's existing scoped owner/SSH credential. The account session is
|
|
448
|
+
not a fallback. `--file` mutations validate a closed request with an explicit
|
|
449
|
+
request ID and revision fences before HTTP. Saved same-intent retries are
|
|
450
|
+
GET-only. Preserve the original request after a lost reply; never generate a
|
|
451
|
+
new request to bypass an uncertain outcome. Pending operations exit 8, terminal
|
|
452
|
+
failures/conflicts exit 5. Accepted Continue/handoff means task admission, not
|
|
453
|
+
completion. Check task state separately.
|
|
454
|
+
|
|
455
|
+
Only `work content` deliberately prints private content. Metadata and receipts
|
|
456
|
+
are closed projections. Do not log private request files or content output.
|
|
457
|
+
|
|
458
|
+
`open`/`review --json` returns a fresh descriptor without SSH. For deliberate
|
|
459
|
+
human interactive access, omit `--json` and supply absolute
|
|
460
|
+
`--connection-file` and `--identity` paths for the exact sandbox grant. Local
|
|
461
|
+
OpenCode 2.0.14 connects through the fixed pinned bridge to the exact existing
|
|
462
|
+
session. The command creates no session and sends no initial prompt. Manager
|
|
463
|
+
review cannot mutate the worker. Takeover checks the current protected hold;
|
|
464
|
+
Resume requires its saved operation, predecessor and revision tuple.
|
|
465
|
+
|
|
318
466
|
## Per-agent access
|
|
319
467
|
|
|
320
468
|
```sh
|
|
@@ -183,6 +183,25 @@ workspace, sandbox lifetime, and original start time. `--wait` requires both
|
|
|
183
183
|
the observed digest and generation to match the accepted target. A change to
|
|
184
184
|
the global production image does not refresh existing sandboxes implicitly.
|
|
185
185
|
|
|
186
|
+
To replace one sandbox's image while keeping its incarnation generation, use
|
|
187
|
+
an explicitly approved digest-pinned image reference:
|
|
188
|
+
|
|
189
|
+
```sh
|
|
190
|
+
warpmetal sandbox action \
|
|
191
|
+
--server <serverId> \
|
|
192
|
+
--sandbox <sandboxId> \
|
|
193
|
+
--action patch_image \
|
|
194
|
+
--confirm patch_image \
|
|
195
|
+
--image-digest <registry/image@sha256:64-lowercase-hex-digest> \
|
|
196
|
+
--wait \
|
|
197
|
+
--json
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
This also briefly disconnects active sessions. The reference must contain an
|
|
201
|
+
immutable SHA-256 digest; tags alone are refused. `--image-digest` is supported
|
|
202
|
+
only for `patch_image`. Its wait requires the exact requested observed digest
|
|
203
|
+
and the accepted generation, so a still-running old container is not completion.
|
|
204
|
+
|
|
186
205
|
Manual deletion is irreversible:
|
|
187
206
|
|
|
188
207
|
```sh
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { CliError } from "./errors.js";
|
|
2
|
+
import { AccountSessionStore } from "./account-session.js";
|
|
3
|
+
import {
|
|
4
|
+
CustomerAuthClient,
|
|
5
|
+
DEFAULT_ACCOUNT_ORIGIN,
|
|
6
|
+
DEFAULT_IDENTITY_ORIGIN,
|
|
7
|
+
validateOrigin,
|
|
8
|
+
} from "./customer-auth.js";
|
|
9
|
+
import { USER_AGENT } from "./version.js";
|
|
10
|
+
|
|
11
|
+
function safeGatewayCode(data) {
|
|
12
|
+
const value = data?.code ?? data?.error?.code;
|
|
13
|
+
return typeof value === "string" && /^[a-z][a-z0-9_]{0,63}$/.test(value)
|
|
14
|
+
? value
|
|
15
|
+
: "unavailable";
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function allowedAccountRoute(method, path) {
|
|
19
|
+
if (typeof path !== "string" || !path.startsWith("/")) return false;
|
|
20
|
+
if (method === "POST") return path === "/account/cli/orders";
|
|
21
|
+
if (method !== "GET") return false;
|
|
22
|
+
return (
|
|
23
|
+
[
|
|
24
|
+
"/account/cli/whoami",
|
|
25
|
+
"/account/cli/devices",
|
|
26
|
+
"/account/cli/orders",
|
|
27
|
+
].includes(path) ||
|
|
28
|
+
/^\/account\/cli\/(?:devices|orders)\/[^/?#]+$/.test(path)
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class AccountGatewayClient {
|
|
33
|
+
constructor({
|
|
34
|
+
accountOrigin,
|
|
35
|
+
accessToken,
|
|
36
|
+
scopes = [],
|
|
37
|
+
fetchImpl = globalThis.fetch,
|
|
38
|
+
timeoutMs = 30_000,
|
|
39
|
+
} = {}) {
|
|
40
|
+
this.accountOrigin = validateOrigin(
|
|
41
|
+
accountOrigin || DEFAULT_ACCOUNT_ORIGIN,
|
|
42
|
+
"WarpMetal account URL",
|
|
43
|
+
);
|
|
44
|
+
if (typeof accessToken !== "string" || accessToken.length === 0) {
|
|
45
|
+
throw new CliError("A WarpMetal account access token is required.", {
|
|
46
|
+
exitCode: 4,
|
|
47
|
+
code: "invalid_token",
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
this.accessToken = accessToken;
|
|
51
|
+
this.scopes = scopes;
|
|
52
|
+
this.fetchImpl = fetchImpl;
|
|
53
|
+
this.timeoutMs = timeoutMs;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async request(method, path, { body, idempotencyKey } = {}) {
|
|
57
|
+
if (!allowedAccountRoute(method, path)) {
|
|
58
|
+
throw new CliError("The requested account gateway route is not allowed.", {
|
|
59
|
+
exitCode: 2,
|
|
60
|
+
code: "account_route_not_allowed",
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
const url = new URL(path, `${this.accountOrigin}/`);
|
|
64
|
+
if (url.origin !== this.accountOrigin || `${url.pathname}${url.search}${url.hash}` !== path) {
|
|
65
|
+
throw new CliError("The requested account gateway route is not allowed.", {
|
|
66
|
+
exitCode: 2,
|
|
67
|
+
code: "account_route_not_allowed",
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
const headers = {
|
|
71
|
+
Accept: "application/json",
|
|
72
|
+
Authorization: `Bearer ${this.accessToken}`,
|
|
73
|
+
"User-Agent": USER_AGENT,
|
|
74
|
+
};
|
|
75
|
+
if (body !== undefined) headers["Content-Type"] = "application/json";
|
|
76
|
+
if (idempotencyKey) headers["Idempotency-Key"] = idempotencyKey;
|
|
77
|
+
let response;
|
|
78
|
+
try {
|
|
79
|
+
response = await this.fetchImpl(url, {
|
|
80
|
+
method,
|
|
81
|
+
redirect: "manual",
|
|
82
|
+
headers,
|
|
83
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
84
|
+
signal: AbortSignal.timeout(this.timeoutMs),
|
|
85
|
+
});
|
|
86
|
+
} catch {
|
|
87
|
+
throw new CliError(`Could not reach ${url.origin}.`, {
|
|
88
|
+
exitCode: 3,
|
|
89
|
+
code: "unavailable",
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
if (response.status >= 300 && response.status < 400) {
|
|
93
|
+
throw new CliError("The account gateway refused an HTTP redirect.", {
|
|
94
|
+
exitCode: 3,
|
|
95
|
+
code: "redirect_refused",
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
let data;
|
|
99
|
+
try {
|
|
100
|
+
const text = await response.text();
|
|
101
|
+
data = text ? JSON.parse(text) : {};
|
|
102
|
+
} catch {
|
|
103
|
+
throw new CliError("The account gateway returned an invalid response.", {
|
|
104
|
+
exitCode: 3,
|
|
105
|
+
code: "invalid_response",
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
if (!response.ok) {
|
|
109
|
+
const code = safeGatewayCode(data);
|
|
110
|
+
throw new CliError(`Account request failed: ${code} (HTTP ${response.status}).`, {
|
|
111
|
+
exitCode: response.status === 401 || response.status === 403 ? 4 : 3,
|
|
112
|
+
code,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
return data;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
whoami() {
|
|
119
|
+
return this.request("GET", "/account/cli/whoami");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
orders() {
|
|
123
|
+
return this.request("GET", "/account/cli/orders");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
order(id) {
|
|
127
|
+
return this.request("GET", `/account/cli/orders/${encodeURIComponent(id)}`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
devices() {
|
|
131
|
+
return this.request("GET", "/account/cli/devices");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
device(id) {
|
|
135
|
+
return this.request("GET", `/account/cli/devices/${encodeURIComponent(id)}`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
prepareOrder(body, idempotencyKey) {
|
|
139
|
+
if (!this.scopes.includes("cli:write")) {
|
|
140
|
+
throw new CliError("This account session does not include cli:write.", {
|
|
141
|
+
exitCode: 4,
|
|
142
|
+
code: "insufficient_scope",
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
return this.request("POST", "/account/cli/orders", { body, idempotencyKey });
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function accountSessionContext({
|
|
150
|
+
stateDirectory,
|
|
151
|
+
identityOrigin = DEFAULT_IDENTITY_ORIGIN,
|
|
152
|
+
accountOrigin = DEFAULT_ACCOUNT_ORIGIN,
|
|
153
|
+
fetchImpl = globalThis.fetch,
|
|
154
|
+
} = {}) {
|
|
155
|
+
const checkedIdentity = validateOrigin(identityOrigin, "WarpMetal Identity URL");
|
|
156
|
+
const checkedAccount = validateOrigin(accountOrigin, "WarpMetal account URL");
|
|
157
|
+
return {
|
|
158
|
+
identityOrigin: checkedIdentity,
|
|
159
|
+
accountOrigin: checkedAccount,
|
|
160
|
+
auth: new CustomerAuthClient({
|
|
161
|
+
identityOrigin: checkedIdentity,
|
|
162
|
+
accountOrigin: checkedAccount,
|
|
163
|
+
fetchImpl,
|
|
164
|
+
}),
|
|
165
|
+
store: new AccountSessionStore(stateDirectory, {
|
|
166
|
+
identityOrigin: checkedIdentity,
|
|
167
|
+
accountOrigin: checkedAccount,
|
|
168
|
+
}),
|
|
169
|
+
fetchImpl,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export async function authenticatedAccountClient(options) {
|
|
174
|
+
const context = accountSessionContext(options);
|
|
175
|
+
return context.store.withLock(async () => {
|
|
176
|
+
let session = await context.store.read();
|
|
177
|
+
if (!session) return null;
|
|
178
|
+
if (Date.parse(session.accessExpiresAt) <= Date.now() + 30_000) {
|
|
179
|
+
let refreshed;
|
|
180
|
+
try {
|
|
181
|
+
refreshed = await context.auth.refresh(
|
|
182
|
+
session.refreshToken,
|
|
183
|
+
session.scopes,
|
|
184
|
+
);
|
|
185
|
+
} catch (error) {
|
|
186
|
+
await context.store.clear();
|
|
187
|
+
if (error?.code === "unavailable") {
|
|
188
|
+
throw new CliError(
|
|
189
|
+
"Refresh completion is unknown, so the saved session was cleared. Run warpmetal login again.",
|
|
190
|
+
{ exitCode: 3, code: "refresh_ambiguous" },
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
throw error;
|
|
194
|
+
}
|
|
195
|
+
try {
|
|
196
|
+
session = { ...session, ...refreshed, refreshedAt: new Date().toISOString() };
|
|
197
|
+
await context.store.write(session);
|
|
198
|
+
} catch (error) {
|
|
199
|
+
await context.store.clear();
|
|
200
|
+
throw new CliError(
|
|
201
|
+
"The rotated account session could not be saved, so local credentials were cleared. Run warpmetal login again.",
|
|
202
|
+
{ exitCode: 3, code: "refresh_ambiguous" },
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return {
|
|
207
|
+
client: new AccountGatewayClient({
|
|
208
|
+
accountOrigin: context.accountOrigin,
|
|
209
|
+
accessToken: session.accessToken,
|
|
210
|
+
scopes: session.scopes,
|
|
211
|
+
fetchImpl: context.fetchImpl,
|
|
212
|
+
}),
|
|
213
|
+
session,
|
|
214
|
+
context,
|
|
215
|
+
};
|
|
216
|
+
});
|
|
217
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
chmod,
|
|
4
|
+
mkdir,
|
|
5
|
+
open,
|
|
6
|
+
readFile,
|
|
7
|
+
rename,
|
|
8
|
+
stat,
|
|
9
|
+
unlink,
|
|
10
|
+
} from "node:fs/promises";
|
|
11
|
+
import { isAbsolute, join, resolve } from "node:path";
|
|
12
|
+
|
|
13
|
+
import { CliError } from "./errors.js";
|
|
14
|
+
|
|
15
|
+
const SESSION_VERSION = 1;
|
|
16
|
+
const LOCK_WAIT_MS = 45_000;
|
|
17
|
+
const LOCK_STALE_MS = 30_000;
|
|
18
|
+
|
|
19
|
+
function sessionKey(identityOrigin, accountOrigin) {
|
|
20
|
+
return createHash("sha256")
|
|
21
|
+
.update(`${identityOrigin}\n${accountOrigin}\n`, "utf8")
|
|
22
|
+
.digest("hex");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function privateDirectory(path) {
|
|
26
|
+
await mkdir(path, { recursive: true, mode: 0o700 });
|
|
27
|
+
if (process.platform !== "win32") await chmod(path, 0o700);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function atomicWrite(path, value) {
|
|
31
|
+
const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
32
|
+
let handle;
|
|
33
|
+
try {
|
|
34
|
+
handle = await open(temporary, "wx", 0o600);
|
|
35
|
+
await handle.writeFile(`${JSON.stringify(value, null, 2)}\n`, "utf8");
|
|
36
|
+
await handle.sync();
|
|
37
|
+
await handle.close();
|
|
38
|
+
handle = undefined;
|
|
39
|
+
await rename(temporary, path);
|
|
40
|
+
} catch (error) {
|
|
41
|
+
await handle?.close().catch(() => {});
|
|
42
|
+
await unlink(temporary).catch(() => {});
|
|
43
|
+
throw error;
|
|
44
|
+
}
|
|
45
|
+
if (process.platform !== "win32") await chmod(path, 0o600);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function validateSession(value, identityOrigin, accountOrigin) {
|
|
49
|
+
if (
|
|
50
|
+
!value ||
|
|
51
|
+
value.version !== SESSION_VERSION ||
|
|
52
|
+
value.identityOrigin !== identityOrigin ||
|
|
53
|
+
value.accountOrigin !== accountOrigin ||
|
|
54
|
+
typeof value.accessToken !== "string" ||
|
|
55
|
+
typeof value.refreshToken !== "string" ||
|
|
56
|
+
!value.refreshToken.startsWith("wmclr_") ||
|
|
57
|
+
typeof value.accessExpiresAt !== "string" ||
|
|
58
|
+
!Number.isFinite(Date.parse(value.accessExpiresAt)) ||
|
|
59
|
+
!Array.isArray(value.scopes) ||
|
|
60
|
+
value.scopes.length === 0 ||
|
|
61
|
+
new Set(value.scopes).size !== value.scopes.length ||
|
|
62
|
+
value.scopes.some((scope) => !["cli:read", "cli:write"].includes(scope)) ||
|
|
63
|
+
!value.scopes.includes("cli:read")
|
|
64
|
+
) {
|
|
65
|
+
throw new CliError(
|
|
66
|
+
"The saved WarpMetal account session is invalid. Run warpmetal logout, then login again.",
|
|
67
|
+
{ code: "invalid_session" },
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
return value;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function processExists(pid) {
|
|
74
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) return false;
|
|
75
|
+
try {
|
|
76
|
+
process.kill(pid, 0);
|
|
77
|
+
return true;
|
|
78
|
+
} catch (error) {
|
|
79
|
+
return error?.code === "EPERM";
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function pause(milliseconds) {
|
|
84
|
+
return new Promise((resolvePause) => setTimeout(resolvePause, milliseconds));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export class AccountSessionStore {
|
|
88
|
+
constructor(directory, { identityOrigin, accountOrigin } = {}) {
|
|
89
|
+
if (typeof directory !== "string" || directory.length === 0) {
|
|
90
|
+
throw new CliError("A WarpMetal state directory is required.", {
|
|
91
|
+
exitCode: 2,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
this.stateDirectory = isAbsolute(directory) ? directory : resolve(directory);
|
|
95
|
+
this.identityOrigin = identityOrigin;
|
|
96
|
+
this.accountOrigin = accountOrigin;
|
|
97
|
+
this.directory = join(this.stateDirectory, "account-sessions");
|
|
98
|
+
const key = sessionKey(identityOrigin, accountOrigin);
|
|
99
|
+
this.path = join(this.directory, `${key}.json`);
|
|
100
|
+
this.lockPath = join(this.directory, `${key}.lock`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async read() {
|
|
104
|
+
try {
|
|
105
|
+
const value = JSON.parse(await readFile(this.path, "utf8"));
|
|
106
|
+
return validateSession(value, this.identityOrigin, this.accountOrigin);
|
|
107
|
+
} catch (error) {
|
|
108
|
+
if (error?.code === "ENOENT") return null;
|
|
109
|
+
if (error instanceof CliError) throw error;
|
|
110
|
+
throw new CliError(
|
|
111
|
+
"The saved WarpMetal account session could not be read. Run warpmetal logout, then login again.",
|
|
112
|
+
{ code: "invalid_session" },
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async write(session) {
|
|
118
|
+
await privateDirectory(this.directory);
|
|
119
|
+
await atomicWrite(
|
|
120
|
+
this.path,
|
|
121
|
+
validateSession(
|
|
122
|
+
{
|
|
123
|
+
...session,
|
|
124
|
+
version: SESSION_VERSION,
|
|
125
|
+
identityOrigin: this.identityOrigin,
|
|
126
|
+
accountOrigin: this.accountOrigin,
|
|
127
|
+
},
|
|
128
|
+
this.identityOrigin,
|
|
129
|
+
this.accountOrigin,
|
|
130
|
+
),
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async clear() {
|
|
135
|
+
try {
|
|
136
|
+
await unlink(this.path);
|
|
137
|
+
return true;
|
|
138
|
+
} catch (error) {
|
|
139
|
+
if (error?.code === "ENOENT") return false;
|
|
140
|
+
throw error;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async #recoverStaleLock() {
|
|
145
|
+
let info;
|
|
146
|
+
try {
|
|
147
|
+
info = await stat(this.lockPath);
|
|
148
|
+
} catch (error) {
|
|
149
|
+
if (error?.code === "ENOENT") return;
|
|
150
|
+
throw error;
|
|
151
|
+
}
|
|
152
|
+
if (Date.now() - info.mtimeMs < LOCK_STALE_MS) return;
|
|
153
|
+
let owner;
|
|
154
|
+
try {
|
|
155
|
+
owner = JSON.parse(await readFile(this.lockPath, "utf8"));
|
|
156
|
+
} catch {
|
|
157
|
+
owner = null;
|
|
158
|
+
}
|
|
159
|
+
if (owner && processExists(owner.pid)) return;
|
|
160
|
+
try {
|
|
161
|
+
await unlink(this.lockPath);
|
|
162
|
+
} catch (error) {
|
|
163
|
+
if (error?.code !== "ENOENT") throw error;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async withLock(callback) {
|
|
168
|
+
await privateDirectory(this.directory);
|
|
169
|
+
const deadline = Date.now() + LOCK_WAIT_MS;
|
|
170
|
+
let handle;
|
|
171
|
+
while (!handle) {
|
|
172
|
+
try {
|
|
173
|
+
const candidate = await open(this.lockPath, "wx", 0o600);
|
|
174
|
+
try {
|
|
175
|
+
await candidate.writeFile(
|
|
176
|
+
`${JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() })}\n`,
|
|
177
|
+
"utf8",
|
|
178
|
+
);
|
|
179
|
+
await candidate.sync();
|
|
180
|
+
handle = candidate;
|
|
181
|
+
} catch (error) {
|
|
182
|
+
await candidate.close().catch(() => {});
|
|
183
|
+
await unlink(this.lockPath).catch(() => {});
|
|
184
|
+
throw error;
|
|
185
|
+
}
|
|
186
|
+
} catch (error) {
|
|
187
|
+
if (error?.code !== "EEXIST") throw error;
|
|
188
|
+
await this.#recoverStaleLock();
|
|
189
|
+
if (Date.now() >= deadline) {
|
|
190
|
+
throw new CliError(
|
|
191
|
+
"Another WarpMetal process is updating this account session. Try again.",
|
|
192
|
+
{ exitCode: 3, code: "session_locked" },
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
await pause(50 + Math.floor(Math.random() * 75));
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
try {
|
|
199
|
+
return await callback();
|
|
200
|
+
} finally {
|
|
201
|
+
await handle.close();
|
|
202
|
+
try {
|
|
203
|
+
await unlink(this.lockPath);
|
|
204
|
+
} catch (error) {
|
|
205
|
+
if (error?.code !== "ENOENT") throw error;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|