@clawling/clawchat-plugin-openclaw 2026.9.14-2 → 2026.9.17-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,7 +1,7 @@
1
1
  import { ClawlingApiError, } from "./api-types.js";
2
2
  import { CHANNEL_ID } from "./config.js";
3
3
  export function buildPluginReportBody(input) {
4
- return {
4
+ const body = {
5
5
  device_id: input.deviceId,
6
6
  platform: input.platform,
7
7
  plugin_version: input.pluginVersion,
@@ -9,6 +9,16 @@ export function buildPluginReportBody(input) {
9
9
  runtime_name: input.runtimeName,
10
10
  runtime_version: input.runtimeVersion,
11
11
  };
12
+ const ob = input.onboarding;
13
+ if (ob?.wiki_report_id !== undefined)
14
+ body.wiki_report_id = ob.wiki_report_id;
15
+ if (ob?.capability_tier !== undefined)
16
+ body.capability_tier = ob.capability_tier;
17
+ if (ob?.capability_ceiling !== undefined)
18
+ body.capability_ceiling = ob.capability_ceiling;
19
+ if (ob?.capabilities !== undefined)
20
+ body.capabilities = ob.capabilities;
21
+ return body;
12
22
  }
13
23
  /**
14
24
  * §A.0 — decode the access token's `exp` claim locally (base64url-decode the
@@ -42,13 +52,23 @@ const CODE_INTERNAL = 1; // CodeInternal — transient.
42
52
  const CODE_BAD_REQUEST = 400; // bad body / device id — permanent (client bug).
43
53
  /**
44
54
  * CodeInvalidRefresh — returned for BOTH a genuinely revoked/invalid refresh
45
- * token AND a single-use refresh token that was already CONSUMED by a prior
46
- * successful rotation (a duplicate-supervisor / concurrent-refresh / stale-store
47
- * race). The stateless `authRefresh` cannot tell the two apart, so it reports
48
- * `permanent`; the stateful `RefreshManager` re-classifies a consumed-rotation
49
- * race back to transient (see §B race) before any auto-logout.
55
+ * token AND a refresh token that was already CONSUMED by a prior successful
56
+ * rotation (a duplicate-supervisor / concurrent-refresh / stale-store race), once
57
+ * the backend's short replay grace window no longer covers it. The stateless
58
+ * `authRefresh` cannot tell the two apart, so it reports `permanent`; the
59
+ * stateful `RefreshManager` re-classifies a consumed-rotation race back to
60
+ * transient (see §B race) before any auto-logout.
50
61
  */
51
62
  export const CODE_INVALID_REFRESH = 10003;
63
+ /**
64
+ * §B attempt deadline — total wall clock for one refresh attempt (request AND
65
+ * response body). A hit aborts the request and is TRANSIENT. Kept below
66
+ * `MIN_REFRESH_INTERVAL_MS` (30s) so the min-interval floor, not the deadline,
67
+ * sets the retry cadence: a retry of the same refresh token then lands inside
68
+ * the backend refresh grace window if the server rotated but the response was
69
+ * lost.
70
+ */
71
+ export const REFRESH_REQUEST_TIMEOUT_MS = 20_000;
52
72
  /**
53
73
  * §0/§B — call `POST /v1/auth/refresh` to rotate the access+refresh token.
54
74
  *
@@ -58,6 +78,11 @@ export const CODE_INVALID_REFRESH = 10003;
58
78
  * always HTTP 200 — branch on the envelope `code`, NOT on HTTP status. This is
59
79
  * a standalone function (not a method on the token-bearing client) precisely
60
80
  * because no bearer token participates.
81
+ *
82
+ * The whole attempt is bounded by `REFRESH_REQUEST_TIMEOUT_MS` (§B attempt
83
+ * deadline): the request is aborted via its signal, and the deadline is also
84
+ * raced against the attempt so a fetch implementation that ignores the signal
85
+ * cannot hang the caller.
61
86
  */
62
87
  export async function authRefresh(opts, params) {
63
88
  const baseUrl = opts.baseUrl.replace(/\/+$/, "");
@@ -65,6 +90,26 @@ export async function authRefresh(opts, params) {
65
90
  if (!params.refreshToken?.trim()) {
66
91
  return { kind: "permanent", code: CODE_INVALID_REFRESH, message: "missing refresh token" };
67
92
  }
93
+ const timeoutMs = opts.timeoutMs ?? REFRESH_REQUEST_TIMEOUT_MS;
94
+ const controller = new AbortController();
95
+ let timer;
96
+ const deadline = new Promise((resolve) => {
97
+ timer = setTimeout(() => {
98
+ controller.abort(new Error("refresh request timed out"));
99
+ resolve({ kind: "transient", message: `refresh request timed out after ${timeoutMs}ms` });
100
+ }, timeoutMs);
101
+ });
102
+ try {
103
+ return await Promise.race([
104
+ authRefreshAttempt(baseUrl, fetchImpl, params, controller.signal),
105
+ deadline,
106
+ ]);
107
+ }
108
+ finally {
109
+ clearTimeout(timer);
110
+ }
111
+ }
112
+ async function authRefreshAttempt(baseUrl, fetchImpl, params, signal) {
68
113
  let res;
69
114
  try {
70
115
  res = await fetchImpl(`${baseUrl}/v1/auth/refresh`, {
@@ -75,6 +120,8 @@ export async function authRefresh(opts, params) {
75
120
  "x-device-id": params.deviceId,
76
121
  },
77
122
  body: JSON.stringify({ refresh_token: params.refreshToken.trim() }),
123
+ // Still in effect while the body is read below.
124
+ signal,
78
125
  });
79
126
  }
80
127
  catch (err) {
@@ -117,8 +164,9 @@ export async function authRefresh(opts, params) {
117
164
  const refreshToken = typeof data.refresh_token === "string" ? data.refresh_token : "";
118
165
  if (!accessToken || !refreshToken) {
119
166
  // Rotation succeeded server-side but the body is malformed — transient so
120
- // we retry; the next attempt will return 10003 (rotation single-use) and
121
- // escalate to permanent (§B transient→permanent).
167
+ // we retry; inside the backend grace window the retry redeems the old
168
+ // token again, after it the retry returns 10003 and escalates to
169
+ // permanent (§B transient→permanent).
122
170
  return { kind: "transient", status: 200, message: "refresh: rotation body incomplete" };
123
171
  }
124
172
  return { kind: "success", accessToken, refreshToken };
@@ -396,6 +444,13 @@ export function createOpenclawClawlingApiClient(opts) {
396
444
  async deleteMomentComment(params) {
397
445
  return await call("DELETE", `/v1/moments/${encodeURIComponent(String(params.momentId))}/comments/${encodeURIComponent(String(params.commentId))}`);
398
446
  },
447
+ async getDirectConversation(peerId) {
448
+ assertNonBlankId(peerId, "getDirectConversation: peerId");
449
+ return await call("POST", "/v1/conversations/direct", {
450
+ body: JSON.stringify({ peer_id: peerId.trim() }),
451
+ headers: { "content-type": "application/json" },
452
+ });
453
+ },
399
454
  async getConversation(conversationId) {
400
455
  return await call("GET", `/v1/conversations/${encodeURIComponent(conversationId)}`);
401
456
  },
@@ -427,7 +482,24 @@ export function createOpenclawClawlingApiClient(opts) {
427
482
  headers: { "content-type": "application/json" },
428
483
  });
429
484
  },
430
- async agentsConnect({ code: inviteCode, platform, type, user_id: userId }) {
485
+ async agentsConnectCheck({ code, platform, user_id: userId, context }) {
486
+ if (!code?.trim()) {
487
+ throw new ClawlingApiError("validation", "agentsConnectCheck: code is required");
488
+ }
489
+ const body = { code: code.trim(), platform: platform.trim() };
490
+ if (userId?.trim())
491
+ body.user_id = userId.trim();
492
+ if (opts.pluginVersion?.trim())
493
+ body.plugin_version = opts.pluginVersion.trim();
494
+ for (const [k, v] of Object.entries(context ?? {}))
495
+ if (v)
496
+ body[k] = v;
497
+ return await call("POST", "/v1/agents/connect/check", {
498
+ headers: { "content-type": "application/json" },
499
+ body: JSON.stringify(body),
500
+ });
501
+ },
502
+ async agentsConnect({ code: inviteCode, platform, type, user_id: userId, context }) {
431
503
  if (!inviteCode?.trim()) {
432
504
  throw new ClawlingApiError("validation", "agentsConnect: inviteCode is required");
433
505
  }
@@ -448,6 +520,9 @@ export function createOpenclawClawlingApiClient(opts) {
448
520
  if (opts.pluginVersion?.trim()) {
449
521
  body.plugin_version = opts.pluginVersion.trim();
450
522
  }
523
+ for (const [k, v] of Object.entries(context ?? {}))
524
+ if (v)
525
+ body[k] = v;
451
526
  return await call("POST", "/v1/agents/connect", {
452
527
  // `X-Device-Id` is added globally via `authHeaders` on every request.
453
528
  headers: { "content-type": "application/json" },
@@ -83,6 +83,7 @@ export const openclawClawlingAccountConfigSchema = {
83
83
  forwardToolCalls: { type: "boolean" },
84
84
  richInteractions: { type: "boolean" },
85
85
  awarenessNote: { type: "boolean" },
86
+ friendGreeting: { type: "boolean" },
86
87
  livewareSample: { type: "boolean" },
87
88
  reconnect: {
88
89
  type: "object",
@@ -448,6 +449,7 @@ export function resolveOpenclawClawlingAccount(cfg, accountId, env = process.env
448
449
  const forwardToolCalls = typeof channel.forwardToolCalls === "boolean" ? channel.forwardToolCalls : false;
449
450
  const richInteractions = typeof channel.richInteractions === "boolean" ? channel.richInteractions : false;
450
451
  const awarenessNote = typeof channel.awarenessNote === "boolean" ? channel.awarenessNote : false;
452
+ const friendGreeting = typeof channel.friendGreeting === "boolean" ? channel.friendGreeting : true;
451
453
  const livewareSample = typeof channel.livewareSample === "boolean" ? channel.livewareSample : true;
452
454
  return {
453
455
  accountId: resolvedAccountId,
@@ -475,6 +477,7 @@ export function resolveOpenclawClawlingAccount(cfg, accountId, env = process.env
475
477
  forwardToolCalls,
476
478
  richInteractions,
477
479
  awarenessNote,
480
+ friendGreeting,
478
481
  livewareSample,
479
482
  allowFrom: [],
480
483
  reconnect: readReconnect(channel.reconnect),
@@ -0,0 +1,74 @@
1
+ import { ClawlingApiError } from "./api-types.js";
2
+ import { AGENTS_CONNECT_PLATFORM, RECONNECT_GUIDE_URL } from "./onboarding-context.js";
3
+ /**
4
+ * Non-consuming pre-check of a connect code. Records the "checked" funnel
5
+ * stage server-side and tells us whether the code is bound to an existing
6
+ * agent (the reconnect prompt). Any transport-level failure — older backend,
7
+ * rate limit, network — degrades to `null`: the pre-check is telemetry plus a
8
+ * courtesy, never a gate in front of `/connect`.
9
+ */
10
+ export async function preCheckConnectCode(client, input, log) {
11
+ try {
12
+ const res = await client.agentsConnectCheck({
13
+ code: input.code,
14
+ platform: AGENTS_CONNECT_PLATFORM,
15
+ ...(input.userId ? { user_id: input.userId } : {}),
16
+ ...(input.context ? { context: input.context } : {}),
17
+ });
18
+ // A 200 with no data says nothing about the code: degrade exactly like an
19
+ // unreachable endpoint (Hermes' `evaluate_precheck({})` does the same).
20
+ if (!res || typeof res !== "object" || Object.keys(res).length === 0) {
21
+ log("Connect-code pre-check returned no data; continuing without it.");
22
+ return null;
23
+ }
24
+ return {
25
+ pairable: res.pairable === true,
26
+ status: typeof res.status === "string" ? res.status : "",
27
+ boundAgent: res.bound_agent === true,
28
+ userIdStatus: typeof res.user_id_status === "string" ? res.user_id_status : "",
29
+ };
30
+ }
31
+ catch (err) {
32
+ const kind = err instanceof ClawlingApiError ? err.kind : "error";
33
+ log(`Connect-code pre-check unavailable (${kind}); continuing without it.`);
34
+ return null;
35
+ }
36
+ }
37
+ /** Owner-facing explanation for `pairable: false`. No flags, no minutes, one URL. */
38
+ export function unpairableMessage(pre) {
39
+ if (pre.userIdStatus === "owner_mismatch" && pre.boundAgent) {
40
+ // The server cannot tell us whether the bound agent shares this identity's
41
+ // owner, only that it is a different agent — so say exactly that.
42
+ return ("This connect code is the reconnect prompt for a different agent than the identity stored here. " +
43
+ "Ask your owner to send the reconnect prompt from THIS agent's chat in the ClawChat app.");
44
+ }
45
+ if (pre.userIdStatus === "invalid") {
46
+ return ("The identity stored here is not a valid ClawChat user id, so it cannot be restored. " +
47
+ "Activate as a brand-new agent with --new-account.");
48
+ }
49
+ if (pre.userIdStatus === "owner_mismatch") {
50
+ return ("This connect code belongs to a different ClawChat account than the identity stored here. " +
51
+ "Ask the owner of THIS agent for a code, or activate as a brand-new agent with --new-account.");
52
+ }
53
+ switch (pre.status) {
54
+ case "paired":
55
+ return ("This connect code was already redeemed. If this agent lost its connection, ask your owner " +
56
+ `to send you the reconnect prompt from the ClawChat app and follow ${RECONNECT_GUIDE_URL}; ` +
57
+ "otherwise ask for a fresh code.");
58
+ case "expired":
59
+ case "invalid":
60
+ return `This connect code is ${pre.status}. Ask your owner for a fresh code from the ClawChat app.`;
61
+ default:
62
+ return `This connect code is not pairable (status=${pre.status || "unknown"}). Ask your owner for a fresh code from the ClawChat app.`;
63
+ }
64
+ }
65
+ /**
66
+ * Refusal for a new-identity intent (`--new-account` or the interactive
67
+ * choice) on a bound code: without a user_id, `/connect` restores the bound
68
+ * agent instead of creating one. No flags, no minutes, no URL.
69
+ */
70
+ export function boundCodeNewIdentityMessage() {
71
+ return ("This is a reconnect code bound to an existing agent, so it cannot create a new agent. " +
72
+ "Ask your owner for a normal connect code from the ClawChat app, or use this reconnect prompt " +
73
+ "on the agent it belongs to.");
74
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * First message to a newly added NON-owner friend.
3
+ *
4
+ * `friend.added` used to be a pure awareness event. The server creates the
5
+ * direct conversation inside the friend-accept transaction, but the signal
6
+ * only carries the counterparty `usr_…` — so the runtime resolves the
7
+ * conversation through `POST /v1/conversations/direct` and then feeds ONE
8
+ * synthetic inbound turn (built here) into the normal dispatch path, the same
9
+ * way the activation bootstrap greets the owner.
10
+ *
11
+ * The prompt is deliberately distinct from the owner activation prompt: this
12
+ * reader is a stranger, so "you are connected and ready" makes no sense and
13
+ * the agent must say whose agent it is instead.
14
+ */
15
+ import fs from "node:fs";
16
+ import os from "node:os";
17
+ import path from "node:path";
18
+ import { EVENT } from "./protocol-types.js";
19
+ export const FRIEND_GREETING_FALLBACK = [
20
+ "A ClawChat user has just become your friend. You are now in a direct conversation with them; they are not your owner.",
21
+ "Reply now with one short, friendly greeting message in this conversation: introduce yourself by name, say you are an AI agent acting on behalf of your owner, and invite them to tell you what they need.",
22
+ "Send it as a normal chat reply. Do not write or create any files or notes, and do not call tools just to greet.",
23
+ "Do not share your owner's private information, and do not ask the user for personal information.",
24
+ ].join("\n");
25
+ // Cross-plugin, user-editable override read lazily so edits apply on the next
26
+ // friend without a restart. Any read failure falls back to the built-in text.
27
+ // Mirrors `buildActivationBootstrapText` (`~/clawchat/greeting.md`).
28
+ export function buildFriendGreetingText(homeDir = os.homedir()) {
29
+ const greetingPath = path.join(homeDir, "clawchat", "friend-greeting.md");
30
+ try {
31
+ const raw = fs.readFileSync(greetingPath);
32
+ const override = new TextDecoder("utf-8", { fatal: true }).decode(raw).trim();
33
+ if (override.length > 0) {
34
+ return override;
35
+ }
36
+ }
37
+ catch (error) {
38
+ const code = error.code;
39
+ if (code !== "ENOENT") {
40
+ console.warn(`clawchat.friend-greeting failed to read override ${greetingPath}:`, error);
41
+ }
42
+ }
43
+ return FRIEND_GREETING_FALLBACK;
44
+ }
45
+ /**
46
+ * Synthetic inbound envelope for the friend greeting turn. Same invariant as
47
+ * `buildActivationBootstrapEnvelope`: `conversationId` MUST be a conversation
48
+ * idcode — the agent's reply inherits this chat_id, and anything else is
49
+ * refused at the outbound boundary after a full LLM turn has been spent.
50
+ */
51
+ export function buildFriendGreetingEnvelope(params) {
52
+ const { account, conversationId, friendUserId } = params;
53
+ const text = buildFriendGreetingText();
54
+ const now = Date.now();
55
+ return {
56
+ version: "2",
57
+ event: EVENT.MESSAGE_SEND,
58
+ trace_id: `clawchat-plugin-openclaw-friend-greeting-${now}`,
59
+ emitted_at: now,
60
+ chat_id: conversationId,
61
+ chat_type: "direct",
62
+ to: { id: account.userId, type: "direct" },
63
+ sender: { id: friendUserId, type: "direct", nick_name: "" },
64
+ payload: {
65
+ message_id: `clawchat-plugin-openclaw-friend-greeting-${conversationId}-${now}`,
66
+ message_mode: "normal",
67
+ message: {
68
+ body: { fragments: [{ kind: "text", text }] },
69
+ context: { mentions: [], reply: null },
70
+ streaming: {
71
+ status: "static",
72
+ sequence: 0,
73
+ mutation_policy: "sealed",
74
+ started_at: null,
75
+ completed_at: null,
76
+ },
77
+ },
78
+ },
79
+ };
80
+ }
@@ -6,23 +6,12 @@ import { openclawClawlingAccountSlotPath, patchOpenclawClawlingAccountSection }
6
6
  import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/setup";
7
7
  import { CHANNEL_ID, mergeOpenclawClawchatRuntimePluginActivation, mergeOpenclawClawchatToolAllow, normalizeOpenclawClawlingAccountId, readOpenclawClawlingAccountSection, resolveOpenclawClawlingAccount, } from "./config.js";
8
8
  import { getClawChatStore } from "./storage.js";
9
- /**
10
- * Platform tag sent to `/v1/agents/connect`. Identifies the host of this
11
- * agent runtime — openclaw's bundled clawchat channel.
12
- */
13
- export const AGENTS_CONNECT_PLATFORM = "openclaw";
14
- /**
15
- * Agent type tag sent to `/v1/agents/connect`. The clawchat channel is
16
- * always a bot; humans don't log in through this flow.
17
- */
18
- export const AGENTS_CONNECT_TYPE = "clawbot";
19
- /**
20
- * `/v1/agents/connect` envelope code for "the supplied user_id matches no
21
- * agent". Servers that predate the server-side stale-user_id fallback return
22
- * this instead of degrading to a fresh pairing, so the client sheds the id and
23
- * retries once.
24
- */
25
- export const AGENT_NOT_FOUND_CODE = 16001;
9
+ import { buildOnboardingContext, RECONNECT_GUIDE_URL } from "./onboarding-context.js";
10
+ import { boundCodeNewIdentityMessage, preCheckConnectCode, unpairableMessage } from "./connect-check.js";
11
+ // Re-exported: `src/login.runtime.test.ts` and `src/commands.ts` import these
12
+ // from here, so the historical import path keeps working after the move.
13
+ import { AGENTS_CONNECT_PLATFORM, AGENTS_CONNECT_TYPE, AGENT_NOT_FOUND_CODE, } from "./onboarding-context.js";
14
+ export { AGENTS_CONNECT_PLATFORM, AGENTS_CONNECT_TYPE, AGENT_NOT_FOUND_CODE };
26
15
  /**
27
16
  * Thrown when redeeming an invite code would silently replace a live activation.
28
17
  *
@@ -43,9 +32,11 @@ export class ExistingActivationError extends Error {
43
32
  "spent. Re-run stating the intent:\n" +
44
33
  " - Pair as a BRAND-NEW agent (this instance stops using the identity " +
45
34
  "above): /clawchat-activate <CODE> --new-account\n" +
46
- " - RESTORE the identity above (re-pairs that same agent; if it was " +
47
- "deleted this brings it back with its history): " +
48
- "/clawchat-activate <CODE> --repair\n" +
35
+ " - RESTORE the identity above: do not spend a fresh code on it. Ask your " +
36
+ "owner to send you the reconnect prompt from the ClawChat app — its code is " +
37
+ "bound to this identity and usually restores it on its own; if activation " +
38
+ "still reports it as already paired, run it again with --repair " +
39
+ `(/clawchat-activate <CODE> --repair) — and follow ${RECONNECT_GUIDE_URL}\n` +
49
40
  " - To run a SECOND agent alongside this one instead, activate it as a " +
50
41
  "named account: /clawchat-activate <CODE> --account <name> (or: openclaw " +
51
42
  "channels add --channel clawchat-plugin-openclaw --account <name> --token <CODE>)");
@@ -197,7 +188,50 @@ export async function runOpenclawClawlingLogin(params) {
197
188
  // `DEFAULT_BASE_URL` / `DEFAULT_WEBSOCKET_URL` when the operator has not
198
189
  // overridden them, so login works without a prior `openclaw channels setup --channel clawchat-plugin-openclaw`.
199
190
  const account = resolveOpenclawClawlingAccount(cfg, accountId);
200
- // Fork before the invite code is even read, so neither branch spends the code
191
+ const inviteCode = (await (params.readInviteCode ?? (() => promptInviteCodeFromStdin(runtime)))()).trim();
192
+ if (!inviteCode) {
193
+ throw new Error("Login aborted: invite code is required.");
194
+ }
195
+ const apiClient = (params.apiClientFactory ?? createOpenclawClawlingApiClient)({
196
+ baseUrl: account.baseUrl,
197
+ mediaBaseUrl: account.mediaBaseUrl,
198
+ // Pre-login we may not have a token yet. Send the current one (or empty)
199
+ // — the server should accept an unauthenticated invite-code exchange.
200
+ token: account.token || "",
201
+ pluginVersion: resolvePluginVersion(),
202
+ });
203
+ // Telemetry the backend joins with wiki field reports; ignored by older servers.
204
+ const onboarding = buildOnboardingContext();
205
+ // Non-consuming pre-check. `null` = endpoint unavailable, carry on. A bound
206
+ // code (the owner's reconnect prompt) is the owner's own proof of intent, so
207
+ // it settles the "new agent or restore?" question below without a flag.
208
+ //
209
+ // The pre-check only carries the `user_id` that `/connect` would replay. An
210
+ // explicit `newAccount` never replays it, so it must not be judged against
211
+ // it either: the server marks `pairable:false` for an `owner_mismatch` /
212
+ // `invalid` id, and that would block the very escape the refusal recommends.
213
+ const storedUserId = account.userId.trim();
214
+ const precheckUserId = params.newAccount ? "" : storedUserId;
215
+ runtime.log("Checking the invite code …");
216
+ let precheck = await preCheckConnectCode(apiClient, { code: inviteCode, userId: precheckUserId || undefined, context: onboarding }, runtime.log);
217
+ // Whether the operator will be asked "new agent or restore?" below. When
218
+ // they will, a refusal caused only by the stored identity (not by the code)
219
+ // is deferred: choosing a brand-new identity drops that id, so the verdict
220
+ // on it no longer applies.
221
+ const identityUnsettled = storedUserId !== "" && account.token.trim() !== "" && !params.newAccount && !params.repair;
222
+ const refusedForIdentity = precheck !== null &&
223
+ !precheck.pairable &&
224
+ precheckUserId !== "" &&
225
+ // Only a live code: a dead one (expired / paired / invalid) refuses on its
226
+ // own account whatever identity is chosen, so there is nothing to defer.
227
+ precheck.status === "pending" &&
228
+ (precheck.userIdStatus === "owner_mismatch" || precheck.userIdStatus === "invalid");
229
+ const deferredRefusal = identityUnsettled && refusedForIdentity ? precheck : null;
230
+ if (precheck && !precheck.pairable && !deferredRefusal) {
231
+ throw new Error(unpairableMessage(precheck));
232
+ }
233
+ const boundToIncumbent = precheck?.pairable === true && precheck.boundAgent === true && precheckUserId !== "";
234
+ // Fork before the invite code is spent, so neither branch spends the code
201
235
  // before the outcome is settled. "Live" is decided by whether a usable token
202
236
  // remains: auto-logout (§C) blanks the tokens but preserves the identity, so a
203
237
  // logged-out instance still re-pairs with no flag at all, which is the flow
@@ -207,46 +241,56 @@ export async function runOpenclawClawlingLogin(params) {
207
241
  // ambiguous, because redeeming a code here can either mint a new agent or
208
242
  // re-pair the incumbent one. Ask which, and only refuse when nobody answers.
209
243
  // An explicit `newAccount` / `repair` from the caller has already settled it,
210
- // so no prompt.
244
+ // so no prompt. Nor is one needed when the pre-check proved the code is the
245
+ // owner's own reconnect prompt for this very agent (`boundToIncumbent`) — a
246
+ // bound code can only ever restore the incumbent identity.
211
247
  let newAccount = params.newAccount;
212
- if (account.userId.trim() && account.token.trim() && !newAccount && !params.repair) {
248
+ if (identityUnsettled && !boundToIncumbent) {
213
249
  const identity = account.agentId.trim()
214
- ? `agent ${account.agentId.trim()} (shadow user ${account.userId.trim()})`
215
- : `agent ${account.userId.trim()}`;
250
+ ? `agent ${account.agentId.trim()} (shadow user ${storedUserId})`
251
+ : `agent ${storedUserId}`;
216
252
  const intent = await (params.readActivationIntent ??
217
253
  (() => promptActivationIntentFromStdin(runtime, identity)))();
218
254
  // Only "new-account" changes what gets sent. Restoring needs no flag:
219
255
  // replaying the stored `user_id` IS the re-pair, and that is already the
220
256
  // default below — so choosing it simply means "don't refuse".
221
- if (intent === "new-account")
257
+ if (intent === "new-account") {
222
258
  newAccount = true;
259
+ if (deferredRefusal) {
260
+ // The first verdict judged the id we are now dropping; re-check the
261
+ // code on its own before spending it.
262
+ precheck = await preCheckConnectCode(apiClient, { code: inviteCode, context: onboarding }, runtime.log);
263
+ if (precheck && !precheck.pairable) {
264
+ throw new Error(unpairableMessage(precheck));
265
+ }
266
+ }
267
+ }
268
+ else if (deferredRefusal) {
269
+ // Restoring (or nobody to ask) replays the id the server already refused.
270
+ throw new Error(unpairableMessage(deferredRefusal));
271
+ }
223
272
  else if (intent === null) {
224
- throw new ExistingActivationError(account.userId.trim(), account.agentId.trim());
273
+ throw new ExistingActivationError(storedUserId, account.agentId.trim());
225
274
  }
226
275
  }
227
- const inviteCode = (await (params.readInviteCode ?? (() => promptInviteCodeFromStdin(runtime)))()).trim();
228
- if (!inviteCode) {
229
- throw new Error("Login aborted: invite code is required.");
276
+ // A new identity (flag or interactive choice) sends no user_id, and /connect
277
+ // without one on a bound code silently RESTORES the bound agent and spends
278
+ // the code. A reconnect code can never mint an agent, so refuse first.
279
+ if (newAccount && precheck?.boundAgent === true) {
280
+ throw new Error(boundCodeNewIdentityMessage());
230
281
  }
231
- const apiClient = (params.apiClientFactory ?? createOpenclawClawlingApiClient)({
232
- baseUrl: account.baseUrl,
233
- mediaBaseUrl: account.mediaBaseUrl,
234
- // Pre-login we may not have a token yet. Send the current one (or empty)
235
- // — the server should accept an unauthenticated invite-code exchange.
236
- token: account.token || "",
237
- pluginVersion: resolvePluginVersion(),
238
- });
239
282
  runtime.log("Verifying invite code …");
240
283
  let result;
241
284
  // A brand-new identity is precisely "do not replay" — the replay is the only
242
285
  // thing that would bind this code to the incumbent agent.
243
- const existingUserId = newAccount ? "" : account.userId.trim();
286
+ const existingUserId = newAccount ? "" : storedUserId;
244
287
  try {
245
288
  result = await apiClient.agentsConnect({
246
289
  code: inviteCode,
247
290
  platform: AGENTS_CONNECT_PLATFORM,
248
291
  type: AGENTS_CONNECT_TYPE,
249
292
  ...(existingUserId ? { user_id: existingUserId } : {}),
293
+ context: onboarding,
250
294
  });
251
295
  }
252
296
  catch (err) {
@@ -266,6 +310,7 @@ export async function runOpenclawClawlingLogin(params) {
266
310
  code: inviteCode,
267
311
  platform: AGENTS_CONNECT_PLATFORM,
268
312
  type: AGENTS_CONNECT_TYPE,
313
+ context: onboarding,
269
314
  });
270
315
  }
271
316
  catch (retryErr) {
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Onboarding telemetry the plugin attaches to `/v1/agents/connect` and
3
+ * `/v1/agents/connect/check` (all optional, all ignored by older backends).
4
+ *
5
+ * Values mirror the connection wiki's manifest vocabulary so the backend can
6
+ * join plugin activations with wiki field reports without an alias table:
7
+ * - `agent_kind` = the wiki `match.agent` name for this host
8
+ * - `lane` = "self": this plugin holds its own WebSocket + token
9
+ * - `matched_install` = the wiki page slug that routes to this plugin
10
+ * Nothing here is a credential and nothing here gates activation.
11
+ */
12
+ export const AGENT_KIND = "openclaw";
13
+ export const LANE = "self";
14
+ export const MATCHED_INSTALL = "install/official-openclaw";
15
+ /** Public connection wiki. `/start.md` is the entry page; `/reconnect.md` the re-pair page. */
16
+ export const START_GUIDE_URL = "https://agent-connection.clawling.com/start.md";
17
+ export const RECONNECT_GUIDE_URL = "https://agent-connection.clawling.com/reconnect.md";
18
+ const WIKI_VERSION_MAX = 40;
19
+ /** Wiki `os` enum (windows | macos | linux); "" for anything else so callers omit it. */
20
+ export function hostOs(plat = process.platform) {
21
+ switch (plat) {
22
+ case "win32":
23
+ return "windows";
24
+ case "darwin":
25
+ return "macos";
26
+ case "linux":
27
+ return "linux";
28
+ default:
29
+ return "";
30
+ }
31
+ }
32
+ /**
33
+ * The wiki build stamp the agent saw on the install page. The official install
34
+ * page tells the agent to export `CLAWCHAT_WIKI_VERSION` before activating; a
35
+ * hand-run `openclaw channels add` simply has none.
36
+ */
37
+ export function wikiVersion(env = process.env) {
38
+ return (env.CLAWCHAT_WIKI_VERSION ?? "").trim().slice(0, WIKI_VERSION_MAX);
39
+ }
40
+ export function buildOnboardingContext(opts = {}) {
41
+ const out = {
42
+ agent_kind: AGENT_KIND,
43
+ lane: LANE,
44
+ matched_install: MATCHED_INSTALL,
45
+ };
46
+ const os = hostOs(opts.platform);
47
+ if (os)
48
+ out.os = os;
49
+ const wiki = wikiVersion(opts.env);
50
+ if (wiki)
51
+ out.wiki_version = wiki;
52
+ return out;
53
+ }
54
+ /**
55
+ * Platform tag sent to `/v1/agents/connect`. Identifies the host of this
56
+ * agent runtime — openclaw's bundled clawchat channel.
57
+ */
58
+ export const AGENTS_CONNECT_PLATFORM = "openclaw";
59
+ /**
60
+ * Agent type tag sent to `/v1/agents/connect`. The clawchat channel is
61
+ * always a bot; humans don't log in through this flow.
62
+ */
63
+ export const AGENTS_CONNECT_TYPE = "clawbot";
64
+ /**
65
+ * `/v1/agents/connect` envelope code for "the supplied user_id matches no
66
+ * agent". Servers that predate the server-side stale-user_id fallback return
67
+ * this instead of degrading to a fresh pairing, so the client sheds the id and
68
+ * retries once.
69
+ */
70
+ export const AGENT_NOT_FOUND_CODE = 16001;
@@ -0,0 +1,50 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ const REPORT_ID_RE = /^[A-Za-z0-9_-]{1,64}$/;
5
+ const CAP_KEYS = ["headless", "mcp", "permission_hook", "session_line"];
6
+ function tier(v) {
7
+ return Number.isInteger(v) && v >= 1 && v <= 4 ? v : undefined;
8
+ }
9
+ /**
10
+ * Read + validate `~/clawchat/onboarding.json`. Never throws: an unresolvable
11
+ * home directory, an absent/unreadable file, or malformed JSON all fall
12
+ * through to `null`, same as a file with no valid fields — this must be safe
13
+ * to call unconditionally from a best-effort report path.
14
+ */
15
+ export function readOnboardingReport(homeDir) {
16
+ try {
17
+ const base = homeDir ?? os.homedir();
18
+ const file = path.join(base, "clawchat", "onboarding.json");
19
+ const raw = JSON.parse(fs.readFileSync(file, "utf8"));
20
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
21
+ return null;
22
+ const o = raw;
23
+ const out = {};
24
+ if (typeof o.wiki_report_id === "string" && REPORT_ID_RE.test(o.wiki_report_id))
25
+ out.wiki_report_id = o.wiki_report_id;
26
+ const t = tier(o.capability_tier);
27
+ if (t !== undefined)
28
+ out.capability_tier = t;
29
+ const c = tier(o.capability_ceiling);
30
+ // The backend rejects the WHOLE report (22004) when ceiling < tier, which
31
+ // would silently stop the version row updating. Keep the tier, drop the
32
+ // inconsistent ceiling.
33
+ if (c !== undefined && (t === undefined || c >= t))
34
+ out.capability_ceiling = c;
35
+ if (o.capabilities && typeof o.capabilities === "object") {
36
+ const caps = {};
37
+ for (const k of CAP_KEYS) {
38
+ const v = o.capabilities[k];
39
+ if (typeof v === "boolean")
40
+ caps[k] = v;
41
+ }
42
+ if (Object.keys(caps).length > 0)
43
+ out.capabilities = caps;
44
+ }
45
+ return Object.keys(out).length > 0 ? out : null;
46
+ }
47
+ catch {
48
+ return null;
49
+ }
50
+ }
@@ -56,6 +56,7 @@ export async function reportPluginVersionSafe(p) {
56
56
  agentVersion: p.agentVersion,
57
57
  runtimeName: "node",
58
58
  runtimeVersion: process.version,
59
+ onboarding: p.onboarding ?? null,
59
60
  }, { authenticated: p.authenticated });
60
61
  }
61
62
  catch (err) {