@masons/agent-network 0.5.12 → 0.5.13

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,5 +1,5 @@
1
1
  /**
2
- * CLI Login Path — OAuth 2.0 Device Authorization Grant (RFC 8628).
2
+ * CLI Login Path — Stripe-style browser-mediated loopback handoff.
3
3
  *
4
4
  * Implements `login()` hook for OpenClaw's 2026.4.x channel-plugin contract:
5
5
  * api.registerChannel({ plugin: { ..., auth: { login } } })
@@ -9,28 +9,50 @@
9
9
  * `"Channel ... does not support login"` if absent. See WhatsApp + Feishu
10
10
  * bundled extensions for reference implementations of the same contract.
11
11
  *
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.)
12
+ * Flow (W4 PR 3 — `loopbackHandoffFlow`):
23
13
  *
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.
14
+ * 1. Bind a one-shot HTTP listener on `127.0.0.1:0` (OS-assigned ephemeral
15
+ * port). The listener serves a single `POST /handoff` plus the matching
16
+ * CORS preflight, then closes.
17
+ * 2. Generate a 24-byte (32-char base64url) URL-safe random nonce. The
18
+ * nonce travels in the URL fragment (`#nonce=…`), never the query
19
+ * string fragments do not reach `apps/web` access logs nor referer
20
+ * headers (Alt W4-B2 lock).
21
+ * 3. Open the user's browser to the handoff URL (no-handle path uses
22
+ * `${idpBaseUrl}/console/handoff?port=<port>#nonce=<nonce>`; with
23
+ * `--handle` the per-handle deep-link variant is the entry).
24
+ * 4. The user authenticates against Better Auth on `apps/web`, picks (or
25
+ * creates) an agent, and the page POSTs `{token, agent, key, nonce}`
26
+ * to the listener. The listener verifies the body's `nonce` matches
27
+ * the value it placed in the fragment, accepts the token, and closes.
28
+ * 5. Persist `{connectorUrl, token}` to `openclaw.json` via
29
+ * `writeCredentials()`. `connectorUrl` is derived from `apiHost`
30
+ * (`wss://<apiHost>/gateway`) for cloud / preview deployments; self-
31
+ * hosted users keep an explicit `connectorUrl` in their `openclaw.json`
32
+ * and the legacy value is preserved across the handoff (see
33
+ * `loadExistingConnectorUrl()`).
31
34
  *
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).
35
+ * Why move off device flow:
36
+ *
37
+ * - One step shorter (no `user_code` to type into the browser).
38
+ * - No periodic IdP polling (was 5s default, 5+s on `slow_down`).
39
+ * - Browser remains fully authenticated across login and key issuance —
40
+ * same Better Auth session is reused, so the user never re-types their
41
+ * password mid-flow.
42
+ * - The runtime API key carrier (`masons_rt_…`) replaces the legacy
43
+ * connector-minted `sk-…` carrier; W4 PR 1 substrate plus the connector
44
+ * mirror wiring earlier in this PR make both carriers verifiable through
45
+ * plugin v0.5.13's life until the W6 cleanup retires the legacy path.
46
+ *
47
+ * The legacy device-flow helpers (`deviceCodeFlow`, `pollUntilAuthorized`,
48
+ * `agentSetup`, `createAgentLoop`) remain in this file as
49
+ * `@deprecated` references for the W6 cleanup window. They have no call
50
+ * site after the migration and should not be invoked by any new code.
51
+ *
52
+ * See #1264 for original design rationale (single-driver Node semantic —
53
+ * re-running this flow rotates the runtime key and evicts any previously-
54
+ * connected Runtime); see the W4 strategic-pass at
55
+ * `#1466 issuecomment-4362892782` for the loopback handoff lock.
34
56
  */
35
57
  /**
36
58
  * Minimal OpenClaw runtime surface passed to `auth.login`. Exposed as
@@ -67,8 +89,20 @@ interface AuthLoginContext {
67
89
  * and bumps the state-cache generation so Layer B's dynamic context re-reads
68
90
  * on the next turn.
69
91
  *
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
92
+ * Flow (W4 PR 3):
93
+ * 1. Run `loopbackHandoffFlow` against `apps/web` a Stripe-style
94
+ * browser-mediated runtime-key issuance that delivers the plaintext
95
+ * `masons_rt_…` token to a one-shot loopback listener bound at
96
+ * `127.0.0.1:0` with a fragment-nonce cross-origin discipline.
97
+ * 2. Resolve the connector URL — preserve any user-set
98
+ * `accounts.default.connectorUrl` from `openclaw.json` (for self-
99
+ * hosted topologies); fall back to the cloud derivation
100
+ * `wss://<apiHost>/gateway` matching the connector's
101
+ * `CONNECTOR_PLUGIN_URL` env default.
102
+ * 3. Persist `{connectorUrl, token}` atomically.
103
+ *
104
+ * Errors during the handoff are surfaced via `ctx.runtime.error` (not
105
+ * thrown) so OpenClaw CLI doesn't display a stack trace — matches the
72
106
  * WhatsApp/Feishu pattern.
73
107
  */
74
108
  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;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuDG;AA2CH;;;;;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;AAw2BD;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,wBAAsB,KAAK,CAAC,GAAG,EAAE,gBAAgB,GAAG,OAAO,CAAC,IAAI,CAAC,CAuDhE"}
package/dist/cli-setup.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * CLI Login Path — OAuth 2.0 Device Authorization Grant (RFC 8628).
2
+ * CLI Login Path — Stripe-style browser-mediated loopback handoff.
3
3
  *
4
4
  * Implements `login()` hook for OpenClaw's 2026.4.x channel-plugin contract:
5
5
  * api.registerChannel({ plugin: { ..., auth: { login } } })
@@ -9,29 +9,56 @@
9
9
  * `"Channel ... does not support login"` if absent. See WhatsApp + Feishu
10
10
  * bundled extensions for reference implementations of the same contract.
11
11
  *
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.)
12
+ * Flow (W4 PR 3 — `loopbackHandoffFlow`):
23
13
  *
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.
14
+ * 1. Bind a one-shot HTTP listener on `127.0.0.1:0` (OS-assigned ephemeral
15
+ * port). The listener serves a single `POST /handoff` plus the matching
16
+ * CORS preflight, then closes.
17
+ * 2. Generate a 24-byte (32-char base64url) URL-safe random nonce. The
18
+ * nonce travels in the URL fragment (`#nonce=…`), never the query
19
+ * string fragments do not reach `apps/web` access logs nor referer
20
+ * headers (Alt W4-B2 lock).
21
+ * 3. Open the user's browser to the handoff URL (no-handle path uses
22
+ * `${idpBaseUrl}/console/handoff?port=<port>#nonce=<nonce>`; with
23
+ * `--handle` the per-handle deep-link variant is the entry).
24
+ * 4. The user authenticates against Better Auth on `apps/web`, picks (or
25
+ * creates) an agent, and the page POSTs `{token, agent, key, nonce}`
26
+ * to the listener. The listener verifies the body's `nonce` matches
27
+ * the value it placed in the fragment, accepts the token, and closes.
28
+ * 5. Persist `{connectorUrl, token}` to `openclaw.json` via
29
+ * `writeCredentials()`. `connectorUrl` is derived from `apiHost`
30
+ * (`wss://<apiHost>/gateway`) for cloud / preview deployments; self-
31
+ * hosted users keep an explicit `connectorUrl` in their `openclaw.json`
32
+ * and the legacy value is preserved across the handoff (see
33
+ * `loadExistingConnectorUrl()`).
31
34
  *
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).
35
+ * Why move off device flow:
36
+ *
37
+ * - One step shorter (no `user_code` to type into the browser).
38
+ * - No periodic IdP polling (was 5s default, 5+s on `slow_down`).
39
+ * - Browser remains fully authenticated across login and key issuance —
40
+ * same Better Auth session is reused, so the user never re-types their
41
+ * password mid-flow.
42
+ * - The runtime API key carrier (`masons_rt_…`) replaces the legacy
43
+ * connector-minted `sk-…` carrier; W4 PR 1 substrate plus the connector
44
+ * mirror wiring earlier in this PR make both carriers verifiable through
45
+ * plugin v0.5.13's life until the W6 cleanup retires the legacy path.
46
+ *
47
+ * The legacy device-flow helpers (`deviceCodeFlow`, `pollUntilAuthorized`,
48
+ * `agentSetup`, `createAgentLoop`) remain in this file as
49
+ * `@deprecated` references for the W6 cleanup window. They have no call
50
+ * site after the migration and should not be invoked by any new code.
51
+ *
52
+ * See #1264 for original design rationale (single-driver Node semantic —
53
+ * re-running this flow rotates the runtime key and evicts any previously-
54
+ * connected Runtime); see the W4 strategic-pass at
55
+ * `#1466 issuecomment-4362892782` for the loopback handoff lock.
34
56
  */
57
+ import { randomBytes } from "node:crypto";
58
+ import { readFile } from "node:fs/promises";
59
+ import { createServer, } from "node:http";
60
+ import { homedir } from "node:os";
61
+ import { join } from "node:path";
35
62
  import { cancel, confirm as clackConfirm, select as clackSelect, text as clackText, isCancel, } from "@clack/prompts";
36
63
  import { writeCredentials } from "./config.js";
37
64
  import { DEFAULT_API_HOST, onboard, PlatformApiError, } from "./platform-client.js";
@@ -80,6 +107,345 @@ const CREATE_NEW_OPTION = "Create new agent";
80
107
  // Reserved-handle check (e.g., "masons", "openclaw") is server-only — too
81
108
  // volatile for the plugin to track; the round-trip is acceptable for that.
82
109
  const HANDLE_REGEX = /^[a-z][a-z0-9_-]{2,14}$/;
110
+ /**
111
+ * Path under the loopback origin where `apps/web` POSTs the handoff payload.
112
+ * The listener accepts a CORS preflight on the same path. Pinning to a
113
+ * single path lets the listener reject any other request URL — defense in
114
+ * depth against a malicious local process binding the same port between
115
+ * the listener `listen()` and the browser POST.
116
+ */
117
+ const HANDOFF_PATH = "/handoff";
118
+ /**
119
+ * Hard cap on how long the listener waits for the browser POST. Long
120
+ * enough for the user to walk through sign-in + agent picker + handoff,
121
+ * short enough that a wedged listener doesn't sit on a port forever.
122
+ */
123
+ const HANDOFF_TIMEOUT_MS = 5 * 60 * 1000;
124
+ /** Bytes of entropy in the one-time fragment nonce. */
125
+ const HANDOFF_NONCE_BYTES = 24;
126
+ /**
127
+ * Run the Stripe-style loopback handoff. Returns the runtime API key
128
+ * + the agent identity the browser issued it for. The listener stops
129
+ * after a single successful POST; subsequent reuse of the URL is
130
+ * rejected.
131
+ *
132
+ * `channelInput` is the optional argument from
133
+ * `openclaw channels login --channel agent-network -- <input>`. When
134
+ * the input parses as a handle (`^[a-z][a-z0-9_-]{2,14}$`) the flow
135
+ * uses the per-handle deep-link entry; otherwise it falls back to the
136
+ * picker. Anything that isn't a handle prints a warning and falls
137
+ * through to the picker.
138
+ */
139
+ async function loopbackHandoffFlow(idpBaseUrl, runtime, channelInput) {
140
+ const nonce = randomBytes(HANDOFF_NONCE_BYTES).toString("base64url");
141
+ // Bind the listener BEFORE printing the URL — the URL must carry the
142
+ // OS-assigned port and the listener must be ready to accept the POST
143
+ // before the user has any chance of opening the link.
144
+ const { server, port } = await bindLoopbackListener();
145
+ // The Promise we hand the inner request handler — resolved on a
146
+ // valid POST. The reject path is owned by the timeout race below
147
+ // (`Promise.race`); the request handler never produces a "fatal"
148
+ // error worth rejecting the outer promise — invalid bodies / wrong
149
+ // nonces / duplicate posts respond 4xx and let the listener keep
150
+ // running until the timeout or a valid POST wins.
151
+ let resolveResult = null;
152
+ const handoffPromise = new Promise((res) => {
153
+ resolveResult = res;
154
+ });
155
+ const browserOriginAllowList = computeAllowedBrowserOrigins(idpBaseUrl);
156
+ let dispatched = false;
157
+ server.on("request", (req, res) => {
158
+ // Two distinct security boundaries are layered on this listener and
159
+ // it's worth keeping them straight:
160
+ //
161
+ // - The browser-origin allow-list (`computeAllowedBrowserOrigins`
162
+ // + ACAO echo) closes the cross-origin browser-scrape surface
163
+ // where a malicious page in the user's already-authenticated
164
+ // browser tries to read the listener's response. ACAO is what
165
+ // stops the browser from delivering the response to a non-
166
+ // apps/web origin.
167
+ // - The fragment nonce closes the local-process-race surface
168
+ // where another process on the same machine binds the same
169
+ // port between `server.listen()` and the browser POST. A
170
+ // malicious local process can fake any `Origin` header at
171
+ // will, but it cannot guess the 24-byte URL-fragment nonce
172
+ // the listener generated.
173
+ //
174
+ // CORS preflight (`OPTIONS /handoff`) is browser-only — non-browser
175
+ // local processes do not preflight. We answer 204 with ACAO pinned
176
+ // to the request's origin if it is in the allow-list; otherwise
177
+ // 403 with no headers (browser blocks the subsequent POST).
178
+ const origin = req.headers.origin ?? "";
179
+ if (req.method === "OPTIONS" && req.url === HANDOFF_PATH) {
180
+ if (browserOriginAllowList.has(origin)) {
181
+ res.writeHead(204, {
182
+ "Access-Control-Allow-Origin": origin,
183
+ "Access-Control-Allow-Methods": "POST, OPTIONS",
184
+ "Access-Control-Allow-Headers": "content-type",
185
+ "Access-Control-Max-Age": "60",
186
+ });
187
+ }
188
+ else {
189
+ res.writeHead(403);
190
+ }
191
+ res.end();
192
+ return;
193
+ }
194
+ if (req.method !== "POST" || req.url !== HANDOFF_PATH) {
195
+ res.writeHead(404);
196
+ res.end();
197
+ return;
198
+ }
199
+ if (!browserOriginAllowList.has(origin)) {
200
+ res.writeHead(403);
201
+ res.end();
202
+ return;
203
+ }
204
+ // Read the POST body with a hard cap. The expected payload is
205
+ // ~1 KB; reject anything larger as protection against a wedged
206
+ // attacker streaming bytes into the listener.
207
+ const MAX_BODY = 64 * 1024;
208
+ const chunks = [];
209
+ let total = 0;
210
+ let aborted = false;
211
+ req.on("data", (chunk) => {
212
+ if (aborted)
213
+ return;
214
+ total += chunk.length;
215
+ if (total > MAX_BODY) {
216
+ aborted = true;
217
+ // Origin already validated against the allow-list above; ACAO
218
+ // is therefore safe to echo and lets the browser surface the
219
+ // 413 status to the page (rather than an opaque CORS error).
220
+ res.writeHead(413, { "Access-Control-Allow-Origin": origin });
221
+ res.end();
222
+ req.destroy();
223
+ return;
224
+ }
225
+ chunks.push(chunk);
226
+ });
227
+ req.on("end", () => {
228
+ if (aborted)
229
+ return;
230
+ let parsed = null;
231
+ try {
232
+ parsed = JSON.parse(Buffer.concat(chunks).toString("utf-8"));
233
+ }
234
+ catch {
235
+ res.writeHead(400, { "Access-Control-Allow-Origin": origin });
236
+ res.end();
237
+ return;
238
+ }
239
+ if (!parsed ||
240
+ typeof parsed.token !== "string" ||
241
+ typeof parsed.nonce !== "string" ||
242
+ !parsed.agent ||
243
+ typeof parsed.agent.agentId !== "string" ||
244
+ typeof parsed.agent.handle !== "string") {
245
+ res.writeHead(400, { "Access-Control-Allow-Origin": origin });
246
+ res.end();
247
+ return;
248
+ }
249
+ if (parsed.nonce !== nonce) {
250
+ // Wrong nonce — possibly a stale browser tab from an earlier
251
+ // login attempt that lost the race for the port, or a
252
+ // malicious local process. Reject without revealing why.
253
+ res.writeHead(403, { "Access-Control-Allow-Origin": origin });
254
+ res.end();
255
+ return;
256
+ }
257
+ if (dispatched) {
258
+ // The listener is one-shot. Any subsequent POST after the
259
+ // first valid one is suspect — the browser succeeded once
260
+ // already; nothing else should be hitting this URL.
261
+ res.writeHead(409, { "Access-Control-Allow-Origin": origin });
262
+ res.end();
263
+ return;
264
+ }
265
+ dispatched = true;
266
+ res.writeHead(200, { "Access-Control-Allow-Origin": origin });
267
+ res.end();
268
+ resolveResult?.({
269
+ token: parsed.token,
270
+ agent: {
271
+ agentId: parsed.agent.agentId,
272
+ handle: parsed.agent.handle,
273
+ name: typeof parsed.agent.name === "string"
274
+ ? parsed.agent.name
275
+ : parsed.agent.handle,
276
+ },
277
+ });
278
+ });
279
+ req.on("error", () => {
280
+ if (!dispatched) {
281
+ // Connection-level error before we got a body. Log nothing —
282
+ // half-formed requests are noisy and usually irrelevant.
283
+ }
284
+ });
285
+ });
286
+ // The handoff URL the user (or `open`/`xdg-open`) navigates to. The
287
+ // nonce sits in the fragment so it never lands in apps/web access
288
+ // logs nor referer headers.
289
+ const handle = parseHandleFromInput(channelInput);
290
+ const baseUrl = handle
291
+ ? `${idpBaseUrl}/console/agents/${encodeURIComponent(handle)}/runtime-keys/handoff`
292
+ : `${idpBaseUrl}/console/handoff`;
293
+ const url = `${baseUrl}?port=${port}#nonce=${nonce}`;
294
+ runtime.writeStdout([
295
+ "",
296
+ "Open this link in your browser to finish the handoff:",
297
+ ` ${url}`,
298
+ "",
299
+ "Waiting for the browser to deliver the runtime key…",
300
+ "",
301
+ ].join("\n"));
302
+ // Best-effort spawn `open` / `xdg-open` / `start` so the browser
303
+ // pops automatically. Failure is non-fatal — the printed URL is the
304
+ // documented fallback.
305
+ void tryOpenBrowser(url, runtime);
306
+ // Race the listener against the timeout. Unwrap whichever wins.
307
+ const timeoutPromise = new Promise((_, rej) => {
308
+ setTimeout(() => {
309
+ rej(new DeviceFlowError("Loopback handoff timed out without receiving the runtime key. " +
310
+ "Re-run setup; if your browser cannot reach 127.0.0.1, copy the " +
311
+ "URL into a browser on the same machine."));
312
+ }, HANDOFF_TIMEOUT_MS).unref();
313
+ });
314
+ try {
315
+ return await Promise.race([handoffPromise, timeoutPromise]);
316
+ }
317
+ finally {
318
+ // Closing the listener after the first valid POST mirrors the
319
+ // "one-shot accept" discipline. Outstanding sockets (e.g., a
320
+ // preflight that arrived after the dispatch) are dropped on close.
321
+ server.close();
322
+ }
323
+ }
324
+ async function bindLoopbackListener() {
325
+ return await new Promise((resolve, reject) => {
326
+ const server = createServer();
327
+ server.on("error", reject);
328
+ // `127.0.0.1` (not `0.0.0.0`) — only the local machine can reach
329
+ // the listener. Browsers permit cross-origin fetch from HTTPS to
330
+ // 127.0.0.1 because it is a "potentially trustworthy origin"
331
+ // (W3C secure-contexts spec).
332
+ server.listen(0, "127.0.0.1", () => {
333
+ const addr = server.address();
334
+ if (!addr || typeof addr === "string") {
335
+ reject(new Error("Failed to bind loopback listener — no address"));
336
+ return;
337
+ }
338
+ resolve({ server, port: addr.port });
339
+ });
340
+ });
341
+ }
342
+ /**
343
+ * Compute the allowed browser origins for the cross-origin POST. The
344
+ * allow-list pins to the exact apps/web origin (scheme + host + port)
345
+ * so a local malicious process bound to a different origin cannot
346
+ * scrape the listener even if it happens to discover the port.
347
+ *
348
+ * Both the bare `idpBaseUrl` and (for development) `http://localhost:3007`
349
+ * are allowed when `idpBaseUrl` resolves to a localhost hostname so the
350
+ * `pnpm dev` workflow works without flag-flipping.
351
+ */
352
+ function computeAllowedBrowserOrigins(idpBaseUrl) {
353
+ const origins = new Set();
354
+ try {
355
+ const url = new URL(idpBaseUrl);
356
+ origins.add(url.origin);
357
+ if (url.hostname === "localhost" || url.hostname === "127.0.0.1") {
358
+ origins.add("http://localhost:3007");
359
+ origins.add("http://127.0.0.1:3007");
360
+ }
361
+ }
362
+ catch {
363
+ // Malformed idpBaseUrl — fall through with an empty allow-list.
364
+ // Every browser POST will be 403'd; the user sees a timeout.
365
+ }
366
+ return origins;
367
+ }
368
+ /**
369
+ * Best-effort browser launch. Returns immediately; failures are
370
+ * surfaced as a quiet log rather than a thrown error — the user has
371
+ * the printed URL as a documented fallback.
372
+ */
373
+ async function tryOpenBrowser(url, runtime) {
374
+ const { spawn } = await import("node:child_process");
375
+ const platform = process.platform;
376
+ const cmd = platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open";
377
+ const args = platform === "win32" ? ["/c", "start", "", url] : [url];
378
+ try {
379
+ const child = spawn(cmd, args, {
380
+ detached: true,
381
+ stdio: "ignore",
382
+ });
383
+ child.on("error", (err) => {
384
+ runtime.log(`(could not auto-open browser: ${err.message} — copy the link above)`);
385
+ });
386
+ child.unref();
387
+ }
388
+ catch (err) {
389
+ const message = err instanceof Error ? err.message : "unknown error";
390
+ runtime.log(`(could not auto-open browser: ${message} — copy the link above)`);
391
+ }
392
+ }
393
+ /**
394
+ * Parse `channelInput` as a handle. Returns the lowercased handle on
395
+ * match, `undefined` otherwise. Mirrors the `HANDLE_REGEX` used in the
396
+ * legacy device flow's create form.
397
+ */
398
+ function parseHandleFromInput(input) {
399
+ if (typeof input !== "string")
400
+ return undefined;
401
+ const trimmed = input.trim().toLowerCase();
402
+ if (!HANDLE_REGEX.test(trimmed))
403
+ return undefined;
404
+ return trimmed;
405
+ }
406
+ /**
407
+ * Read the existing `accounts.default.connectorUrl` from `openclaw.json`
408
+ * if present. Used to preserve the self-hosted user's deployment-specific
409
+ * connector URL across the handoff — the loopback flow does not learn
410
+ * it from `apps/api` (issuance is decoupled from connector topology),
411
+ * but we don't want to clobber a user-set value.
412
+ *
413
+ * Returns `undefined` when the file is absent, malformed, or doesn't
414
+ * contain a connectorUrl. Caller falls back to `deriveCloudConnectorUrl`.
415
+ */
416
+ async function loadExistingConnectorUrl() {
417
+ const home = process.env.OPENCLAW_HOME || join(homedir(), ".openclaw");
418
+ const path = join(home, "openclaw.json");
419
+ try {
420
+ const raw = await readFile(path, "utf-8");
421
+ const config = JSON.parse(raw);
422
+ const channels = config.channels;
423
+ const network = channels?.["agent-network"];
424
+ const accounts = network?.accounts;
425
+ const def = accounts?.default;
426
+ const value = def?.connectorUrl;
427
+ if (typeof value === "string" && value.length > 0)
428
+ return value;
429
+ }
430
+ catch {
431
+ // file missing / malformed / unreadable → fall through to derivation
432
+ }
433
+ return undefined;
434
+ }
435
+ /**
436
+ * Derive the WebSocket connector URL from the apiHost. Matches the
437
+ * connector's `CONNECTOR_PLUGIN_URL` env default
438
+ * (`wss://<host>/gateway`) for cloud and preview deployments.
439
+ *
440
+ * Self-hosted users running a non-standard topology should set
441
+ * `accounts.default.connectorUrl` explicitly in `openclaw.json`; the
442
+ * loopback flow preserves that value via `loadExistingConnectorUrl`.
443
+ */
444
+ function deriveCloudConnectorUrl(apiHost) {
445
+ // Strip any incidental scheme so we never produce `wss://https://…`.
446
+ const host = apiHost.replace(/^https?:\/\//, "");
447
+ return `wss://${host}/gateway`;
448
+ }
83
449
  class DeviceFlowError extends Error {
84
450
  }
85
451
  class DeviceFlowExpired extends Error {
@@ -95,7 +461,14 @@ class CancelError extends DeviceFlowError {
95
461
  /**
96
462
  * Run the RFC 8628 device flow loop. Returns the access_token on success.
97
463
  * On expiration, restarts the loop transparently (user gets a fresh code).
464
+ *
465
+ * @deprecated W6 cleanup — superseded by `loopbackHandoffFlow`. No call
466
+ * site after the W4 PR 3 migration; the function is kept as legacy
467
+ * reference until the W6 cleanup retires the connector-side device-flow
468
+ * verifier (`apps/connector/src/core/verify-device-session-token.ts`)
469
+ * and the IdP-side `oauth/device/` route. Do not invoke from new code.
98
470
  */
471
+ // biome-ignore lint/correctness/noUnusedVariables: retained as @deprecated reference for W6 cleanup
99
472
  async function deviceCodeFlow(idpBaseUrl, prompter) {
100
473
  for (;;) {
101
474
  const init = await initDeviceCode(idpBaseUrl);
@@ -144,6 +517,8 @@ async function initDeviceCode(idpBaseUrl) {
144
517
  }
145
518
  return (await res.json());
146
519
  }
520
+ /** @deprecated W6 cleanup — see `deviceCodeFlow` JSDoc. */
521
+ // biome-ignore lint/correctness/noUnusedVariables: retained as @deprecated reference for W6 cleanup
147
522
  async function pollUntilAuthorized(idpBaseUrl, deviceCode, intervalSeconds, prompter) {
148
523
  let interval = intervalSeconds;
149
524
  for (;;) {
@@ -199,6 +574,8 @@ function sleep(ms) {
199
574
  // ---------------------------------------------------------------------------
200
575
  // Onboard (agent select / create)
201
576
  // ---------------------------------------------------------------------------
577
+ /** @deprecated W6 cleanup — see `deviceCodeFlow` JSDoc. */
578
+ // biome-ignore lint/correctness/noUnusedVariables: retained as @deprecated reference for W6 cleanup
202
579
  async function agentSetup(apiHost, accessToken, prompter) {
203
580
  const platformCfg = { apiHost };
204
581
  // First call: empty body. The server's list mode is intentionally
@@ -256,6 +633,8 @@ async function agentSetup(apiHost, accessToken, prompter) {
256
633
  // not from an empty `{}` first call.
257
634
  throw new DeviceFlowError(`Unexpected onboard error on initial list call: ${listResult.data.code}`);
258
635
  }
636
+ /** @deprecated W6 cleanup — see `deviceCodeFlow` JSDoc. */
637
+ // biome-ignore lint/correctness/noUnusedVariables: retained as @deprecated reference for W6 cleanup
259
638
  async function createAgentLoop(platformCfg, accessToken, prompter) {
260
639
  for (;;) {
261
640
  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 +677,12 @@ async function createAgentLoop(platformCfg, accessToken, prompter) {
298
677
  * uses internally, so the terminal UX is consistent with other channel
299
678
  * plugins). Cancel sentinels (user Ctrl+C) are mapped to `DeviceFlowError`
300
679
  * so the login function can treat cancels as a clean exit condition.
680
+ *
681
+ * @deprecated W6 cleanup — only the `@deprecated` device-flow helpers
682
+ * consume this. The active loopback handoff uses `clack` indirectly
683
+ * via the spinner-style stdout writes in `loopbackHandoffFlow`.
301
684
  */
685
+ // biome-ignore lint/correctness/noUnusedVariables: retained for the @deprecated device-flow helpers
302
686
  function createClackPrompter() {
303
687
  const abortOnCancel = (result) => {
304
688
  if (isCancel(result)) {
@@ -350,8 +734,20 @@ function createClackPrompter() {
350
734
  * and bumps the state-cache generation so Layer B's dynamic context re-reads
351
735
  * on the next turn.
352
736
  *
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
737
+ * Flow (W4 PR 3):
738
+ * 1. Run `loopbackHandoffFlow` against `apps/web` a Stripe-style
739
+ * browser-mediated runtime-key issuance that delivers the plaintext
740
+ * `masons_rt_…` token to a one-shot loopback listener bound at
741
+ * `127.0.0.1:0` with a fragment-nonce cross-origin discipline.
742
+ * 2. Resolve the connector URL — preserve any user-set
743
+ * `accounts.default.connectorUrl` from `openclaw.json` (for self-
744
+ * hosted topologies); fall back to the cloud derivation
745
+ * `wss://<apiHost>/gateway` matching the connector's
746
+ * `CONNECTOR_PLUGIN_URL` env default.
747
+ * 3. Persist `{connectorUrl, token}` atomically.
748
+ *
749
+ * Errors during the handoff are surfaced via `ctx.runtime.error` (not
750
+ * thrown) so OpenClaw CLI doesn't display a stack trace — matches the
355
751
  * WhatsApp/Feishu pattern.
356
752
  */
357
753
  export async function login(ctx) {
@@ -361,37 +757,37 @@ export async function login(ctx) {
361
757
  const idpBaseUrl = typeof ctx.cfg.idpBaseUrl === "string"
362
758
  ? ctx.cfg.idpBaseUrl
363
759
  : DEFAULT_IDP_BASE_URL;
364
- const prompter = createClackPrompter();
365
- let creds;
760
+ let handoff;
366
761
  try {
367
- const accessToken = await deviceCodeFlow(idpBaseUrl, prompter);
368
- creds = await agentSetup(apiHost, accessToken, prompter);
762
+ handoff = await loopbackHandoffFlow(idpBaseUrl, ctx.runtime, ctx.channelInput);
369
763
  }
370
764
  catch (err) {
371
765
  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.
766
+ // User pressed Ctrl+C clack already emitted the cancel UI.
374
767
  return;
375
768
  }
376
769
  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.
770
+ // Loopback error with a human-friendly message. Print and return
771
+ // cleanly — CLI shows nothing else on a return. (The class name
772
+ // is `DeviceFlowError` for legacy reasons; it serves as a
773
+ // generic "expected setup failure" sentinel for both flows.)
379
774
  ctx.runtime.error(err.message);
380
775
  return;
381
776
  }
382
777
  if (err instanceof PlatformApiError) {
383
- ctx.runtime.error(`Onboard failed (HTTP ${err.status} ${err.code}): ${err.message}`);
778
+ ctx.runtime.error(`Setup failed (HTTP ${err.status} ${err.code}): ${err.message}`);
384
779
  return;
385
780
  }
386
781
  // Unknown error — let CLI surface the stack. Surfaces platform bugs
387
782
  // that aren't covered by our structured error types.
388
783
  throw err;
389
784
  }
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}`);
785
+ // `connectorUrl` is decoupled from `apps/api` (the issuance host) by
786
+ // design apps/api does not know about connector deployment topology.
787
+ // Preserve any explicit user-set value (self-hosted) and fall back to
788
+ // the well-known cloud / preview shape (`wss://<apiHost>/gateway`).
789
+ const existingConnectorUrl = await loadExistingConnectorUrl();
790
+ const connectorUrl = existingConnectorUrl ?? deriveCloudConnectorUrl(apiHost);
791
+ await writeCredentials({ connectorUrl, token: handoff.token }, apiHost, idpBaseUrl);
792
+ ctx.runtime.log(`✓ Connected as @${handoff.agent.handle}`);
397
793
  }
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.13";
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.13";
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.13",
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
- }