@masons/agent-network 0.5.14 → 0.5.16

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.
Files changed (66) hide show
  1. package/dist/channel.d.ts +0 -7
  2. package/dist/channel.d.ts.map +1 -1
  3. package/dist/channel.js +3 -174
  4. package/dist/cli-setup.d.ts +0 -109
  5. package/dist/cli-setup.d.ts.map +1 -1
  6. package/dist/cli-setup.js +16 -570
  7. package/dist/config-fs.d.ts +4 -0
  8. package/dist/config-fs.d.ts.map +1 -0
  9. package/dist/config-fs.js +23 -0
  10. package/dist/config-schema.js +2 -2
  11. package/dist/config.d.ts +2 -210
  12. package/dist/config.d.ts.map +1 -1
  13. package/dist/config.js +14 -334
  14. package/dist/connector-client.d.ts +0 -32
  15. package/dist/connector-client.d.ts.map +1 -1
  16. package/dist/connector-client.js +1 -89
  17. package/dist/constants.d.ts +0 -1
  18. package/dist/constants.d.ts.map +1 -1
  19. package/dist/constants.js +2 -3
  20. package/dist/conversation-manager.d.ts +0 -106
  21. package/dist/conversation-manager.d.ts.map +1 -1
  22. package/dist/conversation-manager.js +2 -131
  23. package/dist/environment-context.d.ts +0 -24
  24. package/dist/environment-context.d.ts.map +1 -1
  25. package/dist/environment-context.js +0 -42
  26. package/dist/handle-utils.d.ts +0 -14
  27. package/dist/handle-utils.d.ts.map +1 -1
  28. package/dist/handle-utils.js +0 -14
  29. package/dist/index.js +0 -9
  30. package/dist/owner-notes.d.ts +0 -33
  31. package/dist/owner-notes.d.ts.map +1 -1
  32. package/dist/owner-notes.js +2 -41
  33. package/dist/owner-session-state.d.ts +0 -26
  34. package/dist/owner-session-state.d.ts.map +1 -1
  35. package/dist/owner-session-state.js +0 -37
  36. package/dist/platform-client.d.ts +13 -202
  37. package/dist/platform-client.d.ts.map +1 -1
  38. package/dist/platform-client.js +22 -171
  39. package/dist/plugin.d.ts +5 -0
  40. package/dist/plugin.d.ts.map +1 -1
  41. package/dist/plugin.js +3 -167
  42. package/dist/sent-message-buffer.d.ts +0 -36
  43. package/dist/sent-message-buffer.d.ts.map +1 -1
  44. package/dist/sent-message-buffer.js +1 -45
  45. package/dist/tools.d.ts +0 -28
  46. package/dist/tools.d.ts.map +1 -1
  47. package/dist/tools.js +36 -240
  48. package/dist/turn-context.d.ts +0 -45
  49. package/dist/turn-context.d.ts.map +1 -1
  50. package/dist/turn-context.js +0 -57
  51. package/dist/types.d.ts +0 -67
  52. package/dist/types.d.ts.map +1 -1
  53. package/dist/types.js +0 -7
  54. package/dist/update-cache.d.ts +0 -17
  55. package/dist/update-cache.d.ts.map +1 -1
  56. package/dist/update-cache.js +1 -21
  57. package/dist/update-check.d.ts +1 -40
  58. package/dist/update-check.d.ts.map +1 -1
  59. package/dist/update-check.js +7 -66
  60. package/dist/version.d.ts +1 -2
  61. package/dist/version.d.ts.map +1 -1
  62. package/dist/version.js +1 -2
  63. package/openclaw.plugin.json +94 -3
  64. package/package.json +11 -10
  65. package/skills/agent-network/SKILL.md +21 -47
  66. package/skills/agent-network/references/troubleshooting.md +5 -5
package/dist/cli-setup.js CHANGED
@@ -1,158 +1,19 @@
1
- /**
2
- * CLI Login Path — encrypted server-bridge handoff (v0.5.14).
3
- *
4
- * Implements `login()` hook for OpenClaw's 2026.4.x channel-plugin contract:
5
- * api.registerChannel({ plugin: { ..., auth: { login } } })
6
- *
7
- * Invoked by `openclaw channels login --channel agent-network`. CLI dispatch
8
- * at `dist/channels-cli-Cc40S0aS.js:85` reads `plugin.auth?.login` and throws
9
- * `"Channel ... does not support login"` if absent.
10
- *
11
- * Flow (v0.5.14 — `serverBridgeHandoffFlow`):
12
- *
13
- * 1. Generate an ephemeral RSA-2048 keypair in the plugin process. The
14
- * private key never leaves process memory; the SPKI-encoded public
15
- * key travels to `apps/api` for the browser to encrypt against.
16
- * 2. POST `${apiHost}/v1/cli-handoff/init` with the SPKI pubkey; receive
17
- * back a `session_id` (`cli_…`) keyed in apps/api's Redis with a 5-min
18
- * TTL.
19
- * 3. Print a handoff URL (`${idpBaseUrl}/console/handoff?session=<id>` or
20
- * the per-handle variant when `channelInput` parses as a handle) for
21
- * the user to open in their browser. The plugin does NOT spawn a
22
- * browser — `child_process` is forbidden by OpenClaw's plugin scanner
23
- * (rules `dangerous-exec`, `env-harvesting`).
24
- * 4. The user signs in to apps/web, picks or creates an agent, and the
25
- * browser runs WebCrypto client-side: generates an ephemeral AES-256-GCM
26
- * key, encrypts `{token, agent, key}` JSON under it, wraps the AES key
27
- * under the plugin's RSA pubkey via RSA-OAEP-256, and POSTs the
28
- * `{wrapped_key, iv, ciphertext}` triple to
29
- * `${apiHost}/v1/cli-handoff/:session/complete` (authenticated server-
30
- * action mediated by apps/web). Server stores ciphertext only.
31
- * 5. Plugin polls `${apiHost}/v1/cli-handoff/:session/poll` every 2s until
32
- * the bridge returns `{status: "completed", payload}`.
33
- * 6. Plugin decrypts in-process: RSA-OAEP-256 unwrap → AES-256-GCM
34
- * decrypt → JSON parse → validate `masons_rt_v1_` prefix.
35
- * 7. Persist `{connectorUrl, token}` to `openclaw.json` via
36
- * `writeCredentials()`. `connectorUrl` derives `wss://<apiHost>/gateway`
37
- * for cloud / preview deployments; self-hosted users keep an explicit
38
- * `connectorUrl` in their `openclaw.json` and the value is preserved
39
- * across the handoff (see `loadExistingConnectorUrl()`).
40
- *
41
- * Why this shape (vs the v0.5.13 loopback handoff or device flow):
42
- *
43
- * - **Sandbox-friendly.** No `child_process` (no auto-open), no
44
- * `node:http.createServer` listener, no `process.env` read inside the
45
- * network code path. OpenClaw's plugin scanner accepts the resulting
46
- * code surface — the path that blocked v0.5.13 is closed.
47
- * - **End-to-end encrypted.** The apps/api Redis cache stores ciphertext
48
- * only. A stolen Mongo / Redis dump yields nothing useful — the wrapped
49
- * AES key is unrecoverable without the plugin's RSA private key (which
50
- * lives only in plugin process memory). Mitigates the Shai-Hulud
51
- * "plaintext-at-rest, even short-lived" concern that originally caused
52
- * Decision B Alt B3 to be rejected; see #1475 issuecomment-4365154888 § A
53
- * for the formal reject-supersede record.
54
- * - **`masons_rt_…` carrier on connector.** Connector verifies new keys
55
- * via the W4 PR 1 mirror substrate; this file does not change carrier
56
- * semantics, only the transport that gets the plaintext from
57
- * apps/web → CLI.
58
- *
59
- * Legacy device-flow helpers (`deviceCodeFlow`, `pollUntilAuthorized`,
60
- * `agentSetup`, `createAgentLoop`) remain in this file as `@deprecated`
61
- * reference for the W6 cleanup window. They have no call site after the
62
- * v0.5.13 → v0.5.14 migration and should not be invoked by any new code.
63
- *
64
- * See #1264 for original design rationale (single-driver Node semantic —
65
- * re-running this flow rotates the runtime key and evicts any previously-
66
- * connected Runtime); see the v0.5.14 strategic-pass at
67
- * `#1475 issuecomment-4365154888` for the lock that supersedes both
68
- * Alt B1 (loopback, scanner-blocked) and the prior Alt B3 reject.
69
- */
70
1
  import { createDecipheriv, constants as cryptoConstants, generateKeyPairSync, privateDecrypt, } from "node:crypto";
71
- import { cancel, confirm as clackConfirm, select as clackSelect, text as clackText, isCancel, } from "@clack/prompts";
72
- import { getOpenClawHome, writeCredentials } from "./config.js";
73
- import { DEFAULT_API_HOST, onboard, PlatformApiError, } from "./platform-client.js";
74
- // ---------------------------------------------------------------------------
75
- // Constants
76
- // ---------------------------------------------------------------------------
77
- /**
78
- * Better Auth IdP base URL. Default targets the preview environment.
79
- *
80
- * TODO: flip to the production URL when W8 (api.masons.ai consolidation)
81
- * lands. Plugins shipped with this default before that flip will still
82
- * work — the user can override by setting `idpBaseUrl` in their
83
- * `openclaw.json` channel config (or by re-running setup which writes
84
- * whatever is current).
85
- */
2
+ import { readExistingConnectorUrl, writeCredentials } from "./config.js";
3
+ import { DEFAULT_API_HOST, DEFAULT_CONNECTOR_URL, PlatformApiError, } from "./platform-client.js";
86
4
  const DEFAULT_IDP_BASE_URL = "https://preview.masons.ai";
87
- /**
88
- * OAuth client_id registered for OpenClaw at the IdP — see
89
- * `apps/web/scripts/seed.ts`. Hardcoded because there's only one OpenClaw
90
- * client today; if multiple wrappers (e.g. self-hosted vs Claude Desktop
91
- * variant) ever need distinct client_ids, expose this via the channel
92
- * config schema.
93
- */
94
- const OAUTH_CLIENT_ID = "openclaw";
95
- /**
96
- * Scopes requested at device-flow initiation. `openid` + `profile` + `email`
97
- * are the standard OIDC scopes; `offline_access` requests a refresh token
98
- * (Better Auth uses session tokens here, not OAuth opaque tokens — the
99
- * scope is mostly indicative). Stays in sync with the scopes registered on
100
- * the `oauthProvider` plugin at apps/web/lib/auth.ts.
101
- */
102
- const SCOPE = "openid profile email offline_access";
103
- /** Default polling interval if the IdP does not return one. */
104
- const DEFAULT_POLL_INTERVAL_S = 5;
105
- const CREATE_NEW_OPTION = "Create new agent";
106
- // Handle pre-validation regex — strict subset of the server's authoritative
107
- // `validateHandle` (packages/db/src/utils/handle-validation.ts:3). Used to
108
- // fail-fast in the terminal before round-tripping the server. Kept inline
109
- // because the published npm plugin can't import @workspace/db.
110
- //
111
- // Server contract: `/^[a-z][a-z0-9_-]{2,14}$/` against the lowercased input.
112
- // MUST be a strict subset (false-negatives OK, false-positives forbidden) —
113
- // otherwise the user sees "passed client check" then "server rejected" for
114
- // the same input, which is confusing.
115
- //
116
- // Reserved-handle check (e.g., "masons", "openclaw") is server-only — too
117
- // volatile for the plugin to track; the round-trip is acceptable for that.
118
5
  const HANDLE_REGEX = /^[a-z][a-z0-9_-]{2,14}$/;
119
- /**
120
- * Hard cap on how long the plugin polls the bridge before giving up.
121
- * Aligned with the apps/api `/v1/cli-handoff/init` Redis TTL (5 min) so
122
- * the plugin and the bridge time out together — a longer plugin window
123
- * would tail off after the session expired anyway.
124
- */
125
6
  const HANDOFF_TIMEOUT_MS = 5 * 60 * 1000;
126
- /**
127
- * Polling interval. Stripe CLI uses ~1s; gh CLI device flow uses 5s. 2s
128
- * lands in between — adequate spinner-style UX without thrashing the
129
- * Redis cache.
130
- */
131
7
  const HANDOFF_POLL_INTERVAL_MS = 2000;
132
- /**
133
- * Run the encrypted server-bridge handoff. Returns the decrypted
134
- * runtime API key + the agent identity the browser issued it for.
135
- *
136
- * `channelInput` is the optional argument from
137
- * `openclaw channels login --channel agent-network -- <input>`. When
138
- * the input parses as a handle (`^[a-z][a-z0-9_-]{2,14}$`) the flow
139
- * directs the user to the per-handle deep-link entry; otherwise it
140
- * falls back to the picker page. Anything that isn't a handle silently
141
- * falls through.
142
- */
8
+ class SetupFlowError extends Error {
9
+ }
143
10
  async function serverBridgeHandoffFlow(apiHost, idpBaseUrl, runtime, channelInput) {
144
- // 1. Generate an ephemeral RSA-2048 keypair. The private key never
145
- // leaves the plugin process; the SPKI-encoded public key travels
146
- // to apps/api so the browser can encrypt against it.
147
11
  const { publicKey, privateKey } = generateKeyPairSync("rsa", {
148
12
  modulusLength: 2048,
149
13
  });
150
14
  const cliPubkeySpki = publicKey
151
15
  .export({ type: "spki", format: "der" })
152
16
  .toString("base64url");
153
- // 2. POST /v1/cli-handoff/init — opens a session keyed in apps/api's
154
- // Redis cache. Anonymous endpoint; the CLI has no MASONS credential
155
- // yet (delivering one is the point of this flow).
156
17
  const apiBase = normalizeHttpBase(apiHost);
157
18
  let session_id;
158
19
  try {
@@ -162,24 +23,21 @@ async function serverBridgeHandoffFlow(apiHost, idpBaseUrl, runtime, channelInpu
162
23
  body: JSON.stringify({ cli_pubkey: cliPubkeySpki }),
163
24
  });
164
25
  if (!initRes.ok) {
165
- throw new DeviceFlowError(`Setup service rejected init (HTTP ${initRes.status}). Retry in a moment; if it persists, check that ${apiBase} is reachable and not behind a proxy that strips the request body.`);
26
+ throw new SetupFlowError(`Setup service rejected init (HTTP ${initRes.status}). Retry in a moment; if it persists, check that ${apiBase} is reachable and not behind a proxy that strips the request body.`);
166
27
  }
167
28
  const initBody = (await initRes.json());
168
29
  if (typeof initBody.session_id !== "string" ||
169
30
  initBody.session_id.length === 0) {
170
- throw new DeviceFlowError("Setup service returned a malformed response (missing session_id). Retry; if it persists, this is a server-side bug worth reporting.");
31
+ throw new SetupFlowError("Setup service returned a malformed response (missing session_id). Retry; if it persists, this is a server-side bug worth reporting.");
171
32
  }
172
33
  session_id = initBody.session_id;
173
34
  }
174
35
  catch (err) {
175
- if (err instanceof DeviceFlowError)
36
+ if (err instanceof SetupFlowError)
176
37
  throw err;
177
38
  const message = err instanceof Error ? err.message : "unknown error";
178
- throw new DeviceFlowError(`Could not reach the setup service at ${apiBase} (${message}). Retry in a moment.`);
39
+ throw new SetupFlowError(`Could not reach the setup service at ${apiBase} (${message}). Retry in a moment.`);
179
40
  }
180
- // 3. Print the handoff URL. No browser auto-open — `child_process`
181
- // is forbidden by the OpenClaw plugin scanner. The user opens
182
- // the link manually in their browser.
183
41
  const handle = parseHandleFromInput(channelInput);
184
42
  const handoffPath = handle
185
43
  ? `/console/agents/${encodeURIComponent(handle)}/runtime-keys/handoff`
@@ -193,26 +51,20 @@ async function serverBridgeHandoffFlow(apiHost, idpBaseUrl, runtime, channelInpu
193
51
  "Waiting for the runtime key to be issued and delivered…",
194
52
  "",
195
53
  ].join("\n"));
196
- // 4. Long-poll /v1/cli-handoff/:session/poll until the bridge
197
- // returns `{status: "completed", payload}`. Hard cap at
198
- // HANDOFF_TIMEOUT_MS so a forgotten browser tab does not pin
199
- // the plugin process.
200
54
  const envelope = await pollForCompletion(apiBase, session_id);
201
- // 5. Decrypt in-process. RSA-OAEP-256 unwrap → AES-256-GCM decrypt
202
- // → JSON parse → validate runtime key prefix.
203
55
  let payload;
204
56
  try {
205
57
  payload = decryptBridgeEnvelope(privateKey, envelope);
206
58
  }
207
59
  catch (err) {
208
60
  const message = err instanceof Error ? err.message : "unknown error";
209
- throw new DeviceFlowError(`Handoff received but could not be decrypted (${message}). The link may have been tampered with — re-run setup. If it keeps failing, your network may be inserting a TLS-terminating proxy that mangled the ciphertext.`);
61
+ throw new SetupFlowError(`Handoff received but could not be decrypted (${message}). The link may have been tampered with — re-run setup. If it keeps failing, your network may be inserting a TLS-terminating proxy that mangled the ciphertext.`);
210
62
  }
211
63
  if (typeof payload.token !== "string" ||
212
64
  !payload.token.startsWith("masons_rt_v1_") ||
213
65
  typeof payload.agent?.handle !== "string" ||
214
66
  typeof payload.agent?.agentId !== "string") {
215
- throw new DeviceFlowError("Handoff payload is missing required fields. Re-run setup; if it keeps happening, this is a server-side bug worth reporting.");
67
+ throw new SetupFlowError("Handoff payload is missing required fields. Re-run setup; if it keeps happening, this is a server-side bug worth reporting.");
216
68
  }
217
69
  return {
218
70
  token: payload.token,
@@ -225,11 +77,6 @@ async function serverBridgeHandoffFlow(apiHost, idpBaseUrl, runtime, channelInpu
225
77
  },
226
78
  };
227
79
  }
228
- /**
229
- * Long-poll `/v1/cli-handoff/:session/poll` until the bridge returns
230
- * a completed envelope or the overall window expires. 2s interval is
231
- * the locked architect choice (#1475 issuecomment-4365154888 § B.2).
232
- */
233
80
  async function pollForCompletion(apiBase, sessionId) {
234
81
  const start = Date.now();
235
82
  while (Date.now() - start < HANDOFF_TIMEOUT_MS) {
@@ -238,16 +85,13 @@ async function pollForCompletion(apiBase, sessionId) {
238
85
  res = await fetch(`${apiBase}/v1/cli-handoff/${encodeURIComponent(sessionId)}/poll`, { method: "GET" });
239
86
  }
240
87
  catch (_err) {
241
- // Transient network error — try again on the next tick. Persistent
242
- // network failures will surface as the timeout below.
243
88
  await sleep(HANDOFF_POLL_INTERVAL_MS);
244
89
  continue;
245
90
  }
246
91
  if (res.status === 404) {
247
- throw new DeviceFlowError("Handoff session expired before the runtime key was delivered. Re-run setup.");
92
+ throw new SetupFlowError("Handoff session expired before the runtime key was delivered. Re-run setup.");
248
93
  }
249
94
  if (!res.ok) {
250
- // Other 4xx/5xx — back off and retry until the timeout fires.
251
95
  await sleep(HANDOFF_POLL_INTERVAL_MS);
252
96
  continue;
253
97
  }
@@ -255,23 +99,14 @@ async function pollForCompletion(apiBase, sessionId) {
255
99
  if (body.status === "completed" && body.payload) {
256
100
  return body.payload;
257
101
  }
258
- // status === "pending" (or anything else) → keep polling.
259
102
  await sleep(HANDOFF_POLL_INTERVAL_MS);
260
103
  }
261
- throw new DeviceFlowError("Handoff timed out before the runtime key was delivered. Re-run setup; if you're behind a proxy that blocks long-running fetches, retry on a different network.");
104
+ throw new SetupFlowError("Handoff timed out before the runtime key was delivered. Re-run setup; if you're behind a proxy that blocks long-running fetches, retry on a different network.");
262
105
  }
263
- /**
264
- * Decrypt the bridge envelope. RSA-OAEP-256 unwraps the AES key;
265
- * AES-256-GCM decrypts the ciphertext (auth tag is the last 16 bytes
266
- * of the ciphertext per the WebCrypto AES-GCM output convention).
267
- */
268
106
  function decryptBridgeEnvelope(privateKey, envelope) {
269
107
  const wrappedKey = Buffer.from(envelope.wrapped_key, "base64url");
270
108
  const iv = Buffer.from(envelope.iv, "base64url");
271
109
  const fullCiphertext = Buffer.from(envelope.ciphertext, "base64url");
272
- // WebCrypto's `crypto.subtle.encrypt({name: "AES-GCM"}, ...)` returns
273
- // ciphertext || tag concatenated, with a 16-byte auth tag. Node's
274
- // `createDecipheriv("aes-256-gcm", ...)` API expects them split.
275
110
  if (fullCiphertext.length < 17) {
276
111
  throw new Error("ciphertext shorter than auth tag");
277
112
  }
@@ -294,27 +129,12 @@ function decryptBridgeEnvelope(privateKey, envelope) {
294
129
  ]);
295
130
  return JSON.parse(plaintext.toString("utf-8"));
296
131
  }
297
- /**
298
- * Normalize an `apiHost` config value to an HTTPS base URL. Accepts:
299
- * - bare host (`preview-connectorapi.masons.ai`)
300
- * - https URL (`https://preview-connectorapi.masons.ai`)
301
- * - https URL with trailing slash
302
- * Returns no trailing slash.
303
- */
304
132
  function normalizeHttpBase(apiHost) {
305
133
  const trimmed = apiHost.replace(/\/+$/, "");
306
134
  if (/^https?:\/\//.test(trimmed))
307
135
  return trimmed;
308
136
  return `https://${trimmed}`;
309
137
  }
310
- // `sleep` is shared with the legacy device-flow helpers below — single
311
- // definition lives there to satisfy biome's `noDuplicateCase` /
312
- // TS's "duplicate function implementation" check.
313
- /**
314
- * Parse `channelInput` as a handle. Returns the lowercased handle on
315
- * match, `undefined` otherwise. Mirrors the `HANDLE_REGEX` used in the
316
- * legacy device flow's create form.
317
- */
318
138
  function parseHandleFromInput(input) {
319
139
  if (typeof input !== "string")
320
140
  return undefined;
@@ -323,373 +143,12 @@ function parseHandleFromInput(input) {
323
143
  return undefined;
324
144
  return trimmed;
325
145
  }
326
- /**
327
- * Read the existing `accounts.default.connectorUrl` from `openclaw.json`
328
- * if present. Used to preserve the self-hosted user's deployment-specific
329
- * connector URL across the handoff — the bridge does not learn it from
330
- * `apps/api` (issuance is decoupled from connector topology), but we
331
- * don't want to clobber a user-set value.
332
- *
333
- * The home-directory lookup goes through `getOpenClawHome()` in
334
- * `config.ts` rather than reading `process.env.OPENCLAW_HOME` here.
335
- * Keeps the env access in a config-only module so OpenClaw's plugin
336
- * scanner does not flag the network module's import graph for the
337
- * `env-harvesting` rule (env access combined with network send).
338
- *
339
- * Returns `undefined` when the file is absent, malformed, or doesn't
340
- * contain a connectorUrl. Caller falls back to `deriveCloudConnectorUrl`.
341
- */
342
- async function loadExistingConnectorUrl() {
343
- // Imports are dynamic so they don't pull `node:fs` and `node:path`
344
- // into the static import graph alongside the network calls above —
345
- // another small mitigation against scanner heuristics that flag
346
- // "fs read + http fetch in the same module."
347
- const { readFile } = await import("node:fs/promises");
348
- const { join } = await import("node:path");
349
- const home = getOpenClawHome();
350
- const path = join(home, "openclaw.json");
351
- try {
352
- const raw = await readFile(path, "utf-8");
353
- const config = JSON.parse(raw);
354
- const channels = config.channels;
355
- const network = channels?.["agent-network"];
356
- const accounts = network?.accounts;
357
- const def = accounts?.default;
358
- const value = def?.connectorUrl;
359
- if (typeof value === "string" && value.length > 0)
360
- return value;
361
- }
362
- catch {
363
- // file missing / malformed / unreadable → fall through to derivation
364
- }
365
- return undefined;
366
- }
367
- /**
368
- * Derive the WebSocket connector URL from the apiHost. Matches the
369
- * connector's `CONNECTOR_PLUGIN_URL` env default
370
- * (`wss://<host>/gateway`) for cloud and preview deployments.
371
- *
372
- * Self-hosted users running a non-standard topology should set
373
- * `accounts.default.connectorUrl` explicitly in `openclaw.json`; the
374
- * bridge flow preserves that value via `loadExistingConnectorUrl`.
375
- */
376
- function deriveCloudConnectorUrl(apiHost) {
377
- // Strip any incidental scheme so we never produce `wss://https://…`.
378
- const host = apiHost.replace(/^https?:\/\//, "");
379
- return `wss://${host}/gateway`;
380
- }
381
- class DeviceFlowError extends Error {
382
- }
383
- class DeviceFlowExpired extends Error {
384
- }
385
- /**
386
- * User pressed Ctrl+C at any prompter step. Subclass of `DeviceFlowError`
387
- * so the existing throw/catch chain still works, but distinguished so the
388
- * `login` handler can skip the redundant `runtime.error` call (clack's
389
- * `cancel()` already emitted the canonical "cancelled" UI).
390
- */
391
- class CancelError extends DeviceFlowError {
392
- }
393
- /**
394
- * Run the RFC 8628 device flow loop. Returns the access_token on success.
395
- * On expiration, restarts the loop transparently (user gets a fresh code).
396
- *
397
- * @deprecated W6 cleanup — superseded by `serverBridgeHandoffFlow`. No
398
- * call site after the v0.5.13 → v0.5.14 migration; the function is
399
- * kept as legacy reference until the W6 cleanup retires the connector-
400
- * side device-flow verifier
401
- * (`apps/connector/src/core/verify-device-session-token.ts`) and the
402
- * IdP-side `oauth/device/` route. Do not invoke from new code.
403
- */
404
- // biome-ignore lint/correctness/noUnusedVariables: retained as @deprecated reference for W6 cleanup
405
- async function deviceCodeFlow(idpBaseUrl, prompter) {
406
- for (;;) {
407
- const init = await initDeviceCode(idpBaseUrl);
408
- await prompter.text([
409
- "",
410
- `Setup code: ${init.user_code}`,
411
- "",
412
- `Open this link to authorize: ${init.verification_uri_complete ?? `${init.verification_uri}?user_code=${init.user_code}`}`,
413
- "",
414
- "Press Enter after you've authorized in the browser",
415
- ].join("\n"));
416
- try {
417
- const accessToken = await pollUntilAuthorized(idpBaseUrl, init.device_code, init.interval ?? DEFAULT_POLL_INTERVAL_S, prompter);
418
- return accessToken;
419
- }
420
- catch (err) {
421
- if (err instanceof DeviceFlowExpired) {
422
- await prompter.text("Setup code expired. Press Enter for a new one.");
423
- continue;
424
- }
425
- throw err;
426
- }
427
- }
428
- }
429
- async function initDeviceCode(idpBaseUrl) {
430
- const params = new URLSearchParams();
431
- params.set("client_id", OAUTH_CLIENT_ID);
432
- params.set("scope", SCOPE);
433
- const res = await fetch(`${idpBaseUrl}/api/auth/device/code`, {
434
- method: "POST",
435
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
436
- body: params.toString(),
437
- });
438
- if (!res.ok) {
439
- // 4xx at initiation → likely a misconfigured client_id or scope. Surface
440
- // a clear message rather than retrying — this is a setup error, not a
441
- // transient one.
442
- const body = (await res.json().catch(() => ({})));
443
- const code = typeof body.error === "string" ? body.error : "unknown";
444
- const description = typeof body.error_description === "string"
445
- ? body.error_description
446
- : `IdP returned ${res.status}`;
447
- throw new DeviceFlowError(`Device code initiation failed (${code}): ${description}. ` +
448
- `Verify that "${OAUTH_CLIENT_ID}" is registered with the device_code grant ` +
449
- `and is in the deviceAuthorization plugin's allowlist at the IdP.`);
450
- }
451
- return (await res.json());
452
- }
453
- /** @deprecated W6 cleanup — see `deviceCodeFlow` JSDoc. */
454
- // biome-ignore lint/correctness/noUnusedVariables: retained as @deprecated reference for W6 cleanup
455
- async function pollUntilAuthorized(idpBaseUrl, deviceCode, intervalSeconds, prompter) {
456
- let interval = intervalSeconds;
457
- for (;;) {
458
- const params = new URLSearchParams();
459
- params.set("grant_type", "urn:ietf:params:oauth:grant-type:device_code");
460
- params.set("device_code", deviceCode);
461
- params.set("client_id", OAUTH_CLIENT_ID);
462
- const res = await fetch(`${idpBaseUrl}/api/auth/device/token`, {
463
- method: "POST",
464
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
465
- body: params.toString(),
466
- });
467
- if (res.ok) {
468
- const success = (await res.json());
469
- return success.access_token;
470
- }
471
- const body = (await res.json().catch(() => ({})));
472
- if (body.error === "authorization_pending") {
473
- // User hasn't approved yet — wait and ask if they want to continue.
474
- const retry = await prompter.confirm("Not authorized yet. Have you entered the code in your browser?");
475
- if (!retry) {
476
- // User gave up on this code — same recovery as expiration: ask
477
- // the outer loop to restart with a fresh code. Throwing
478
- // DeviceFlowExpired (rather than a separate "abandoned" sentinel)
479
- // is intentional — both paths recover identically.
480
- throw new DeviceFlowExpired();
481
- }
482
- await sleep(interval * 1000);
483
- continue;
484
- }
485
- if (body.error === "slow_down") {
486
- // RFC 8628 §3.5 — server requests we slow down. Bump interval +5s.
487
- interval += 5;
488
- await sleep(interval * 1000);
489
- continue;
490
- }
491
- if (body.error === "expired_token") {
492
- throw new DeviceFlowExpired();
493
- }
494
- if (body.error === "access_denied") {
495
- throw new DeviceFlowError("Authorization was denied. Re-run setup if this was a mistake.");
496
- }
497
- // Unknown error — bail with whatever we got from the server.
498
- throw new DeviceFlowError(`Device token poll failed: ${body.error ?? "unknown"} ` +
499
- `(${body.error_description ?? "no description"})`);
500
- }
501
- }
502
146
  function sleep(ms) {
503
147
  return new Promise((resolve) => {
504
148
  setTimeout(resolve, ms);
505
149
  });
506
150
  }
507
- // ---------------------------------------------------------------------------
508
- // Onboard (agent select / create)
509
- // ---------------------------------------------------------------------------
510
- /** @deprecated W6 cleanup — see `deviceCodeFlow` JSDoc. */
511
- // biome-ignore lint/correctness/noUnusedVariables: retained as @deprecated reference for W6 cleanup
512
- async function agentSetup(apiHost, accessToken, prompter) {
513
- const platformCfg = { apiHost };
514
- // First call: empty body. The server's list mode is intentionally
515
- // non-mutating — it returns 412 (no agents) or 422 (here are your agents,
516
- // pick one). Rotation only happens on the explicit `{agentId}` call after
517
- // the user picks. Even with a single agent, the picker shows so the user
518
- // explicitly confirms before any existing Runtime is evicted.
519
- const listResult = await onboard(platformCfg, accessToken, {});
520
- if (listResult.kind === "ok") {
521
- // List mode never returns 200 by contract. If a future server version
522
- // changes that, surface explicitly rather than silently accepting a
523
- // rotation the user didn't confirm.
524
- throw new DeviceFlowError("Server returned an unexpected 200 from the list call — refusing " +
525
- "to use it without explicit user selection. Please report this.");
526
- }
527
- if (listResult.data.code === "no_agents") {
528
- // Zero agents — prompt for handle to create one.
529
- return await createAgentLoop(platformCfg, accessToken, prompter);
530
- }
531
- if (listResult.data.code === "agent_required") {
532
- // Show picker. Single-agent case still goes through the picker (one
533
- // agent + "Create new" choice) so the user explicitly confirms which
534
- // agent this Runtime is claiming. The selection triggers api_key
535
- // rotation — see "single-driver Node semantic" in CHANGELOG.
536
- const agents = listResult.data.agents;
537
- const choices = [
538
- ...agents.map((a) => a.name ? `@${a.handle} (${a.name})` : `@${a.handle}`),
539
- CREATE_NEW_OPTION,
540
- ];
541
- const promptLabel = agents.length === 1
542
- ? "You have one agent. Confirm to claim it for this Runtime " +
543
- "(its api_key will be rotated; any previously-connected Runtime " +
544
- "for this agent will be disconnected):"
545
- : "You have multiple agents. Pick one to claim for this Runtime " +
546
- "(its api_key will be rotated; any previously-connected Runtime " +
547
- "for the chosen agent will be disconnected):";
548
- const selected = await prompter.select(promptLabel, choices);
549
- if (selected === CREATE_NEW_OPTION) {
550
- return await createAgentLoop(platformCfg, accessToken, prompter);
551
- }
552
- const idx = choices.indexOf(selected);
553
- const agent = agents[idx];
554
- if (!agent) {
555
- throw new DeviceFlowError("Picker returned an unrecognized choice. Please re-run setup.");
556
- }
557
- const selectResult = await onboard(platformCfg, accessToken, {
558
- agentId: agent.id,
559
- });
560
- if (selectResult.kind !== "ok") {
561
- throw new DeviceFlowError(`Agent selection failed: ${selectResult.data.code}`);
562
- }
563
- return selectResult.data;
564
- }
565
- // invalid_handle / handle_taken can only happen on a `create` call —
566
- // not from an empty `{}` first call.
567
- throw new DeviceFlowError(`Unexpected onboard error on initial list call: ${listResult.data.code}`);
568
- }
569
- /** @deprecated W6 cleanup — see `deviceCodeFlow` JSDoc. */
570
- // biome-ignore lint/correctness/noUnusedVariables: retained as @deprecated reference for W6 cleanup
571
- async function createAgentLoop(platformCfg, accessToken, prompter) {
572
- for (;;) {
573
- const handle = await prompter.text("Choose a handle for your Agent (3-15 chars, start with a letter, then letters/numbers/hyphens/underscores):");
574
- // Lowercase + trim mirrors the server's normalizeHandle step. If the user
575
- // typed "Alice", we test "alice" against the regex — same outcome the
576
- // server would produce. Prevents the false-negative case where a valid
577
- // handle is rejected client-side just for case.
578
- const normalized = handle.trim().toLowerCase();
579
- // Client-side fail-fast — saves a server round-trip on obvious mistakes.
580
- // Server's validateHandle is the authoritative source for both format
581
- // and the reserved-handle list.
582
- if (!HANDLE_REGEX.test(normalized)) {
583
- await prompter.text(`"${handle}" must be 3-15 chars, start with a letter, then only letters/numbers/hyphens/underscores. Press Enter to try again.`);
584
- continue;
585
- }
586
- const result = await onboard(platformCfg, accessToken, {
587
- create: { handle: normalized },
588
- });
589
- if (result.kind === "ok") {
590
- return result.data;
591
- }
592
- if (result.data.code === "handle_taken") {
593
- await prompter.text(`"${normalized}" is already taken. Press Enter to try another.`);
594
- continue;
595
- }
596
- if (result.data.code === "invalid_handle") {
597
- await prompter.text(`"${normalized}" failed server validation: ${result.data.message}. Press Enter to try again.`);
598
- continue;
599
- }
600
- // Other structured errors shouldn't reach here on a create call —
601
- // surface with whatever we got.
602
- throw new DeviceFlowError(`Unexpected onboard error on create: ${result.data.code}`);
603
- }
604
- }
605
- // ---------------------------------------------------------------------------
606
- // Clack-backed Prompter adapter
607
- // ---------------------------------------------------------------------------
608
- /**
609
- * Build a Prompter backed by `@clack/prompts` (the same library OpenClaw CLI
610
- * uses internally, so the terminal UX is consistent with other channel
611
- * plugins). Cancel sentinels (user Ctrl+C) are mapped to `DeviceFlowError`
612
- * so the login function can treat cancels as a clean exit condition.
613
- *
614
- * @deprecated W6 cleanup — only the `@deprecated` device-flow helpers
615
- * consume this. The active server-bridge handoff
616
- * (`serverBridgeHandoffFlow`) uses `clack` indirectly via the
617
- * spinner-style stdout writes in `runtime.writeStdout`.
618
- */
619
- // biome-ignore lint/correctness/noUnusedVariables: retained for the @deprecated device-flow helpers
620
- function createClackPrompter() {
621
- const abortOnCancel = (result) => {
622
- if (isCancel(result)) {
623
- // Emit clack's canonical cancel UI (bracketed red "cancelled") here so
624
- // the user sees a clean exit indicator, then throw `CancelError` so the
625
- // outer `login` catch returns silently (avoids printing the same line
626
- // twice — UI from `cancel()` + a redundant `runtime.error` afterwards).
627
- cancel("Setup cancelled.");
628
- throw new CancelError("Setup cancelled by user.");
629
- }
630
- };
631
- return {
632
- async text(label) {
633
- // `text(label, opts?.default)` previously mapped `opts.default` to clack's
634
- // `placeholder` — but clack's `placeholder` is hint-only (not auto-submitted
635
- // on empty input), and no current caller passes `opts.default`. Dropped
636
- // the mapping until a real default-value need surfaces; revisit by
637
- // implementing `clackText({ message, defaultValue })` then.
638
- const result = await clackText({ message: label });
639
- abortOnCancel(result);
640
- return result;
641
- },
642
- async confirm(label) {
643
- const result = await clackConfirm({ message: label });
644
- abortOnCancel(result);
645
- return result;
646
- },
647
- async select(label, choices) {
648
- // Narrow the generic — we use `string` values throughout the flow.
649
- const result = await clackSelect({
650
- message: label,
651
- options: choices.map((choice) => ({ value: choice, label: choice })),
652
- });
653
- abortOnCancel(result);
654
- return result;
655
- },
656
- };
657
- }
658
- // ---------------------------------------------------------------------------
659
- // Public hook — `plugin.auth.login` (OpenClaw 2026.4.x contract)
660
- // ---------------------------------------------------------------------------
661
- /**
662
- * Channel login hook — invoked by `openclaw channels login --channel agent-network`.
663
- *
664
- * Unlike the legacy `configureInteractive` contract (which returned a config
665
- * object for the CLI to persist), the 2026.4.x `auth.login` contract returns
666
- * void and expects the plugin to persist any config changes itself. We use
667
- * `writeCredentials()` in config.ts, which writes `openclaw.json` atomically
668
- * and bumps the state-cache generation so Layer B's dynamic context re-reads
669
- * on the next turn.
670
- *
671
- * Flow (v0.5.14):
672
- * 1. Run `serverBridgeHandoffFlow` against `apps/api` + `apps/web` —
673
- * an encrypted server-bridge handoff that delivers the plaintext
674
- * `masons_rt_…` token from the browser to the plugin via apps/api's
675
- * Redis cache (storing ciphertext only). RSA-OAEP-256 wraps an
676
- * ephemeral AES-256-GCM key the browser generates; CLI's RSA
677
- * private key (held only in plugin process memory) decrypts the
678
- * AES key in-process to recover the plaintext.
679
- * 2. Resolve the connector URL — preserve any user-set
680
- * `accounts.default.connectorUrl` from `openclaw.json` (for self-
681
- * hosted topologies); fall back to the cloud derivation
682
- * `wss://<apiHost>/gateway` matching the connector's
683
- * `CONNECTOR_PLUGIN_URL` env default.
684
- * 3. Persist `{connectorUrl, token}` atomically.
685
- *
686
- * Errors during the handoff are surfaced via `ctx.runtime.error` (not
687
- * thrown) so OpenClaw CLI doesn't display a stack trace — matches the
688
- * WhatsApp/Feishu pattern.
689
- */
690
151
  export async function login(ctx) {
691
- // Channel-level config (`cfg.apiHost`, `cfg.idpBaseUrl`) — both have
692
- // defaults. Existing values from openclaw.json are preserved on re-run.
693
152
  const apiHost = typeof ctx.cfg.apiHost === "string" ? ctx.cfg.apiHost : DEFAULT_API_HOST;
694
153
  const idpBaseUrl = typeof ctx.cfg.idpBaseUrl === "string"
695
154
  ? ctx.cfg.idpBaseUrl
@@ -699,15 +158,7 @@ export async function login(ctx) {
699
158
  handoff = await serverBridgeHandoffFlow(apiHost, idpBaseUrl, ctx.runtime, ctx.channelInput);
700
159
  }
701
160
  catch (err) {
702
- if (err instanceof CancelError) {
703
- // User pressed Ctrl+C — clack already emitted the cancel UI.
704
- return;
705
- }
706
- if (err instanceof DeviceFlowError) {
707
- // Bridge error with a human-friendly message. Print and return
708
- // cleanly — CLI shows nothing else on a return. (The class name
709
- // is `DeviceFlowError` for legacy reasons; it serves as a
710
- // generic "expected setup failure" sentinel for both flows.)
161
+ if (err instanceof SetupFlowError) {
711
162
  ctx.runtime.error(err.message);
712
163
  return;
713
164
  }
@@ -715,16 +166,11 @@ export async function login(ctx) {
715
166
  ctx.runtime.error(`Setup failed (HTTP ${err.status} ${err.code}): ${err.message}`);
716
167
  return;
717
168
  }
718
- // Unknown error — let CLI surface the stack. Surfaces platform bugs
719
- // that aren't covered by our structured error types.
720
169
  throw err;
721
170
  }
722
- // `connectorUrl` is decoupled from `apps/api` (the issuance host) by
723
- // design apps/api does not know about connector deployment topology.
724
- // Preserve any explicit user-set value (self-hosted) and fall back to
725
- // the well-known cloud / preview shape (`wss://<apiHost>/gateway`).
726
- const existingConnectorUrl = await loadExistingConnectorUrl();
727
- const connectorUrl = existingConnectorUrl ?? deriveCloudConnectorUrl(apiHost);
171
+ const existingConnectorUrl = await readExistingConnectorUrl();
172
+ const configuredConnectorUrl = typeof ctx.cfg.connectorUrl === "string" ? ctx.cfg.connectorUrl : undefined;
173
+ const connectorUrl = existingConnectorUrl ?? configuredConnectorUrl ?? DEFAULT_CONNECTOR_URL;
728
174
  await writeCredentials({ connectorUrl, token: handoff.token }, apiHost, idpBaseUrl);
729
175
  ctx.runtime.log(`✓ Connected as @${handoff.agent.handle}`);
730
176
  }