@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.
- package/README.md +1 -1
- package/dist/index.js +4 -0
- package/dist/src/clawchat-memory.js +6 -1
- package/dist/src/config.js +6 -0
- package/dist/src/connect-check.js +8 -1
- package/dist/src/connect-failure.js +78 -0
- package/dist/src/host-tools-guard.js +163 -0
- package/dist/src/host-version.js +35 -0
- package/dist/src/login.runtime.js +31 -4
- package/dist/src/memory-scope.js +166 -0
- package/dist/src/owner-identity.js +29 -0
- package/dist/src/reply-dispatcher.js +26 -13
- package/dist/src/runtime.js +73 -12
- package/dist/src/storage.js +41 -2
- package/dist/src/tools.js +122 -16
- package/dist/src/turn-boundary.js +51 -0
- package/index.ts +4 -0
- package/openclaw.plugin.json +4 -0
- package/package.json +3 -3
- package/src/clawchat-memory.ts +13 -0
- package/src/config.ts +11 -0
- package/src/connect-check.ts +8 -1
- package/src/connect-failure.ts +109 -0
- package/src/host-tools-guard.ts +186 -0
- package/src/host-version.ts +35 -0
- package/src/login.runtime.ts +36 -4
- package/src/memory-scope.ts +191 -0
- package/src/owner-identity.ts +49 -0
- package/src/reply-dispatcher.ts +31 -13
- package/src/runtime.ts +80 -12
- package/src/storage.ts +42 -1
- package/src/tools.ts +133 -20
- package/src/turn-boundary.ts +61 -0
package/openclaw.plugin.json
CHANGED
|
@@ -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-
|
|
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.
|
|
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.
|
|
96
|
+
"minHostVersion": ">=2026.8.1"
|
|
97
97
|
}
|
|
98
98
|
}
|
|
99
99
|
}
|
package/src/clawchat-memory.ts
CHANGED
|
@@ -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),
|
package/src/connect-check.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/src/login.runtime.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
462
|
+
throw connectFailedError(retryErr);
|
|
431
463
|
}
|
|
432
464
|
throw retryErr;
|
|
433
465
|
}
|
|
434
466
|
} else {
|
|
435
|
-
throw
|
|
467
|
+
throw connectFailedError(err);
|
|
436
468
|
}
|
|
437
469
|
} else {
|
|
438
470
|
throw err;
|