warpmetal 0.8.13 → 0.9.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/README.md +49 -2
- package/package.json +2 -2
- package/skills/warpmetal/SKILL.md +7 -1
- package/skills/warpmetal/references/cli-reference.md +96 -0
- package/src/account-gateway.js +217 -0
- package/src/account-session.js +209 -0
- package/src/api.js +4 -0
- package/src/cli.js +308 -5
- package/src/customer-auth.js +329 -0
- package/src/models.js +85 -0
- package/src/runtime.js +76 -3
- package/src/state.js +20 -4
package/README.md
CHANGED
|
@@ -62,7 +62,7 @@ integration.
|
|
|
62
62
|
|
|
63
63
|
The repository also contains a skills-only WarpMetal plugin for the public
|
|
64
64
|
Plugins Directory shared by Codex and ChatGPT. The plugin remains a separate
|
|
65
|
-
artifact from the npm CLI and requires `warpmetal` CLI version 0.
|
|
65
|
+
artifact from the npm CLI and requires `warpmetal` CLI version 0.9.0 or newer.
|
|
66
66
|
|
|
67
67
|
To test the repository marketplace after the plugin lands on `main`:
|
|
68
68
|
|
|
@@ -84,9 +84,13 @@ the repo marketplace is only for development, testing, and direct distribution.
|
|
|
84
84
|
## First commands
|
|
85
85
|
|
|
86
86
|
```sh
|
|
87
|
+
warpmetal login
|
|
88
|
+
warpmetal auth status
|
|
87
89
|
warpmetal health
|
|
88
90
|
warpmetal catalog
|
|
91
|
+
warpmetal models --auth-mode chatgpt_subscription
|
|
89
92
|
warpmetal order prepare \
|
|
93
|
+
--account --without-agent-boxes \
|
|
90
94
|
--plan agent \
|
|
91
95
|
--hostname codex-workspace \
|
|
92
96
|
--os '<exact name from warpmetal catalog>' \
|
|
@@ -94,11 +98,54 @@ warpmetal order prepare \
|
|
|
94
98
|
--json
|
|
95
99
|
```
|
|
96
100
|
|
|
101
|
+
`warpmetal login` starts WarpMetal account authorization before any SSH key or
|
|
102
|
+
order configuration is needed. It opens the browser approval page by default;
|
|
103
|
+
use `--no-browser` to print the page and user code without opening it. The
|
|
104
|
+
default session requests `cli:read cli:write`; `--read-only` requests only
|
|
105
|
+
`cli:read`. Check the current account with `warpmetal auth status` and revoke
|
|
106
|
+
the CLI session with `warpmetal logout`.
|
|
107
|
+
|
|
108
|
+
The CLI stores only its own rotating account session under the private
|
|
109
|
+
WarpMetal state directory. Account sessions are separate from browser sessions,
|
|
110
|
+
legacy order owner tokens and SSH credentials, and are bound to the exact
|
|
111
|
+
Identity and account origins that issued them. `--identity-url` and
|
|
112
|
+
`--account-url` accept explicit origins for testing or alternate deployments;
|
|
113
|
+
credentials are never sent after an HTTP redirect or reused for other origins.
|
|
114
|
+
If a refresh response is lost, the CLI clears the local session and requires a
|
|
115
|
+
new login rather than risking reuse of a rotated credential. Logout always
|
|
116
|
+
clears the matching local session, and reports when remote revocation could not
|
|
117
|
+
be confirmed.
|
|
118
|
+
|
|
119
|
+
Use `warpmetal account orders` and `warpmetal account devices` for your account's
|
|
120
|
+
order history and active servers; add `--task <id>` or `--server <id>` for one
|
|
121
|
+
item. `order prepare --account` binds the unpaid order to the signed-in account
|
|
122
|
+
and uses its verified contact. It never issues an owner token. Login and account
|
|
123
|
+
creation do not require SSH keys or order configuration.
|
|
124
|
+
|
|
125
|
+
New account preparation defaults to Agent Boxes and an Agent team. Supply the
|
|
126
|
+
selected team in `--runtime-file`; no provider or model is silently selected.
|
|
127
|
+
Use `--without-team` for a single small persistent box, or
|
|
128
|
+
`--without-agent-boxes` for a VPS without boxes. An explicit runtime file keeps
|
|
129
|
+
its existing selections. `warpmetal models --json` supplies published model and
|
|
130
|
+
authentication choices; server readiness checks still apply.
|
|
131
|
+
|
|
132
|
+
Without `--account`, order preparation retains the existing owner-token
|
|
133
|
+
workflow, including payment and SSH automation. This release adds account order
|
|
134
|
+
preparation and inventory, not a new account payment or runtime-execution grant.
|
|
135
|
+
The account scopes do not authorize charges, renewals, deletion or Fleet access.
|
|
136
|
+
|
|
137
|
+
`warpmetal catalog` lists VPS plans. `warpmetal models` reads the separate,
|
|
138
|
+
public Agent Teams model catalog. It never signs in to a model provider or
|
|
139
|
+
creates an order. Use `--provider` or `--auth-mode api_key|chatgpt_subscription`
|
|
140
|
+
to filter published entries; JSON preserves the catalog snapshot, provenance,
|
|
141
|
+
freshness state and published authentication modes.
|
|
142
|
+
|
|
97
143
|
The generated key defaults to
|
|
98
144
|
`${WARPMETAL_HOME:-~/.config/warpmetal}/ssh/warpmetal-codex-workspace`. A
|
|
99
145
|
collision receives a random suffix; existing keys are never overwritten. Once
|
|
100
146
|
checkout returns `serverId`, the CLI binds that ID to the identity so
|
|
101
|
-
`warpmetal server login`
|
|
147
|
+
`warpmetal server login` is the distinct SSH challenge flow for one server.
|
|
148
|
+
It and `warpmetal runtime install` can select the generated identity without
|
|
102
149
|
an `--identity` flag. Use `--ssh-public-key-file` instead when supplying a
|
|
103
150
|
user-managed public key.
|
|
104
151
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "warpmetal",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Agent-safe CLI and skill for purchasing, renewing, and managing WarpMetal VPS servers",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"node": ">=20.0.0"
|
|
17
17
|
},
|
|
18
18
|
"scripts": {
|
|
19
|
-
"check": "node --check bin/warpmetal.js && node --check src/api.js && node --check src/args.js && node --check src/cli.js && node --check src/connection.js && node --check src/errors.js && node --check src/host-trust.js && node --check src/install-skill.js && node --check src/installer.js && node --check src/payment.js && node --check src/runtime.js && node --check src/ssh-alias.js && node --check src/ssh.js && node --check src/state.js && node --check src/version.js",
|
|
19
|
+
"check": "node --check bin/warpmetal.js && node --check src/account-gateway.js && node --check src/account-session.js && node --check src/api.js && node --check src/args.js && node --check src/cli.js && node --check src/connection.js && node --check src/customer-auth.js && node --check src/errors.js && node --check src/host-trust.js && node --check src/install-skill.js && node --check src/installer.js && node --check src/models.js && node --check src/payment.js && node --check src/runtime.js && node --check src/ssh-alias.js && node --check src/ssh.js && node --check src/state.js && node --check src/version.js",
|
|
20
20
|
"plugin:check": "node --test test/plugin.test.js",
|
|
21
21
|
"test": "node --test",
|
|
22
22
|
"prepack": "npm run check && npm test"
|
|
@@ -11,7 +11,7 @@ with ad hoc HTTP commands.
|
|
|
11
11
|
|
|
12
12
|
## Start safely
|
|
13
13
|
|
|
14
|
-
1. Run `warpmetal --version` and require version `0.
|
|
14
|
+
1. Run `warpmetal --version` and require version `0.9.0` or newer for this
|
|
15
15
|
plugin. If it is missing or older, explain the compatibility requirement,
|
|
16
16
|
ask before installing or upgrading software, and use only the official npm
|
|
17
17
|
package from `https://www.npmjs.com/package/warpmetal`.
|
|
@@ -44,8 +44,14 @@ Run:
|
|
|
44
44
|
```sh
|
|
45
45
|
warpmetal health --json
|
|
46
46
|
warpmetal catalog --json
|
|
47
|
+
warpmetal models --json
|
|
47
48
|
```
|
|
48
49
|
|
|
50
|
+
`catalog` is the live VPS plan catalog. `models` is the separate public Agent
|
|
51
|
+
Teams model catalog; it makes one read-only request and never infers model
|
|
52
|
+
availability, credentials, entitlement, or provider support. Filter only with
|
|
53
|
+
published `--provider` or `--auth-mode api_key|chatgpt_subscription` values.
|
|
54
|
+
|
|
49
55
|
Stop the current purchase if `purchasingReady` is false. In unattended
|
|
50
56
|
scheduling, recheck after 60 seconds, then double the delay after each failed
|
|
51
57
|
check up to 15 minutes and honor a longer `Retry-After`; never hot-loop. In an
|
|
@@ -3,6 +3,7 @@
|
|
|
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
|
|
@@ -17,11 +18,106 @@
|
|
|
17
18
|
```sh
|
|
18
19
|
warpmetal health --json
|
|
19
20
|
warpmetal catalog [--plan <planId>] --json
|
|
21
|
+
warpmetal models [--provider <id>] [--auth-mode <api_key|chatgpt_subscription>] [--json]
|
|
20
22
|
```
|
|
21
23
|
|
|
24
|
+
## Public model catalog
|
|
25
|
+
|
|
26
|
+
`warpmetal models` performs a read-only `GET /agent-team-model-catalog` request.
|
|
27
|
+
It preserves the published snapshot metadata, source provenance, freshness state
|
|
28
|
+
and authentication modes. `--provider` and `--auth-mode` filter entries and
|
|
29
|
+
remove recommendations whose published entry is no longer present. The command
|
|
30
|
+
does not infer eligibility or contact a model provider. `catalog` remains the
|
|
31
|
+
VPS plan catalog.
|
|
32
|
+
|
|
22
33
|
`health` exits with code 3 when the service responds but purchasing is paused.
|
|
23
34
|
The catalog remains useful for read-only discovery.
|
|
24
35
|
|
|
36
|
+
## Account authentication
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
warpmetal login [--no-browser] [--read-only] --json
|
|
40
|
+
warpmetal auth status --json
|
|
41
|
+
warpmetal logout --json
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
`login` uses the public `warpmetal-customer-cli` device client. It requests
|
|
45
|
+
`cli:read cli:write` by default or only `cli:read` with `--read-only`, prints the
|
|
46
|
+
bounded verification page and user code to stderr, and opens that page unless
|
|
47
|
+
`--no-browser` is present. Polling follows the server interval and treats
|
|
48
|
+
pending, slow-down, denial, expiry and cancellation as distinct outcomes. JSON
|
|
49
|
+
stdout contains only safe account identity and scope fields; access, refresh
|
|
50
|
+
and device credentials are never output.
|
|
51
|
+
|
|
52
|
+
The rotating CLI refresh family is independent of browser sessions, legacy
|
|
53
|
+
owner tokens and SSH credentials. It is stored in an owner-only, atomic session
|
|
54
|
+
file under the private WarpMetal state directory. Concurrent commands serialize
|
|
55
|
+
refresh. If refresh completion is ambiguous, the saved session is cleared and a
|
|
56
|
+
new login is required rather than replaying the old credential.
|
|
57
|
+
|
|
58
|
+
Sessions are bound to the exact Identity and account origins. Defaults are
|
|
59
|
+
`https://identity.warpmetal.com` and `https://warpmetal.com`; explicit
|
|
60
|
+
`--identity-url` and `--account-url` values must be HTTPS origins (localhost may
|
|
61
|
+
use HTTP). Redirects are refused, and credentials for one origin pair are never
|
|
62
|
+
sent to another. `logout` attempts remote family revocation and always clears
|
|
63
|
+
the matching local session, reporting whether revocation was confirmed.
|
|
64
|
+
|
|
65
|
+
Account login does not grant server SSH access. `warpmetal server login` remains
|
|
66
|
+
the separate, server-specific SSH challenge command.
|
|
67
|
+
|
|
68
|
+
## Account orders and devices
|
|
69
|
+
|
|
70
|
+
```sh
|
|
71
|
+
warpmetal account orders [--task <taskId>] --json
|
|
72
|
+
warpmetal account devices [--server <serverId>] --json
|
|
73
|
+
warpmetal order prepare --account --without-agent-boxes \
|
|
74
|
+
--plan <planId> --hostname <name> --os '<exact catalog OS>' \
|
|
75
|
+
--generate-ssh-key [--idempotency-key <key>] --json
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Reads require `cli:read`; unpaid preparation requires `cli:write`. Only these
|
|
79
|
+
allowlisted operations are granted. Account scope never authorizes a payment,
|
|
80
|
+
renewal, server deletion, credential change or runtime execution. Collections
|
|
81
|
+
return the server's bounded page and `nextCursor`; order history is distinct from
|
|
82
|
+
active devices and can include released or cancelled orders.
|
|
83
|
+
|
|
84
|
+
Account mode signs in before checking order fields. It derives contact and
|
|
85
|
+
ownership from the signed-in principal, rejects `--email`, uses the private
|
|
86
|
+
account gateway, and records `ownershipMode: account` plus principal/origin in
|
|
87
|
+
private local state without an owner token. Use the same idempotency key and
|
|
88
|
+
unchanged payload to retry an ambiguous preparation. Do not retry a charge.
|
|
89
|
+
Existing owner-token checkout commands do not become account payment commands.
|
|
90
|
+
|
|
91
|
+
Boxes/team are selected by default for new account preparation: provide
|
|
92
|
+
`--runtime-file` with your chosen team, `--without-team` for one persistent small
|
|
93
|
+
`main` box, or `--without-agent-boxes` for no runtime. Explicit runtime files keep
|
|
94
|
+
their box/team choices, and older scripts without `--account` retain their
|
|
95
|
+
defaults. Server model/runtime readiness gates remain binding.
|
|
96
|
+
|
|
97
|
+
The optional `teams` sibling of `sandboxes` uses the deployed team-v1 schema:
|
|
98
|
+
|
|
99
|
+
```json
|
|
100
|
+
{
|
|
101
|
+
"sandboxes": [{"name":"manager","size":"small"},{"name":"builder","size":"small"}],
|
|
102
|
+
"teams": {"version":1,"teams":[{
|
|
103
|
+
"name":"Build team",
|
|
104
|
+
"members":[
|
|
105
|
+
{"sandboxName":"manager","role":"manager","providerId":"openai"},
|
|
106
|
+
{"sandboxName":"builder","role":"worker","providerId":"anthropic"}
|
|
107
|
+
],
|
|
108
|
+
"links":[{"from":"manager","to":"builder","capability":"task.delegate"}],
|
|
109
|
+
"startPolicy":"manual"
|
|
110
|
+
}]}
|
|
111
|
+
}
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Provider names in this example are preferences, not qualification proof. Optional
|
|
115
|
+
`modelId` and `authMode` must reflect the selected published entry; omitted values
|
|
116
|
+
mean choose later, never automatic credentials. A team has one manager and 2–8
|
|
117
|
+
distinct declared boxes. Credentials, unknown fields, automatic start and
|
|
118
|
+
cross-team links are rejected. Team/setup files are for order preparation;
|
|
119
|
+
`sandbox create --file` accepts a sandbox batch only.
|
|
120
|
+
|
|
25
121
|
## Purchase and provisioning
|
|
26
122
|
|
|
27
123
|
```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
|
+
}
|
package/src/api.js
CHANGED
|
@@ -135,6 +135,10 @@ export class WarpMetalClient {
|
|
|
135
135
|
return this.request("GET", "/catalog");
|
|
136
136
|
}
|
|
137
137
|
|
|
138
|
+
modelCatalog() {
|
|
139
|
+
return this.request("GET", "/agent-team-model-catalog");
|
|
140
|
+
}
|
|
141
|
+
|
|
138
142
|
prepareOrder(body, idempotencyKey) {
|
|
139
143
|
return this.request("POST", "/orders", { body, idempotencyKey });
|
|
140
144
|
}
|