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 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.8.13 or newer.
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` and `warpmetal runtime install` can select it without
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.8.13",
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.8.13` or newer for this
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
  }