@masons/agent-network 0.4.26 → 0.5.0
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 +21 -5
- package/dist/cli-setup.d.ts.map +1 -1
- package/dist/cli-setup.js +272 -92
- package/dist/connector-client.d.ts.map +1 -1
- package/dist/connector-client.js +44 -3
- package/dist/platform-client.d.ts +84 -74
- package/dist/platform-client.d.ts.map +1 -1
- package/dist/platform-client.js +77 -108
- package/dist/plugin.d.ts +5 -0
- package/dist/plugin.d.ts.map +1 -1
- package/dist/plugin.js +23 -5
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +52 -161
- package/dist/types.d.ts +1 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.d.ts.map +1 -1
- package/dist/version.js +1 -1
- package/openclaw.plugin.json +7 -2
- package/package.json +1 -1
package/dist/cli-setup.d.ts
CHANGED
|
@@ -1,13 +1,27 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* CLI Setup Path — Device
|
|
2
|
+
* CLI Setup Path — OAuth 2.0 Device Authorization Grant (RFC 8628).
|
|
3
3
|
*
|
|
4
4
|
* Implements `configureInteractive()` hook for OpenClaw's
|
|
5
5
|
* `openclaw channels login --channel agent-network` command.
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
7
|
+
* Flow:
|
|
8
|
+
* 1. POST {idpBaseUrl}/api/auth/device/code with client_id + scope
|
|
9
|
+
* → display user_code + verification_uri to the user
|
|
10
|
+
* 2. Poll {idpBaseUrl}/api/auth/device/token until access_token issued
|
|
11
|
+
* → handles `expired_token`, `authorization_pending`, `slow_down`
|
|
12
|
+
* 3. POST {apiHost}/runtime/v1/onboard with Bearer access_token
|
|
13
|
+
* → list / select existing agent / create new (terminal prompter)
|
|
14
|
+
* 4. Return credentials → OpenClaw CLI persists them to openclaw.json
|
|
10
15
|
*
|
|
16
|
+
* Pre-migration this file existed as a `setup_codes`-based bespoke flow
|
|
17
|
+
* (deleted in fd5568e8). This re-implementation is purely standards-based:
|
|
18
|
+
* the IdP is Better Auth's `deviceAuthorization` plugin, which speaks
|
|
19
|
+
* RFC 8628; the onboard step is a thin MASONS-specific shim that exchanges
|
|
20
|
+
* the user-scope access_token for an agent-scope api_key.
|
|
21
|
+
*
|
|
22
|
+
* See #1264 for the design rationale (in particular the "single-driver
|
|
23
|
+
* Node semantic" — re-running this flow rotates the api_key and evicts
|
|
24
|
+
* any previously-connected Runtime).
|
|
11
25
|
*/
|
|
12
26
|
interface Prompter {
|
|
13
27
|
text(label: string, opts?: {
|
|
@@ -31,7 +45,9 @@ type SetupResult = "skip" | {
|
|
|
31
45
|
/**
|
|
32
46
|
* Interactive Device Code Flow for OpenClaw CLI.
|
|
33
47
|
*
|
|
34
|
-
* Called by `openclaw channels login --channel agent-network`
|
|
48
|
+
* Called by `openclaw channels login --channel agent-network` (wired via
|
|
49
|
+
* `setup: { configureInteractive }` on the channel registration in plugin.ts).
|
|
50
|
+
*
|
|
35
51
|
* Returns `{ cfg, accountId }` — OpenClaw CLI handles writing to openclaw.json.
|
|
36
52
|
*/
|
|
37
53
|
export declare function configureInteractive(ctx: ChannelSetupContext): Promise<SetupResult>;
|
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;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAaH,UAAU,QAAQ;IAChB,IAAI,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE;QAAE,OAAO,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IAClE,OAAO,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IACzC,MAAM,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;CAC3D;AAED,UAAU,mBAAmB;IAC3B,UAAU,EAAE,OAAO,CAAC;IACpB,KAAK,EAAE,MAAM,CAAC;IACd,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC7B,OAAO,EAAE,OAAO,CAAC;IACjB,QAAQ,EAAE,QAAQ,CAAC;IACnB,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAClC;AAED,KAAK,WAAW,GACZ,MAAM,GACN;IAAE,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAAC,SAAS,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC;AA0XzD;;;;;;;GAOG;AACH,wBAAsB,oBAAoB,CACxC,GAAG,EAAE,mBAAmB,GACvB,OAAO,CAAC,WAAW,CAAC,CA+CtB"}
|
package/dist/cli-setup.js
CHANGED
|
@@ -1,145 +1,325 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* CLI Setup Path — Device
|
|
2
|
+
* CLI Setup Path — OAuth 2.0 Device Authorization Grant (RFC 8628).
|
|
3
3
|
*
|
|
4
4
|
* Implements `configureInteractive()` hook for OpenClaw's
|
|
5
5
|
* `openclaw channels login --channel agent-network` command.
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
7
|
+
* Flow:
|
|
8
|
+
* 1. POST {idpBaseUrl}/api/auth/device/code with client_id + scope
|
|
9
|
+
* → display user_code + verification_uri to the user
|
|
10
|
+
* 2. Poll {idpBaseUrl}/api/auth/device/token until access_token issued
|
|
11
|
+
* → handles `expired_token`, `authorization_pending`, `slow_down`
|
|
12
|
+
* 3. POST {apiHost}/runtime/v1/onboard with Bearer access_token
|
|
13
|
+
* → list / select existing agent / create new (terminal prompter)
|
|
14
|
+
* 4. Return credentials → OpenClaw CLI persists them to openclaw.json
|
|
10
15
|
*
|
|
16
|
+
* Pre-migration this file existed as a `setup_codes`-based bespoke flow
|
|
17
|
+
* (deleted in fd5568e8). This re-implementation is purely standards-based:
|
|
18
|
+
* the IdP is Better Auth's `deviceAuthorization` plugin, which speaks
|
|
19
|
+
* RFC 8628; the onboard step is a thin MASONS-specific shim that exchanges
|
|
20
|
+
* the user-scope access_token for an agent-scope api_key.
|
|
21
|
+
*
|
|
22
|
+
* See #1264 for the design rationale (in particular the "single-driver
|
|
23
|
+
* Node semantic" — re-running this flow rotates the api_key and evicts
|
|
24
|
+
* any previously-connected Runtime).
|
|
11
25
|
*/
|
|
12
|
-
import { DEFAULT_API_HOST,
|
|
26
|
+
import { DEFAULT_API_HOST, onboard, PlatformApiError, } from "./platform-client.js";
|
|
13
27
|
// ---------------------------------------------------------------------------
|
|
14
28
|
// Constants
|
|
15
29
|
// ---------------------------------------------------------------------------
|
|
16
|
-
const CREATE_NEW_OPTION = "Create new agent";
|
|
17
|
-
// ---------------------------------------------------------------------------
|
|
18
|
-
// Implementation
|
|
19
|
-
// ---------------------------------------------------------------------------
|
|
20
30
|
/**
|
|
21
|
-
*
|
|
31
|
+
* Better Auth IdP base URL. Default targets the preview environment.
|
|
22
32
|
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
33
|
+
* TODO: flip to the production URL when W8 (api.masons.ai consolidation)
|
|
34
|
+
* lands. Plugins shipped with this default before that flip will still
|
|
35
|
+
* work — the user can override by setting `idpBaseUrl` in their
|
|
36
|
+
* `openclaw.json` channel config (or by re-running setup which writes
|
|
37
|
+
* whatever is current).
|
|
25
38
|
*/
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
39
|
+
const DEFAULT_IDP_BASE_URL = "https://preview.masons.ai";
|
|
40
|
+
/**
|
|
41
|
+
* OAuth client_id registered for OpenClaw at the IdP — see
|
|
42
|
+
* `apps/web/scripts/seed.ts`. Hardcoded because there's only one OpenClaw
|
|
43
|
+
* client today; if multiple wrappers (e.g. self-hosted vs Claude Desktop
|
|
44
|
+
* variant) ever need distinct client_ids, expose this via the channel
|
|
45
|
+
* config schema.
|
|
46
|
+
*/
|
|
47
|
+
const OAUTH_CLIENT_ID = "openclaw";
|
|
48
|
+
/**
|
|
49
|
+
* Scopes requested at device-flow initiation. `openid` + `profile` + `email`
|
|
50
|
+
* are the standard OIDC scopes; `offline_access` requests a refresh token
|
|
51
|
+
* (Better Auth uses session tokens here, not OAuth opaque tokens — the
|
|
52
|
+
* scope is mostly indicative). Stays in sync with the scopes registered on
|
|
53
|
+
* the `oauthProvider` plugin at apps/web/lib/auth.ts.
|
|
54
|
+
*/
|
|
55
|
+
const SCOPE = "openid profile email offline_access";
|
|
56
|
+
/** Default polling interval if the IdP does not return one. */
|
|
57
|
+
const DEFAULT_POLL_INTERVAL_S = 5;
|
|
58
|
+
const CREATE_NEW_OPTION = "Create new agent";
|
|
59
|
+
// Handle pre-validation regex — strict subset of the server's authoritative
|
|
60
|
+
// `validateHandle` (packages/db/src/utils/handle-validation.ts:3). Used to
|
|
61
|
+
// fail-fast in the terminal before round-tripping the server. Kept inline
|
|
62
|
+
// because the published npm plugin can't import @workspace/db.
|
|
63
|
+
//
|
|
64
|
+
// Server contract: `/^[a-z][a-z0-9_-]{2,14}$/` against the lowercased input.
|
|
65
|
+
// MUST be a strict subset (false-negatives OK, false-positives forbidden) —
|
|
66
|
+
// otherwise the user sees "passed client check" then "server rejected" for
|
|
67
|
+
// the same input, which is confusing.
|
|
68
|
+
//
|
|
69
|
+
// Reserved-handle check (e.g., "masons", "openclaw") is server-only — too
|
|
70
|
+
// volatile for the plugin to track; the round-trip is acceptable for that.
|
|
71
|
+
const HANDLE_REGEX = /^[a-z][a-z0-9_-]{2,14}$/;
|
|
72
|
+
class DeviceFlowError extends Error {
|
|
73
|
+
}
|
|
74
|
+
class DeviceFlowExpired extends Error {
|
|
36
75
|
}
|
|
37
|
-
// ---------------------------------------------------------------------------
|
|
38
|
-
// Device Code Flow
|
|
39
|
-
// ---------------------------------------------------------------------------
|
|
40
76
|
/**
|
|
41
|
-
* Run the
|
|
42
|
-
*
|
|
77
|
+
* Run the RFC 8628 device flow loop. Returns the access_token on success.
|
|
78
|
+
* On expiration, restarts the loop transparently (user gets a fresh code).
|
|
43
79
|
*/
|
|
44
|
-
async function deviceCodeFlow(
|
|
80
|
+
async function deviceCodeFlow(idpBaseUrl, prompter) {
|
|
45
81
|
for (;;) {
|
|
46
|
-
const
|
|
47
|
-
// Display setup code and authorization link.
|
|
48
|
-
// Uses prompter.text() as a "press Enter to continue" prompt —
|
|
49
|
-
// OpenClaw's prompter has no display-only method, so text() with
|
|
50
|
-
// discarded input is the established pattern (see WhatsApp channel).
|
|
82
|
+
const init = await initDeviceCode(idpBaseUrl);
|
|
51
83
|
await prompter.text([
|
|
52
84
|
"",
|
|
53
|
-
`Setup code: ${
|
|
85
|
+
`Setup code: ${init.user_code}`,
|
|
54
86
|
"",
|
|
55
|
-
`Open this link to authorize: ${
|
|
87
|
+
`Open this link to authorize: ${init.verification_uri_complete ?? `${init.verification_uri}?user_code=${init.user_code}`}`,
|
|
56
88
|
"",
|
|
57
89
|
"Press Enter after you've authorized in the browser",
|
|
58
90
|
].join("\n"));
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
91
|
+
try {
|
|
92
|
+
const accessToken = await pollUntilAuthorized(idpBaseUrl, init.device_code, init.interval ?? DEFAULT_POLL_INTERVAL_S, prompter);
|
|
93
|
+
return accessToken;
|
|
94
|
+
}
|
|
95
|
+
catch (err) {
|
|
96
|
+
if (err instanceof DeviceFlowExpired) {
|
|
97
|
+
await prompter.text("Setup code expired. Press Enter for a new one.");
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
throw err;
|
|
63
101
|
}
|
|
64
|
-
return pollResult.setup_token;
|
|
65
102
|
}
|
|
66
103
|
}
|
|
67
|
-
async function
|
|
104
|
+
async function initDeviceCode(idpBaseUrl) {
|
|
105
|
+
const params = new URLSearchParams();
|
|
106
|
+
params.set("client_id", OAUTH_CLIENT_ID);
|
|
107
|
+
params.set("scope", SCOPE);
|
|
108
|
+
const res = await fetch(`${idpBaseUrl}/api/auth/device/code`, {
|
|
109
|
+
method: "POST",
|
|
110
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
111
|
+
body: params.toString(),
|
|
112
|
+
});
|
|
113
|
+
if (!res.ok) {
|
|
114
|
+
// 4xx at initiation → likely a misconfigured client_id or scope. Surface
|
|
115
|
+
// a clear message rather than retrying — this is a setup error, not a
|
|
116
|
+
// transient one.
|
|
117
|
+
const body = (await res.json().catch(() => ({})));
|
|
118
|
+
const code = typeof body.error === "string" ? body.error : "unknown";
|
|
119
|
+
const description = typeof body.error_description === "string"
|
|
120
|
+
? body.error_description
|
|
121
|
+
: `IdP returned ${res.status}`;
|
|
122
|
+
throw new DeviceFlowError(`Device code initiation failed (${code}): ${description}. ` +
|
|
123
|
+
`Verify that "${OAUTH_CLIENT_ID}" is registered with the device_code grant ` +
|
|
124
|
+
`and is in the deviceAuthorization plugin's allowlist at the IdP.`);
|
|
125
|
+
}
|
|
126
|
+
return (await res.json());
|
|
127
|
+
}
|
|
128
|
+
async function pollUntilAuthorized(idpBaseUrl, deviceCode, intervalSeconds, prompter) {
|
|
129
|
+
let interval = intervalSeconds;
|
|
68
130
|
for (;;) {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
131
|
+
const params = new URLSearchParams();
|
|
132
|
+
params.set("grant_type", "urn:ietf:params:oauth:grant-type:device_code");
|
|
133
|
+
params.set("device_code", deviceCode);
|
|
134
|
+
params.set("client_id", OAUTH_CLIENT_ID);
|
|
135
|
+
const res = await fetch(`${idpBaseUrl}/api/auth/device/token`, {
|
|
136
|
+
method: "POST",
|
|
137
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
138
|
+
body: params.toString(),
|
|
139
|
+
});
|
|
140
|
+
if (res.ok) {
|
|
141
|
+
const success = (await res.json());
|
|
142
|
+
return success.access_token;
|
|
72
143
|
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
continue;
|
|
144
|
+
const body = (await res.json().catch(() => ({})));
|
|
145
|
+
if (body.error === "authorization_pending") {
|
|
146
|
+
// User hasn't approved yet — wait and ask if they want to continue.
|
|
147
|
+
const retry = await prompter.confirm("Not authorized yet. Have you entered the code in your browser?");
|
|
148
|
+
if (!retry) {
|
|
149
|
+
// User gave up on this code — same recovery as expiration: ask
|
|
150
|
+
// the outer loop to restart with a fresh code. Throwing
|
|
151
|
+
// DeviceFlowExpired (rather than a separate "abandoned" sentinel)
|
|
152
|
+
// is intentional — both paths recover identically.
|
|
153
|
+
throw new DeviceFlowExpired();
|
|
84
154
|
}
|
|
85
|
-
|
|
155
|
+
await sleep(interval * 1000);
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
if (body.error === "slow_down") {
|
|
159
|
+
// RFC 8628 §3.5 — server requests we slow down. Bump interval +5s.
|
|
160
|
+
interval += 5;
|
|
161
|
+
await sleep(interval * 1000);
|
|
162
|
+
continue;
|
|
86
163
|
}
|
|
164
|
+
if (body.error === "expired_token") {
|
|
165
|
+
throw new DeviceFlowExpired();
|
|
166
|
+
}
|
|
167
|
+
if (body.error === "access_denied") {
|
|
168
|
+
throw new DeviceFlowError("Authorization was denied. Re-run setup if this was a mistake.");
|
|
169
|
+
}
|
|
170
|
+
// Unknown error — bail with whatever we got from the server.
|
|
171
|
+
throw new DeviceFlowError(`Device token poll failed: ${body.error ?? "unknown"} ` +
|
|
172
|
+
`(${body.error_description ?? "no description"})`);
|
|
87
173
|
}
|
|
88
174
|
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
175
|
+
function sleep(ms) {
|
|
176
|
+
return new Promise((resolve) => {
|
|
177
|
+
setTimeout(resolve, ms);
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
// Onboard (agent select / create)
|
|
182
|
+
// ---------------------------------------------------------------------------
|
|
183
|
+
async function agentSetup(apiHost, accessToken, prompter) {
|
|
184
|
+
const platformCfg = { apiHost };
|
|
185
|
+
// First call: empty body. The server's list mode is intentionally
|
|
186
|
+
// non-mutating — it returns 412 (no agents) or 422 (here are your agents,
|
|
187
|
+
// pick one). Rotation only happens on the explicit `{agentId}` call after
|
|
188
|
+
// the user picks. Even with a single agent, the picker shows so the user
|
|
189
|
+
// explicitly confirms before any existing Runtime is evicted.
|
|
190
|
+
const listResult = await onboard(platformCfg, accessToken, {});
|
|
191
|
+
if (listResult.kind === "ok") {
|
|
192
|
+
// List mode never returns 200 by contract. If a future server version
|
|
193
|
+
// changes that, surface explicitly rather than silently accepting a
|
|
194
|
+
// rotation the user didn't confirm.
|
|
195
|
+
throw new DeviceFlowError("Server returned an unexpected 200 from the list call — refusing " +
|
|
196
|
+
"to use it without explicit user selection. Please report this.");
|
|
197
|
+
}
|
|
198
|
+
if (listResult.data.code === "no_agents") {
|
|
199
|
+
// Zero agents — prompt for handle to create one.
|
|
200
|
+
return await createAgentLoop(platformCfg, accessToken, prompter);
|
|
201
|
+
}
|
|
202
|
+
if (listResult.data.code === "agent_required") {
|
|
203
|
+
// Show picker. Single-agent case still goes through the picker (one
|
|
204
|
+
// agent + "Create new" choice) so the user explicitly confirms which
|
|
205
|
+
// agent this Runtime is claiming. The selection triggers api_key
|
|
206
|
+
// rotation — see "single-driver Node semantic" in CHANGELOG.
|
|
207
|
+
const agents = listResult.data.agents;
|
|
93
208
|
const choices = [
|
|
94
|
-
...agents.map((a) =>
|
|
209
|
+
...agents.map((a) => a.name ? `@${a.handle} (${a.name})` : `@${a.handle}`),
|
|
95
210
|
CREATE_NEW_OPTION,
|
|
96
211
|
];
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
212
|
+
const promptLabel = agents.length === 1
|
|
213
|
+
? "You have one agent. Confirm to claim it for this Runtime " +
|
|
214
|
+
"(its api_key will be rotated; any previously-connected Runtime " +
|
|
215
|
+
"for this agent will be disconnected):"
|
|
216
|
+
: "You have multiple agents. Pick one to claim for this Runtime " +
|
|
217
|
+
"(its api_key will be rotated; any previously-connected Runtime " +
|
|
218
|
+
"for the chosen agent will be disconnected):";
|
|
219
|
+
const selected = await prompter.select(promptLabel, choices);
|
|
220
|
+
if (selected === CREATE_NEW_OPTION) {
|
|
221
|
+
return await createAgentLoop(platformCfg, accessToken, prompter);
|
|
106
222
|
}
|
|
223
|
+
const idx = choices.indexOf(selected);
|
|
224
|
+
const agent = agents[idx];
|
|
225
|
+
if (!agent) {
|
|
226
|
+
throw new DeviceFlowError("Picker returned an unrecognized choice. Please re-run setup.");
|
|
227
|
+
}
|
|
228
|
+
const selectResult = await onboard(platformCfg, accessToken, {
|
|
229
|
+
agentId: agent.id,
|
|
230
|
+
});
|
|
231
|
+
if (selectResult.kind !== "ok") {
|
|
232
|
+
throw new DeviceFlowError(`Agent selection failed: ${selectResult.data.code}`);
|
|
233
|
+
}
|
|
234
|
+
return selectResult.data;
|
|
107
235
|
}
|
|
108
|
-
//
|
|
109
|
-
|
|
236
|
+
// invalid_handle / handle_taken can only happen on a `create` call —
|
|
237
|
+
// not from an empty `{}` first call.
|
|
238
|
+
throw new DeviceFlowError(`Unexpected onboard error on initial list call: ${listResult.data.code}`);
|
|
110
239
|
}
|
|
111
|
-
async function
|
|
240
|
+
async function createAgentLoop(platformCfg, accessToken, prompter) {
|
|
112
241
|
for (;;) {
|
|
113
|
-
const handle = await prompter.text("Choose a handle for your Agent (3-15 chars,
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
242
|
+
const handle = await prompter.text("Choose a handle for your Agent (3-15 chars, start with a letter, then letters/numbers/hyphens/underscores):");
|
|
243
|
+
// Lowercase + trim mirrors the server's normalizeHandle step. If the user
|
|
244
|
+
// typed "Alice", we test "alice" against the regex — same outcome the
|
|
245
|
+
// server would produce. Prevents the false-negative case where a valid
|
|
246
|
+
// handle is rejected client-side just for case.
|
|
247
|
+
const normalized = handle.trim().toLowerCase();
|
|
248
|
+
// Client-side fail-fast — saves a server round-trip on obvious mistakes.
|
|
249
|
+
// Server's validateHandle is the authoritative source for both format
|
|
250
|
+
// and the reserved-handle list.
|
|
251
|
+
if (!HANDLE_REGEX.test(normalized)) {
|
|
252
|
+
await prompter.text(`"${handle}" must be 3-15 chars, start with a letter, then only letters/numbers/hyphens/underscores. Press Enter to try again.`);
|
|
253
|
+
continue;
|
|
117
254
|
}
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
throw err; // Unexpected error — propagate
|
|
255
|
+
const result = await onboard(platformCfg, accessToken, {
|
|
256
|
+
create: { handle: normalized },
|
|
257
|
+
});
|
|
258
|
+
if (result.kind === "ok") {
|
|
259
|
+
return result.data;
|
|
260
|
+
}
|
|
261
|
+
if (result.data.code === "handle_taken") {
|
|
262
|
+
await prompter.text(`"${normalized}" is already taken. Press Enter to try another.`);
|
|
263
|
+
continue;
|
|
130
264
|
}
|
|
265
|
+
if (result.data.code === "invalid_handle") {
|
|
266
|
+
await prompter.text(`"${normalized}" failed server validation: ${result.data.message}. Press Enter to try again.`);
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
// Other structured errors shouldn't reach here on a create call —
|
|
270
|
+
// surface with whatever we got.
|
|
271
|
+
throw new DeviceFlowError(`Unexpected onboard error on create: ${result.data.code}`);
|
|
131
272
|
}
|
|
132
273
|
}
|
|
133
274
|
// ---------------------------------------------------------------------------
|
|
134
|
-
//
|
|
275
|
+
// Public hook
|
|
135
276
|
// ---------------------------------------------------------------------------
|
|
136
|
-
|
|
277
|
+
/**
|
|
278
|
+
* Interactive Device Code Flow for OpenClaw CLI.
|
|
279
|
+
*
|
|
280
|
+
* Called by `openclaw channels login --channel agent-network` (wired via
|
|
281
|
+
* `setup: { configureInteractive }` on the channel registration in plugin.ts).
|
|
282
|
+
*
|
|
283
|
+
* Returns `{ cfg, accountId }` — OpenClaw CLI handles writing to openclaw.json.
|
|
284
|
+
*/
|
|
285
|
+
export async function configureInteractive(ctx) {
|
|
286
|
+
const { prompter } = ctx;
|
|
287
|
+
// Channel-level config (`cfg.apiHost`, `cfg.idpBaseUrl`) — both have
|
|
288
|
+
// defaults. Existing values from openclaw.json are preserved on re-run.
|
|
289
|
+
const apiHost = typeof ctx.cfg.apiHost === "string" ? ctx.cfg.apiHost : DEFAULT_API_HOST;
|
|
290
|
+
const idpBaseUrl = typeof ctx.cfg.idpBaseUrl === "string"
|
|
291
|
+
? ctx.cfg.idpBaseUrl
|
|
292
|
+
: DEFAULT_IDP_BASE_URL;
|
|
293
|
+
let creds;
|
|
294
|
+
try {
|
|
295
|
+
const accessToken = await deviceCodeFlow(idpBaseUrl, prompter);
|
|
296
|
+
creds = await agentSetup(apiHost, accessToken, prompter);
|
|
297
|
+
}
|
|
298
|
+
catch (err) {
|
|
299
|
+
if (err instanceof DeviceFlowError) {
|
|
300
|
+
// Human-friendly surface — user reads this and re-runs setup.
|
|
301
|
+
await prompter.text(`${err.message}\n\nPress Enter to exit.`);
|
|
302
|
+
return "skip";
|
|
303
|
+
}
|
|
304
|
+
if (err instanceof PlatformApiError) {
|
|
305
|
+
await prompter.text(`Onboard failed (HTTP ${err.status} ${err.code}): ${err.message}. ` +
|
|
306
|
+
"Press Enter to exit.");
|
|
307
|
+
return "skip";
|
|
308
|
+
}
|
|
309
|
+
throw err;
|
|
310
|
+
}
|
|
311
|
+
// The openclaw.json schema for an account is { connectorUrl, token }
|
|
312
|
+
// (see config-schema.ts). On the wire the field is called `apiKey` to
|
|
313
|
+
// disambiguate from "session token" / "JWT" — but locally on disk it's
|
|
314
|
+
// persisted as `token` for backward compatibility with existing OpenClaw
|
|
315
|
+
// configs and the channel.resolveAccount() reader at channel.ts:485.
|
|
137
316
|
return {
|
|
138
317
|
cfg: {
|
|
139
318
|
accounts: {
|
|
140
|
-
default: { connectorUrl, token },
|
|
319
|
+
default: { connectorUrl: creds.connectorUrl, token: creds.apiKey },
|
|
141
320
|
},
|
|
142
321
|
apiHost,
|
|
322
|
+
idpBaseUrl,
|
|
143
323
|
},
|
|
144
324
|
accountId: "default",
|
|
145
325
|
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"connector-client.d.ts","sourceRoot":"","sources":["../src/connector-client.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAI3C,OAAO,EAEL,KAAK,qBAAqB,EAI1B,KAAK,oBAAoB,EACzB,KAAK,mBAAmB,EAWxB,KAAK,YAAY,EACjB,KAAK,oBAAoB,EAC1B,MAAM,YAAY,CAAC;
|
|
1
|
+
{"version":3,"file":"connector-client.d.ts","sourceRoot":"","sources":["../src/connector-client.ts"],"names":[],"mappings":"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"}
|
package/dist/connector-client.js
CHANGED
|
@@ -12,6 +12,13 @@ const BACKOFF_INITIAL_MS = 1_000;
|
|
|
12
12
|
const BACKOFF_MAX_MS = 30_000;
|
|
13
13
|
const ALREADY_CONNECTED_RETRY_MS = 2_000;
|
|
14
14
|
const ALREADY_CONNECTED_MAX_RETRIES = 30;
|
|
15
|
+
// --- Single-driver Node semantic (#1264) ---
|
|
16
|
+
// The connector closes with WS code 4001 + this reason when a new Runtime
|
|
17
|
+
// REGISTERs for the same agentId. The current api_key may have been rotated
|
|
18
|
+
// by /runtime/v1/onboard select mode, so reconnecting with the cached token
|
|
19
|
+
// is futile (would 401 forever).
|
|
20
|
+
const REPLACED_BY_NEW_RUNTIME_REASON = "Replaced by new Runtime";
|
|
21
|
+
const REPLACED_BY_NEW_RUNTIME_CODE = 4001;
|
|
15
22
|
// --- Error class for structured errors ---
|
|
16
23
|
/**
|
|
17
24
|
* Error with machine-readable `code` from Connector structured errors.
|
|
@@ -146,6 +153,8 @@ export class ConnectorClient extends EventEmitter {
|
|
|
146
153
|
this.startRegisterTimeout();
|
|
147
154
|
});
|
|
148
155
|
ws.on("message", this.handleMessage);
|
|
156
|
+
// ws's "close" event passes (code, reason) — handleClose accepts both.
|
|
157
|
+
// Pass the bound arrow reference so cleanupConnection can `off()` it.
|
|
149
158
|
ws.on("close", this.handleClose);
|
|
150
159
|
ws.on("error", this.handleError);
|
|
151
160
|
});
|
|
@@ -277,7 +286,13 @@ export class ConnectorClient extends EventEmitter {
|
|
|
277
286
|
this.ws?.close(4001, reason);
|
|
278
287
|
return;
|
|
279
288
|
}
|
|
280
|
-
// "Already connected" — track retries for short backoff
|
|
289
|
+
// "Already connected" — track retries for short backoff.
|
|
290
|
+
// As of connector #1264 the server flips this to single-driver
|
|
291
|
+
// eviction (close code 4001 + reason "Replaced by new Runtime",
|
|
292
|
+
// handled in handleClose). The REGISTER_ACK error path with this
|
|
293
|
+
// reason is now only reachable against pre-#1264 connector
|
|
294
|
+
// deployments during a rollout window — kept for that compat,
|
|
295
|
+
// safe to remove in 0.7.0 once preview/production are upgraded.
|
|
281
296
|
if (reason === "Already connected") {
|
|
282
297
|
this.alreadyConnectedRetries++;
|
|
283
298
|
}
|
|
@@ -319,10 +334,36 @@ export class ConnectorClient extends EventEmitter {
|
|
|
319
334
|
this.emit("error", new Error(`[${event.code}] ${event.message}`));
|
|
320
335
|
}
|
|
321
336
|
// --- Connection close ---
|
|
322
|
-
handleClose = () => {
|
|
323
|
-
|
|
337
|
+
handleClose = (code, reason) => {
|
|
338
|
+
const reasonStr = reason
|
|
339
|
+
? typeof reason === "string"
|
|
340
|
+
? reason
|
|
341
|
+
: reason.toString()
|
|
342
|
+
: "";
|
|
343
|
+
dbg("connection closed code=%s reason=%s intentional=%s", code ?? "?", reasonStr, this.intentionalClose);
|
|
324
344
|
const wasRegistering = this.registerReject !== null;
|
|
325
345
|
this.cleanupConnection();
|
|
346
|
+
// Single-driver eviction (#1264) — connector closes us with code 4001
|
|
347
|
+
// and reason "Replaced by new Runtime" when a new REGISTER claims our
|
|
348
|
+
// agentId. The api_key we hold may have been rotated by the new
|
|
349
|
+
// Runtime's /runtime/v1/onboard call; reconnecting would 401 forever.
|
|
350
|
+
// Treat as terminal: stop reconnects, surface an actionable message,
|
|
351
|
+
// emit a distinct event so consumers (channel.ts, etc.) can react.
|
|
352
|
+
const replacedByNewRuntime = code === REPLACED_BY_NEW_RUNTIME_CODE &&
|
|
353
|
+
reasonStr === REPLACED_BY_NEW_RUNTIME_REASON;
|
|
354
|
+
if (replacedByNewRuntime) {
|
|
355
|
+
this.intentionalClose = true;
|
|
356
|
+
const msg = "This agent was claimed by another Runtime " +
|
|
357
|
+
"(api_key rotated, this Runtime disconnected). " +
|
|
358
|
+
"Re-run `openclaw channels login --channel agent-network` " +
|
|
359
|
+
"to drive this agent from here again.";
|
|
360
|
+
this.emit("error", new Error(msg));
|
|
361
|
+
if (wasRegistering) {
|
|
362
|
+
this.rejectRegister(new Error(msg));
|
|
363
|
+
}
|
|
364
|
+
this.emit("disconnected");
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
326
367
|
if (wasRegistering) {
|
|
327
368
|
// Connection closed during REGISTER — check for special retry logic
|
|
328
369
|
// (handled by rejectRegister which was already called if ACK error came first)
|