@masons/agent-network 0.5.12 → 0.5.14

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.
@@ -1,36 +1,71 @@
1
1
  /**
2
- * CLI Login Path — OAuth 2.0 Device Authorization Grant (RFC 8628).
2
+ * CLI Login Path — encrypted server-bridge handoff (v0.5.14).
3
3
  *
4
4
  * Implements `login()` hook for OpenClaw's 2026.4.x channel-plugin contract:
5
5
  * api.registerChannel({ plugin: { ..., auth: { login } } })
6
6
  *
7
7
  * Invoked by `openclaw channels login --channel agent-network`. CLI dispatch
8
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.
9
+ * `"Channel ... does not support login"` if absent.
11
10
  *
12
- * Flow:
13
- * 1. POST {idpBaseUrl}/api/auth/device/code with client_id + scope
14
- * → display user_code + verification_uri to the user
15
- * 2. Poll {idpBaseUrl}/api/auth/device/token until access_token issued
16
- * → handles `expired_token`, `authorization_pending`, `slow_down`
17
- * 3. POST {apiHost}/runtime/v1/onboard with Bearer access_token
18
- * → list / select existing agent / create new (terminal prompter)
19
- * 4. Persist credentials directly via `writeCredentials()` into
20
- * `openclaw.json`. (The CLI does NOT auto-persist after login returns —
21
- * unlike the legacy `configureInteractive` contract — so the plugin
22
- * owns the file write; see `runChannelLogin` in channels-cli source.)
11
+ * Flow (v0.5.14 — `serverBridgeHandoffFlow`):
23
12
  *
24
- * History. Pre-migration this file existed as a `setup_codes`-based bespoke
25
- * flow (deleted in fd5568e8). A device-flow re-implementation landed in 0.5.0
26
- * under `configureInteractive` mounted at `plugin.setup.configureInteractive`
27
- * but that mount path is not read by any known OpenClaw version (neither
28
- * 2026.3.x's top-level `plugin.configureInteractive` nor 2026.4.x's
29
- * `plugin.auth.login`). 0.5.1 corrects the mount point and adopts the
30
- * 2026.4.x `{cfg, accountId, runtime, verbose, channelInput}` signature.
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()`).
31
40
  *
32
- * See #1264 for the design rationale (single-driver Node semantic — re-running
33
- * this flow rotates the api_key and evicts any previously-connected Runtime).
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.
34
69
  */
35
70
  /**
36
71
  * Minimal OpenClaw runtime surface passed to `auth.login`. Exposed as
@@ -67,8 +102,23 @@ interface AuthLoginContext {
67
102
  * and bumps the state-cache generation so Layer B's dynamic context re-reads
68
103
  * on the next turn.
69
104
  *
70
- * Errors during device flow or onboarding are surfaced via `ctx.runtime.error`
71
- * (not thrown) so OpenClaw CLI doesn't display a stack trace matches the
105
+ * Flow (v0.5.14):
106
+ * 1. Run `serverBridgeHandoffFlow` against `apps/api` + `apps/web`
107
+ * an encrypted server-bridge handoff that delivers the plaintext
108
+ * `masons_rt_…` token from the browser to the plugin via apps/api's
109
+ * Redis cache (storing ciphertext only). RSA-OAEP-256 wraps an
110
+ * ephemeral AES-256-GCM key the browser generates; CLI's RSA
111
+ * private key (held only in plugin process memory) decrypts the
112
+ * AES key in-process to recover the plaintext.
113
+ * 2. Resolve the connector URL — preserve any user-set
114
+ * `accounts.default.connectorUrl` from `openclaw.json` (for self-
115
+ * hosted topologies); fall back to the cloud derivation
116
+ * `wss://<apiHost>/gateway` matching the connector's
117
+ * `CONNECTOR_PLUGIN_URL` env default.
118
+ * 3. Persist `{connectorUrl, token}` atomically.
119
+ *
120
+ * Errors during the handoff are surfaced via `ctx.runtime.error` (not
121
+ * thrown) so OpenClaw CLI doesn't display a stack trace — matches the
72
122
  * WhatsApp/Feishu pattern.
73
123
  */
74
124
  export declare function login(ctx: AuthLoginContext): Promise<void>;
@@ -1 +1 @@
1
- {"version":3,"file":"cli-setup.d.ts","sourceRoot":"","sources":["../src/cli-setup.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AAiCH;;;;;GAKG;AACH,UAAU,eAAe;IACvB,GAAG,CAAC,GAAG,IAAI,EAAE,OAAO,EAAE,GAAG,IAAI,CAAC;IAC9B,KAAK,CAAC,GAAG,IAAI,EAAE,OAAO,EAAE,GAAG,IAAI,CAAC;IAChC,WAAW,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACjC,SAAS,CAAC,KAAK,EAAE,OAAO,EAAE,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAChD,IAAI,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;CAC1B;AAED;;;;GAIG;AACH,UAAU,gBAAgB;IACxB,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC7B,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,OAAO,EAAE,eAAe,CAAC;IACzB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAobD;;;;;;;;;;;;;GAaG;AACH,wBAAsB,KAAK,CAAC,GAAG,EAAE,gBAAgB,GAAG,OAAO,CAAC,IAAI,CAAC,CAmDhE"}
1
+ {"version":3,"file":"cli-setup.d.ts","sourceRoot":"","sources":["../src/cli-setup.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoEG;AAwCH;;;;;GAKG;AACH,UAAU,eAAe;IACvB,GAAG,CAAC,GAAG,IAAI,EAAE,OAAO,EAAE,GAAG,IAAI,CAAC;IAC9B,KAAK,CAAC,GAAG,IAAI,EAAE,OAAO,EAAE,GAAG,IAAI,CAAC;IAChC,WAAW,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACjC,SAAS,CAAC,KAAK,EAAE,OAAO,EAAE,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAChD,IAAI,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;CAC1B;AAED;;;;GAIG;AACH,UAAU,gBAAgB;IACxB,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC7B,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,OAAO,EAAE,eAAe,CAAC;IACzB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AA0yBD;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,wBAAsB,KAAK,CAAC,GAAG,EAAE,gBAAgB,GAAG,OAAO,CAAC,IAAI,CAAC,CAwDhE"}
package/dist/cli-setup.js CHANGED
@@ -1,39 +1,75 @@
1
1
  /**
2
- * CLI Login Path — OAuth 2.0 Device Authorization Grant (RFC 8628).
2
+ * CLI Login Path — encrypted server-bridge handoff (v0.5.14).
3
3
  *
4
4
  * Implements `login()` hook for OpenClaw's 2026.4.x channel-plugin contract:
5
5
  * api.registerChannel({ plugin: { ..., auth: { login } } })
6
6
  *
7
7
  * Invoked by `openclaw channels login --channel agent-network`. CLI dispatch
8
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.
9
+ * `"Channel ... does not support login"` if absent.
11
10
  *
12
- * Flow:
13
- * 1. POST {idpBaseUrl}/api/auth/device/code with client_id + scope
14
- * → display user_code + verification_uri to the user
15
- * 2. Poll {idpBaseUrl}/api/auth/device/token until access_token issued
16
- * → handles `expired_token`, `authorization_pending`, `slow_down`
17
- * 3. POST {apiHost}/runtime/v1/onboard with Bearer access_token
18
- * → list / select existing agent / create new (terminal prompter)
19
- * 4. Persist credentials directly via `writeCredentials()` into
20
- * `openclaw.json`. (The CLI does NOT auto-persist after login returns —
21
- * unlike the legacy `configureInteractive` contract — so the plugin
22
- * owns the file write; see `runChannelLogin` in channels-cli source.)
11
+ * Flow (v0.5.14 — `serverBridgeHandoffFlow`):
23
12
  *
24
- * History. Pre-migration this file existed as a `setup_codes`-based bespoke
25
- * flow (deleted in fd5568e8). A device-flow re-implementation landed in 0.5.0
26
- * under `configureInteractive` mounted at `plugin.setup.configureInteractive`
27
- * but that mount path is not read by any known OpenClaw version (neither
28
- * 2026.3.x's top-level `plugin.configureInteractive` nor 2026.4.x's
29
- * `plugin.auth.login`). 0.5.1 corrects the mount point and adopts the
30
- * 2026.4.x `{cfg, accountId, runtime, verbose, channelInput}` signature.
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()`).
31
40
  *
32
- * See #1264 for the design rationale (single-driver Node semantic — re-running
33
- * this flow rotates the api_key and evicts any previously-connected Runtime).
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.
34
69
  */
70
+ import { createDecipheriv, constants as cryptoConstants, generateKeyPairSync, privateDecrypt, } from "node:crypto";
35
71
  import { cancel, confirm as clackConfirm, select as clackSelect, text as clackText, isCancel, } from "@clack/prompts";
36
- import { writeCredentials } from "./config.js";
72
+ import { getOpenClawHome, writeCredentials } from "./config.js";
37
73
  import { DEFAULT_API_HOST, onboard, PlatformApiError, } from "./platform-client.js";
38
74
  // ---------------------------------------------------------------------------
39
75
  // Constants
@@ -80,6 +116,268 @@ const CREATE_NEW_OPTION = "Create new agent";
80
116
  // Reserved-handle check (e.g., "masons", "openclaw") is server-only — too
81
117
  // volatile for the plugin to track; the round-trip is acceptable for that.
82
118
  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
+ 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
+ const HANDOFF_POLL_INTERVAL_MS = 2000;
132
+ /**
133
+ * Run the encrypted server-bridge handoff. Returns the decrypted
134
+ * runtime API key + the agent identity the browser issued it for.
135
+ *
136
+ * `channelInput` is the optional argument from
137
+ * `openclaw channels login --channel agent-network -- <input>`. When
138
+ * the input parses as a handle (`^[a-z][a-z0-9_-]{2,14}$`) the flow
139
+ * directs the user to the per-handle deep-link entry; otherwise it
140
+ * falls back to the picker page. Anything that isn't a handle silently
141
+ * falls through.
142
+ */
143
+ 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
+ const { publicKey, privateKey } = generateKeyPairSync("rsa", {
148
+ modulusLength: 2048,
149
+ });
150
+ const cliPubkeySpki = publicKey
151
+ .export({ type: "spki", format: "der" })
152
+ .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
+ const apiBase = normalizeHttpBase(apiHost);
157
+ let session_id;
158
+ try {
159
+ const initRes = await fetch(`${apiBase}/v1/cli-handoff/init`, {
160
+ method: "POST",
161
+ headers: { "content-type": "application/json" },
162
+ body: JSON.stringify({ cli_pubkey: cliPubkeySpki }),
163
+ });
164
+ if (!initRes.ok) {
165
+ throw new DeviceFlowError(`Setup service rejected init (HTTP ${initRes.status}). Retry in a moment; if it persists, check that ${apiBase} is reachable and not behind a proxy that strips the request body.`);
166
+ }
167
+ const initBody = (await initRes.json());
168
+ if (typeof initBody.session_id !== "string" ||
169
+ initBody.session_id.length === 0) {
170
+ throw new DeviceFlowError("Setup service returned a malformed response (missing session_id). Retry; if it persists, this is a server-side bug worth reporting.");
171
+ }
172
+ session_id = initBody.session_id;
173
+ }
174
+ catch (err) {
175
+ if (err instanceof DeviceFlowError)
176
+ throw err;
177
+ const message = err instanceof Error ? err.message : "unknown error";
178
+ throw new DeviceFlowError(`Could not reach the setup service at ${apiBase} (${message}). Retry in a moment.`);
179
+ }
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
+ const handle = parseHandleFromInput(channelInput);
184
+ const handoffPath = handle
185
+ ? `/console/agents/${encodeURIComponent(handle)}/runtime-keys/handoff`
186
+ : `/console/handoff`;
187
+ const handoffUrl = `${idpBaseUrl}${handoffPath}?session=${encodeURIComponent(session_id)}`;
188
+ runtime.writeStdout([
189
+ "",
190
+ "Open this link in your browser to finish setup:",
191
+ ` ${handoffUrl}`,
192
+ "",
193
+ "Waiting for the runtime key to be issued and delivered…",
194
+ "",
195
+ ].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
+ 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
+ let payload;
204
+ try {
205
+ payload = decryptBridgeEnvelope(privateKey, envelope);
206
+ }
207
+ catch (err) {
208
+ const message = err instanceof Error ? err.message : "unknown error";
209
+ throw new DeviceFlowError(`Handoff received but could not be decrypted (${message}). The link may have been tampered with — re-run setup. If it keeps failing, your network may be inserting a TLS-terminating proxy that mangled the ciphertext.`);
210
+ }
211
+ if (typeof payload.token !== "string" ||
212
+ !payload.token.startsWith("masons_rt_v1_") ||
213
+ typeof payload.agent?.handle !== "string" ||
214
+ typeof payload.agent?.agentId !== "string") {
215
+ throw new DeviceFlowError("Handoff payload is missing required fields. Re-run setup; if it keeps happening, this is a server-side bug worth reporting.");
216
+ }
217
+ return {
218
+ token: payload.token,
219
+ agent: {
220
+ agentId: payload.agent.agentId,
221
+ handle: payload.agent.handle,
222
+ name: typeof payload.agent.name === "string"
223
+ ? payload.agent.name
224
+ : payload.agent.handle,
225
+ },
226
+ };
227
+ }
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
+ async function pollForCompletion(apiBase, sessionId) {
234
+ const start = Date.now();
235
+ while (Date.now() - start < HANDOFF_TIMEOUT_MS) {
236
+ let res;
237
+ try {
238
+ res = await fetch(`${apiBase}/v1/cli-handoff/${encodeURIComponent(sessionId)}/poll`, { method: "GET" });
239
+ }
240
+ catch (_err) {
241
+ // Transient network error — try again on the next tick. Persistent
242
+ // network failures will surface as the timeout below.
243
+ await sleep(HANDOFF_POLL_INTERVAL_MS);
244
+ continue;
245
+ }
246
+ if (res.status === 404) {
247
+ throw new DeviceFlowError("Handoff session expired before the runtime key was delivered. Re-run setup.");
248
+ }
249
+ if (!res.ok) {
250
+ // Other 4xx/5xx — back off and retry until the timeout fires.
251
+ await sleep(HANDOFF_POLL_INTERVAL_MS);
252
+ continue;
253
+ }
254
+ const body = (await res.json());
255
+ if (body.status === "completed" && body.payload) {
256
+ return body.payload;
257
+ }
258
+ // status === "pending" (or anything else) → keep polling.
259
+ await sleep(HANDOFF_POLL_INTERVAL_MS);
260
+ }
261
+ throw new DeviceFlowError("Handoff timed out before the runtime key was delivered. Re-run setup; if you're behind a proxy that blocks long-running fetches, retry on a different network.");
262
+ }
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
+ function decryptBridgeEnvelope(privateKey, envelope) {
269
+ const wrappedKey = Buffer.from(envelope.wrapped_key, "base64url");
270
+ const iv = Buffer.from(envelope.iv, "base64url");
271
+ 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
+ if (fullCiphertext.length < 17) {
276
+ throw new Error("ciphertext shorter than auth tag");
277
+ }
278
+ const tagOffset = fullCiphertext.length - 16;
279
+ const ciphertext = fullCiphertext.subarray(0, tagOffset);
280
+ const authTag = fullCiphertext.subarray(tagOffset);
281
+ const aesKey = privateDecrypt({
282
+ key: privateKey,
283
+ padding: cryptoConstants.RSA_PKCS1_OAEP_PADDING,
284
+ oaepHash: "sha256",
285
+ }, wrappedKey);
286
+ if (aesKey.length !== 32) {
287
+ throw new Error(`unexpected AES key length: ${aesKey.length}`);
288
+ }
289
+ const decipher = createDecipheriv("aes-256-gcm", aesKey, iv);
290
+ decipher.setAuthTag(authTag);
291
+ const plaintext = Buffer.concat([
292
+ decipher.update(ciphertext),
293
+ decipher.final(),
294
+ ]);
295
+ return JSON.parse(plaintext.toString("utf-8"));
296
+ }
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
+ function normalizeHttpBase(apiHost) {
305
+ const trimmed = apiHost.replace(/\/+$/, "");
306
+ if (/^https?:\/\//.test(trimmed))
307
+ return trimmed;
308
+ return `https://${trimmed}`;
309
+ }
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
+ function parseHandleFromInput(input) {
319
+ if (typeof input !== "string")
320
+ return undefined;
321
+ const trimmed = input.trim().toLowerCase();
322
+ if (!HANDLE_REGEX.test(trimmed))
323
+ return undefined;
324
+ return trimmed;
325
+ }
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
+ }
83
381
  class DeviceFlowError extends Error {
84
382
  }
85
383
  class DeviceFlowExpired extends Error {
@@ -95,7 +393,15 @@ class CancelError extends DeviceFlowError {
95
393
  /**
96
394
  * Run the RFC 8628 device flow loop. Returns the access_token on success.
97
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.
98
403
  */
404
+ // biome-ignore lint/correctness/noUnusedVariables: retained as @deprecated reference for W6 cleanup
99
405
  async function deviceCodeFlow(idpBaseUrl, prompter) {
100
406
  for (;;) {
101
407
  const init = await initDeviceCode(idpBaseUrl);
@@ -144,6 +450,8 @@ async function initDeviceCode(idpBaseUrl) {
144
450
  }
145
451
  return (await res.json());
146
452
  }
453
+ /** @deprecated W6 cleanup — see `deviceCodeFlow` JSDoc. */
454
+ // biome-ignore lint/correctness/noUnusedVariables: retained as @deprecated reference for W6 cleanup
147
455
  async function pollUntilAuthorized(idpBaseUrl, deviceCode, intervalSeconds, prompter) {
148
456
  let interval = intervalSeconds;
149
457
  for (;;) {
@@ -199,6 +507,8 @@ function sleep(ms) {
199
507
  // ---------------------------------------------------------------------------
200
508
  // Onboard (agent select / create)
201
509
  // ---------------------------------------------------------------------------
510
+ /** @deprecated W6 cleanup — see `deviceCodeFlow` JSDoc. */
511
+ // biome-ignore lint/correctness/noUnusedVariables: retained as @deprecated reference for W6 cleanup
202
512
  async function agentSetup(apiHost, accessToken, prompter) {
203
513
  const platformCfg = { apiHost };
204
514
  // First call: empty body. The server's list mode is intentionally
@@ -256,6 +566,8 @@ async function agentSetup(apiHost, accessToken, prompter) {
256
566
  // not from an empty `{}` first call.
257
567
  throw new DeviceFlowError(`Unexpected onboard error on initial list call: ${listResult.data.code}`);
258
568
  }
569
+ /** @deprecated W6 cleanup — see `deviceCodeFlow` JSDoc. */
570
+ // biome-ignore lint/correctness/noUnusedVariables: retained as @deprecated reference for W6 cleanup
259
571
  async function createAgentLoop(platformCfg, accessToken, prompter) {
260
572
  for (;;) {
261
573
  const handle = await prompter.text("Choose a handle for your Agent (3-15 chars, start with a letter, then letters/numbers/hyphens/underscores):");
@@ -298,7 +610,13 @@ async function createAgentLoop(platformCfg, accessToken, prompter) {
298
610
  * uses internally, so the terminal UX is consistent with other channel
299
611
  * plugins). Cancel sentinels (user Ctrl+C) are mapped to `DeviceFlowError`
300
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`.
301
618
  */
619
+ // biome-ignore lint/correctness/noUnusedVariables: retained for the @deprecated device-flow helpers
302
620
  function createClackPrompter() {
303
621
  const abortOnCancel = (result) => {
304
622
  if (isCancel(result)) {
@@ -350,8 +668,23 @@ function createClackPrompter() {
350
668
  * and bumps the state-cache generation so Layer B's dynamic context re-reads
351
669
  * on the next turn.
352
670
  *
353
- * Errors during device flow or onboarding are surfaced via `ctx.runtime.error`
354
- * (not thrown) so OpenClaw CLI doesn't display a stack trace matches the
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
355
688
  * WhatsApp/Feishu pattern.
356
689
  */
357
690
  export async function login(ctx) {
@@ -361,37 +694,37 @@ export async function login(ctx) {
361
694
  const idpBaseUrl = typeof ctx.cfg.idpBaseUrl === "string"
362
695
  ? ctx.cfg.idpBaseUrl
363
696
  : DEFAULT_IDP_BASE_URL;
364
- const prompter = createClackPrompter();
365
- let creds;
697
+ let handoff;
366
698
  try {
367
- const accessToken = await deviceCodeFlow(idpBaseUrl, prompter);
368
- creds = await agentSetup(apiHost, accessToken, prompter);
699
+ handoff = await serverBridgeHandoffFlow(apiHost, idpBaseUrl, ctx.runtime, ctx.channelInput);
369
700
  }
370
701
  catch (err) {
371
702
  if (err instanceof CancelError) {
372
- // User pressed Ctrl+C. The clack `cancel()` call inside the prompter
373
- // adapter already emitted the cancellation UI; nothing else to print.
703
+ // User pressed Ctrl+C clack already emitted the cancel UI.
374
704
  return;
375
705
  }
376
706
  if (err instanceof DeviceFlowError) {
377
- // Device-flow / onboard error with a human-friendly message.
378
- // Print and return cleanly — CLI shows nothing else on a return.
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.)
379
711
  ctx.runtime.error(err.message);
380
712
  return;
381
713
  }
382
714
  if (err instanceof PlatformApiError) {
383
- ctx.runtime.error(`Onboard failed (HTTP ${err.status} ${err.code}): ${err.message}`);
715
+ ctx.runtime.error(`Setup failed (HTTP ${err.status} ${err.code}): ${err.message}`);
384
716
  return;
385
717
  }
386
718
  // Unknown error — let CLI surface the stack. Surfaces platform bugs
387
719
  // that aren't covered by our structured error types.
388
720
  throw err;
389
721
  }
390
- // Persist: `openclaw.json` schema for an account is `{connectorUrl, token}`
391
- // (see config-schema.ts). On the wire the onboard response field is
392
- // `apiKey` to disambiguate from OAuth "session token" / JWT — but locally
393
- // on disk it's persisted as `token` for backward compatibility with the
394
- // existing `channel.resolveAccount()` reader.
395
- await writeCredentials({ connectorUrl: creds.connectorUrl, token: creds.apiKey }, apiHost, idpBaseUrl);
396
- ctx.runtime.log(`✓ Connected as @${creds.handle}`);
722
+ // `connectorUrl` is decoupled from `apps/api` (the issuance host) by
723
+ // design apps/api does not know about connector deployment topology.
724
+ // Preserve any explicit user-set value (self-hosted) and fall back to
725
+ // the well-known cloud / preview shape (`wss://<apiHost>/gateway`).
726
+ const existingConnectorUrl = await loadExistingConnectorUrl();
727
+ const connectorUrl = existingConnectorUrl ?? deriveCloudConnectorUrl(apiHost);
728
+ await writeCredentials({ connectorUrl, token: handoff.token }, apiHost, idpBaseUrl);
729
+ ctx.runtime.log(`✓ Connected as @${handoff.agent.handle}`);
397
730
  }
package/dist/version.d.ts CHANGED
@@ -1,3 +1,3 @@
1
1
  /** Plugin version — must match package.json. Validated by prepublishOnly. */
2
- export declare const PLUGIN_VERSION = "0.5.12";
2
+ export declare const PLUGIN_VERSION = "0.5.14";
3
3
  //# sourceMappingURL=version.d.ts.map
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  /** Plugin version — must match package.json. Validated by prepublishOnly. */
2
- export const PLUGIN_VERSION = "0.5.12";
2
+ export const PLUGIN_VERSION = "0.5.14";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@masons/agent-network",
3
- "version": "0.5.12",
3
+ "version": "0.5.14",
4
4
  "description": "MASONS plugin for OpenClaw — connect your agent to the agent network",
5
5
  "license": "MIT",
6
6
  "author": "MASONS.ai <hello@masons.ai> (https://masons.ai)",
@@ -1,89 +0,0 @@
1
- /**
2
- * Session Lifecycle — manages MSTP session state per contact.
3
- *
4
- * State machine: idle -> creating -> active -> recreating -> failed.
5
- * Internal key: MSTP address (not handle) — supports same handle on different Connectors.
6
- *
7
- * Responsibilities:
8
- * - Maps MSTP address to session state (sessionId, status, pending promise)
9
- * - Auto-creates sessions on demand via ensureSession()
10
- * - Deduplicates concurrent creates (second caller awaits first's promise)
11
- * - Registers inbound sessions from remote agents
12
- * - Invalidates all sessions on transport disconnect
13
- * - Subscribes to ConnectorClient events: session_created, session_error, session_ended, disconnected
14
- *
15
- * @see docs/openclaw/session-abstraction-system-design.md §6.1
16
- */
17
- import type { ConnectorClient } from "./connector-client.js";
18
- export type SessionStatus = "idle" | "creating" | "active" | "recreating" | "failed";
19
- export interface SessionState {
20
- status: SessionStatus;
21
- sessionId: string | null;
22
- pendingRequestId: string | null;
23
- retryCount: number;
24
- /** Promise that resolves when session creation completes. Used for dedup. */
25
- createPromise: Promise<string> | null;
26
- /** Resolve function for the create promise. */
27
- createResolve: ((sessionId: string) => void) | null;
28
- /** Reject function for the create promise. */
29
- createReject: ((err: Error) => void) | null;
30
- }
31
- export declare class SessionLifecycle {
32
- private readonly client;
33
- /** Primary map: MSTP address -> session state */
34
- private readonly sessions;
35
- /** Reverse index: sessionId -> MSTP address */
36
- private readonly sessionToAddress;
37
- /** Configurable timeout for tests */
38
- private timeoutMs;
39
- constructor(client: ConnectorClient);
40
- /**
41
- * Ensure an active session exists for the given address.
42
- * Returns the sessionId. Creates a new session if none exists.
43
- * Concurrent callers get the same promise (dedup).
44
- */
45
- ensureSession(address: string): Promise<string>;
46
- /**
47
- * Get the active sessionId for an address, or null.
48
- */
49
- getSession(address: string): string | null;
50
- /**
51
- * Close the session for an address.
52
- */
53
- closeSession(address: string, reason?: string): void;
54
- /**
55
- * Register an inbound session (remote initiated).
56
- */
57
- registerInbound(sessionId: string, address: string): void;
58
- /**
59
- * Invalidate all sessions. Called on transport disconnect.
60
- * Conversations are preserved but sessions are cleared.
61
- */
62
- invalidateAll(): void;
63
- /**
64
- * Reverse lookup: sessionId -> MSTP address.
65
- */
66
- getAddressBySessionId(sessionId: string): string | undefined;
67
- /**
68
- * Get all session entries (for listing conversations).
69
- */
70
- listSessions(): Array<{
71
- address: string;
72
- status: SessionStatus;
73
- sessionId: string | null;
74
- }>;
75
- /**
76
- * Remove a session entry entirely (for endConversation).
77
- */
78
- removeSession(address: string): void;
79
- /** @internal Override timeout for tests. */
80
- _setTimeoutForTesting(ms: number): void;
81
- /** @internal Reset for test isolation. */
82
- _resetForTesting(): void;
83
- private subscribe;
84
- private createNewSession;
85
- private handleSessionCreated;
86
- private handleSessionEnded;
87
- private handleSessionError;
88
- }
89
- //# sourceMappingURL=session-lifecycle.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"session-lifecycle.d.ts","sourceRoot":"","sources":["../src/session-lifecycle.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAGH,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;AAQ7D,MAAM,MAAM,aAAa,GACrB,MAAM,GACN,UAAU,GACV,QAAQ,GACR,YAAY,GACZ,QAAQ,CAAC;AAEb,MAAM,WAAW,YAAY;IAC3B,MAAM,EAAE,aAAa,CAAC;IACtB,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,gBAAgB,EAAE,MAAM,GAAG,IAAI,CAAC;IAChC,UAAU,EAAE,MAAM,CAAC;IACnB,6EAA6E;IAC7E,aAAa,EAAE,OAAO,CAAC,MAAM,CAAC,GAAG,IAAI,CAAC;IACtC,+CAA+C;IAC/C,aAAa,EAAE,CAAC,CAAC,SAAS,EAAE,MAAM,KAAK,IAAI,CAAC,GAAG,IAAI,CAAC;IACpD,8CAA8C;IAC9C,YAAY,EAAE,CAAC,CAAC,GAAG,EAAE,KAAK,KAAK,IAAI,CAAC,GAAG,IAAI,CAAC;CAC7C;AAaD,qBAAa,gBAAgB;IAC3B,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAkB;IACzC,iDAAiD;IACjD,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAmC;IAC5D,+CAA+C;IAC/C,OAAO,CAAC,QAAQ,CAAC,gBAAgB,CAA6B;IAE9D,qCAAqC;IACrC,OAAO,CAAC,SAAS,CAA6B;gBAElC,MAAM,EAAE,eAAe;IASnC;;;;OAIG;IACH,aAAa,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAoB/C;;OAEG;IACH,UAAU,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI;IAQ1C;;OAEG;IACH,YAAY,CAAC,OAAO,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI;IAqBpD;;OAEG;IACH,eAAe,CAAC,SAAS,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,IAAI;IAqBzD;;;OAGG;IACH,aAAa,IAAI,IAAI;IAqBrB;;OAEG;IACH,qBAAqB,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS;IAI5D;;OAEG;IACH,YAAY,IAAI,KAAK,CAAC;QACpB,OAAO,EAAE,MAAM,CAAC;QAChB,MAAM,EAAE,aAAa,CAAC;QACtB,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;KAC1B,CAAC;IAgBF;;OAEG;IACH,aAAa,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI;IAcpC,4CAA4C;IAC5C,qBAAqB,CAAC,EAAE,EAAE,MAAM,GAAG,IAAI;IAIvC,0CAA0C;IAC1C,gBAAgB,IAAI,IAAI;IAmBxB,OAAO,CAAC,SAAS;IAsBjB,OAAO,CAAC,gBAAgB;IA+GxB,OAAO,CAAC,oBAAoB;IAuC5B,OAAO,CAAC,kBAAkB;IAiB1B,OAAO,CAAC,kBAAkB;CA0B3B"}
@@ -1,348 +0,0 @@
1
- /**
2
- * Session Lifecycle — manages MSTP session state per contact.
3
- *
4
- * State machine: idle -> creating -> active -> recreating -> failed.
5
- * Internal key: MSTP address (not handle) — supports same handle on different Connectors.
6
- *
7
- * Responsibilities:
8
- * - Maps MSTP address to session state (sessionId, status, pending promise)
9
- * - Auto-creates sessions on demand via ensureSession()
10
- * - Deduplicates concurrent creates (second caller awaits first's promise)
11
- * - Registers inbound sessions from remote agents
12
- * - Invalidates all sessions on transport disconnect
13
- * - Subscribes to ConnectorClient events: session_created, session_error, session_ended, disconnected
14
- *
15
- * @see docs/openclaw/session-abstraction-system-design.md §6.1
16
- */
17
- import createDebug from "debug";
18
- const dbg = createDebug("agent-network:session-lifecycle");
19
- // ---------------------------------------------------------------------------
20
- // Constants
21
- // ---------------------------------------------------------------------------
22
- const SESSION_CREATE_TIMEOUT_MS = 15_000;
23
- const MAX_RETRY_COUNT = 1;
24
- // ---------------------------------------------------------------------------
25
- // SessionLifecycle
26
- // ---------------------------------------------------------------------------
27
- export class SessionLifecycle {
28
- client;
29
- /** Primary map: MSTP address -> session state */
30
- sessions = new Map();
31
- /** Reverse index: sessionId -> MSTP address */
32
- sessionToAddress = new Map();
33
- /** Configurable timeout for tests */
34
- timeoutMs = SESSION_CREATE_TIMEOUT_MS;
35
- constructor(client) {
36
- this.client = client;
37
- this.subscribe();
38
- }
39
- // -------------------------------------------------------------------------
40
- // Public API
41
- // -------------------------------------------------------------------------
42
- /**
43
- * Ensure an active session exists for the given address.
44
- * Returns the sessionId. Creates a new session if none exists.
45
- * Concurrent callers get the same promise (dedup).
46
- */
47
- ensureSession(address) {
48
- const state = this.sessions.get(address);
49
- // Active session — return immediately
50
- if (state?.status === "active" && state.sessionId) {
51
- return Promise.resolve(state.sessionId);
52
- }
53
- // Creating/recreating — dedup: return existing promise
54
- if ((state?.status === "creating" || state?.status === "recreating") &&
55
- state.createPromise) {
56
- return state.createPromise;
57
- }
58
- // Idle, failed, or no state — create new session
59
- return this.createNewSession(address, state?.retryCount ?? 0);
60
- }
61
- /**
62
- * Get the active sessionId for an address, or null.
63
- */
64
- getSession(address) {
65
- const state = this.sessions.get(address);
66
- if (state?.status === "active" && state.sessionId) {
67
- return state.sessionId;
68
- }
69
- return null;
70
- }
71
- /**
72
- * Close the session for an address.
73
- */
74
- closeSession(address, reason) {
75
- const state = this.sessions.get(address);
76
- if (!state)
77
- return;
78
- if (state.sessionId) {
79
- this.client.endSession(state.sessionId, reason);
80
- this.sessionToAddress.delete(state.sessionId);
81
- }
82
- // Reject any pending create promise
83
- if (state.createReject) {
84
- state.createReject(new Error("Session closed by caller"));
85
- state.createResolve = null;
86
- state.createReject = null;
87
- state.createPromise = null;
88
- }
89
- this.sessions.delete(address);
90
- dbg("closeSession address=%s", address);
91
- }
92
- /**
93
- * Register an inbound session (remote initiated).
94
- */
95
- registerInbound(sessionId, address) {
96
- // If there's an existing session for this address, clean up
97
- const existing = this.sessions.get(address);
98
- if (existing?.sessionId && existing.sessionId !== sessionId) {
99
- this.sessionToAddress.delete(existing.sessionId);
100
- }
101
- const state = {
102
- status: "active",
103
- sessionId,
104
- pendingRequestId: null,
105
- retryCount: 0,
106
- createPromise: null,
107
- createResolve: null,
108
- createReject: null,
109
- };
110
- this.sessions.set(address, state);
111
- this.sessionToAddress.set(sessionId, address);
112
- dbg("registerInbound sessionId=%s address=%s", sessionId, address);
113
- }
114
- /**
115
- * Invalidate all sessions. Called on transport disconnect.
116
- * Conversations are preserved but sessions are cleared.
117
- */
118
- invalidateAll() {
119
- const count = this.sessions.size;
120
- for (const [address, state] of this.sessions) {
121
- // Reject pending creates
122
- if (state.createReject) {
123
- state.createReject(new Error("Transport disconnected"));
124
- state.createResolve = null;
125
- state.createReject = null;
126
- state.createPromise = null;
127
- }
128
- // Reset to idle — next send() will auto-create
129
- state.status = "idle";
130
- state.sessionId = null;
131
- state.pendingRequestId = null;
132
- state.retryCount = 0;
133
- this.sessions.set(address, state);
134
- }
135
- this.sessionToAddress.clear();
136
- dbg("invalidateAll cleared %d sessions", count);
137
- }
138
- /**
139
- * Reverse lookup: sessionId -> MSTP address.
140
- */
141
- getAddressBySessionId(sessionId) {
142
- return this.sessionToAddress.get(sessionId);
143
- }
144
- /**
145
- * Get all session entries (for listing conversations).
146
- */
147
- listSessions() {
148
- const entries = [];
149
- for (const [address, state] of this.sessions) {
150
- entries.push({
151
- address,
152
- status: state.status,
153
- sessionId: state.sessionId,
154
- });
155
- }
156
- return entries;
157
- }
158
- /**
159
- * Remove a session entry entirely (for endConversation).
160
- */
161
- removeSession(address) {
162
- const state = this.sessions.get(address);
163
- if (state?.sessionId) {
164
- this.sessionToAddress.delete(state.sessionId);
165
- }
166
- if (state?.createReject) {
167
- state.createReject(new Error("Session removed"));
168
- state.createResolve = null;
169
- state.createReject = null;
170
- state.createPromise = null;
171
- }
172
- this.sessions.delete(address);
173
- }
174
- /** @internal Override timeout for tests. */
175
- _setTimeoutForTesting(ms) {
176
- this.timeoutMs = ms;
177
- }
178
- /** @internal Reset for test isolation. */
179
- _resetForTesting() {
180
- // Reject pending creates silently
181
- for (const state of this.sessions.values()) {
182
- if (state.createReject) {
183
- state.createReject(new Error("Reset for testing"));
184
- state.createResolve = null;
185
- state.createReject = null;
186
- state.createPromise = null;
187
- }
188
- }
189
- this.sessions.clear();
190
- this.sessionToAddress.clear();
191
- this.timeoutMs = SESSION_CREATE_TIMEOUT_MS;
192
- }
193
- // -------------------------------------------------------------------------
194
- // Private
195
- // -------------------------------------------------------------------------
196
- subscribe() {
197
- this.client.on("session_created", (event) => {
198
- this.handleSessionCreated(event.sessionId, event.requestId, event.direction);
199
- });
200
- this.client.on("session_error", (sessionId, message) => {
201
- this.handleSessionError(sessionId, message);
202
- });
203
- this.client.on("session_ended", (event) => {
204
- this.handleSessionEnded(event.sessionId);
205
- });
206
- this.client.on("disconnected", () => {
207
- this.invalidateAll();
208
- });
209
- }
210
- createNewSession(address, previousRetryCount) {
211
- const status = previousRetryCount > 0 ? "recreating" : "creating";
212
- let createResolve = null;
213
- let createReject = null;
214
- const createPromise = new Promise((resolve, reject) => {
215
- createResolve = resolve;
216
- createReject = reject;
217
- });
218
- const { requestId, sent } = this.client.createSession(address);
219
- if (!sent) {
220
- const state = {
221
- status: "failed",
222
- sessionId: null,
223
- pendingRequestId: null,
224
- retryCount: previousRetryCount,
225
- createPromise: null,
226
- createResolve: null,
227
- createReject: null,
228
- };
229
- this.sessions.set(address, state);
230
- return Promise.reject(new Error("Failed to create session: transport unavailable"));
231
- }
232
- const state = {
233
- status,
234
- sessionId: null,
235
- pendingRequestId: requestId,
236
- retryCount: previousRetryCount,
237
- createPromise,
238
- createResolve,
239
- createReject,
240
- };
241
- this.sessions.set(address, state);
242
- // Timeout
243
- const timer = setTimeout(() => {
244
- const current = this.sessions.get(address);
245
- if (current &&
246
- current.pendingRequestId === requestId &&
247
- (current.status === "creating" || current.status === "recreating")) {
248
- dbg("session create timeout address=%s requestId=%s", address, requestId);
249
- // Can we retry?
250
- if (current.retryCount < MAX_RETRY_COUNT) {
251
- // Move to failed, then retry
252
- current.status = "failed";
253
- current.retryCount++;
254
- current.pendingRequestId = null;
255
- const retryResolve = current.createResolve;
256
- const retryReject = current.createReject;
257
- current.createPromise = null;
258
- current.createResolve = null;
259
- current.createReject = null;
260
- this.sessions.set(address, current);
261
- // Retry: create a new session, pipe result to original promise
262
- this.createNewSession(address, current.retryCount)
263
- .then((sessionId) => retryResolve?.(sessionId))
264
- .catch((err) => retryReject?.(err));
265
- }
266
- else {
267
- // Max retries exhausted
268
- current.status = "idle";
269
- current.pendingRequestId = null;
270
- current.retryCount = 0;
271
- if (current.createReject) {
272
- current.createReject(new Error("Session creation timed out. The remote agent may be unavailable."));
273
- }
274
- current.createPromise = null;
275
- current.createResolve = null;
276
- current.createReject = null;
277
- this.sessions.set(address, current);
278
- }
279
- }
280
- }, this.timeoutMs);
281
- // Attach cleanup to the promise to clear the timer if resolved early
282
- createPromise
283
- .then(() => clearTimeout(timer))
284
- .catch(() => clearTimeout(timer));
285
- dbg("createNewSession address=%s requestId=%s status=%s retry=%d", address, requestId, status, previousRetryCount);
286
- return createPromise;
287
- }
288
- handleSessionCreated(sessionId, requestId, direction) {
289
- // Outbound: match by requestId
290
- if (direction === "outbound" && requestId) {
291
- for (const [address, state] of this.sessions) {
292
- if (state.pendingRequestId === requestId &&
293
- (state.status === "creating" || state.status === "recreating")) {
294
- state.status = "active";
295
- state.sessionId = sessionId;
296
- state.pendingRequestId = null;
297
- this.sessionToAddress.set(sessionId, address);
298
- if (state.createResolve) {
299
- state.createResolve(sessionId);
300
- }
301
- state.createPromise = null;
302
- state.createResolve = null;
303
- state.createReject = null;
304
- dbg("session created (outbound) address=%s sessionId=%s", address, sessionId);
305
- return;
306
- }
307
- }
308
- }
309
- // Inbound sessions are registered externally via registerInbound()
310
- // from the channel adapter (which has the metadata to derive the address).
311
- // We don't handle inbound SESSION_CREATED here.
312
- }
313
- handleSessionEnded(sessionId) {
314
- const address = this.sessionToAddress.get(sessionId);
315
- if (!address)
316
- return;
317
- const state = this.sessions.get(address);
318
- if (!state)
319
- return;
320
- // Only clear if this session is the current one
321
- if (state.sessionId === sessionId) {
322
- state.status = "idle";
323
- state.sessionId = null;
324
- dbg("session ended address=%s sessionId=%s", address, sessionId);
325
- }
326
- this.sessionToAddress.delete(sessionId);
327
- }
328
- handleSessionError(sessionId, message) {
329
- const address = this.sessionToAddress.get(sessionId);
330
- if (!address)
331
- return;
332
- const state = this.sessions.get(address);
333
- if (!state)
334
- return;
335
- dbg("session error address=%s sessionId=%s message=%s", address, sessionId, message);
336
- // Session error on active session — mark for auto-recreate on next send().
337
- // Reset retryCount to 0: this is a new failure context (transport error),
338
- // not a continuation of a creation timeout. The next ensureSession() gets
339
- // a fresh retry budget.
340
- if (state.status === "active") {
341
- this.sessionToAddress.delete(sessionId);
342
- state.status = "idle";
343
- state.sessionId = null;
344
- state.retryCount = 0;
345
- dbg("auto-recreate queued address=%s", address);
346
- }
347
- }
348
- }