@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.
- package/dist/channel.d.ts +0 -7
- package/dist/channel.d.ts.map +1 -1
- package/dist/channel.js +3 -174
- package/dist/cli-setup.d.ts +0 -93
- package/dist/cli-setup.d.ts.map +1 -1
- package/dist/cli-setup.js +124 -741
- package/dist/config-fs.d.ts +4 -0
- package/dist/config-fs.d.ts.map +1 -0
- package/dist/config-fs.js +23 -0
- package/dist/config-schema.js +2 -2
- package/dist/config.d.ts +2 -210
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +14 -334
- package/dist/connector-client.d.ts +0 -32
- package/dist/connector-client.d.ts.map +1 -1
- package/dist/connector-client.js +1 -89
- package/dist/constants.d.ts +0 -1
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +2 -3
- package/dist/conversation-manager.d.ts +0 -106
- package/dist/conversation-manager.d.ts.map +1 -1
- package/dist/conversation-manager.js +2 -131
- package/dist/environment-context.d.ts +0 -24
- package/dist/environment-context.d.ts.map +1 -1
- package/dist/environment-context.js +0 -42
- package/dist/handle-utils.d.ts +0 -14
- package/dist/handle-utils.d.ts.map +1 -1
- package/dist/handle-utils.js +0 -14
- package/dist/index.js +0 -9
- package/dist/owner-notes.d.ts +0 -33
- package/dist/owner-notes.d.ts.map +1 -1
- package/dist/owner-notes.js +2 -41
- package/dist/owner-session-state.d.ts +0 -26
- package/dist/owner-session-state.d.ts.map +1 -1
- package/dist/owner-session-state.js +0 -37
- package/dist/platform-client.d.ts +13 -202
- package/dist/platform-client.d.ts.map +1 -1
- package/dist/platform-client.js +22 -171
- package/dist/plugin.d.ts +5 -0
- package/dist/plugin.d.ts.map +1 -1
- package/dist/plugin.js +3 -167
- package/dist/sent-message-buffer.d.ts +0 -36
- package/dist/sent-message-buffer.d.ts.map +1 -1
- package/dist/sent-message-buffer.js +1 -45
- package/dist/tools.d.ts +0 -28
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +36 -240
- package/dist/turn-context.d.ts +0 -45
- package/dist/turn-context.d.ts.map +1 -1
- package/dist/turn-context.js +0 -57
- package/dist/types.d.ts +0 -67
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +0 -7
- package/dist/update-cache.d.ts +0 -17
- package/dist/update-cache.d.ts.map +1 -1
- package/dist/update-cache.js +1 -21
- package/dist/update-check.d.ts +1 -40
- package/dist/update-check.d.ts.map +1 -1
- package/dist/update-check.js +7 -66
- package/dist/version.d.ts +1 -2
- package/dist/version.d.ts.map +1 -1
- package/dist/version.js +1 -2
- package/openclaw.plugin.json +57 -3
- package/package.json +11 -10
- package/skills/agent-network/SKILL.md +21 -47
- package/skills/agent-network/references/troubleshooting.md +5 -5
package/dist/cli-setup.js
CHANGED
|
@@ -1,776 +1,164 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
|
291
|
-
?
|
|
292
|
-
:
|
|
293
|
-
const
|
|
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
|
|
297
|
-
` ${
|
|
48
|
+
"Open this link in your browser to finish setup:",
|
|
49
|
+
` ${handoffUrl}`,
|
|
298
50
|
"",
|
|
299
|
-
"Waiting for the
|
|
51
|
+
"Waiting for the runtime key to be issued and delivered…",
|
|
300
52
|
"",
|
|
301
53
|
].join("\n"));
|
|
302
|
-
|
|
303
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
395
|
-
|
|
396
|
-
|
|
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
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
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
|
-
|
|
463
|
-
|
|
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
|
-
|
|
485
|
-
return accessToken;
|
|
85
|
+
res = await fetch(`${apiBase}/v1/cli-handoff/${encodeURIComponent(sessionId)}/poll`, { method: "GET" });
|
|
486
86
|
}
|
|
487
|
-
catch (
|
|
488
|
-
|
|
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 (
|
|
553
|
-
|
|
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 (
|
|
562
|
-
|
|
94
|
+
if (!res.ok) {
|
|
95
|
+
await sleep(HANDOFF_POLL_INTERVAL_MS);
|
|
96
|
+
continue;
|
|
563
97
|
}
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
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
|
|
158
|
+
handoff = await serverBridgeHandoffFlow(apiHost, idpBaseUrl, ctx.runtime, ctx.channelInput);
|
|
763
159
|
}
|
|
764
160
|
catch (err) {
|
|
765
|
-
if (err instanceof
|
|
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
|
-
|
|
786
|
-
|
|
787
|
-
|
|
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
|
}
|