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.
@@ -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
+ }