@masons/agent-network 0.4.26 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,39 +1,76 @@
1
1
  /**
2
- * CLI Setup Path — Device Code Flow via terminal prompts.
2
+ * CLI Login Path — OAuth 2.0 Device Authorization Grant (RFC 8628).
3
3
  *
4
- * Implements `configureInteractive()` hook for OpenClaw's
5
- * `openclaw channels login --channel agent-network` command.
4
+ * Implements `login()` hook for OpenClaw's 2026.4.x channel-plugin contract:
5
+ * api.registerChannel({ plugin: { ..., auth: { login } } })
6
6
  *
7
- * Follows the WhatsApp channel pattern: CLI displays a code,
8
- * user authorizes in browser, credentials are returned to OpenClaw
9
- * which persists them to openclaw.json.
7
+ * Invoked by `openclaw channels login --channel agent-network`. CLI dispatch
8
+ * at `dist/channels-cli-Cc40S0aS.js:85` reads `plugin.auth?.login` and throws
9
+ * `"Channel ... does not support login"` if absent. See WhatsApp + Feishu
10
+ * bundled extensions for reference implementations of the same contract.
10
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.)
23
+ *
24
+ * History. Pre-migration this file existed as a `setup_codes`-based bespoke
25
+ * flow (deleted in fd5568e8). A device-flow re-implementation landed in 0.5.0
26
+ * under `configureInteractive` mounted at `plugin.setup.configureInteractive`
27
+ * — but that mount path is not read by any known OpenClaw version (neither
28
+ * 2026.3.x's top-level `plugin.configureInteractive` nor 2026.4.x's
29
+ * `plugin.auth.login`). 0.5.1 corrects the mount point and adopts the
30
+ * 2026.4.x `{cfg, accountId, runtime, verbose, channelInput}` signature.
31
+ *
32
+ * See #1264 for the design rationale (single-driver Node semantic — re-running
33
+ * this flow rotates the api_key and evicts any previously-connected Runtime).
11
34
  */
12
- interface Prompter {
13
- text(label: string, opts?: {
14
- default?: string;
15
- }): Promise<string>;
16
- confirm(label: string): Promise<boolean>;
17
- select(label: string, choices: string[]): Promise<string>;
18
- }
19
- interface ChannelSetupContext {
20
- configured: boolean;
21
- label: string;
22
- cfg: Record<string, unknown>;
23
- runtime: unknown;
24
- prompter: Prompter;
25
- options: Record<string, unknown>;
35
+ /**
36
+ * Minimal OpenClaw runtime surface passed to `auth.login`. Exposed as
37
+ * `defaultRuntime` in `openclaw/dist/runtime-Dx7oeLYq.js` — plugins get
38
+ * only the output/exit channel, not a prompter (plugins build their own
39
+ * via @clack/prompts, which is what OpenClaw CLI uses internally too).
40
+ */
41
+ interface OpenClawRuntime {
42
+ log(...args: unknown[]): void;
43
+ error(...args: unknown[]): void;
44
+ writeStdout(value: string): void;
45
+ writeJson(value: unknown, space?: number): void;
46
+ exit(code: number): void;
26
47
  }
27
- type SetupResult = "skip" | {
48
+ /**
49
+ * Context passed by OpenClaw CLI to `plugin.auth.login`. Matches the call
50
+ * site at `dist/channels-cli-Cc40S0aS.js:89`:
51
+ * await login({ cfg, accountId, runtime, verbose, channelInput });
52
+ */
53
+ interface AuthLoginContext {
28
54
  cfg: Record<string, unknown>;
29
55
  accountId?: string;
30
- };
56
+ runtime: OpenClawRuntime;
57
+ verbose?: boolean;
58
+ channelInput?: string;
59
+ }
31
60
  /**
32
- * Interactive Device Code Flow for OpenClaw CLI.
61
+ * Channel login hook invoked by `openclaw channels login --channel agent-network`.
62
+ *
63
+ * Unlike the legacy `configureInteractive` contract (which returned a config
64
+ * object for the CLI to persist), the 2026.4.x `auth.login` contract returns
65
+ * void and expects the plugin to persist any config changes itself. We use
66
+ * `writeCredentials()` in config.ts, which writes `openclaw.json` atomically
67
+ * and bumps the state-cache generation so Layer B's dynamic context re-reads
68
+ * on the next turn.
33
69
  *
34
- * Called by `openclaw channels login --channel agent-network` or the onboarding wizard.
35
- * Returns `{ cfg, accountId }` OpenClaw CLI handles writing to openclaw.json.
70
+ * Errors during device flow or onboarding are surfaced via `ctx.runtime.error`
71
+ * (not thrown) so OpenClaw CLI doesn't display a stack trace matches the
72
+ * WhatsApp/Feishu pattern.
36
73
  */
37
- export declare function configureInteractive(ctx: ChannelSetupContext): Promise<SetupResult>;
74
+ export declare function login(ctx: AuthLoginContext): Promise<void>;
38
75
  export {};
39
76
  //# sourceMappingURL=cli-setup.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"cli-setup.d.ts","sourceRoot":"","sources":["../src/cli-setup.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAkBH,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;AAYzD;;;;;GAKG;AACH,wBAAsB,oBAAoB,CACxC,GAAG,EAAE,mBAAmB,GACvB,OAAO,CAAC,WAAW,CAAC,CAetB"}
1
+ {"version":3,"file":"cli-setup.d.ts","sourceRoot":"","sources":["../src/cli-setup.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AAiCH;;;;;GAKG;AACH,UAAU,eAAe;IACvB,GAAG,CAAC,GAAG,IAAI,EAAE,OAAO,EAAE,GAAG,IAAI,CAAC;IAC9B,KAAK,CAAC,GAAG,IAAI,EAAE,OAAO,EAAE,GAAG,IAAI,CAAC;IAChC,WAAW,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACjC,SAAS,CAAC,KAAK,EAAE,OAAO,EAAE,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAChD,IAAI,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;CAC1B;AAED;;;;GAIG;AACH,UAAU,gBAAgB;IACxB,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC7B,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,OAAO,EAAE,eAAe,CAAC;IACzB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAobD;;;;;;;;;;;;;GAaG;AACH,wBAAsB,KAAK,CAAC,GAAG,EAAE,gBAAgB,GAAG,OAAO,CAAC,IAAI,CAAC,CAmDhE"}
package/dist/cli-setup.js CHANGED
@@ -1,146 +1,397 @@
1
1
  /**
2
- * CLI Setup Path — Device Code Flow via terminal prompts.
2
+ * CLI Login Path — OAuth 2.0 Device Authorization Grant (RFC 8628).
3
3
  *
4
- * Implements `configureInteractive()` hook for OpenClaw's
5
- * `openclaw channels login --channel agent-network` command.
4
+ * Implements `login()` hook for OpenClaw's 2026.4.x channel-plugin contract:
5
+ * api.registerChannel({ plugin: { ..., auth: { login } } })
6
6
  *
7
- * Follows the WhatsApp channel pattern: CLI displays a code,
8
- * user authorizes in browser, credentials are returned to OpenClaw
9
- * which persists them to openclaw.json.
7
+ * Invoked by `openclaw channels login --channel agent-network`. CLI dispatch
8
+ * at `dist/channels-cli-Cc40S0aS.js:85` reads `plugin.auth?.login` and throws
9
+ * `"Channel ... does not support login"` if absent. See WhatsApp + Feishu
10
+ * bundled extensions for reference implementations of the same contract.
10
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.)
23
+ *
24
+ * History. Pre-migration this file existed as a `setup_codes`-based bespoke
25
+ * flow (deleted in fd5568e8). A device-flow re-implementation landed in 0.5.0
26
+ * under `configureInteractive` mounted at `plugin.setup.configureInteractive`
27
+ * — but that mount path is not read by any known OpenClaw version (neither
28
+ * 2026.3.x's top-level `plugin.configureInteractive` nor 2026.4.x's
29
+ * `plugin.auth.login`). 0.5.1 corrects the mount point and adopts the
30
+ * 2026.4.x `{cfg, accountId, runtime, verbose, channelInput}` signature.
31
+ *
32
+ * See #1264 for the design rationale (single-driver Node semantic — re-running
33
+ * this flow rotates the api_key and evicts any previously-connected Runtime).
11
34
  */
12
- import { DEFAULT_API_HOST, initSetup, listAgents, onboard, PlatformApiError, pollSetup, reconnect, SetupExpiredError, SetupPendingError, } from "./platform-client.js";
35
+ import { cancel, confirm as clackConfirm, select as clackSelect, text as clackText, isCancel, } from "@clack/prompts";
36
+ import { writeCredentials } from "./config.js";
37
+ import { DEFAULT_API_HOST, onboard, PlatformApiError, } from "./platform-client.js";
13
38
  // ---------------------------------------------------------------------------
14
39
  // Constants
15
40
  // ---------------------------------------------------------------------------
16
- const CREATE_NEW_OPTION = "Create new agent";
17
- // ---------------------------------------------------------------------------
18
- // Implementation
19
- // ---------------------------------------------------------------------------
20
41
  /**
21
- * Interactive Device Code Flow for OpenClaw CLI.
42
+ * Better Auth IdP base URL. Default targets the preview environment.
22
43
  *
23
- * Called by `openclaw channels login --channel agent-network` or the onboarding wizard.
24
- * Returns `{ cfg, accountId }` OpenClaw CLI handles writing to openclaw.json.
44
+ * TODO: flip to the production URL when W8 (api.masons.ai consolidation)
45
+ * lands. Plugins shipped with this default before that flip will still
46
+ * work — the user can override by setting `idpBaseUrl` in their
47
+ * `openclaw.json` channel config (or by re-running setup which writes
48
+ * whatever is current).
25
49
  */
26
- export async function configureInteractive(ctx) {
27
- const { prompter } = ctx;
28
- // Resolve API host from existing config or default
29
- const apiHost = typeof ctx.cfg.apiHost === "string" ? ctx.cfg.apiHost : DEFAULT_API_HOST;
30
- const cfg = { apiHost };
31
- // --- Phase 1: Device Code Flow (init + authorize + poll) ---
32
- const setupToken = await deviceCodeFlow(cfg, prompter);
33
- // --- Phase 2: Agent selection or creation ---
34
- const finalCreds = await agentSetup(cfg, setupToken, prompter);
35
- return buildResult(finalCreds.connectorUrl, finalCreds.token, apiHost);
50
+ const DEFAULT_IDP_BASE_URL = "https://preview.masons.ai";
51
+ /**
52
+ * OAuth client_id registered for OpenClaw at the IdP — see
53
+ * `apps/web/scripts/seed.ts`. Hardcoded because there's only one OpenClaw
54
+ * client today; if multiple wrappers (e.g. self-hosted vs Claude Desktop
55
+ * variant) ever need distinct client_ids, expose this via the channel
56
+ * config schema.
57
+ */
58
+ const OAUTH_CLIENT_ID = "openclaw";
59
+ /**
60
+ * Scopes requested at device-flow initiation. `openid` + `profile` + `email`
61
+ * are the standard OIDC scopes; `offline_access` requests a refresh token
62
+ * (Better Auth uses session tokens here, not OAuth opaque tokens — the
63
+ * scope is mostly indicative). Stays in sync with the scopes registered on
64
+ * the `oauthProvider` plugin at apps/web/lib/auth.ts.
65
+ */
66
+ const SCOPE = "openid profile email offline_access";
67
+ /** Default polling interval if the IdP does not return one. */
68
+ const DEFAULT_POLL_INTERVAL_S = 5;
69
+ const CREATE_NEW_OPTION = "Create new agent";
70
+ // Handle pre-validation regex — strict subset of the server's authoritative
71
+ // `validateHandle` (packages/db/src/utils/handle-validation.ts:3). Used to
72
+ // fail-fast in the terminal before round-tripping the server. Kept inline
73
+ // because the published npm plugin can't import @workspace/db.
74
+ //
75
+ // Server contract: `/^[a-z][a-z0-9_-]{2,14}$/` against the lowercased input.
76
+ // MUST be a strict subset (false-negatives OK, false-positives forbidden) —
77
+ // otherwise the user sees "passed client check" then "server rejected" for
78
+ // the same input, which is confusing.
79
+ //
80
+ // Reserved-handle check (e.g., "masons", "openclaw") is server-only — too
81
+ // volatile for the plugin to track; the round-trip is acceptable for that.
82
+ const HANDLE_REGEX = /^[a-z][a-z0-9_-]{2,14}$/;
83
+ class DeviceFlowError extends Error {
84
+ }
85
+ class DeviceFlowExpired extends Error {
36
86
  }
37
- // ---------------------------------------------------------------------------
38
- // Device Code Flow
39
- // ---------------------------------------------------------------------------
40
87
  /**
41
- * Run the Device Code Flow loop. Returns an authorized setup token.
42
- * Handles code expiration by restarting with a fresh init.
88
+ * User pressed Ctrl+C at any prompter step. Subclass of `DeviceFlowError`
89
+ * so the existing throw/catch chain still works, but distinguished so the
90
+ * `login` handler can skip the redundant `runtime.error` call (clack's
91
+ * `cancel()` already emitted the canonical "cancelled" UI).
43
92
  */
44
- async function deviceCodeFlow(cfg, prompter) {
93
+ class CancelError extends DeviceFlowError {
94
+ }
95
+ /**
96
+ * Run the RFC 8628 device flow loop. Returns the access_token on success.
97
+ * On expiration, restarts the loop transparently (user gets a fresh code).
98
+ */
99
+ async function deviceCodeFlow(idpBaseUrl, prompter) {
45
100
  for (;;) {
46
- const initResult = await initSetup(cfg);
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).
101
+ const init = await initDeviceCode(idpBaseUrl);
51
102
  await prompter.text([
52
103
  "",
53
- `Setup code: ${initResult.setup_code}`,
104
+ `Setup code: ${init.user_code}`,
54
105
  "",
55
- `Open this link to authorize: ${initResult.verification_uri}`,
106
+ `Open this link to authorize: ${init.verification_uri_complete ?? `${init.verification_uri}?user_code=${init.user_code}`}`,
56
107
  "",
57
108
  "Press Enter after you've authorized in the browser",
58
109
  ].join("\n"));
59
- // Poll until authorized or expired
60
- const pollResult = await pollUntilAuthorized(cfg, initResult.setup_token, prompter);
61
- if (pollResult === "expired") {
62
- continue;
110
+ try {
111
+ const accessToken = await pollUntilAuthorized(idpBaseUrl, init.device_code, init.interval ?? DEFAULT_POLL_INTERVAL_S, prompter);
112
+ return accessToken;
113
+ }
114
+ catch (err) {
115
+ if (err instanceof DeviceFlowExpired) {
116
+ await prompter.text("Setup code expired. Press Enter for a new one.");
117
+ continue;
118
+ }
119
+ throw err;
63
120
  }
64
- return pollResult.setup_token;
65
121
  }
66
122
  }
67
- async function pollUntilAuthorized(cfg, setupToken, prompter) {
123
+ async function initDeviceCode(idpBaseUrl) {
124
+ const params = new URLSearchParams();
125
+ params.set("client_id", OAUTH_CLIENT_ID);
126
+ params.set("scope", SCOPE);
127
+ const res = await fetch(`${idpBaseUrl}/api/auth/device/code`, {
128
+ method: "POST",
129
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
130
+ body: params.toString(),
131
+ });
132
+ if (!res.ok) {
133
+ // 4xx at initiation → likely a misconfigured client_id or scope. Surface
134
+ // a clear message rather than retrying — this is a setup error, not a
135
+ // transient one.
136
+ const body = (await res.json().catch(() => ({})));
137
+ const code = typeof body.error === "string" ? body.error : "unknown";
138
+ const description = typeof body.error_description === "string"
139
+ ? body.error_description
140
+ : `IdP returned ${res.status}`;
141
+ throw new DeviceFlowError(`Device code initiation failed (${code}): ${description}. ` +
142
+ `Verify that "${OAUTH_CLIENT_ID}" is registered with the device_code grant ` +
143
+ `and is in the deviceAuthorization plugin's allowlist at the IdP.`);
144
+ }
145
+ return (await res.json());
146
+ }
147
+ async function pollUntilAuthorized(idpBaseUrl, deviceCode, intervalSeconds, prompter) {
148
+ let interval = intervalSeconds;
68
149
  for (;;) {
69
- try {
70
- const result = await pollSetup(cfg, setupToken);
71
- return result;
150
+ const params = new URLSearchParams();
151
+ params.set("grant_type", "urn:ietf:params:oauth:grant-type:device_code");
152
+ params.set("device_code", deviceCode);
153
+ params.set("client_id", OAUTH_CLIENT_ID);
154
+ const res = await fetch(`${idpBaseUrl}/api/auth/device/token`, {
155
+ method: "POST",
156
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
157
+ body: params.toString(),
158
+ });
159
+ if (res.ok) {
160
+ const success = (await res.json());
161
+ return success.access_token;
72
162
  }
73
- catch (err) {
74
- if (err instanceof SetupExpiredError) {
75
- await prompter.text("Setup code expired. Press Enter to generate a new one.");
76
- return "expired";
163
+ const body = (await res.json().catch(() => ({})));
164
+ if (body.error === "authorization_pending") {
165
+ // User hasn't approved yet wait and ask if they want to continue.
166
+ const retry = await prompter.confirm("Not authorized yet. Have you entered the code in your browser?");
167
+ if (!retry) {
168
+ // User gave up on this code — same recovery as expiration: ask
169
+ // the outer loop to restart with a fresh code. Throwing
170
+ // DeviceFlowExpired (rather than a separate "abandoned" sentinel)
171
+ // is intentional — both paths recover identically.
172
+ throw new DeviceFlowExpired();
77
173
  }
78
- if (err instanceof SetupPendingError) {
79
- const retry = await prompter.confirm("Not authorized yet. Have you entered the code in your browser?");
80
- if (!retry) {
81
- return "expired"; // User gave up — treat as restart
82
- }
83
- continue;
84
- }
85
- throw err; // Unexpected error — propagate
174
+ await sleep(interval * 1000);
175
+ continue;
176
+ }
177
+ if (body.error === "slow_down") {
178
+ // RFC 8628 §3.5 — server requests we slow down. Bump interval +5s.
179
+ interval += 5;
180
+ await sleep(interval * 1000);
181
+ continue;
86
182
  }
183
+ if (body.error === "expired_token") {
184
+ throw new DeviceFlowExpired();
185
+ }
186
+ if (body.error === "access_denied") {
187
+ throw new DeviceFlowError("Authorization was denied. Re-run setup if this was a mistake.");
188
+ }
189
+ // Unknown error — bail with whatever we got from the server.
190
+ throw new DeviceFlowError(`Device token poll failed: ${body.error ?? "unknown"} ` +
191
+ `(${body.error_description ?? "no description"})`);
87
192
  }
88
193
  }
89
- async function agentSetup(cfg, setupToken, prompter) {
90
- const { agents } = await listAgents(cfg, setupToken);
91
- if (agents.length > 0) {
92
- // User has existing agents — let them choose or create new
194
+ function sleep(ms) {
195
+ return new Promise((resolve) => {
196
+ setTimeout(resolve, ms);
197
+ });
198
+ }
199
+ // ---------------------------------------------------------------------------
200
+ // Onboard (agent select / create)
201
+ // ---------------------------------------------------------------------------
202
+ async function agentSetup(apiHost, accessToken, prompter) {
203
+ const platformCfg = { apiHost };
204
+ // First call: empty body. The server's list mode is intentionally
205
+ // non-mutating — it returns 412 (no agents) or 422 (here are your agents,
206
+ // pick one). Rotation only happens on the explicit `{agentId}` call after
207
+ // the user picks. Even with a single agent, the picker shows so the user
208
+ // explicitly confirms before any existing Runtime is evicted.
209
+ const listResult = await onboard(platformCfg, accessToken, {});
210
+ if (listResult.kind === "ok") {
211
+ // List mode never returns 200 by contract. If a future server version
212
+ // changes that, surface explicitly rather than silently accepting a
213
+ // rotation the user didn't confirm.
214
+ throw new DeviceFlowError("Server returned an unexpected 200 from the list call — refusing " +
215
+ "to use it without explicit user selection. Please report this.");
216
+ }
217
+ if (listResult.data.code === "no_agents") {
218
+ // Zero agents — prompt for handle to create one.
219
+ return await createAgentLoop(platformCfg, accessToken, prompter);
220
+ }
221
+ if (listResult.data.code === "agent_required") {
222
+ // Show picker. Single-agent case still goes through the picker (one
223
+ // agent + "Create new" choice) so the user explicitly confirms which
224
+ // agent this Runtime is claiming. The selection triggers api_key
225
+ // rotation — see "single-driver Node semantic" in CHANGELOG.
226
+ const agents = listResult.data.agents;
93
227
  const choices = [
94
- ...agents.map((a) => `${a.handle} (${a.address})`),
228
+ ...agents.map((a) => a.name ? `@${a.handle} (${a.name})` : `@${a.handle}`),
95
229
  CREATE_NEW_OPTION,
96
230
  ];
97
- const selected = await prompter.select("You already have agent(s). Choose one to reconnect or create new:", choices);
98
- if (selected !== CREATE_NEW_OPTION) {
99
- // Find the selected agent and reconnect
100
- const idx = choices.indexOf(selected);
101
- const agent = agents[idx];
102
- if (agent) {
103
- const result = await reconnect(cfg, setupToken, agent.id);
104
- return { connectorUrl: result.connectorUrl, token: result.token };
105
- }
231
+ const promptLabel = agents.length === 1
232
+ ? "You have one agent. Confirm to claim it for this Runtime " +
233
+ "(its api_key will be rotated; any previously-connected Runtime " +
234
+ "for this agent will be disconnected):"
235
+ : "You have multiple agents. Pick one to claim for this Runtime " +
236
+ "(its api_key will be rotated; any previously-connected Runtime " +
237
+ "for the chosen agent will be disconnected):";
238
+ const selected = await prompter.select(promptLabel, choices);
239
+ if (selected === CREATE_NEW_OPTION) {
240
+ return await createAgentLoop(platformCfg, accessToken, prompter);
241
+ }
242
+ const idx = choices.indexOf(selected);
243
+ const agent = agents[idx];
244
+ if (!agent) {
245
+ throw new DeviceFlowError("Picker returned an unrecognized choice. Please re-run setup.");
106
246
  }
247
+ const selectResult = await onboard(platformCfg, accessToken, {
248
+ agentId: agent.id,
249
+ });
250
+ if (selectResult.kind !== "ok") {
251
+ throw new DeviceFlowError(`Agent selection failed: ${selectResult.data.code}`);
252
+ }
253
+ return selectResult.data;
107
254
  }
108
- // Create new agent
109
- return createAgent(cfg, setupToken, prompter);
255
+ // invalid_handle / handle_taken can only happen on a `create` call —
256
+ // not from an empty `{}` first call.
257
+ throw new DeviceFlowError(`Unexpected onboard error on initial list call: ${listResult.data.code}`);
110
258
  }
111
- async function createAgent(cfg, setupToken, prompter) {
259
+ async function createAgentLoop(platformCfg, accessToken, prompter) {
112
260
  for (;;) {
113
- const handle = await prompter.text("Choose a handle for your Agent (3-15 chars, lowercase letters, numbers, hyphens, underscores):");
114
- try {
115
- const result = await onboard(cfg, setupToken, { handle: handle.trim() });
116
- return { connectorUrl: result.connectorUrl, token: result.token };
261
+ const handle = await prompter.text("Choose a handle for your Agent (3-15 chars, start with a letter, then letters/numbers/hyphens/underscores):");
262
+ // Lowercase + trim mirrors the server's normalizeHandle step. If the user
263
+ // typed "Alice", we test "alice" against the regex — same outcome the
264
+ // server would produce. Prevents the false-negative case where a valid
265
+ // handle is rejected client-side just for case.
266
+ const normalized = handle.trim().toLowerCase();
267
+ // Client-side fail-fast — saves a server round-trip on obvious mistakes.
268
+ // Server's validateHandle is the authoritative source for both format
269
+ // and the reserved-handle list.
270
+ if (!HANDLE_REGEX.test(normalized)) {
271
+ await prompter.text(`"${handle}" must be 3-15 chars, start with a letter, then only letters/numbers/hyphens/underscores. Press Enter to try again.`);
272
+ continue;
117
273
  }
118
- catch (err) {
119
- if (err instanceof PlatformApiError) {
120
- if (err.code === "handle_taken") {
121
- await prompter.text(`"${handle}" is already taken. Press Enter to try another.`);
122
- continue;
123
- }
124
- if (err.code === "invalid_handle") {
125
- await prompter.text(`"${handle}" is not valid. Use 3-15 lowercase letters, numbers, hyphens, or underscores. Press Enter to try again.`);
126
- continue;
127
- }
128
- }
129
- throw err; // Unexpected error propagate
274
+ const result = await onboard(platformCfg, accessToken, {
275
+ create: { handle: normalized },
276
+ });
277
+ if (result.kind === "ok") {
278
+ return result.data;
279
+ }
280
+ if (result.data.code === "handle_taken") {
281
+ await prompter.text(`"${normalized}" is already taken. Press Enter to try another.`);
282
+ continue;
283
+ }
284
+ if (result.data.code === "invalid_handle") {
285
+ await prompter.text(`"${normalized}" failed server validation: ${result.data.message}. Press Enter to try again.`);
286
+ continue;
130
287
  }
288
+ // Other structured errors shouldn't reach here on a create call —
289
+ // surface with whatever we got.
290
+ throw new DeviceFlowError(`Unexpected onboard error on create: ${result.data.code}`);
131
291
  }
132
292
  }
133
293
  // ---------------------------------------------------------------------------
134
- // Result builder
294
+ // Clack-backed Prompter adapter
135
295
  // ---------------------------------------------------------------------------
136
- function buildResult(connectorUrl, token, apiHost) {
296
+ /**
297
+ * Build a Prompter backed by `@clack/prompts` (the same library OpenClaw CLI
298
+ * uses internally, so the terminal UX is consistent with other channel
299
+ * plugins). Cancel sentinels (user Ctrl+C) are mapped to `DeviceFlowError`
300
+ * so the login function can treat cancels as a clean exit condition.
301
+ */
302
+ function createClackPrompter() {
303
+ const abortOnCancel = (result) => {
304
+ if (isCancel(result)) {
305
+ // Emit clack's canonical cancel UI (bracketed red "cancelled") here so
306
+ // the user sees a clean exit indicator, then throw `CancelError` so the
307
+ // outer `login` catch returns silently (avoids printing the same line
308
+ // twice — UI from `cancel()` + a redundant `runtime.error` afterwards).
309
+ cancel("Setup cancelled.");
310
+ throw new CancelError("Setup cancelled by user.");
311
+ }
312
+ };
137
313
  return {
138
- cfg: {
139
- accounts: {
140
- default: { connectorUrl, token },
141
- },
142
- apiHost,
314
+ async text(label) {
315
+ // `text(label, opts?.default)` previously mapped `opts.default` to clack's
316
+ // `placeholder` but clack's `placeholder` is hint-only (not auto-submitted
317
+ // on empty input), and no current caller passes `opts.default`. Dropped
318
+ // the mapping until a real default-value need surfaces; revisit by
319
+ // implementing `clackText({ message, defaultValue })` then.
320
+ const result = await clackText({ message: label });
321
+ abortOnCancel(result);
322
+ return result;
323
+ },
324
+ async confirm(label) {
325
+ const result = await clackConfirm({ message: label });
326
+ abortOnCancel(result);
327
+ return result;
328
+ },
329
+ async select(label, choices) {
330
+ // Narrow the generic — we use `string` values throughout the flow.
331
+ const result = await clackSelect({
332
+ message: label,
333
+ options: choices.map((choice) => ({ value: choice, label: choice })),
334
+ });
335
+ abortOnCancel(result);
336
+ return result;
143
337
  },
144
- accountId: "default",
145
338
  };
146
339
  }
340
+ // ---------------------------------------------------------------------------
341
+ // Public hook — `plugin.auth.login` (OpenClaw 2026.4.x contract)
342
+ // ---------------------------------------------------------------------------
343
+ /**
344
+ * Channel login hook — invoked by `openclaw channels login --channel agent-network`.
345
+ *
346
+ * Unlike the legacy `configureInteractive` contract (which returned a config
347
+ * object for the CLI to persist), the 2026.4.x `auth.login` contract returns
348
+ * void and expects the plugin to persist any config changes itself. We use
349
+ * `writeCredentials()` in config.ts, which writes `openclaw.json` atomically
350
+ * and bumps the state-cache generation so Layer B's dynamic context re-reads
351
+ * on the next turn.
352
+ *
353
+ * Errors during device flow or onboarding are surfaced via `ctx.runtime.error`
354
+ * (not thrown) so OpenClaw CLI doesn't display a stack trace — matches the
355
+ * WhatsApp/Feishu pattern.
356
+ */
357
+ export async function login(ctx) {
358
+ // Channel-level config (`cfg.apiHost`, `cfg.idpBaseUrl`) — both have
359
+ // defaults. Existing values from openclaw.json are preserved on re-run.
360
+ const apiHost = typeof ctx.cfg.apiHost === "string" ? ctx.cfg.apiHost : DEFAULT_API_HOST;
361
+ const idpBaseUrl = typeof ctx.cfg.idpBaseUrl === "string"
362
+ ? ctx.cfg.idpBaseUrl
363
+ : DEFAULT_IDP_BASE_URL;
364
+ const prompter = createClackPrompter();
365
+ let creds;
366
+ try {
367
+ const accessToken = await deviceCodeFlow(idpBaseUrl, prompter);
368
+ creds = await agentSetup(apiHost, accessToken, prompter);
369
+ }
370
+ catch (err) {
371
+ if (err instanceof CancelError) {
372
+ // User pressed Ctrl+C. The clack `cancel()` call inside the prompter
373
+ // adapter already emitted the cancellation UI; nothing else to print.
374
+ return;
375
+ }
376
+ if (err instanceof DeviceFlowError) {
377
+ // Device-flow / onboard error with a human-friendly message.
378
+ // Print and return cleanly — CLI shows nothing else on a return.
379
+ ctx.runtime.error(err.message);
380
+ return;
381
+ }
382
+ if (err instanceof PlatformApiError) {
383
+ ctx.runtime.error(`Onboard failed (HTTP ${err.status} ${err.code}): ${err.message}`);
384
+ return;
385
+ }
386
+ // Unknown error — let CLI surface the stack. Surfaces platform bugs
387
+ // that aren't covered by our structured error types.
388
+ throw err;
389
+ }
390
+ // Persist: `openclaw.json` schema for an account is `{connectorUrl, token}`
391
+ // (see config-schema.ts). On the wire the onboard response field is
392
+ // `apiKey` to disambiguate from OAuth "session token" / JWT — but locally
393
+ // on disk it's persisted as `token` for backward compatibility with the
394
+ // existing `channel.resolveAccount()` reader.
395
+ await writeCredentials({ connectorUrl: creds.connectorUrl, token: creds.apiKey }, apiHost, idpBaseUrl);
396
+ ctx.runtime.log(`✓ Connected as @${creds.handle}`);
397
+ }
package/dist/config.d.ts CHANGED
@@ -160,12 +160,16 @@ export interface NetworkCredentials {
160
160
  token: string;
161
161
  }
162
162
  /**
163
- * Write credentials + apiHost to `openclaw.json`.
163
+ * Write credentials + apiHost (+ optional idpBaseUrl) to `openclaw.json`.
164
164
  *
165
- * Atomic: credentials and apiHost are written in a single operation
166
- * to avoid partial-write race conditions.
165
+ * Atomic: all fields are written in a single persistConfig() call to avoid
166
+ * partial-write race conditions.
167
+ *
168
+ * `idpBaseUrl` is written only when provided, mirroring `apiHost`'s pattern —
169
+ * omitted values leave any existing field in place (useful when callers want
170
+ * to refresh credentials without overriding IdP URL).
167
171
  */
168
- export declare function writeCredentials(creds: NetworkCredentials, apiHost?: string): Promise<void>;
172
+ export declare function writeCredentials(creds: NetworkCredentials, apiHost?: string, idpBaseUrl?: string): Promise<void>;
169
173
  /**
170
174
  * Write pending connection target handle.
171
175
  *