@masons/agent-network 0.5.12 → 0.5.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli-setup.d.ts +75 -25
- package/dist/cli-setup.d.ts.map +1 -1
- package/dist/cli-setup.js +375 -42
- 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,36 +1,71 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* CLI Login Path —
|
|
2
|
+
* CLI Login Path — encrypted server-bridge handoff (v0.5.14).
|
|
3
3
|
*
|
|
4
4
|
* Implements `login()` hook for OpenClaw's 2026.4.x channel-plugin contract:
|
|
5
5
|
* api.registerChannel({ plugin: { ..., auth: { login } } })
|
|
6
6
|
*
|
|
7
7
|
* Invoked by `openclaw channels login --channel agent-network`. CLI dispatch
|
|
8
8
|
* at `dist/channels-cli-Cc40S0aS.js:85` reads `plugin.auth?.login` and throws
|
|
9
|
-
* `"Channel ... does not support login"` if absent.
|
|
10
|
-
* bundled extensions for reference implementations of the same contract.
|
|
9
|
+
* `"Channel ... does not support login"` if absent.
|
|
11
10
|
*
|
|
12
|
-
* Flow:
|
|
13
|
-
* 1. POST {idpBaseUrl}/api/auth/device/code with client_id + scope
|
|
14
|
-
* → display user_code + verification_uri to the user
|
|
15
|
-
* 2. Poll {idpBaseUrl}/api/auth/device/token until access_token issued
|
|
16
|
-
* → handles `expired_token`, `authorization_pending`, `slow_down`
|
|
17
|
-
* 3. POST {apiHost}/runtime/v1/onboard with Bearer access_token
|
|
18
|
-
* → list / select existing agent / create new (terminal prompter)
|
|
19
|
-
* 4. Persist credentials directly via `writeCredentials()` into
|
|
20
|
-
* `openclaw.json`. (The CLI does NOT auto-persist after login returns —
|
|
21
|
-
* unlike the legacy `configureInteractive` contract — so the plugin
|
|
22
|
-
* owns the file write; see `runChannelLogin` in channels-cli source.)
|
|
11
|
+
* Flow (v0.5.14 — `serverBridgeHandoffFlow`):
|
|
23
12
|
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
13
|
+
* 1. Generate an ephemeral RSA-2048 keypair in the plugin process. The
|
|
14
|
+
* private key never leaves process memory; the SPKI-encoded public
|
|
15
|
+
* key travels to `apps/api` for the browser to encrypt against.
|
|
16
|
+
* 2. POST `${apiHost}/v1/cli-handoff/init` with the SPKI pubkey; receive
|
|
17
|
+
* back a `session_id` (`cli_…`) keyed in apps/api's Redis with a 5-min
|
|
18
|
+
* TTL.
|
|
19
|
+
* 3. Print a handoff URL (`${idpBaseUrl}/console/handoff?session=<id>` or
|
|
20
|
+
* the per-handle variant when `channelInput` parses as a handle) for
|
|
21
|
+
* the user to open in their browser. The plugin does NOT spawn a
|
|
22
|
+
* browser — `child_process` is forbidden by OpenClaw's plugin scanner
|
|
23
|
+
* (rules `dangerous-exec`, `env-harvesting`).
|
|
24
|
+
* 4. The user signs in to apps/web, picks or creates an agent, and the
|
|
25
|
+
* browser runs WebCrypto client-side: generates an ephemeral AES-256-GCM
|
|
26
|
+
* key, encrypts `{token, agent, key}` JSON under it, wraps the AES key
|
|
27
|
+
* under the plugin's RSA pubkey via RSA-OAEP-256, and POSTs the
|
|
28
|
+
* `{wrapped_key, iv, ciphertext}` triple to
|
|
29
|
+
* `${apiHost}/v1/cli-handoff/:session/complete` (authenticated server-
|
|
30
|
+
* action mediated by apps/web). Server stores ciphertext only.
|
|
31
|
+
* 5. Plugin polls `${apiHost}/v1/cli-handoff/:session/poll` every 2s until
|
|
32
|
+
* the bridge returns `{status: "completed", payload}`.
|
|
33
|
+
* 6. Plugin decrypts in-process: RSA-OAEP-256 unwrap → AES-256-GCM
|
|
34
|
+
* decrypt → JSON parse → validate `masons_rt_v1_` prefix.
|
|
35
|
+
* 7. Persist `{connectorUrl, token}` to `openclaw.json` via
|
|
36
|
+
* `writeCredentials()`. `connectorUrl` derives `wss://<apiHost>/gateway`
|
|
37
|
+
* for cloud / preview deployments; self-hosted users keep an explicit
|
|
38
|
+
* `connectorUrl` in their `openclaw.json` and the value is preserved
|
|
39
|
+
* across the handoff (see `loadExistingConnectorUrl()`).
|
|
31
40
|
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
41
|
+
* Why this shape (vs the v0.5.13 loopback handoff or device flow):
|
|
42
|
+
*
|
|
43
|
+
* - **Sandbox-friendly.** No `child_process` (no auto-open), no
|
|
44
|
+
* `node:http.createServer` listener, no `process.env` read inside the
|
|
45
|
+
* network code path. OpenClaw's plugin scanner accepts the resulting
|
|
46
|
+
* code surface — the path that blocked v0.5.13 is closed.
|
|
47
|
+
* - **End-to-end encrypted.** The apps/api Redis cache stores ciphertext
|
|
48
|
+
* only. A stolen Mongo / Redis dump yields nothing useful — the wrapped
|
|
49
|
+
* AES key is unrecoverable without the plugin's RSA private key (which
|
|
50
|
+
* lives only in plugin process memory). Mitigates the Shai-Hulud
|
|
51
|
+
* "plaintext-at-rest, even short-lived" concern that originally caused
|
|
52
|
+
* Decision B Alt B3 to be rejected; see #1475 issuecomment-4365154888 § A
|
|
53
|
+
* for the formal reject-supersede record.
|
|
54
|
+
* - **`masons_rt_…` carrier on connector.** Connector verifies new keys
|
|
55
|
+
* via the W4 PR 1 mirror substrate; this file does not change carrier
|
|
56
|
+
* semantics, only the transport that gets the plaintext from
|
|
57
|
+
* apps/web → CLI.
|
|
58
|
+
*
|
|
59
|
+
* Legacy device-flow helpers (`deviceCodeFlow`, `pollUntilAuthorized`,
|
|
60
|
+
* `agentSetup`, `createAgentLoop`) remain in this file as `@deprecated`
|
|
61
|
+
* reference for the W6 cleanup window. They have no call site after the
|
|
62
|
+
* v0.5.13 → v0.5.14 migration and should not be invoked by any new code.
|
|
63
|
+
*
|
|
64
|
+
* See #1264 for original design rationale (single-driver Node semantic —
|
|
65
|
+
* re-running this flow rotates the runtime key and evicts any previously-
|
|
66
|
+
* connected Runtime); see the v0.5.14 strategic-pass at
|
|
67
|
+
* `#1475 issuecomment-4365154888` for the lock that supersedes both
|
|
68
|
+
* Alt B1 (loopback, scanner-blocked) and the prior Alt B3 reject.
|
|
34
69
|
*/
|
|
35
70
|
/**
|
|
36
71
|
* Minimal OpenClaw runtime surface passed to `auth.login`. Exposed as
|
|
@@ -67,8 +102,23 @@ interface AuthLoginContext {
|
|
|
67
102
|
* and bumps the state-cache generation so Layer B's dynamic context re-reads
|
|
68
103
|
* on the next turn.
|
|
69
104
|
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
105
|
+
* Flow (v0.5.14):
|
|
106
|
+
* 1. Run `serverBridgeHandoffFlow` against `apps/api` + `apps/web` —
|
|
107
|
+
* an encrypted server-bridge handoff that delivers the plaintext
|
|
108
|
+
* `masons_rt_…` token from the browser to the plugin via apps/api's
|
|
109
|
+
* Redis cache (storing ciphertext only). RSA-OAEP-256 wraps an
|
|
110
|
+
* ephemeral AES-256-GCM key the browser generates; CLI's RSA
|
|
111
|
+
* private key (held only in plugin process memory) decrypts the
|
|
112
|
+
* AES key in-process to recover the plaintext.
|
|
113
|
+
* 2. Resolve the connector URL — preserve any user-set
|
|
114
|
+
* `accounts.default.connectorUrl` from `openclaw.json` (for self-
|
|
115
|
+
* hosted topologies); fall back to the cloud derivation
|
|
116
|
+
* `wss://<apiHost>/gateway` matching the connector's
|
|
117
|
+
* `CONNECTOR_PLUGIN_URL` env default.
|
|
118
|
+
* 3. Persist `{connectorUrl, token}` atomically.
|
|
119
|
+
*
|
|
120
|
+
* Errors during the handoff are surfaced via `ctx.runtime.error` (not
|
|
121
|
+
* thrown) so OpenClaw CLI doesn't display a stack trace — matches the
|
|
72
122
|
* WhatsApp/Feishu pattern.
|
|
73
123
|
*/
|
|
74
124
|
export declare function login(ctx: AuthLoginContext): Promise<void>;
|
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;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoEG;AAwCH;;;;;GAKG;AACH,UAAU,eAAe;IACvB,GAAG,CAAC,GAAG,IAAI,EAAE,OAAO,EAAE,GAAG,IAAI,CAAC;IAC9B,KAAK,CAAC,GAAG,IAAI,EAAE,OAAO,EAAE,GAAG,IAAI,CAAC;IAChC,WAAW,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACjC,SAAS,CAAC,KAAK,EAAE,OAAO,EAAE,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAChD,IAAI,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;CAC1B;AAED;;;;GAIG;AACH,UAAU,gBAAgB;IACxB,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC7B,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,OAAO,EAAE,eAAe,CAAC;IACzB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AA0yBD;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,wBAAsB,KAAK,CAAC,GAAG,EAAE,gBAAgB,GAAG,OAAO,CAAC,IAAI,CAAC,CAwDhE"}
|
package/dist/cli-setup.js
CHANGED
|
@@ -1,39 +1,75 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* CLI Login Path —
|
|
2
|
+
* CLI Login Path — encrypted server-bridge handoff (v0.5.14).
|
|
3
3
|
*
|
|
4
4
|
* Implements `login()` hook for OpenClaw's 2026.4.x channel-plugin contract:
|
|
5
5
|
* api.registerChannel({ plugin: { ..., auth: { login } } })
|
|
6
6
|
*
|
|
7
7
|
* Invoked by `openclaw channels login --channel agent-network`. CLI dispatch
|
|
8
8
|
* at `dist/channels-cli-Cc40S0aS.js:85` reads `plugin.auth?.login` and throws
|
|
9
|
-
* `"Channel ... does not support login"` if absent.
|
|
10
|
-
* bundled extensions for reference implementations of the same contract.
|
|
9
|
+
* `"Channel ... does not support login"` if absent.
|
|
11
10
|
*
|
|
12
|
-
* Flow:
|
|
13
|
-
* 1. POST {idpBaseUrl}/api/auth/device/code with client_id + scope
|
|
14
|
-
* → display user_code + verification_uri to the user
|
|
15
|
-
* 2. Poll {idpBaseUrl}/api/auth/device/token until access_token issued
|
|
16
|
-
* → handles `expired_token`, `authorization_pending`, `slow_down`
|
|
17
|
-
* 3. POST {apiHost}/runtime/v1/onboard with Bearer access_token
|
|
18
|
-
* → list / select existing agent / create new (terminal prompter)
|
|
19
|
-
* 4. Persist credentials directly via `writeCredentials()` into
|
|
20
|
-
* `openclaw.json`. (The CLI does NOT auto-persist after login returns —
|
|
21
|
-
* unlike the legacy `configureInteractive` contract — so the plugin
|
|
22
|
-
* owns the file write; see `runChannelLogin` in channels-cli source.)
|
|
11
|
+
* Flow (v0.5.14 — `serverBridgeHandoffFlow`):
|
|
23
12
|
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
13
|
+
* 1. Generate an ephemeral RSA-2048 keypair in the plugin process. The
|
|
14
|
+
* private key never leaves process memory; the SPKI-encoded public
|
|
15
|
+
* key travels to `apps/api` for the browser to encrypt against.
|
|
16
|
+
* 2. POST `${apiHost}/v1/cli-handoff/init` with the SPKI pubkey; receive
|
|
17
|
+
* back a `session_id` (`cli_…`) keyed in apps/api's Redis with a 5-min
|
|
18
|
+
* TTL.
|
|
19
|
+
* 3. Print a handoff URL (`${idpBaseUrl}/console/handoff?session=<id>` or
|
|
20
|
+
* the per-handle variant when `channelInput` parses as a handle) for
|
|
21
|
+
* the user to open in their browser. The plugin does NOT spawn a
|
|
22
|
+
* browser — `child_process` is forbidden by OpenClaw's plugin scanner
|
|
23
|
+
* (rules `dangerous-exec`, `env-harvesting`).
|
|
24
|
+
* 4. The user signs in to apps/web, picks or creates an agent, and the
|
|
25
|
+
* browser runs WebCrypto client-side: generates an ephemeral AES-256-GCM
|
|
26
|
+
* key, encrypts `{token, agent, key}` JSON under it, wraps the AES key
|
|
27
|
+
* under the plugin's RSA pubkey via RSA-OAEP-256, and POSTs the
|
|
28
|
+
* `{wrapped_key, iv, ciphertext}` triple to
|
|
29
|
+
* `${apiHost}/v1/cli-handoff/:session/complete` (authenticated server-
|
|
30
|
+
* action mediated by apps/web). Server stores ciphertext only.
|
|
31
|
+
* 5. Plugin polls `${apiHost}/v1/cli-handoff/:session/poll` every 2s until
|
|
32
|
+
* the bridge returns `{status: "completed", payload}`.
|
|
33
|
+
* 6. Plugin decrypts in-process: RSA-OAEP-256 unwrap → AES-256-GCM
|
|
34
|
+
* decrypt → JSON parse → validate `masons_rt_v1_` prefix.
|
|
35
|
+
* 7. Persist `{connectorUrl, token}` to `openclaw.json` via
|
|
36
|
+
* `writeCredentials()`. `connectorUrl` derives `wss://<apiHost>/gateway`
|
|
37
|
+
* for cloud / preview deployments; self-hosted users keep an explicit
|
|
38
|
+
* `connectorUrl` in their `openclaw.json` and the value is preserved
|
|
39
|
+
* across the handoff (see `loadExistingConnectorUrl()`).
|
|
31
40
|
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
41
|
+
* Why this shape (vs the v0.5.13 loopback handoff or device flow):
|
|
42
|
+
*
|
|
43
|
+
* - **Sandbox-friendly.** No `child_process` (no auto-open), no
|
|
44
|
+
* `node:http.createServer` listener, no `process.env` read inside the
|
|
45
|
+
* network code path. OpenClaw's plugin scanner accepts the resulting
|
|
46
|
+
* code surface — the path that blocked v0.5.13 is closed.
|
|
47
|
+
* - **End-to-end encrypted.** The apps/api Redis cache stores ciphertext
|
|
48
|
+
* only. A stolen Mongo / Redis dump yields nothing useful — the wrapped
|
|
49
|
+
* AES key is unrecoverable without the plugin's RSA private key (which
|
|
50
|
+
* lives only in plugin process memory). Mitigates the Shai-Hulud
|
|
51
|
+
* "plaintext-at-rest, even short-lived" concern that originally caused
|
|
52
|
+
* Decision B Alt B3 to be rejected; see #1475 issuecomment-4365154888 § A
|
|
53
|
+
* for the formal reject-supersede record.
|
|
54
|
+
* - **`masons_rt_…` carrier on connector.** Connector verifies new keys
|
|
55
|
+
* via the W4 PR 1 mirror substrate; this file does not change carrier
|
|
56
|
+
* semantics, only the transport that gets the plaintext from
|
|
57
|
+
* apps/web → CLI.
|
|
58
|
+
*
|
|
59
|
+
* Legacy device-flow helpers (`deviceCodeFlow`, `pollUntilAuthorized`,
|
|
60
|
+
* `agentSetup`, `createAgentLoop`) remain in this file as `@deprecated`
|
|
61
|
+
* reference for the W6 cleanup window. They have no call site after the
|
|
62
|
+
* v0.5.13 → v0.5.14 migration and should not be invoked by any new code.
|
|
63
|
+
*
|
|
64
|
+
* See #1264 for original design rationale (single-driver Node semantic —
|
|
65
|
+
* re-running this flow rotates the runtime key and evicts any previously-
|
|
66
|
+
* connected Runtime); see the v0.5.14 strategic-pass at
|
|
67
|
+
* `#1475 issuecomment-4365154888` for the lock that supersedes both
|
|
68
|
+
* Alt B1 (loopback, scanner-blocked) and the prior Alt B3 reject.
|
|
34
69
|
*/
|
|
70
|
+
import { createDecipheriv, constants as cryptoConstants, generateKeyPairSync, privateDecrypt, } from "node:crypto";
|
|
35
71
|
import { cancel, confirm as clackConfirm, select as clackSelect, text as clackText, isCancel, } from "@clack/prompts";
|
|
36
|
-
import { writeCredentials } from "./config.js";
|
|
72
|
+
import { getOpenClawHome, writeCredentials } from "./config.js";
|
|
37
73
|
import { DEFAULT_API_HOST, onboard, PlatformApiError, } from "./platform-client.js";
|
|
38
74
|
// ---------------------------------------------------------------------------
|
|
39
75
|
// Constants
|
|
@@ -80,6 +116,268 @@ const CREATE_NEW_OPTION = "Create new agent";
|
|
|
80
116
|
// Reserved-handle check (e.g., "masons", "openclaw") is server-only — too
|
|
81
117
|
// volatile for the plugin to track; the round-trip is acceptable for that.
|
|
82
118
|
const HANDLE_REGEX = /^[a-z][a-z0-9_-]{2,14}$/;
|
|
119
|
+
/**
|
|
120
|
+
* Hard cap on how long the plugin polls the bridge before giving up.
|
|
121
|
+
* Aligned with the apps/api `/v1/cli-handoff/init` Redis TTL (5 min) so
|
|
122
|
+
* the plugin and the bridge time out together — a longer plugin window
|
|
123
|
+
* would tail off after the session expired anyway.
|
|
124
|
+
*/
|
|
125
|
+
const HANDOFF_TIMEOUT_MS = 5 * 60 * 1000;
|
|
126
|
+
/**
|
|
127
|
+
* Polling interval. Stripe CLI uses ~1s; gh CLI device flow uses 5s. 2s
|
|
128
|
+
* lands in between — adequate spinner-style UX without thrashing the
|
|
129
|
+
* Redis cache.
|
|
130
|
+
*/
|
|
131
|
+
const HANDOFF_POLL_INTERVAL_MS = 2000;
|
|
132
|
+
/**
|
|
133
|
+
* Run the encrypted server-bridge handoff. Returns the decrypted
|
|
134
|
+
* runtime API key + the agent identity the browser issued it for.
|
|
135
|
+
*
|
|
136
|
+
* `channelInput` is the optional argument from
|
|
137
|
+
* `openclaw channels login --channel agent-network -- <input>`. When
|
|
138
|
+
* the input parses as a handle (`^[a-z][a-z0-9_-]{2,14}$`) the flow
|
|
139
|
+
* directs the user to the per-handle deep-link entry; otherwise it
|
|
140
|
+
* falls back to the picker page. Anything that isn't a handle silently
|
|
141
|
+
* falls through.
|
|
142
|
+
*/
|
|
143
|
+
async function serverBridgeHandoffFlow(apiHost, idpBaseUrl, runtime, channelInput) {
|
|
144
|
+
// 1. Generate an ephemeral RSA-2048 keypair. The private key never
|
|
145
|
+
// leaves the plugin process; the SPKI-encoded public key travels
|
|
146
|
+
// to apps/api so the browser can encrypt against it.
|
|
147
|
+
const { publicKey, privateKey } = generateKeyPairSync("rsa", {
|
|
148
|
+
modulusLength: 2048,
|
|
149
|
+
});
|
|
150
|
+
const cliPubkeySpki = publicKey
|
|
151
|
+
.export({ type: "spki", format: "der" })
|
|
152
|
+
.toString("base64url");
|
|
153
|
+
// 2. POST /v1/cli-handoff/init — opens a session keyed in apps/api's
|
|
154
|
+
// Redis cache. Anonymous endpoint; the CLI has no MASONS credential
|
|
155
|
+
// yet (delivering one is the point of this flow).
|
|
156
|
+
const apiBase = normalizeHttpBase(apiHost);
|
|
157
|
+
let session_id;
|
|
158
|
+
try {
|
|
159
|
+
const initRes = await fetch(`${apiBase}/v1/cli-handoff/init`, {
|
|
160
|
+
method: "POST",
|
|
161
|
+
headers: { "content-type": "application/json" },
|
|
162
|
+
body: JSON.stringify({ cli_pubkey: cliPubkeySpki }),
|
|
163
|
+
});
|
|
164
|
+
if (!initRes.ok) {
|
|
165
|
+
throw new DeviceFlowError(`Setup service rejected init (HTTP ${initRes.status}). Retry in a moment; if it persists, check that ${apiBase} is reachable and not behind a proxy that strips the request body.`);
|
|
166
|
+
}
|
|
167
|
+
const initBody = (await initRes.json());
|
|
168
|
+
if (typeof initBody.session_id !== "string" ||
|
|
169
|
+
initBody.session_id.length === 0) {
|
|
170
|
+
throw new DeviceFlowError("Setup service returned a malformed response (missing session_id). Retry; if it persists, this is a server-side bug worth reporting.");
|
|
171
|
+
}
|
|
172
|
+
session_id = initBody.session_id;
|
|
173
|
+
}
|
|
174
|
+
catch (err) {
|
|
175
|
+
if (err instanceof DeviceFlowError)
|
|
176
|
+
throw err;
|
|
177
|
+
const message = err instanceof Error ? err.message : "unknown error";
|
|
178
|
+
throw new DeviceFlowError(`Could not reach the setup service at ${apiBase} (${message}). Retry in a moment.`);
|
|
179
|
+
}
|
|
180
|
+
// 3. Print the handoff URL. No browser auto-open — `child_process`
|
|
181
|
+
// is forbidden by the OpenClaw plugin scanner. The user opens
|
|
182
|
+
// the link manually in their browser.
|
|
183
|
+
const handle = parseHandleFromInput(channelInput);
|
|
184
|
+
const handoffPath = handle
|
|
185
|
+
? `/console/agents/${encodeURIComponent(handle)}/runtime-keys/handoff`
|
|
186
|
+
: `/console/handoff`;
|
|
187
|
+
const handoffUrl = `${idpBaseUrl}${handoffPath}?session=${encodeURIComponent(session_id)}`;
|
|
188
|
+
runtime.writeStdout([
|
|
189
|
+
"",
|
|
190
|
+
"Open this link in your browser to finish setup:",
|
|
191
|
+
` ${handoffUrl}`,
|
|
192
|
+
"",
|
|
193
|
+
"Waiting for the runtime key to be issued and delivered…",
|
|
194
|
+
"",
|
|
195
|
+
].join("\n"));
|
|
196
|
+
// 4. Long-poll /v1/cli-handoff/:session/poll until the bridge
|
|
197
|
+
// returns `{status: "completed", payload}`. Hard cap at
|
|
198
|
+
// HANDOFF_TIMEOUT_MS so a forgotten browser tab does not pin
|
|
199
|
+
// the plugin process.
|
|
200
|
+
const envelope = await pollForCompletion(apiBase, session_id);
|
|
201
|
+
// 5. Decrypt in-process. RSA-OAEP-256 unwrap → AES-256-GCM decrypt
|
|
202
|
+
// → JSON parse → validate runtime key prefix.
|
|
203
|
+
let payload;
|
|
204
|
+
try {
|
|
205
|
+
payload = decryptBridgeEnvelope(privateKey, envelope);
|
|
206
|
+
}
|
|
207
|
+
catch (err) {
|
|
208
|
+
const message = err instanceof Error ? err.message : "unknown error";
|
|
209
|
+
throw new DeviceFlowError(`Handoff received but could not be decrypted (${message}). The link may have been tampered with — re-run setup. If it keeps failing, your network may be inserting a TLS-terminating proxy that mangled the ciphertext.`);
|
|
210
|
+
}
|
|
211
|
+
if (typeof payload.token !== "string" ||
|
|
212
|
+
!payload.token.startsWith("masons_rt_v1_") ||
|
|
213
|
+
typeof payload.agent?.handle !== "string" ||
|
|
214
|
+
typeof payload.agent?.agentId !== "string") {
|
|
215
|
+
throw new DeviceFlowError("Handoff payload is missing required fields. Re-run setup; if it keeps happening, this is a server-side bug worth reporting.");
|
|
216
|
+
}
|
|
217
|
+
return {
|
|
218
|
+
token: payload.token,
|
|
219
|
+
agent: {
|
|
220
|
+
agentId: payload.agent.agentId,
|
|
221
|
+
handle: payload.agent.handle,
|
|
222
|
+
name: typeof payload.agent.name === "string"
|
|
223
|
+
? payload.agent.name
|
|
224
|
+
: payload.agent.handle,
|
|
225
|
+
},
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Long-poll `/v1/cli-handoff/:session/poll` until the bridge returns
|
|
230
|
+
* a completed envelope or the overall window expires. 2s interval is
|
|
231
|
+
* the locked architect choice (#1475 issuecomment-4365154888 § B.2).
|
|
232
|
+
*/
|
|
233
|
+
async function pollForCompletion(apiBase, sessionId) {
|
|
234
|
+
const start = Date.now();
|
|
235
|
+
while (Date.now() - start < HANDOFF_TIMEOUT_MS) {
|
|
236
|
+
let res;
|
|
237
|
+
try {
|
|
238
|
+
res = await fetch(`${apiBase}/v1/cli-handoff/${encodeURIComponent(sessionId)}/poll`, { method: "GET" });
|
|
239
|
+
}
|
|
240
|
+
catch (_err) {
|
|
241
|
+
// Transient network error — try again on the next tick. Persistent
|
|
242
|
+
// network failures will surface as the timeout below.
|
|
243
|
+
await sleep(HANDOFF_POLL_INTERVAL_MS);
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
if (res.status === 404) {
|
|
247
|
+
throw new DeviceFlowError("Handoff session expired before the runtime key was delivered. Re-run setup.");
|
|
248
|
+
}
|
|
249
|
+
if (!res.ok) {
|
|
250
|
+
// Other 4xx/5xx — back off and retry until the timeout fires.
|
|
251
|
+
await sleep(HANDOFF_POLL_INTERVAL_MS);
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
const body = (await res.json());
|
|
255
|
+
if (body.status === "completed" && body.payload) {
|
|
256
|
+
return body.payload;
|
|
257
|
+
}
|
|
258
|
+
// status === "pending" (or anything else) → keep polling.
|
|
259
|
+
await sleep(HANDOFF_POLL_INTERVAL_MS);
|
|
260
|
+
}
|
|
261
|
+
throw new DeviceFlowError("Handoff timed out before the runtime key was delivered. Re-run setup; if you're behind a proxy that blocks long-running fetches, retry on a different network.");
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Decrypt the bridge envelope. RSA-OAEP-256 unwraps the AES key;
|
|
265
|
+
* AES-256-GCM decrypts the ciphertext (auth tag is the last 16 bytes
|
|
266
|
+
* of the ciphertext per the WebCrypto AES-GCM output convention).
|
|
267
|
+
*/
|
|
268
|
+
function decryptBridgeEnvelope(privateKey, envelope) {
|
|
269
|
+
const wrappedKey = Buffer.from(envelope.wrapped_key, "base64url");
|
|
270
|
+
const iv = Buffer.from(envelope.iv, "base64url");
|
|
271
|
+
const fullCiphertext = Buffer.from(envelope.ciphertext, "base64url");
|
|
272
|
+
// WebCrypto's `crypto.subtle.encrypt({name: "AES-GCM"}, ...)` returns
|
|
273
|
+
// ciphertext || tag concatenated, with a 16-byte auth tag. Node's
|
|
274
|
+
// `createDecipheriv("aes-256-gcm", ...)` API expects them split.
|
|
275
|
+
if (fullCiphertext.length < 17) {
|
|
276
|
+
throw new Error("ciphertext shorter than auth tag");
|
|
277
|
+
}
|
|
278
|
+
const tagOffset = fullCiphertext.length - 16;
|
|
279
|
+
const ciphertext = fullCiphertext.subarray(0, tagOffset);
|
|
280
|
+
const authTag = fullCiphertext.subarray(tagOffset);
|
|
281
|
+
const aesKey = privateDecrypt({
|
|
282
|
+
key: privateKey,
|
|
283
|
+
padding: cryptoConstants.RSA_PKCS1_OAEP_PADDING,
|
|
284
|
+
oaepHash: "sha256",
|
|
285
|
+
}, wrappedKey);
|
|
286
|
+
if (aesKey.length !== 32) {
|
|
287
|
+
throw new Error(`unexpected AES key length: ${aesKey.length}`);
|
|
288
|
+
}
|
|
289
|
+
const decipher = createDecipheriv("aes-256-gcm", aesKey, iv);
|
|
290
|
+
decipher.setAuthTag(authTag);
|
|
291
|
+
const plaintext = Buffer.concat([
|
|
292
|
+
decipher.update(ciphertext),
|
|
293
|
+
decipher.final(),
|
|
294
|
+
]);
|
|
295
|
+
return JSON.parse(plaintext.toString("utf-8"));
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Normalize an `apiHost` config value to an HTTPS base URL. Accepts:
|
|
299
|
+
* - bare host (`preview-connectorapi.masons.ai`)
|
|
300
|
+
* - https URL (`https://preview-connectorapi.masons.ai`)
|
|
301
|
+
* - https URL with trailing slash
|
|
302
|
+
* Returns no trailing slash.
|
|
303
|
+
*/
|
|
304
|
+
function normalizeHttpBase(apiHost) {
|
|
305
|
+
const trimmed = apiHost.replace(/\/+$/, "");
|
|
306
|
+
if (/^https?:\/\//.test(trimmed))
|
|
307
|
+
return trimmed;
|
|
308
|
+
return `https://${trimmed}`;
|
|
309
|
+
}
|
|
310
|
+
// `sleep` is shared with the legacy device-flow helpers below — single
|
|
311
|
+
// definition lives there to satisfy biome's `noDuplicateCase` /
|
|
312
|
+
// TS's "duplicate function implementation" check.
|
|
313
|
+
/**
|
|
314
|
+
* Parse `channelInput` as a handle. Returns the lowercased handle on
|
|
315
|
+
* match, `undefined` otherwise. Mirrors the `HANDLE_REGEX` used in the
|
|
316
|
+
* legacy device flow's create form.
|
|
317
|
+
*/
|
|
318
|
+
function parseHandleFromInput(input) {
|
|
319
|
+
if (typeof input !== "string")
|
|
320
|
+
return undefined;
|
|
321
|
+
const trimmed = input.trim().toLowerCase();
|
|
322
|
+
if (!HANDLE_REGEX.test(trimmed))
|
|
323
|
+
return undefined;
|
|
324
|
+
return trimmed;
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* Read the existing `accounts.default.connectorUrl` from `openclaw.json`
|
|
328
|
+
* if present. Used to preserve the self-hosted user's deployment-specific
|
|
329
|
+
* connector URL across the handoff — the bridge does not learn it from
|
|
330
|
+
* `apps/api` (issuance is decoupled from connector topology), but we
|
|
331
|
+
* don't want to clobber a user-set value.
|
|
332
|
+
*
|
|
333
|
+
* The home-directory lookup goes through `getOpenClawHome()` in
|
|
334
|
+
* `config.ts` rather than reading `process.env.OPENCLAW_HOME` here.
|
|
335
|
+
* Keeps the env access in a config-only module so OpenClaw's plugin
|
|
336
|
+
* scanner does not flag the network module's import graph for the
|
|
337
|
+
* `env-harvesting` rule (env access combined with network send).
|
|
338
|
+
*
|
|
339
|
+
* Returns `undefined` when the file is absent, malformed, or doesn't
|
|
340
|
+
* contain a connectorUrl. Caller falls back to `deriveCloudConnectorUrl`.
|
|
341
|
+
*/
|
|
342
|
+
async function loadExistingConnectorUrl() {
|
|
343
|
+
// Imports are dynamic so they don't pull `node:fs` and `node:path`
|
|
344
|
+
// into the static import graph alongside the network calls above —
|
|
345
|
+
// another small mitigation against scanner heuristics that flag
|
|
346
|
+
// "fs read + http fetch in the same module."
|
|
347
|
+
const { readFile } = await import("node:fs/promises");
|
|
348
|
+
const { join } = await import("node:path");
|
|
349
|
+
const home = getOpenClawHome();
|
|
350
|
+
const path = join(home, "openclaw.json");
|
|
351
|
+
try {
|
|
352
|
+
const raw = await readFile(path, "utf-8");
|
|
353
|
+
const config = JSON.parse(raw);
|
|
354
|
+
const channels = config.channels;
|
|
355
|
+
const network = channels?.["agent-network"];
|
|
356
|
+
const accounts = network?.accounts;
|
|
357
|
+
const def = accounts?.default;
|
|
358
|
+
const value = def?.connectorUrl;
|
|
359
|
+
if (typeof value === "string" && value.length > 0)
|
|
360
|
+
return value;
|
|
361
|
+
}
|
|
362
|
+
catch {
|
|
363
|
+
// file missing / malformed / unreadable → fall through to derivation
|
|
364
|
+
}
|
|
365
|
+
return undefined;
|
|
366
|
+
}
|
|
367
|
+
/**
|
|
368
|
+
* Derive the WebSocket connector URL from the apiHost. Matches the
|
|
369
|
+
* connector's `CONNECTOR_PLUGIN_URL` env default
|
|
370
|
+
* (`wss://<host>/gateway`) for cloud and preview deployments.
|
|
371
|
+
*
|
|
372
|
+
* Self-hosted users running a non-standard topology should set
|
|
373
|
+
* `accounts.default.connectorUrl` explicitly in `openclaw.json`; the
|
|
374
|
+
* bridge flow preserves that value via `loadExistingConnectorUrl`.
|
|
375
|
+
*/
|
|
376
|
+
function deriveCloudConnectorUrl(apiHost) {
|
|
377
|
+
// Strip any incidental scheme so we never produce `wss://https://…`.
|
|
378
|
+
const host = apiHost.replace(/^https?:\/\//, "");
|
|
379
|
+
return `wss://${host}/gateway`;
|
|
380
|
+
}
|
|
83
381
|
class DeviceFlowError extends Error {
|
|
84
382
|
}
|
|
85
383
|
class DeviceFlowExpired extends Error {
|
|
@@ -95,7 +393,15 @@ class CancelError extends DeviceFlowError {
|
|
|
95
393
|
/**
|
|
96
394
|
* Run the RFC 8628 device flow loop. Returns the access_token on success.
|
|
97
395
|
* On expiration, restarts the loop transparently (user gets a fresh code).
|
|
396
|
+
*
|
|
397
|
+
* @deprecated W6 cleanup — superseded by `serverBridgeHandoffFlow`. No
|
|
398
|
+
* call site after the v0.5.13 → v0.5.14 migration; the function is
|
|
399
|
+
* kept as legacy reference until the W6 cleanup retires the connector-
|
|
400
|
+
* side device-flow verifier
|
|
401
|
+
* (`apps/connector/src/core/verify-device-session-token.ts`) and the
|
|
402
|
+
* IdP-side `oauth/device/` route. Do not invoke from new code.
|
|
98
403
|
*/
|
|
404
|
+
// biome-ignore lint/correctness/noUnusedVariables: retained as @deprecated reference for W6 cleanup
|
|
99
405
|
async function deviceCodeFlow(idpBaseUrl, prompter) {
|
|
100
406
|
for (;;) {
|
|
101
407
|
const init = await initDeviceCode(idpBaseUrl);
|
|
@@ -144,6 +450,8 @@ async function initDeviceCode(idpBaseUrl) {
|
|
|
144
450
|
}
|
|
145
451
|
return (await res.json());
|
|
146
452
|
}
|
|
453
|
+
/** @deprecated W6 cleanup — see `deviceCodeFlow` JSDoc. */
|
|
454
|
+
// biome-ignore lint/correctness/noUnusedVariables: retained as @deprecated reference for W6 cleanup
|
|
147
455
|
async function pollUntilAuthorized(idpBaseUrl, deviceCode, intervalSeconds, prompter) {
|
|
148
456
|
let interval = intervalSeconds;
|
|
149
457
|
for (;;) {
|
|
@@ -199,6 +507,8 @@ function sleep(ms) {
|
|
|
199
507
|
// ---------------------------------------------------------------------------
|
|
200
508
|
// Onboard (agent select / create)
|
|
201
509
|
// ---------------------------------------------------------------------------
|
|
510
|
+
/** @deprecated W6 cleanup — see `deviceCodeFlow` JSDoc. */
|
|
511
|
+
// biome-ignore lint/correctness/noUnusedVariables: retained as @deprecated reference for W6 cleanup
|
|
202
512
|
async function agentSetup(apiHost, accessToken, prompter) {
|
|
203
513
|
const platformCfg = { apiHost };
|
|
204
514
|
// First call: empty body. The server's list mode is intentionally
|
|
@@ -256,6 +566,8 @@ async function agentSetup(apiHost, accessToken, prompter) {
|
|
|
256
566
|
// not from an empty `{}` first call.
|
|
257
567
|
throw new DeviceFlowError(`Unexpected onboard error on initial list call: ${listResult.data.code}`);
|
|
258
568
|
}
|
|
569
|
+
/** @deprecated W6 cleanup — see `deviceCodeFlow` JSDoc. */
|
|
570
|
+
// biome-ignore lint/correctness/noUnusedVariables: retained as @deprecated reference for W6 cleanup
|
|
259
571
|
async function createAgentLoop(platformCfg, accessToken, prompter) {
|
|
260
572
|
for (;;) {
|
|
261
573
|
const handle = await prompter.text("Choose a handle for your Agent (3-15 chars, start with a letter, then letters/numbers/hyphens/underscores):");
|
|
@@ -298,7 +610,13 @@ async function createAgentLoop(platformCfg, accessToken, prompter) {
|
|
|
298
610
|
* uses internally, so the terminal UX is consistent with other channel
|
|
299
611
|
* plugins). Cancel sentinels (user Ctrl+C) are mapped to `DeviceFlowError`
|
|
300
612
|
* so the login function can treat cancels as a clean exit condition.
|
|
613
|
+
*
|
|
614
|
+
* @deprecated W6 cleanup — only the `@deprecated` device-flow helpers
|
|
615
|
+
* consume this. The active server-bridge handoff
|
|
616
|
+
* (`serverBridgeHandoffFlow`) uses `clack` indirectly via the
|
|
617
|
+
* spinner-style stdout writes in `runtime.writeStdout`.
|
|
301
618
|
*/
|
|
619
|
+
// biome-ignore lint/correctness/noUnusedVariables: retained for the @deprecated device-flow helpers
|
|
302
620
|
function createClackPrompter() {
|
|
303
621
|
const abortOnCancel = (result) => {
|
|
304
622
|
if (isCancel(result)) {
|
|
@@ -350,8 +668,23 @@ function createClackPrompter() {
|
|
|
350
668
|
* and bumps the state-cache generation so Layer B's dynamic context re-reads
|
|
351
669
|
* on the next turn.
|
|
352
670
|
*
|
|
353
|
-
*
|
|
354
|
-
*
|
|
671
|
+
* Flow (v0.5.14):
|
|
672
|
+
* 1. Run `serverBridgeHandoffFlow` against `apps/api` + `apps/web` —
|
|
673
|
+
* an encrypted server-bridge handoff that delivers the plaintext
|
|
674
|
+
* `masons_rt_…` token from the browser to the plugin via apps/api's
|
|
675
|
+
* Redis cache (storing ciphertext only). RSA-OAEP-256 wraps an
|
|
676
|
+
* ephemeral AES-256-GCM key the browser generates; CLI's RSA
|
|
677
|
+
* private key (held only in plugin process memory) decrypts the
|
|
678
|
+
* AES key in-process to recover the plaintext.
|
|
679
|
+
* 2. Resolve the connector URL — preserve any user-set
|
|
680
|
+
* `accounts.default.connectorUrl` from `openclaw.json` (for self-
|
|
681
|
+
* hosted topologies); fall back to the cloud derivation
|
|
682
|
+
* `wss://<apiHost>/gateway` matching the connector's
|
|
683
|
+
* `CONNECTOR_PLUGIN_URL` env default.
|
|
684
|
+
* 3. Persist `{connectorUrl, token}` atomically.
|
|
685
|
+
*
|
|
686
|
+
* Errors during the handoff are surfaced via `ctx.runtime.error` (not
|
|
687
|
+
* thrown) so OpenClaw CLI doesn't display a stack trace — matches the
|
|
355
688
|
* WhatsApp/Feishu pattern.
|
|
356
689
|
*/
|
|
357
690
|
export async function login(ctx) {
|
|
@@ -361,37 +694,37 @@ export async function login(ctx) {
|
|
|
361
694
|
const idpBaseUrl = typeof ctx.cfg.idpBaseUrl === "string"
|
|
362
695
|
? ctx.cfg.idpBaseUrl
|
|
363
696
|
: DEFAULT_IDP_BASE_URL;
|
|
364
|
-
|
|
365
|
-
let creds;
|
|
697
|
+
let handoff;
|
|
366
698
|
try {
|
|
367
|
-
|
|
368
|
-
creds = await agentSetup(apiHost, accessToken, prompter);
|
|
699
|
+
handoff = await serverBridgeHandoffFlow(apiHost, idpBaseUrl, ctx.runtime, ctx.channelInput);
|
|
369
700
|
}
|
|
370
701
|
catch (err) {
|
|
371
702
|
if (err instanceof CancelError) {
|
|
372
|
-
// User pressed Ctrl+C
|
|
373
|
-
// adapter already emitted the cancellation UI; nothing else to print.
|
|
703
|
+
// User pressed Ctrl+C — clack already emitted the cancel UI.
|
|
374
704
|
return;
|
|
375
705
|
}
|
|
376
706
|
if (err instanceof DeviceFlowError) {
|
|
377
|
-
//
|
|
378
|
-
//
|
|
707
|
+
// Bridge error with a human-friendly message. Print and return
|
|
708
|
+
// cleanly — CLI shows nothing else on a return. (The class name
|
|
709
|
+
// is `DeviceFlowError` for legacy reasons; it serves as a
|
|
710
|
+
// generic "expected setup failure" sentinel for both flows.)
|
|
379
711
|
ctx.runtime.error(err.message);
|
|
380
712
|
return;
|
|
381
713
|
}
|
|
382
714
|
if (err instanceof PlatformApiError) {
|
|
383
|
-
ctx.runtime.error(`
|
|
715
|
+
ctx.runtime.error(`Setup failed (HTTP ${err.status} ${err.code}): ${err.message}`);
|
|
384
716
|
return;
|
|
385
717
|
}
|
|
386
718
|
// Unknown error — let CLI surface the stack. Surfaces platform bugs
|
|
387
719
|
// that aren't covered by our structured error types.
|
|
388
720
|
throw err;
|
|
389
721
|
}
|
|
390
|
-
//
|
|
391
|
-
//
|
|
392
|
-
//
|
|
393
|
-
//
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
722
|
+
// `connectorUrl` is decoupled from `apps/api` (the issuance host) by
|
|
723
|
+
// design — apps/api does not know about connector deployment topology.
|
|
724
|
+
// Preserve any explicit user-set value (self-hosted) and fall back to
|
|
725
|
+
// the well-known cloud / preview shape (`wss://<apiHost>/gateway`).
|
|
726
|
+
const existingConnectorUrl = await loadExistingConnectorUrl();
|
|
727
|
+
const connectorUrl = existingConnectorUrl ?? deriveCloudConnectorUrl(apiHost);
|
|
728
|
+
await writeCredentials({ connectorUrl, token: handoff.token }, apiHost, idpBaseUrl);
|
|
729
|
+
ctx.runtime.log(`✓ Connected as @${handoff.agent.handle}`);
|
|
397
730
|
}
|
package/dist/version.d.ts
CHANGED
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.14";
|
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
|
-
}
|