@masons/agent-network 0.5.13 → 0.5.15

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 -93
  5. package/dist/cli-setup.d.ts.map +1 -1
  6. package/dist/cli-setup.js +124 -741
  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 +57 -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,776 +1,164 @@
1
- /**
2
- * CLI Login Path Stripe-style browser-mediated loopback handoff.
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. See WhatsApp + Feishu
10
- * bundled extensions for reference implementations of the same contract.
11
- *
12
- * Flow (W4 PR 3 — `loopbackHandoffFlow`):
13
- *
14
- * 1. Bind a one-shot HTTP listener on `127.0.0.1:0` (OS-assigned ephemeral
15
- * port). The listener serves a single `POST /handoff` plus the matching
16
- * CORS preflight, then closes.
17
- * 2. Generate a 24-byte (32-char base64url) URL-safe random nonce. The
18
- * nonce travels in the URL fragment (`#nonce=…`), never the query
19
- * string — fragments do not reach `apps/web` access logs nor referer
20
- * headers (Alt W4-B2 lock).
21
- * 3. Open the user's browser to the handoff URL (no-handle path uses
22
- * `${idpBaseUrl}/console/handoff?port=<port>#nonce=<nonce>`; with
23
- * `--handle` the per-handle deep-link variant is the entry).
24
- * 4. The user authenticates against Better Auth on `apps/web`, picks (or
25
- * creates) an agent, and the page POSTs `{token, agent, key, nonce}`
26
- * to the listener. The listener verifies the body's `nonce` matches
27
- * the value it placed in the fragment, accepts the token, and closes.
28
- * 5. Persist `{connectorUrl, token}` to `openclaw.json` via
29
- * `writeCredentials()`. `connectorUrl` is derived from `apiHost`
30
- * (`wss://<apiHost>/gateway`) for cloud / preview deployments; self-
31
- * hosted users keep an explicit `connectorUrl` in their `openclaw.json`
32
- * and the legacy value is preserved across the handoff (see
33
- * `loadExistingConnectorUrl()`).
34
- *
35
- * Why move off device flow:
36
- *
37
- * - One step shorter (no `user_code` to type into the browser).
38
- * - No periodic IdP polling (was 5s default, 5+s on `slow_down`).
39
- * - Browser remains fully authenticated across login and key issuance —
40
- * same Better Auth session is reused, so the user never re-types their
41
- * password mid-flow.
42
- * - The runtime API key carrier (`masons_rt_…`) replaces the legacy
43
- * connector-minted `sk-…` carrier; W4 PR 1 substrate plus the connector
44
- * mirror wiring earlier in this PR make both carriers verifiable through
45
- * plugin v0.5.13's life until the W6 cleanup retires the legacy path.
46
- *
47
- * The legacy device-flow helpers (`deviceCodeFlow`, `pollUntilAuthorized`,
48
- * `agentSetup`, `createAgentLoop`) remain in this file as
49
- * `@deprecated` references for the W6 cleanup window. They have no call
50
- * site after the migration and should not be invoked by any new code.
51
- *
52
- * See #1264 for original design rationale (single-driver Node semantic —
53
- * re-running this flow rotates the runtime key and evicts any previously-
54
- * connected Runtime); see the W4 strategic-pass at
55
- * `#1466 issuecomment-4362892782` for the loopback handoff lock.
56
- */
57
- import { randomBytes } from "node:crypto";
58
- import { readFile } from "node:fs/promises";
59
- import { createServer, } from "node:http";
60
- import { homedir } from "node:os";
61
- import { join } from "node:path";
62
- import { cancel, confirm as clackConfirm, select as clackSelect, text as clackText, isCancel, } from "@clack/prompts";
63
- import { writeCredentials } from "./config.js";
64
- import { DEFAULT_API_HOST, onboard, PlatformApiError, } from "./platform-client.js";
65
- // ---------------------------------------------------------------------------
66
- // Constants
67
- // ---------------------------------------------------------------------------
68
- /**
69
- * Better Auth IdP base URL. Default targets the preview environment.
70
- *
71
- * TODO: flip to the production URL when W8 (api.masons.ai consolidation)
72
- * lands. Plugins shipped with this default before that flip will still
73
- * work — the user can override by setting `idpBaseUrl` in their
74
- * `openclaw.json` channel config (or by re-running setup which writes
75
- * whatever is current).
76
- */
1
+ import { createDecipheriv, constants as cryptoConstants, generateKeyPairSync, privateDecrypt, } from "node:crypto";
2
+ import { readExistingConnectorUrl, writeCredentials } from "./config.js";
3
+ import { DEFAULT_API_HOST, DEFAULT_CONNECTOR_URL, PlatformApiError, } from "./platform-client.js";
77
4
  const DEFAULT_IDP_BASE_URL = "https://preview.masons.ai";
78
- /**
79
- * OAuth client_id registered for OpenClaw at the IdP — see
80
- * `apps/web/scripts/seed.ts`. Hardcoded because there's only one OpenClaw
81
- * client today; if multiple wrappers (e.g. self-hosted vs Claude Desktop
82
- * variant) ever need distinct client_ids, expose this via the channel
83
- * config schema.
84
- */
85
- const OAUTH_CLIENT_ID = "openclaw";
86
- /**
87
- * Scopes requested at device-flow initiation. `openid` + `profile` + `email`
88
- * are the standard OIDC scopes; `offline_access` requests a refresh token
89
- * (Better Auth uses session tokens here, not OAuth opaque tokens — the
90
- * scope is mostly indicative). Stays in sync with the scopes registered on
91
- * the `oauthProvider` plugin at apps/web/lib/auth.ts.
92
- */
93
- const SCOPE = "openid profile email offline_access";
94
- /** Default polling interval if the IdP does not return one. */
95
- const DEFAULT_POLL_INTERVAL_S = 5;
96
- const CREATE_NEW_OPTION = "Create new agent";
97
- // Handle pre-validation regex — strict subset of the server's authoritative
98
- // `validateHandle` (packages/db/src/utils/handle-validation.ts:3). Used to
99
- // fail-fast in the terminal before round-tripping the server. Kept inline
100
- // because the published npm plugin can't import @workspace/db.
101
- //
102
- // Server contract: `/^[a-z][a-z0-9_-]{2,14}$/` against the lowercased input.
103
- // MUST be a strict subset (false-negatives OK, false-positives forbidden) —
104
- // otherwise the user sees "passed client check" then "server rejected" for
105
- // the same input, which is confusing.
106
- //
107
- // Reserved-handle check (e.g., "masons", "openclaw") is server-only — too
108
- // volatile for the plugin to track; the round-trip is acceptable for that.
109
5
  const HANDLE_REGEX = /^[a-z][a-z0-9_-]{2,14}$/;
110
- /**
111
- * Path under the loopback origin where `apps/web` POSTs the handoff payload.
112
- * The listener accepts a CORS preflight on the same path. Pinning to a
113
- * single path lets the listener reject any other request URL — defense in
114
- * depth against a malicious local process binding the same port between
115
- * the listener `listen()` and the browser POST.
116
- */
117
- const HANDOFF_PATH = "/handoff";
118
- /**
119
- * Hard cap on how long the listener waits for the browser POST. Long
120
- * enough for the user to walk through sign-in + agent picker + handoff,
121
- * short enough that a wedged listener doesn't sit on a port forever.
122
- */
123
6
  const HANDOFF_TIMEOUT_MS = 5 * 60 * 1000;
124
- /** Bytes of entropy in the one-time fragment nonce. */
125
- const HANDOFF_NONCE_BYTES = 24;
126
- /**
127
- * Run the Stripe-style loopback handoff. Returns the runtime API key
128
- * + the agent identity the browser issued it for. The listener stops
129
- * after a single successful POST; subsequent reuse of the URL is
130
- * rejected.
131
- *
132
- * `channelInput` is the optional argument from
133
- * `openclaw channels login --channel agent-network -- <input>`. When
134
- * the input parses as a handle (`^[a-z][a-z0-9_-]{2,14}$`) the flow
135
- * uses the per-handle deep-link entry; otherwise it falls back to the
136
- * picker. Anything that isn't a handle prints a warning and falls
137
- * through to the picker.
138
- */
139
- async function loopbackHandoffFlow(idpBaseUrl, runtime, channelInput) {
140
- const nonce = randomBytes(HANDOFF_NONCE_BYTES).toString("base64url");
141
- // Bind the listener BEFORE printing the URL — the URL must carry the
142
- // OS-assigned port and the listener must be ready to accept the POST
143
- // before the user has any chance of opening the link.
144
- const { server, port } = await bindLoopbackListener();
145
- // The Promise we hand the inner request handler — resolved on a
146
- // valid POST. The reject path is owned by the timeout race below
147
- // (`Promise.race`); the request handler never produces a "fatal"
148
- // error worth rejecting the outer promise — invalid bodies / wrong
149
- // nonces / duplicate posts respond 4xx and let the listener keep
150
- // running until the timeout or a valid POST wins.
151
- let resolveResult = null;
152
- const handoffPromise = new Promise((res) => {
153
- resolveResult = res;
7
+ const HANDOFF_POLL_INTERVAL_MS = 2000;
8
+ class SetupFlowError extends Error {
9
+ }
10
+ async function serverBridgeHandoffFlow(apiHost, idpBaseUrl, runtime, channelInput) {
11
+ const { publicKey, privateKey } = generateKeyPairSync("rsa", {
12
+ modulusLength: 2048,
154
13
  });
155
- const browserOriginAllowList = computeAllowedBrowserOrigins(idpBaseUrl);
156
- let dispatched = false;
157
- server.on("request", (req, res) => {
158
- // Two distinct security boundaries are layered on this listener and
159
- // it's worth keeping them straight:
160
- //
161
- // - The browser-origin allow-list (`computeAllowedBrowserOrigins`
162
- // + ACAO echo) closes the cross-origin browser-scrape surface
163
- // where a malicious page in the user's already-authenticated
164
- // browser tries to read the listener's response. ACAO is what
165
- // stops the browser from delivering the response to a non-
166
- // apps/web origin.
167
- // - The fragment nonce closes the local-process-race surface
168
- // where another process on the same machine binds the same
169
- // port between `server.listen()` and the browser POST. A
170
- // malicious local process can fake any `Origin` header at
171
- // will, but it cannot guess the 24-byte URL-fragment nonce
172
- // the listener generated.
173
- //
174
- // CORS preflight (`OPTIONS /handoff`) is browser-only — non-browser
175
- // local processes do not preflight. We answer 204 with ACAO pinned
176
- // to the request's origin if it is in the allow-list; otherwise
177
- // 403 with no headers (browser blocks the subsequent POST).
178
- const origin = req.headers.origin ?? "";
179
- if (req.method === "OPTIONS" && req.url === HANDOFF_PATH) {
180
- if (browserOriginAllowList.has(origin)) {
181
- res.writeHead(204, {
182
- "Access-Control-Allow-Origin": origin,
183
- "Access-Control-Allow-Methods": "POST, OPTIONS",
184
- "Access-Control-Allow-Headers": "content-type",
185
- "Access-Control-Max-Age": "60",
186
- });
187
- }
188
- else {
189
- res.writeHead(403);
190
- }
191
- res.end();
192
- return;
14
+ const cliPubkeySpki = publicKey
15
+ .export({ type: "spki", format: "der" })
16
+ .toString("base64url");
17
+ const apiBase = normalizeHttpBase(apiHost);
18
+ let session_id;
19
+ try {
20
+ const initRes = await fetch(`${apiBase}/v1/cli-handoff/init`, {
21
+ method: "POST",
22
+ headers: { "content-type": "application/json" },
23
+ body: JSON.stringify({ cli_pubkey: cliPubkeySpki }),
24
+ });
25
+ if (!initRes.ok) {
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.`);
193
27
  }
194
- if (req.method !== "POST" || req.url !== HANDOFF_PATH) {
195
- res.writeHead(404);
196
- res.end();
197
- return;
28
+ const initBody = (await initRes.json());
29
+ if (typeof initBody.session_id !== "string" ||
30
+ initBody.session_id.length === 0) {
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.");
198
32
  }
199
- if (!browserOriginAllowList.has(origin)) {
200
- res.writeHead(403);
201
- res.end();
202
- return;
203
- }
204
- // Read the POST body with a hard cap. The expected payload is
205
- // ~1 KB; reject anything larger as protection against a wedged
206
- // attacker streaming bytes into the listener.
207
- const MAX_BODY = 64 * 1024;
208
- const chunks = [];
209
- let total = 0;
210
- let aborted = false;
211
- req.on("data", (chunk) => {
212
- if (aborted)
213
- return;
214
- total += chunk.length;
215
- if (total > MAX_BODY) {
216
- aborted = true;
217
- // Origin already validated against the allow-list above; ACAO
218
- // is therefore safe to echo and lets the browser surface the
219
- // 413 status to the page (rather than an opaque CORS error).
220
- res.writeHead(413, { "Access-Control-Allow-Origin": origin });
221
- res.end();
222
- req.destroy();
223
- return;
224
- }
225
- chunks.push(chunk);
226
- });
227
- req.on("end", () => {
228
- if (aborted)
229
- return;
230
- let parsed = null;
231
- try {
232
- parsed = JSON.parse(Buffer.concat(chunks).toString("utf-8"));
233
- }
234
- catch {
235
- res.writeHead(400, { "Access-Control-Allow-Origin": origin });
236
- res.end();
237
- return;
238
- }
239
- if (!parsed ||
240
- typeof parsed.token !== "string" ||
241
- typeof parsed.nonce !== "string" ||
242
- !parsed.agent ||
243
- typeof parsed.agent.agentId !== "string" ||
244
- typeof parsed.agent.handle !== "string") {
245
- res.writeHead(400, { "Access-Control-Allow-Origin": origin });
246
- res.end();
247
- return;
248
- }
249
- if (parsed.nonce !== nonce) {
250
- // Wrong nonce — possibly a stale browser tab from an earlier
251
- // login attempt that lost the race for the port, or a
252
- // malicious local process. Reject without revealing why.
253
- res.writeHead(403, { "Access-Control-Allow-Origin": origin });
254
- res.end();
255
- return;
256
- }
257
- if (dispatched) {
258
- // The listener is one-shot. Any subsequent POST after the
259
- // first valid one is suspect — the browser succeeded once
260
- // already; nothing else should be hitting this URL.
261
- res.writeHead(409, { "Access-Control-Allow-Origin": origin });
262
- res.end();
263
- return;
264
- }
265
- dispatched = true;
266
- res.writeHead(200, { "Access-Control-Allow-Origin": origin });
267
- res.end();
268
- resolveResult?.({
269
- token: parsed.token,
270
- agent: {
271
- agentId: parsed.agent.agentId,
272
- handle: parsed.agent.handle,
273
- name: typeof parsed.agent.name === "string"
274
- ? parsed.agent.name
275
- : parsed.agent.handle,
276
- },
277
- });
278
- });
279
- req.on("error", () => {
280
- if (!dispatched) {
281
- // Connection-level error before we got a body. Log nothing —
282
- // half-formed requests are noisy and usually irrelevant.
283
- }
284
- });
285
- });
286
- // The handoff URL the user (or `open`/`xdg-open`) navigates to. The
287
- // nonce sits in the fragment so it never lands in apps/web access
288
- // logs nor referer headers.
33
+ session_id = initBody.session_id;
34
+ }
35
+ catch (err) {
36
+ if (err instanceof SetupFlowError)
37
+ throw err;
38
+ const message = err instanceof Error ? err.message : "unknown error";
39
+ throw new SetupFlowError(`Could not reach the setup service at ${apiBase} (${message}). Retry in a moment.`);
40
+ }
289
41
  const handle = parseHandleFromInput(channelInput);
290
- const baseUrl = handle
291
- ? `${idpBaseUrl}/console/agents/${encodeURIComponent(handle)}/runtime-keys/handoff`
292
- : `${idpBaseUrl}/console/handoff`;
293
- const url = `${baseUrl}?port=${port}#nonce=${nonce}`;
42
+ const handoffPath = handle
43
+ ? `/console/agents/${encodeURIComponent(handle)}/runtime-keys/handoff`
44
+ : `/console/handoff`;
45
+ const handoffUrl = `${idpBaseUrl}${handoffPath}?session=${encodeURIComponent(session_id)}`;
294
46
  runtime.writeStdout([
295
47
  "",
296
- "Open this link in your browser to finish the handoff:",
297
- ` ${url}`,
48
+ "Open this link in your browser to finish setup:",
49
+ ` ${handoffUrl}`,
298
50
  "",
299
- "Waiting for the browser to deliver the runtime key…",
51
+ "Waiting for the runtime key to be issued and delivered…",
300
52
  "",
301
53
  ].join("\n"));
302
- // Best-effort spawn `open` / `xdg-open` / `start` so the browser
303
- // pops automatically. Failure is non-fatal — the printed URL is the
304
- // documented fallback.
305
- void tryOpenBrowser(url, runtime);
306
- // Race the listener against the timeout. Unwrap whichever wins.
307
- const timeoutPromise = new Promise((_, rej) => {
308
- setTimeout(() => {
309
- rej(new DeviceFlowError("Loopback handoff timed out without receiving the runtime key. " +
310
- "Re-run setup; if your browser cannot reach 127.0.0.1, copy the " +
311
- "URL into a browser on the same machine."));
312
- }, HANDOFF_TIMEOUT_MS).unref();
313
- });
314
- try {
315
- return await Promise.race([handoffPromise, timeoutPromise]);
316
- }
317
- finally {
318
- // Closing the listener after the first valid POST mirrors the
319
- // "one-shot accept" discipline. Outstanding sockets (e.g., a
320
- // preflight that arrived after the dispatch) are dropped on close.
321
- server.close();
322
- }
323
- }
324
- async function bindLoopbackListener() {
325
- return await new Promise((resolve, reject) => {
326
- const server = createServer();
327
- server.on("error", reject);
328
- // `127.0.0.1` (not `0.0.0.0`) — only the local machine can reach
329
- // the listener. Browsers permit cross-origin fetch from HTTPS to
330
- // 127.0.0.1 because it is a "potentially trustworthy origin"
331
- // (W3C secure-contexts spec).
332
- server.listen(0, "127.0.0.1", () => {
333
- const addr = server.address();
334
- if (!addr || typeof addr === "string") {
335
- reject(new Error("Failed to bind loopback listener — no address"));
336
- return;
337
- }
338
- resolve({ server, port: addr.port });
339
- });
340
- });
341
- }
342
- /**
343
- * Compute the allowed browser origins for the cross-origin POST. The
344
- * allow-list pins to the exact apps/web origin (scheme + host + port)
345
- * so a local malicious process bound to a different origin cannot
346
- * scrape the listener even if it happens to discover the port.
347
- *
348
- * Both the bare `idpBaseUrl` and (for development) `http://localhost:3007`
349
- * are allowed when `idpBaseUrl` resolves to a localhost hostname so the
350
- * `pnpm dev` workflow works without flag-flipping.
351
- */
352
- function computeAllowedBrowserOrigins(idpBaseUrl) {
353
- const origins = new Set();
54
+ const envelope = await pollForCompletion(apiBase, session_id);
55
+ let payload;
354
56
  try {
355
- const url = new URL(idpBaseUrl);
356
- origins.add(url.origin);
357
- if (url.hostname === "localhost" || url.hostname === "127.0.0.1") {
358
- origins.add("http://localhost:3007");
359
- origins.add("http://127.0.0.1:3007");
360
- }
361
- }
362
- catch {
363
- // Malformed idpBaseUrl — fall through with an empty allow-list.
364
- // Every browser POST will be 403'd; the user sees a timeout.
365
- }
366
- return origins;
367
- }
368
- /**
369
- * Best-effort browser launch. Returns immediately; failures are
370
- * surfaced as a quiet log rather than a thrown error — the user has
371
- * the printed URL as a documented fallback.
372
- */
373
- async function tryOpenBrowser(url, runtime) {
374
- const { spawn } = await import("node:child_process");
375
- const platform = process.platform;
376
- const cmd = platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open";
377
- const args = platform === "win32" ? ["/c", "start", "", url] : [url];
378
- try {
379
- const child = spawn(cmd, args, {
380
- detached: true,
381
- stdio: "ignore",
382
- });
383
- child.on("error", (err) => {
384
- runtime.log(`(could not auto-open browser: ${err.message} — copy the link above)`);
385
- });
386
- child.unref();
57
+ payload = decryptBridgeEnvelope(privateKey, envelope);
387
58
  }
388
59
  catch (err) {
389
60
  const message = err instanceof Error ? err.message : "unknown error";
390
- runtime.log(`(could not auto-open browser: ${message} — copy the link above)`);
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.`);
391
62
  }
392
- }
393
- /**
394
- * Parse `channelInput` as a handle. Returns the lowercased handle on
395
- * match, `undefined` otherwise. Mirrors the `HANDLE_REGEX` used in the
396
- * legacy device flow's create form.
397
- */
398
- function parseHandleFromInput(input) {
399
- if (typeof input !== "string")
400
- return undefined;
401
- const trimmed = input.trim().toLowerCase();
402
- if (!HANDLE_REGEX.test(trimmed))
403
- return undefined;
404
- return trimmed;
405
- }
406
- /**
407
- * Read the existing `accounts.default.connectorUrl` from `openclaw.json`
408
- * if present. Used to preserve the self-hosted user's deployment-specific
409
- * connector URL across the handoff — the loopback flow does not learn
410
- * it from `apps/api` (issuance is decoupled from connector topology),
411
- * but we don't want to clobber a user-set value.
412
- *
413
- * Returns `undefined` when the file is absent, malformed, or doesn't
414
- * contain a connectorUrl. Caller falls back to `deriveCloudConnectorUrl`.
415
- */
416
- async function loadExistingConnectorUrl() {
417
- const home = process.env.OPENCLAW_HOME || join(homedir(), ".openclaw");
418
- const path = join(home, "openclaw.json");
419
- try {
420
- const raw = await readFile(path, "utf-8");
421
- const config = JSON.parse(raw);
422
- const channels = config.channels;
423
- const network = channels?.["agent-network"];
424
- const accounts = network?.accounts;
425
- const def = accounts?.default;
426
- const value = def?.connectorUrl;
427
- if (typeof value === "string" && value.length > 0)
428
- return value;
429
- }
430
- catch {
431
- // file missing / malformed / unreadable → fall through to derivation
63
+ if (typeof payload.token !== "string" ||
64
+ !payload.token.startsWith("masons_rt_v1_") ||
65
+ typeof payload.agent?.handle !== "string" ||
66
+ typeof payload.agent?.agentId !== "string") {
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.");
432
68
  }
433
- return undefined;
434
- }
435
- /**
436
- * Derive the WebSocket connector URL from the apiHost. Matches the
437
- * connector's `CONNECTOR_PLUGIN_URL` env default
438
- * (`wss://<host>/gateway`) for cloud and preview deployments.
439
- *
440
- * Self-hosted users running a non-standard topology should set
441
- * `accounts.default.connectorUrl` explicitly in `openclaw.json`; the
442
- * loopback flow preserves that value via `loadExistingConnectorUrl`.
443
- */
444
- function deriveCloudConnectorUrl(apiHost) {
445
- // Strip any incidental scheme so we never produce `wss://https://…`.
446
- const host = apiHost.replace(/^https?:\/\//, "");
447
- return `wss://${host}/gateway`;
448
- }
449
- class DeviceFlowError extends Error {
450
- }
451
- class DeviceFlowExpired extends Error {
452
- }
453
- /**
454
- * User pressed Ctrl+C at any prompter step. Subclass of `DeviceFlowError`
455
- * so the existing throw/catch chain still works, but distinguished so the
456
- * `login` handler can skip the redundant `runtime.error` call (clack's
457
- * `cancel()` already emitted the canonical "cancelled" UI).
458
- */
459
- class CancelError extends DeviceFlowError {
69
+ return {
70
+ token: payload.token,
71
+ agent: {
72
+ agentId: payload.agent.agentId,
73
+ handle: payload.agent.handle,
74
+ name: typeof payload.agent.name === "string"
75
+ ? payload.agent.name
76
+ : payload.agent.handle,
77
+ },
78
+ };
460
79
  }
461
- /**
462
- * Run the RFC 8628 device flow loop. Returns the access_token on success.
463
- * On expiration, restarts the loop transparently (user gets a fresh code).
464
- *
465
- * @deprecated W6 cleanup — superseded by `loopbackHandoffFlow`. No call
466
- * site after the W4 PR 3 migration; the function is kept as legacy
467
- * reference until the W6 cleanup retires the connector-side device-flow
468
- * verifier (`apps/connector/src/core/verify-device-session-token.ts`)
469
- * and the IdP-side `oauth/device/` route. Do not invoke from new code.
470
- */
471
- // biome-ignore lint/correctness/noUnusedVariables: retained as @deprecated reference for W6 cleanup
472
- async function deviceCodeFlow(idpBaseUrl, prompter) {
473
- for (;;) {
474
- const init = await initDeviceCode(idpBaseUrl);
475
- await prompter.text([
476
- "",
477
- `Setup code: ${init.user_code}`,
478
- "",
479
- `Open this link to authorize: ${init.verification_uri_complete ?? `${init.verification_uri}?user_code=${init.user_code}`}`,
480
- "",
481
- "Press Enter after you've authorized in the browser",
482
- ].join("\n"));
80
+ async function pollForCompletion(apiBase, sessionId) {
81
+ const start = Date.now();
82
+ while (Date.now() - start < HANDOFF_TIMEOUT_MS) {
83
+ let res;
483
84
  try {
484
- const accessToken = await pollUntilAuthorized(idpBaseUrl, init.device_code, init.interval ?? DEFAULT_POLL_INTERVAL_S, prompter);
485
- return accessToken;
85
+ res = await fetch(`${apiBase}/v1/cli-handoff/${encodeURIComponent(sessionId)}/poll`, { method: "GET" });
486
86
  }
487
- catch (err) {
488
- if (err instanceof DeviceFlowExpired) {
489
- await prompter.text("Setup code expired. Press Enter for a new one.");
490
- continue;
491
- }
492
- throw err;
493
- }
494
- }
495
- }
496
- async function initDeviceCode(idpBaseUrl) {
497
- const params = new URLSearchParams();
498
- params.set("client_id", OAUTH_CLIENT_ID);
499
- params.set("scope", SCOPE);
500
- const res = await fetch(`${idpBaseUrl}/api/auth/device/code`, {
501
- method: "POST",
502
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
503
- body: params.toString(),
504
- });
505
- if (!res.ok) {
506
- // 4xx at initiation → likely a misconfigured client_id or scope. Surface
507
- // a clear message rather than retrying — this is a setup error, not a
508
- // transient one.
509
- const body = (await res.json().catch(() => ({})));
510
- const code = typeof body.error === "string" ? body.error : "unknown";
511
- const description = typeof body.error_description === "string"
512
- ? body.error_description
513
- : `IdP returned ${res.status}`;
514
- throw new DeviceFlowError(`Device code initiation failed (${code}): ${description}. ` +
515
- `Verify that "${OAUTH_CLIENT_ID}" is registered with the device_code grant ` +
516
- `and is in the deviceAuthorization plugin's allowlist at the IdP.`);
517
- }
518
- return (await res.json());
519
- }
520
- /** @deprecated W6 cleanup — see `deviceCodeFlow` JSDoc. */
521
- // biome-ignore lint/correctness/noUnusedVariables: retained as @deprecated reference for W6 cleanup
522
- async function pollUntilAuthorized(idpBaseUrl, deviceCode, intervalSeconds, prompter) {
523
- let interval = intervalSeconds;
524
- for (;;) {
525
- const params = new URLSearchParams();
526
- params.set("grant_type", "urn:ietf:params:oauth:grant-type:device_code");
527
- params.set("device_code", deviceCode);
528
- params.set("client_id", OAUTH_CLIENT_ID);
529
- const res = await fetch(`${idpBaseUrl}/api/auth/device/token`, {
530
- method: "POST",
531
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
532
- body: params.toString(),
533
- });
534
- if (res.ok) {
535
- const success = (await res.json());
536
- return success.access_token;
537
- }
538
- const body = (await res.json().catch(() => ({})));
539
- if (body.error === "authorization_pending") {
540
- // User hasn't approved yet — wait and ask if they want to continue.
541
- const retry = await prompter.confirm("Not authorized yet. Have you entered the code in your browser?");
542
- if (!retry) {
543
- // User gave up on this code — same recovery as expiration: ask
544
- // the outer loop to restart with a fresh code. Throwing
545
- // DeviceFlowExpired (rather than a separate "abandoned" sentinel)
546
- // is intentional — both paths recover identically.
547
- throw new DeviceFlowExpired();
548
- }
549
- await sleep(interval * 1000);
87
+ catch (_err) {
88
+ await sleep(HANDOFF_POLL_INTERVAL_MS);
550
89
  continue;
551
90
  }
552
- if (body.error === "slow_down") {
553
- // RFC 8628 §3.5 server requests we slow down. Bump interval +5s.
554
- interval += 5;
555
- await sleep(interval * 1000);
556
- continue;
557
- }
558
- if (body.error === "expired_token") {
559
- throw new DeviceFlowExpired();
91
+ if (res.status === 404) {
92
+ throw new SetupFlowError("Handoff session expired before the runtime key was delivered. Re-run setup.");
560
93
  }
561
- if (body.error === "access_denied") {
562
- throw new DeviceFlowError("Authorization was denied. Re-run setup if this was a mistake.");
94
+ if (!res.ok) {
95
+ await sleep(HANDOFF_POLL_INTERVAL_MS);
96
+ continue;
563
97
  }
564
- // Unknown error bail with whatever we got from the server.
565
- throw new DeviceFlowError(`Device token poll failed: ${body.error ?? "unknown"} ` +
566
- `(${body.error_description ?? "no description"})`);
567
- }
98
+ const body = (await res.json());
99
+ if (body.status === "completed" && body.payload) {
100
+ return body.payload;
101
+ }
102
+ await sleep(HANDOFF_POLL_INTERVAL_MS);
103
+ }
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.");
105
+ }
106
+ function decryptBridgeEnvelope(privateKey, envelope) {
107
+ const wrappedKey = Buffer.from(envelope.wrapped_key, "base64url");
108
+ const iv = Buffer.from(envelope.iv, "base64url");
109
+ const fullCiphertext = Buffer.from(envelope.ciphertext, "base64url");
110
+ if (fullCiphertext.length < 17) {
111
+ throw new Error("ciphertext shorter than auth tag");
112
+ }
113
+ const tagOffset = fullCiphertext.length - 16;
114
+ const ciphertext = fullCiphertext.subarray(0, tagOffset);
115
+ const authTag = fullCiphertext.subarray(tagOffset);
116
+ const aesKey = privateDecrypt({
117
+ key: privateKey,
118
+ padding: cryptoConstants.RSA_PKCS1_OAEP_PADDING,
119
+ oaepHash: "sha256",
120
+ }, wrappedKey);
121
+ if (aesKey.length !== 32) {
122
+ throw new Error(`unexpected AES key length: ${aesKey.length}`);
123
+ }
124
+ const decipher = createDecipheriv("aes-256-gcm", aesKey, iv);
125
+ decipher.setAuthTag(authTag);
126
+ const plaintext = Buffer.concat([
127
+ decipher.update(ciphertext),
128
+ decipher.final(),
129
+ ]);
130
+ return JSON.parse(plaintext.toString("utf-8"));
131
+ }
132
+ function normalizeHttpBase(apiHost) {
133
+ const trimmed = apiHost.replace(/\/+$/, "");
134
+ if (/^https?:\/\//.test(trimmed))
135
+ return trimmed;
136
+ return `https://${trimmed}`;
137
+ }
138
+ function parseHandleFromInput(input) {
139
+ if (typeof input !== "string")
140
+ return undefined;
141
+ const trimmed = input.trim().toLowerCase();
142
+ if (!HANDLE_REGEX.test(trimmed))
143
+ return undefined;
144
+ return trimmed;
568
145
  }
569
146
  function sleep(ms) {
570
147
  return new Promise((resolve) => {
571
148
  setTimeout(resolve, ms);
572
149
  });
573
150
  }
574
- // ---------------------------------------------------------------------------
575
- // Onboard (agent select / create)
576
- // ---------------------------------------------------------------------------
577
- /** @deprecated W6 cleanup — see `deviceCodeFlow` JSDoc. */
578
- // biome-ignore lint/correctness/noUnusedVariables: retained as @deprecated reference for W6 cleanup
579
- async function agentSetup(apiHost, accessToken, prompter) {
580
- const platformCfg = { apiHost };
581
- // First call: empty body. The server's list mode is intentionally
582
- // non-mutating — it returns 412 (no agents) or 422 (here are your agents,
583
- // pick one). Rotation only happens on the explicit `{agentId}` call after
584
- // the user picks. Even with a single agent, the picker shows so the user
585
- // explicitly confirms before any existing Runtime is evicted.
586
- const listResult = await onboard(platformCfg, accessToken, {});
587
- if (listResult.kind === "ok") {
588
- // List mode never returns 200 by contract. If a future server version
589
- // changes that, surface explicitly rather than silently accepting a
590
- // rotation the user didn't confirm.
591
- throw new DeviceFlowError("Server returned an unexpected 200 from the list call — refusing " +
592
- "to use it without explicit user selection. Please report this.");
593
- }
594
- if (listResult.data.code === "no_agents") {
595
- // Zero agents — prompt for handle to create one.
596
- return await createAgentLoop(platformCfg, accessToken, prompter);
597
- }
598
- if (listResult.data.code === "agent_required") {
599
- // Show picker. Single-agent case still goes through the picker (one
600
- // agent + "Create new" choice) so the user explicitly confirms which
601
- // agent this Runtime is claiming. The selection triggers api_key
602
- // rotation — see "single-driver Node semantic" in CHANGELOG.
603
- const agents = listResult.data.agents;
604
- const choices = [
605
- ...agents.map((a) => a.name ? `@${a.handle} (${a.name})` : `@${a.handle}`),
606
- CREATE_NEW_OPTION,
607
- ];
608
- const promptLabel = agents.length === 1
609
- ? "You have one agent. Confirm to claim it for this Runtime " +
610
- "(its api_key will be rotated; any previously-connected Runtime " +
611
- "for this agent will be disconnected):"
612
- : "You have multiple agents. Pick one to claim for this Runtime " +
613
- "(its api_key will be rotated; any previously-connected Runtime " +
614
- "for the chosen agent will be disconnected):";
615
- const selected = await prompter.select(promptLabel, choices);
616
- if (selected === CREATE_NEW_OPTION) {
617
- return await createAgentLoop(platformCfg, accessToken, prompter);
618
- }
619
- const idx = choices.indexOf(selected);
620
- const agent = agents[idx];
621
- if (!agent) {
622
- throw new DeviceFlowError("Picker returned an unrecognized choice. Please re-run setup.");
623
- }
624
- const selectResult = await onboard(platformCfg, accessToken, {
625
- agentId: agent.id,
626
- });
627
- if (selectResult.kind !== "ok") {
628
- throw new DeviceFlowError(`Agent selection failed: ${selectResult.data.code}`);
629
- }
630
- return selectResult.data;
631
- }
632
- // invalid_handle / handle_taken can only happen on a `create` call —
633
- // not from an empty `{}` first call.
634
- throw new DeviceFlowError(`Unexpected onboard error on initial list call: ${listResult.data.code}`);
635
- }
636
- /** @deprecated W6 cleanup — see `deviceCodeFlow` JSDoc. */
637
- // biome-ignore lint/correctness/noUnusedVariables: retained as @deprecated reference for W6 cleanup
638
- async function createAgentLoop(platformCfg, accessToken, prompter) {
639
- for (;;) {
640
- const handle = await prompter.text("Choose a handle for your Agent (3-15 chars, start with a letter, then letters/numbers/hyphens/underscores):");
641
- // Lowercase + trim mirrors the server's normalizeHandle step. If the user
642
- // typed "Alice", we test "alice" against the regex — same outcome the
643
- // server would produce. Prevents the false-negative case where a valid
644
- // handle is rejected client-side just for case.
645
- const normalized = handle.trim().toLowerCase();
646
- // Client-side fail-fast — saves a server round-trip on obvious mistakes.
647
- // Server's validateHandle is the authoritative source for both format
648
- // and the reserved-handle list.
649
- if (!HANDLE_REGEX.test(normalized)) {
650
- await prompter.text(`"${handle}" must be 3-15 chars, start with a letter, then only letters/numbers/hyphens/underscores. Press Enter to try again.`);
651
- continue;
652
- }
653
- const result = await onboard(platformCfg, accessToken, {
654
- create: { handle: normalized },
655
- });
656
- if (result.kind === "ok") {
657
- return result.data;
658
- }
659
- if (result.data.code === "handle_taken") {
660
- await prompter.text(`"${normalized}" is already taken. Press Enter to try another.`);
661
- continue;
662
- }
663
- if (result.data.code === "invalid_handle") {
664
- await prompter.text(`"${normalized}" failed server validation: ${result.data.message}. Press Enter to try again.`);
665
- continue;
666
- }
667
- // Other structured errors shouldn't reach here on a create call —
668
- // surface with whatever we got.
669
- throw new DeviceFlowError(`Unexpected onboard error on create: ${result.data.code}`);
670
- }
671
- }
672
- // ---------------------------------------------------------------------------
673
- // Clack-backed Prompter adapter
674
- // ---------------------------------------------------------------------------
675
- /**
676
- * Build a Prompter backed by `@clack/prompts` (the same library OpenClaw CLI
677
- * uses internally, so the terminal UX is consistent with other channel
678
- * plugins). Cancel sentinels (user Ctrl+C) are mapped to `DeviceFlowError`
679
- * so the login function can treat cancels as a clean exit condition.
680
- *
681
- * @deprecated W6 cleanup — only the `@deprecated` device-flow helpers
682
- * consume this. The active loopback handoff uses `clack` indirectly
683
- * via the spinner-style stdout writes in `loopbackHandoffFlow`.
684
- */
685
- // biome-ignore lint/correctness/noUnusedVariables: retained for the @deprecated device-flow helpers
686
- function createClackPrompter() {
687
- const abortOnCancel = (result) => {
688
- if (isCancel(result)) {
689
- // Emit clack's canonical cancel UI (bracketed red "cancelled") here so
690
- // the user sees a clean exit indicator, then throw `CancelError` so the
691
- // outer `login` catch returns silently (avoids printing the same line
692
- // twice — UI from `cancel()` + a redundant `runtime.error` afterwards).
693
- cancel("Setup cancelled.");
694
- throw new CancelError("Setup cancelled by user.");
695
- }
696
- };
697
- return {
698
- async text(label) {
699
- // `text(label, opts?.default)` previously mapped `opts.default` to clack's
700
- // `placeholder` — but clack's `placeholder` is hint-only (not auto-submitted
701
- // on empty input), and no current caller passes `opts.default`. Dropped
702
- // the mapping until a real default-value need surfaces; revisit by
703
- // implementing `clackText({ message, defaultValue })` then.
704
- const result = await clackText({ message: label });
705
- abortOnCancel(result);
706
- return result;
707
- },
708
- async confirm(label) {
709
- const result = await clackConfirm({ message: label });
710
- abortOnCancel(result);
711
- return result;
712
- },
713
- async select(label, choices) {
714
- // Narrow the generic — we use `string` values throughout the flow.
715
- const result = await clackSelect({
716
- message: label,
717
- options: choices.map((choice) => ({ value: choice, label: choice })),
718
- });
719
- abortOnCancel(result);
720
- return result;
721
- },
722
- };
723
- }
724
- // ---------------------------------------------------------------------------
725
- // Public hook — `plugin.auth.login` (OpenClaw 2026.4.x contract)
726
- // ---------------------------------------------------------------------------
727
- /**
728
- * Channel login hook — invoked by `openclaw channels login --channel agent-network`.
729
- *
730
- * Unlike the legacy `configureInteractive` contract (which returned a config
731
- * object for the CLI to persist), the 2026.4.x `auth.login` contract returns
732
- * void and expects the plugin to persist any config changes itself. We use
733
- * `writeCredentials()` in config.ts, which writes `openclaw.json` atomically
734
- * and bumps the state-cache generation so Layer B's dynamic context re-reads
735
- * on the next turn.
736
- *
737
- * Flow (W4 PR 3):
738
- * 1. Run `loopbackHandoffFlow` against `apps/web` — a Stripe-style
739
- * browser-mediated runtime-key issuance that delivers the plaintext
740
- * `masons_rt_…` token to a one-shot loopback listener bound at
741
- * `127.0.0.1:0` with a fragment-nonce cross-origin discipline.
742
- * 2. Resolve the connector URL — preserve any user-set
743
- * `accounts.default.connectorUrl` from `openclaw.json` (for self-
744
- * hosted topologies); fall back to the cloud derivation
745
- * `wss://<apiHost>/gateway` matching the connector's
746
- * `CONNECTOR_PLUGIN_URL` env default.
747
- * 3. Persist `{connectorUrl, token}` atomically.
748
- *
749
- * Errors during the handoff are surfaced via `ctx.runtime.error` (not
750
- * thrown) so OpenClaw CLI doesn't display a stack trace — matches the
751
- * WhatsApp/Feishu pattern.
752
- */
753
151
  export async function login(ctx) {
754
- // Channel-level config (`cfg.apiHost`, `cfg.idpBaseUrl`) — both have
755
- // defaults. Existing values from openclaw.json are preserved on re-run.
756
152
  const apiHost = typeof ctx.cfg.apiHost === "string" ? ctx.cfg.apiHost : DEFAULT_API_HOST;
757
153
  const idpBaseUrl = typeof ctx.cfg.idpBaseUrl === "string"
758
154
  ? ctx.cfg.idpBaseUrl
759
155
  : DEFAULT_IDP_BASE_URL;
760
156
  let handoff;
761
157
  try {
762
- handoff = await loopbackHandoffFlow(idpBaseUrl, ctx.runtime, ctx.channelInput);
158
+ handoff = await serverBridgeHandoffFlow(apiHost, idpBaseUrl, ctx.runtime, ctx.channelInput);
763
159
  }
764
160
  catch (err) {
765
- if (err instanceof CancelError) {
766
- // User pressed Ctrl+C — clack already emitted the cancel UI.
767
- return;
768
- }
769
- if (err instanceof DeviceFlowError) {
770
- // Loopback error with a human-friendly message. Print and return
771
- // cleanly — CLI shows nothing else on a return. (The class name
772
- // is `DeviceFlowError` for legacy reasons; it serves as a
773
- // generic "expected setup failure" sentinel for both flows.)
161
+ if (err instanceof SetupFlowError) {
774
162
  ctx.runtime.error(err.message);
775
163
  return;
776
164
  }
@@ -778,16 +166,11 @@ export async function login(ctx) {
778
166
  ctx.runtime.error(`Setup failed (HTTP ${err.status} ${err.code}): ${err.message}`);
779
167
  return;
780
168
  }
781
- // Unknown error — let CLI surface the stack. Surfaces platform bugs
782
- // that aren't covered by our structured error types.
783
169
  throw err;
784
170
  }
785
- // `connectorUrl` is decoupled from `apps/api` (the issuance host) by
786
- // design apps/api does not know about connector deployment topology.
787
- // Preserve any explicit user-set value (self-hosted) and fall back to
788
- // the well-known cloud / preview shape (`wss://<apiHost>/gateway`).
789
- const existingConnectorUrl = await loadExistingConnectorUrl();
790
- 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;
791
174
  await writeCredentials({ connectorUrl, token: handoff.token }, apiHost, idpBaseUrl);
792
175
  ctx.runtime.log(`✓ Connected as @${handoff.agent.handle}`);
793
176
  }