@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.
- package/dist/cli-setup.d.ts +57 -23
- package/dist/cli-setup.d.ts.map +1 -1
- package/dist/cli-setup.js +435 -39
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/dist/session-lifecycle.d.ts +0 -89
- package/dist/session-lifecycle.d.ts.map +0 -1
- package/dist/session-lifecycle.js +0 -348
package/dist/cli-setup.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* CLI Login Path —
|
|
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
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
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
|
-
*
|
|
33
|
-
*
|
|
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
|
-
*
|
|
71
|
-
*
|
|
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>;
|
package/dist/cli-setup.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cli-setup.d.ts","sourceRoot":"","sources":["../src/cli-setup.ts"],"names":[],"mappings":"AAAA
|
|
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 —
|
|
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
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
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
|
-
*
|
|
33
|
-
*
|
|
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
|
-
*
|
|
354
|
-
*
|
|
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
|
-
|
|
365
|
-
let creds;
|
|
760
|
+
let handoff;
|
|
366
761
|
try {
|
|
367
|
-
|
|
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
|
|
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
|
-
//
|
|
378
|
-
//
|
|
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(`
|
|
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
|
-
//
|
|
391
|
-
//
|
|
392
|
-
//
|
|
393
|
-
//
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
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
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.
|
|
2
|
+
export const PLUGIN_VERSION = "0.5.13";
|
package/package.json
CHANGED
|
@@ -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
|
-
}
|