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