@clawling/clawchat-plugin-openclaw 2026.10.9-1 → 2026.10.9-3

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.
@@ -132,6 +132,7 @@
132
132
  "note-cap-group": { "type": "integer" },
133
133
  "note-cap-turn": { "type": "integer" },
134
134
  "delta-budget-chars": { "type": "integer" },
135
+ "non-owner-host-tools": { "type": "string", "enum": ["off", "approve"] },
135
136
  "reconnect": {
136
137
  "type": "object",
137
138
  "additionalProperties": false,
@@ -210,6 +211,7 @@
210
211
  "note-cap-group": { "type": "integer" },
211
212
  "note-cap-turn": { "type": "integer" },
212
213
  "delta-budget-chars": { "type": "integer" },
214
+ "non-owner-host-tools": { "type": "string", "enum": ["off", "approve"] },
213
215
  "reconnect": {
214
216
  "type": "object",
215
217
  "additionalProperties": false,
@@ -294,6 +296,7 @@
294
296
  "note-cap-group": { "type": "integer" },
295
297
  "note-cap-turn": { "type": "integer" },
296
298
  "delta-budget-chars": { "type": "integer" },
299
+ "non-owner-host-tools": { "type": "string", "enum": ["off", "approve"] },
297
300
  "reconnect": {
298
301
  "type": "object",
299
302
  "additionalProperties": false,
@@ -372,6 +375,7 @@
372
375
  "note-cap-group": { "type": "integer" },
373
376
  "note-cap-turn": { "type": "integer" },
374
377
  "delta-budget-chars": { "type": "integer" },
378
+ "non-owner-host-tools": { "type": "string", "enum": ["off", "approve"] },
375
379
  "reconnect": {
376
380
  "type": "object",
377
381
  "additionalProperties": false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clawling/clawchat-plugin-openclaw",
3
- "version": "2026.10.9-1",
3
+ "version": "2026.10.9-3",
4
4
  "description": "OpenClaw ClawChat channel plugin",
5
5
  "license": "MIT",
6
6
  "author": "CLAWLING PTE. LTD.",
@@ -52,7 +52,7 @@
52
52
  "@sinclair/typebox": "0.34.48"
53
53
  },
54
54
  "peerDependencies": {
55
- "openclaw": ">=2026.5.4"
55
+ "openclaw": ">=2026.8.1"
56
56
  },
57
57
  "peerDependenciesMeta": {
58
58
  "openclaw": {
@@ -93,7 +93,7 @@
93
93
  "install": {
94
94
  "npmSpec": "@clawling/clawchat-plugin-openclaw",
95
95
  "defaultChoice": "npm",
96
- "minHostVersion": ">=2026.5.4"
96
+ "minHostVersion": ">=2026.8.1"
97
97
  }
98
98
  }
99
99
  }
@@ -34,6 +34,13 @@ export interface ClawChatMemorySearchResult {
34
34
  query: string;
35
35
  matches: ClawChatMemorySearchMatch[];
36
36
  truncated: boolean;
37
+ /** Set by callers whose conversation scope left notes out (never a count). */
38
+ scopeNote?: string;
39
+ }
40
+
41
+ export interface ClawChatMemorySearchOptions {
42
+ /** Targets this caller may see; others are skipped before any content read. */
43
+ readable?: (target: ClawChatMemoryTarget) => boolean;
37
44
  }
38
45
 
39
46
  const metadataStart = "<!-- clawchat:metadata:start -->";
@@ -434,6 +441,7 @@ function buildSearchMatch(
434
441
  export async function searchClawChatMemory(
435
442
  root: string,
436
443
  params: ClawChatMemorySearchParams,
444
+ options: ClawChatMemorySearchOptions = {},
437
445
  ): Promise<ClawChatMemorySearchResult> {
438
446
  const { query, queryLower, targetTypes, maxResults } = normalizeSearchParams(params);
439
447
  const rootPath = path.resolve(root);
@@ -443,6 +451,11 @@ export async function searchClawChatMemory(
443
451
  for (const targetType of targetTypes) {
444
452
  const targets = await listSafeMemoryFiles(rootPath, rootRealPath, targetType);
445
453
  for (const target of targets) {
454
+ // Filter before reading, matching and counting so unreadable notes
455
+ // leave no trace in matches or `truncated`.
456
+ if (options.readable && !options.readable(target)) {
457
+ continue;
458
+ }
446
459
  const file = await readClawChatMemoryFile(rootPath, target);
447
460
  if (!file.exists) {
448
461
  continue;
package/src/config.ts CHANGED
@@ -125,6 +125,8 @@ export type OpenclawClawlingAccountConfig = {
125
125
  "note-cap-group"?: number;
126
126
  "note-cap-turn"?: number;
127
127
  "delta-budget-chars"?: number;
128
+ /** Host tools in turns the owner did not start: `off` | `approve` (approve currently behaves as off). */
129
+ "non-owner-host-tools"?: string;
128
130
  reconnect?: OpenclawClawlingReconnectConfig;
129
131
  heartbeat?: OpenclawClawlingHeartbeatConfig;
130
132
  ack?: OpenclawClawlingAckConfig;
@@ -190,6 +192,7 @@ export const openclawClawlingAccountConfigSchema = {
190
192
  "note-cap-group": { type: "integer" },
191
193
  "note-cap-turn": { type: "integer" },
192
194
  "delta-budget-chars": { type: "integer" },
195
+ "non-owner-host-tools": { type: "string", enum: ["off", "approve"] },
193
196
  reconnect: {
194
197
  type: "object",
195
198
  additionalProperties: false,
@@ -337,6 +340,8 @@ export type ResolvedOpenclawClawlingAccount = {
337
340
  noteCapTurn: number;
338
341
  /** `delta-budget-chars`: characters of unseen group messages an @-mention adds. */
339
342
  deltaBudgetChars: number;
343
+ /** `non-owner-host-tools`: parsed mode; `approve` is reserved and currently behaves like `off`. */
344
+ nonOwnerHostTools: "off" | "approve";
340
345
  allowFrom: string[];
341
346
  reconnect: Required<OpenclawClawlingReconnectConfig>;
342
347
  heartbeat: Required<OpenclawClawlingHeartbeatConfig>;
@@ -597,6 +602,11 @@ export const SESSION_INT_KEYS = {
597
602
  "delta-budget-chars": [8000, 2000, 32000],
598
603
  } as const satisfies Record<string, readonly [number, number, number]>;
599
604
 
605
+ /** `approve` only when explicitly set; any other value (typo, `on`, boolean) means `off`. */
606
+ export function parseNonOwnerHostTools(raw: unknown): "off" | "approve" {
607
+ return typeof raw === "string" && raw.trim().toLowerCase() === "approve" ? "approve" : "off";
608
+ }
609
+
600
610
  function readSessionInt(channel: Record<string, unknown>, key: keyof typeof SESSION_INT_KEYS): number {
601
611
  const [factory, minimum, maximum] = SESSION_INT_KEYS[key];
602
612
  const raw = channel[key];
@@ -739,6 +749,7 @@ export function resolveOpenclawClawlingAccount(
739
749
  noteCapGroup: readSessionInt(channel, "note-cap-group"),
740
750
  noteCapTurn: readSessionInt(channel, "note-cap-turn"),
741
751
  deltaBudgetChars: readSessionInt(channel, "delta-budget-chars"),
752
+ nonOwnerHostTools: parseNonOwnerHostTools(channel["non-owner-host-tools"]),
742
753
  allowFrom: [],
743
754
  reconnect: readReconnect(channel.reconnect),
744
755
  heartbeat: readHeartbeat(channel.heartbeat),
@@ -1,5 +1,6 @@
1
1
  import { ClawlingApiError } from "./api-types.ts";
2
2
  import type { OpenclawClawlingApiClient } from "./api-client.ts";
3
+ import { readConnectFailureGuidance } from "./connect-failure.ts";
3
4
  import { AGENTS_CONNECT_PLATFORM, RECONNECT_GUIDE_URL } from "./onboarding-context.ts";
4
5
 
5
6
  export interface ConnectPreCheck {
@@ -41,8 +42,14 @@ export async function preCheckConnectCode(
41
42
  userIdStatus: typeof res.user_id_status === "string" ? res.user_id_status : "",
42
43
  };
43
44
  } catch (err) {
45
+ // Still a degrade, never a gate: `/connect` re-evaluates the code and
46
+ // surfaces the same guidance on the error it throws. The guidance is only
47
+ // logged here so a rate-limit or dead code is visible early.
44
48
  const kind = err instanceof ClawlingApiError ? err.kind : "error";
45
- log(`Connect-code pre-check unavailable (${kind}); continuing without it.`);
49
+ const guidance = err instanceof ClawlingApiError ? readConnectFailureGuidance(err.meta?.data) : {};
50
+ const action = guidance.nextAction ? `; next_action: ${guidance.nextAction}` : "";
51
+ const hint = guidance.hint ? `. ${guidance.hint}` : "";
52
+ log(`Connect-code pre-check unavailable (${kind}${action})${hint}; continuing without it.`);
46
53
  return null;
47
54
  }
48
55
  }
@@ -0,0 +1,109 @@
1
+ import type { ClawlingApiError, ClawlingApiErrorKind } from "./api-types.ts";
2
+
3
+ /**
4
+ * Machine-readable guidance the backend attaches to the error envelope's
5
+ * `data` on a failed `POST /v1/agents/connect` (and `/connect/check`). Every
6
+ * field is optional: older servers send none of them, and then the plugin
7
+ * behaves exactly as before.
8
+ *
9
+ * Known `next_action` values (closed enum on the server; any other value is
10
+ * surfaced verbatim and never retried):
11
+ * - `ask_owner_new_code` — the code is dead (unknown / expired); never retry.
12
+ * - `wait_retry` — rate limited; the code was NOT consumed. Wait
13
+ * `retry_after_seconds`, then retry once.
14
+ * - `reconnect` — redeemed from another device; the owner must send
15
+ * a reconnect prompt.
16
+ * - `stop` — an unchanged retry fails the same way.
17
+ */
18
+ export type ConnectNextAction = "ask_owner_new_code" | "wait_retry" | "reconnect" | "stop";
19
+
20
+ export interface ConnectFailureGuidance {
21
+ hint?: string;
22
+ /** A `ConnectNextAction`, or an unknown value passed through verbatim. */
23
+ nextAction?: ConnectNextAction | (string & {});
24
+ retryAfterSeconds?: number;
25
+ }
26
+
27
+ /** Default wait for `wait_retry` when the server omits / garbles the delay. */
28
+ export const WAIT_RETRY_DEFAULT_SECONDS = 60;
29
+ /** Upper bound on any `wait_retry` wait, whatever the server asks for. */
30
+ export const WAIT_RETRY_MAX_SECONDS = 120;
31
+
32
+ function readNonBlankString(value: unknown): string | undefined {
33
+ if (typeof value !== "string") return undefined;
34
+ const trimmed = value.trim();
35
+ return trimmed ? trimmed : undefined;
36
+ }
37
+
38
+ export function readConnectFailureGuidance(data: unknown): ConnectFailureGuidance {
39
+ if (!data || typeof data !== "object" || Array.isArray(data)) return {};
40
+ const record = data as Record<string, unknown>;
41
+ const out: ConnectFailureGuidance = {};
42
+ const hint = readNonBlankString(record.hint);
43
+ if (hint) out.hint = hint;
44
+ const nextAction = readNonBlankString(record.next_action);
45
+ if (nextAction) out.nextAction = nextAction;
46
+ const retryAfter = record.retry_after_seconds;
47
+ if (typeof retryAfter === "number" && Number.isFinite(retryAfter)) {
48
+ out.retryAfterSeconds = retryAfter;
49
+ }
50
+ return out;
51
+ }
52
+
53
+ /** Delay before the single `wait_retry` retry: server value, capped, with a default. */
54
+ export function waitRetryDelayMs(retryAfterSeconds: number | undefined): number {
55
+ const seconds =
56
+ typeof retryAfterSeconds === "number" && Number.isFinite(retryAfterSeconds) && retryAfterSeconds > 0
57
+ ? Math.min(retryAfterSeconds, WAIT_RETRY_MAX_SECONDS)
58
+ : WAIT_RETRY_DEFAULT_SECONDS;
59
+ return Math.round(seconds * 1000);
60
+ }
61
+
62
+ /**
63
+ * Error thrown by the invite-code login when `/v1/agents/connect` fails.
64
+ * The message keeps the historical `agents/connect failed (<kind>): <msg>`
65
+ * prefix; when the server sent guidance it is appended so an agent reading
66
+ * the CLI / chat-command output knows what to do next.
67
+ */
68
+ export class ConnectFailedError extends Error {
69
+ readonly kind: ClawlingApiErrorKind;
70
+ readonly code?: number;
71
+ readonly status?: number;
72
+ readonly hint?: string;
73
+ readonly nextAction?: ConnectFailureGuidance["nextAction"];
74
+ readonly retryAfterSeconds?: number;
75
+
76
+ constructor(
77
+ kind: ClawlingApiErrorKind,
78
+ serverMessage: string,
79
+ meta: { code?: number; status?: number } & ConnectFailureGuidance = {},
80
+ ) {
81
+ super(formatConnectFailureMessage(kind, serverMessage, meta));
82
+ this.name = "ConnectFailedError";
83
+ this.kind = kind;
84
+ if (meta.code !== undefined) this.code = meta.code;
85
+ if (meta.status !== undefined) this.status = meta.status;
86
+ if (meta.hint !== undefined) this.hint = meta.hint;
87
+ if (meta.nextAction !== undefined) this.nextAction = meta.nextAction;
88
+ if (meta.retryAfterSeconds !== undefined) this.retryAfterSeconds = meta.retryAfterSeconds;
89
+ }
90
+ }
91
+
92
+ function formatConnectFailureMessage(
93
+ kind: ClawlingApiErrorKind,
94
+ serverMessage: string,
95
+ guidance: ConnectFailureGuidance,
96
+ ): string {
97
+ let text = `agents/connect failed (${kind}): ${serverMessage}`;
98
+ if (guidance.nextAction) text += ` (next_action: ${guidance.nextAction})`;
99
+ if (guidance.hint) text += `. ${guidance.hint}`;
100
+ return text;
101
+ }
102
+
103
+ export function connectFailedError(err: ClawlingApiError): ConnectFailedError {
104
+ return new ConnectFailedError(err.kind, err.message, {
105
+ ...(err.meta?.code !== undefined ? { code: err.meta.code } : {}),
106
+ ...(err.meta?.status !== undefined ? { status: err.meta.status } : {}),
107
+ ...readConnectFailureGuidance(err.meta?.data),
108
+ });
109
+ }
@@ -0,0 +1,186 @@
1
+ // Layer 2 of the non-owner turn boundary: a fail-closed `before_tool_call`
2
+ // hook. Dispatch (layer 1) already narrows the offered tools for turns the
3
+ // owner did not start; this hook blocks anything else that still reaches
4
+ // execution. Only calls whose session key carries the ClawChat prefix are
5
+ // in scope (D8); everything else passes untouched. Tool arguments are never
6
+ // logged.
7
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
8
+ import { CHANNEL_ID, normalizeOpenclawClawlingAccountId, resolveOpenclawClawlingAccount } from "./config.ts";
9
+ import {
10
+ classifyChat,
11
+ clawChatSessionAccountId,
12
+ NON_OWNER_TOOL_ALLOWLIST,
13
+ parseClawChatSessionKey,
14
+ } from "./turn-boundary.ts";
15
+ import { clawChatDbPathForStateDir, getClawChatStore } from "./storage.ts";
16
+ import { resolveOwnerIdentity } from "./owner-identity.ts";
17
+ import { getRegisteredClawChatToolNames } from "./tools.ts";
18
+
19
+ export const BLOCK_MESSAGE =
20
+ "This tool is not available here: only your owner's direct chat may use host tools on this machine. Do not try to reach them another way; answer without them, or tell the person to ask your owner.";
21
+
22
+ export type HostToolDecision = { block: true; blockReason: string } | undefined;
23
+ export type HostToolInput = {
24
+ toolName: string;
25
+ params: Record<string, unknown>;
26
+ sessionKey?: string;
27
+ ownerDirectConversationId?: string | null;
28
+ registeredTools: ReadonlySet<string>;
29
+ };
30
+
31
+ /**
32
+ * Host tool-search controls (OpenClaw 2026.9.6+). The host adds them to every
33
+ * run on its own and builds their catalog from the run's allowed tools, so in
34
+ * a non-owner turn they reach only the allowlisted tools; the host fires this
35
+ * hook again for the inner tool a `tool_call` runs, and that inner call gets
36
+ * every check below. They are not part of the dispatch allowlist.
37
+ */
38
+ export const HOST_TOOL_SEARCH_CONTROLS: readonly string[] = Object.freeze(["tool_search", "tool_describe", "tool_call"]);
39
+ const ACCOUNT_SCOPED_TOOLS: readonly string[] = ["clawchat_mention_message", "clawchat_react_message"];
40
+
41
+ const block = (): HostToolDecision => ({ block: true, blockReason: BLOCK_MESSAGE });
42
+ const CLAWCHAT_SESSION_PREFIX = new RegExp(`^agent:[^:]+:${CHANNEL_ID}:`, "i");
43
+ const PATH_FIELD = /^(?:filePath|media|iconPath|path|attachments)$/i;
44
+
45
+ /** D8: only the ClawChat session-key prefix ties a call to ClawChat; requester channel alone never does. */
46
+ export function isClawChatToolCall(input: Pick<HostToolInput, "sessionKey">): boolean {
47
+ return CLAWCHAT_SESSION_PREFIX.test(input.sessionKey?.trim() ?? "");
48
+ }
49
+
50
+ /** True when any argument looks like a local file path; only http(s) URLs are allowed. */
51
+ export function hasLocalPathArgument(value: unknown, field = ""): boolean {
52
+ if (typeof value === "string") {
53
+ const text = value.trim();
54
+ if (/^https?:\/\//i.test(text)) return false;
55
+ if (/^(?:\/|[a-z]:[\\/]|\\\\|file:|~|\.\.?[\\/])/i.test(text)) return true;
56
+ return PATH_FIELD.test(field) && text.length > 0;
57
+ }
58
+ if (Array.isArray(value)) return value.some(item => hasLocalPathArgument(item, field));
59
+ return Boolean(value && typeof value === "object" && Object.entries(value).some(
60
+ ([key, item]) => hasLocalPathArgument(item, PATH_FIELD.test(field) ? field : key),
61
+ ));
62
+ }
63
+
64
+ /** Pure decision for one tool call. `undefined` lets the call run. */
65
+ export function decideHostToolCall(input: HostToolInput): HostToolDecision {
66
+ if (!isClawChatToolCall(input)) return undefined;
67
+ const parsed = parseClawChatSessionKey(input.sessionKey);
68
+ if (!parsed) return block();
69
+ if (classifyChat({ ...parsed, ownerDirectConversationId: input.ownerDirectConversationId }) === "owner") {
70
+ return undefined;
71
+ }
72
+ // Outer pre-check only. The inner tool call is checked again and that check
73
+ // is authoritative: the host also accepts dotted/flattened argument keys
74
+ // that this outer check may not recognise.
75
+ if (hasLocalPathArgument(input.params)) return block();
76
+ if (HOST_TOOL_SEARCH_CONTROLS.includes(input.toolName)) return undefined;
77
+ if (ACCOUNT_SCOPED_TOOLS.includes(input.toolName)) {
78
+ // D9: a non-owner turn may only act on its own chat, named explicitly.
79
+ const target = input.params.chatId;
80
+ if (typeof target !== "string" || !target.trim() || target.trim().toLowerCase() !== parsed.chatId) {
81
+ return {
82
+ block: true,
83
+ blockReason: input.toolName === "clawchat_react_message"
84
+ ? "Pass this chat's chatId to react here."
85
+ : "Pass this chat's chatId to mention here.",
86
+ };
87
+ }
88
+ }
89
+ if (NON_OWNER_TOOL_ALLOWLIST.includes(input.toolName)) {
90
+ return input.toolName.startsWith("clawchat_") && !input.registeredTools.has(input.toolName)
91
+ ? block()
92
+ : undefined;
93
+ }
94
+ return block(); // `off` and the reserved `approve` both block in this release.
95
+ }
96
+
97
+ type RequesterContext = { channel?: string; accountId?: string };
98
+
99
+ export function registerHostToolsGuard(
100
+ api: OpenClawPluginApi,
101
+ options: { ownerConversation?: (accountId: string) => string | null } = {},
102
+ ): void {
103
+ const warned = new Set<string>();
104
+ const warn = (failure: string) => {
105
+ if (warned.has(failure)) return;
106
+ warned.add(failure);
107
+ try {
108
+ api.logger.warn(`clawchat-plugin-openclaw: host-tool guard ${failure}; enforcing non-owner policy`);
109
+ } catch {
110
+ // Logging cannot unlock a call.
111
+ }
112
+ };
113
+ const lookupOwnerConversation = (accountId: string): string | null => {
114
+ if (options.ownerConversation) return options.ownerConversation(accountId);
115
+ const account = resolveOpenclawClawlingAccount(api.config, accountId);
116
+ const stateDir = api.runtime.state?.resolveStateDir?.();
117
+ const store = getClawChatStore({
118
+ ...(stateDir ? { dbPath: clawChatDbPathForStateDir(stateDir) } : {}),
119
+ log: { error: message => api.logger.error?.(message) },
120
+ });
121
+ return resolveOwnerIdentity(store, accountId, account.ownerUserId).conversationId;
122
+ };
123
+
124
+ api.on("before_tool_call", (event, sdkCtx) => {
125
+ // `requester` is host 2026.8.1+ (the floor); the development SDK types predate it.
126
+ const ctx = sdkCtx as (typeof sdkCtx & { requester?: RequesterContext }) | undefined;
127
+ try {
128
+ if (!ctx) {
129
+ warn("missing-context");
130
+ return block();
131
+ }
132
+ const sessionKey = ctx.sessionKey;
133
+ if (!isClawChatToolCall({ sessionKey })) return undefined;
134
+ const requester = ctx.requester;
135
+ const parsed = parseClawChatSessionKey(sessionKey);
136
+ if (!parsed || (requester?.channel && requester.channel !== CHANNEL_ID)) {
137
+ warn("unknown-session");
138
+ return block();
139
+ }
140
+ // Direct keys carry their account; group keys do not. A group turn is
141
+ // non-owner whoever speaks, so a group call without the requester
142
+ // account (host tool-search controls and the inner calls they run get
143
+ // a run-level ctx without one) still gets the non-owner policy; only
144
+ // the tools that act as an account need it. Conflicting ids block.
145
+ const keyAccountId = clawChatSessionAccountId(sessionKey);
146
+ const requesterAccountId = requester?.accountId?.trim()
147
+ ? normalizeOpenclawClawlingAccountId(requester.accountId)
148
+ : undefined;
149
+ const accountId = requesterAccountId ?? keyAccountId;
150
+ if (keyAccountId && keyAccountId !== accountId) {
151
+ warn("account-identity");
152
+ return block();
153
+ }
154
+ if (!accountId) {
155
+ if (parsed.kind !== "group" || ACCOUNT_SCOPED_TOOLS.includes(event.toolName)) {
156
+ warn("account-identity");
157
+ return block();
158
+ }
159
+ return decideHostToolCall({
160
+ toolName: event.toolName,
161
+ params: event.params ?? {},
162
+ sessionKey,
163
+ ownerDirectConversationId: null,
164
+ registeredTools: getRegisteredClawChatToolNames(),
165
+ });
166
+ }
167
+ let ownerDirectConversationId: string | null = null;
168
+ try {
169
+ ownerDirectConversationId = lookupOwnerConversation(accountId);
170
+ } catch {
171
+ // Treated as missing below; the allowlist still applies.
172
+ }
173
+ if (!ownerDirectConversationId?.trim()) warn("missing-owner-conversation");
174
+ return decideHostToolCall({
175
+ toolName: event.toolName,
176
+ params: event.params ?? {},
177
+ sessionKey,
178
+ ownerDirectConversationId,
179
+ registeredTools: getRegisteredClawChatToolNames(),
180
+ });
181
+ } catch {
182
+ warn("handler-exception");
183
+ return block();
184
+ }
185
+ });
186
+ }
@@ -0,0 +1,35 @@
1
+ export const HOST_FLOOR = "2026.8.1";
2
+
3
+ function parse(v: string): [number, number, number] | null {
4
+ const m = /^(\d+)\.(\d+)\.(\d+)/.exec(v.trim());
5
+ return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
6
+ }
7
+
8
+ /** Prerelease suffixes count as their base version (10.1-beta.2 ≥ 8.1). Unparseable ⇒ false. */
9
+ export function hostAtLeast(version: string | undefined, min: string): boolean {
10
+ const a = version ? parse(version) : null;
11
+ const b = parse(min);
12
+ if (!a || !b) return false;
13
+ for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i] > b[i];
14
+ return true;
15
+ }
16
+
17
+ /** First host release where tool search is on unless `tools.toolSearch` turns it off. */
18
+ export const HOST_TOOL_SEARCH_DEFAULT_ON = "2026.9.6";
19
+
20
+ const isRecord = (v: unknown): v is Record<string, unknown> =>
21
+ typeof v === "object" && v !== null && !Array.isArray(v);
22
+
23
+ /**
24
+ * Whether the host's `tools.toolSearch` setting leaves tool search on, read the
25
+ * way the host reads it (2026.9.6+): unset or `true` ⇒ on; `false` ⇒ off; an
26
+ * object ⇒ its `enabled` flag, else on only when it sets other options.
27
+ */
28
+ export function hostToolSearchEnabled(config: unknown): boolean {
29
+ const tools = isRecord(config) ? config.tools : undefined;
30
+ const raw = isRecord(tools) ? tools.toolSearch : undefined;
31
+ if (raw === undefined || raw === true) return true;
32
+ if (!isRecord(raw)) return false;
33
+ if (typeof raw.enabled === "boolean") return raw.enabled;
34
+ return Object.keys(raw).some(key => key !== "enabled");
35
+ }
@@ -16,6 +16,7 @@ import {
16
16
  import { getClawChatStore, type ClawChatStore } from "./storage.ts";
17
17
  import { buildOnboardingContext, RECONNECT_GUIDE_URL } from "./onboarding-context.ts";
18
18
  import { boundCodeNewIdentityMessage, preCheckConnectCode, unpairableMessage } from "./connect-check.ts";
19
+ import { connectFailedError, readConnectFailureGuidance, waitRetryDelayMs } from "./connect-failure.ts";
19
20
  // Re-exported: `src/login.runtime.test.ts` and `src/commands.ts` import these
20
21
  // from here, so the historical import path keeps working after the move.
21
22
  import {
@@ -68,6 +69,15 @@ export interface LoginParams {
68
69
  * `promptActivationIntentFromStdin`; overridden by tests.
69
70
  */
70
71
  readActivationIntent?: () => Promise<ActivationIntent | null>;
72
+ /**
73
+ * Wait used before the single `next_action: wait_retry` retry of
74
+ * `/v1/agents/connect`. Defaults to a `setTimeout`; overridden by tests.
75
+ */
76
+ sleep?: (ms: number) => Promise<void>;
77
+ }
78
+
79
+ function defaultSleep(ms: number): Promise<void> {
80
+ return new Promise((resolve) => setTimeout(resolve, ms));
71
81
  }
72
82
 
73
83
  /** The two outcomes available once an identity is already stored. */
@@ -396,8 +406,30 @@ export async function runOpenclawClawlingLogin(params: LoginParams): Promise<voi
396
406
  // A brand-new identity is precisely "do not replay" — the replay is the only
397
407
  // thing that would bind this code to the incumbent agent.
398
408
  const existingUserId = newAccount ? "" : storedUserId;
409
+ // Rate-limit (`next_action: wait_retry`) budget: one wait-and-retry per
410
+ // activation, shared across both requests below.
411
+ let waitRetryUsed = false;
412
+ const sleep = params.sleep ?? defaultSleep;
413
+ const redeem = async (body: Parameters<typeof apiClient.agentsConnect>[0]) => {
414
+ try {
415
+ return await apiClient.agentsConnect(body);
416
+ } catch (err) {
417
+ if (!(err instanceof ClawlingApiError) || waitRetryUsed) throw err;
418
+ const guidance = readConnectFailureGuidance(err.meta?.data);
419
+ // Only wait_retry is retried: a rate-limited request did not consume the
420
+ // code. Every other next_action (and any unknown one) is final.
421
+ if (guidance.nextAction !== "wait_retry") throw err;
422
+ waitRetryUsed = true;
423
+ const delayMs = waitRetryDelayMs(guidance.retryAfterSeconds);
424
+ runtime.log(
425
+ `Connect code redeem rate limited — retrying once in ${Math.round(delayMs / 1000)}s …`,
426
+ );
427
+ await sleep(delayMs);
428
+ return await apiClient.agentsConnect(body);
429
+ }
430
+ };
399
431
  try {
400
- result = await apiClient.agentsConnect({
432
+ result = await redeem({
401
433
  code: inviteCode,
402
434
  platform: AGENTS_CONNECT_PLATFORM,
403
435
  type: AGENTS_CONNECT_TYPE,
@@ -419,7 +451,7 @@ export async function runOpenclawClawlingLogin(params: LoginParams): Promise<voi
419
451
  `Stored userId ${existingUserId} no longer exists on this server — retrying as a fresh pairing …`,
420
452
  );
421
453
  try {
422
- result = await apiClient.agentsConnect({
454
+ result = await redeem({
423
455
  code: inviteCode,
424
456
  platform: AGENTS_CONNECT_PLATFORM,
425
457
  type: AGENTS_CONNECT_TYPE,
@@ -427,12 +459,12 @@ export async function runOpenclawClawlingLogin(params: LoginParams): Promise<voi
427
459
  });
428
460
  } catch (retryErr) {
429
461
  if (retryErr instanceof ClawlingApiError) {
430
- throw new Error(`agents/connect failed (${retryErr.kind}): ${retryErr.message}`);
462
+ throw connectFailedError(retryErr);
431
463
  }
432
464
  throw retryErr;
433
465
  }
434
466
  } else {
435
- throw new Error(`agents/connect failed (${err.kind}): ${err.message}`);
467
+ throw connectFailedError(err);
436
468
  }
437
469
  } else {
438
470
  throw err;