@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/README.md
CHANGED
|
@@ -18,7 +18,7 @@ OpenClaw channel plugin that connects an agent to ClawChat over ClawChat Protoco
|
|
|
18
18
|
npm i @clawling/clawchat-plugin-openclaw
|
|
19
19
|
```
|
|
20
20
|
|
|
21
|
-
Requires `openclaw >= 2026.
|
|
21
|
+
Requires `openclaw >= 2026.8.1` as a peer host.
|
|
22
22
|
|
|
23
23
|
Example LLM prompt:
|
|
24
24
|
|
package/dist/index.js
CHANGED
|
@@ -2,6 +2,7 @@ import { defineChannelPluginEntry } from "openclaw/plugin-sdk/channel-core";
|
|
|
2
2
|
import { openclawClawlingPlugin } from "./src/channel.js";
|
|
3
3
|
import { registerOpenclawClawlingCommands } from "./src/commands.js";
|
|
4
4
|
import { openclawClawlingConfigSchema } from "./src/config.js";
|
|
5
|
+
import { registerHostToolsGuard } from "./src/host-tools-guard.js";
|
|
5
6
|
import { registerClawChatPromptInjection, } from "./src/prompt-injection.js";
|
|
6
7
|
import { setOpenclawClawlingRuntime } from "./src/runtime.js";
|
|
7
8
|
import { registerOpenclawClawlingTools } from "./src/tools.js";
|
|
@@ -15,6 +16,9 @@ const channelEntry = defineChannelPluginEntry({
|
|
|
15
16
|
registerFull(api) {
|
|
16
17
|
registerClawChatPromptInjection(api);
|
|
17
18
|
registerOpenclawClawlingTools(api);
|
|
19
|
+
// Fail-closed host-tool guard for turns the owner did not start. After the
|
|
20
|
+
// tools so their names are recorded; not gated by conversation access.
|
|
21
|
+
registerHostToolsGuard(api);
|
|
18
22
|
// NOTE: the legacy-rename config migration is intentionally NOT registered
|
|
19
23
|
// here. The host's setup-migration runner only loads a plugin's SETUP source
|
|
20
24
|
// (`openclaw.setupEntry`/`runtimeSetupEntry` → setup-entry.ts) and calls its
|
|
@@ -332,7 +332,7 @@ function buildSearchMatch(target, file, queryLower) {
|
|
|
332
332
|
}
|
|
333
333
|
return { ...target, matchedFields, snippets: snippets.slice(0, 3) };
|
|
334
334
|
}
|
|
335
|
-
export async function searchClawChatMemory(root, params) {
|
|
335
|
+
export async function searchClawChatMemory(root, params, options = {}) {
|
|
336
336
|
const { query, queryLower, targetTypes, maxResults } = normalizeSearchParams(params);
|
|
337
337
|
const rootPath = path.resolve(root);
|
|
338
338
|
const rootRealPath = (await pathExists(rootPath)) ? await fs.realpath(rootPath) : rootPath;
|
|
@@ -340,6 +340,11 @@ export async function searchClawChatMemory(root, params) {
|
|
|
340
340
|
for (const targetType of targetTypes) {
|
|
341
341
|
const targets = await listSafeMemoryFiles(rootPath, rootRealPath, targetType);
|
|
342
342
|
for (const target of targets) {
|
|
343
|
+
// Filter before reading, matching and counting so unreadable notes
|
|
344
|
+
// leave no trace in matches or `truncated`.
|
|
345
|
+
if (options.readable && !options.readable(target)) {
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
343
348
|
const file = await readClawChatMemoryFile(rootPath, target);
|
|
344
349
|
if (!file.exists) {
|
|
345
350
|
continue;
|
package/dist/src/config.js
CHANGED
|
@@ -90,6 +90,7 @@ export const openclawClawlingAccountConfigSchema = {
|
|
|
90
90
|
"note-cap-group": { type: "integer" },
|
|
91
91
|
"note-cap-turn": { type: "integer" },
|
|
92
92
|
"delta-budget-chars": { type: "integer" },
|
|
93
|
+
"non-owner-host-tools": { type: "string", enum: ["off", "approve"] },
|
|
93
94
|
reconnect: {
|
|
94
95
|
type: "object",
|
|
95
96
|
additionalProperties: false,
|
|
@@ -402,6 +403,10 @@ export const SESSION_INT_KEYS = {
|
|
|
402
403
|
"note-cap-turn": [4000, 1000, 16000],
|
|
403
404
|
"delta-budget-chars": [8000, 2000, 32000],
|
|
404
405
|
};
|
|
406
|
+
/** `approve` only when explicitly set; any other value (typo, `on`, boolean) means `off`. */
|
|
407
|
+
export function parseNonOwnerHostTools(raw) {
|
|
408
|
+
return typeof raw === "string" && raw.trim().toLowerCase() === "approve" ? "approve" : "off";
|
|
409
|
+
}
|
|
405
410
|
function readSessionInt(channel, key) {
|
|
406
411
|
const [factory, minimum, maximum] = SESSION_INT_KEYS[key];
|
|
407
412
|
const raw = channel[key];
|
|
@@ -513,6 +518,7 @@ export function resolveOpenclawClawlingAccount(cfg, accountId, env = process.env
|
|
|
513
518
|
noteCapGroup: readSessionInt(channel, "note-cap-group"),
|
|
514
519
|
noteCapTurn: readSessionInt(channel, "note-cap-turn"),
|
|
515
520
|
deltaBudgetChars: readSessionInt(channel, "delta-budget-chars"),
|
|
521
|
+
nonOwnerHostTools: parseNonOwnerHostTools(channel["non-owner-host-tools"]),
|
|
516
522
|
allowFrom: [],
|
|
517
523
|
reconnect: readReconnect(channel.reconnect),
|
|
518
524
|
heartbeat: readHeartbeat(channel.heartbeat),
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { ClawlingApiError } from "./api-types.js";
|
|
2
|
+
import { readConnectFailureGuidance } from "./connect-failure.js";
|
|
2
3
|
import { AGENTS_CONNECT_PLATFORM, RECONNECT_GUIDE_URL } from "./onboarding-context.js";
|
|
3
4
|
/**
|
|
4
5
|
* Non-consuming pre-check of a connect code. Records the "checked" funnel
|
|
@@ -29,8 +30,14 @@ export async function preCheckConnectCode(client, input, log) {
|
|
|
29
30
|
};
|
|
30
31
|
}
|
|
31
32
|
catch (err) {
|
|
33
|
+
// Still a degrade, never a gate: `/connect` re-evaluates the code and
|
|
34
|
+
// surfaces the same guidance on the error it throws. The guidance is only
|
|
35
|
+
// logged here so a rate-limit or dead code is visible early.
|
|
32
36
|
const kind = err instanceof ClawlingApiError ? err.kind : "error";
|
|
33
|
-
|
|
37
|
+
const guidance = err instanceof ClawlingApiError ? readConnectFailureGuidance(err.meta?.data) : {};
|
|
38
|
+
const action = guidance.nextAction ? `; next_action: ${guidance.nextAction}` : "";
|
|
39
|
+
const hint = guidance.hint ? `. ${guidance.hint}` : "";
|
|
40
|
+
log(`Connect-code pre-check unavailable (${kind}${action})${hint}; continuing without it.`);
|
|
34
41
|
return null;
|
|
35
42
|
}
|
|
36
43
|
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/** Default wait for `wait_retry` when the server omits / garbles the delay. */
|
|
2
|
+
export const WAIT_RETRY_DEFAULT_SECONDS = 60;
|
|
3
|
+
/** Upper bound on any `wait_retry` wait, whatever the server asks for. */
|
|
4
|
+
export const WAIT_RETRY_MAX_SECONDS = 120;
|
|
5
|
+
function readNonBlankString(value) {
|
|
6
|
+
if (typeof value !== "string")
|
|
7
|
+
return undefined;
|
|
8
|
+
const trimmed = value.trim();
|
|
9
|
+
return trimmed ? trimmed : undefined;
|
|
10
|
+
}
|
|
11
|
+
export function readConnectFailureGuidance(data) {
|
|
12
|
+
if (!data || typeof data !== "object" || Array.isArray(data))
|
|
13
|
+
return {};
|
|
14
|
+
const record = data;
|
|
15
|
+
const out = {};
|
|
16
|
+
const hint = readNonBlankString(record.hint);
|
|
17
|
+
if (hint)
|
|
18
|
+
out.hint = hint;
|
|
19
|
+
const nextAction = readNonBlankString(record.next_action);
|
|
20
|
+
if (nextAction)
|
|
21
|
+
out.nextAction = nextAction;
|
|
22
|
+
const retryAfter = record.retry_after_seconds;
|
|
23
|
+
if (typeof retryAfter === "number" && Number.isFinite(retryAfter)) {
|
|
24
|
+
out.retryAfterSeconds = retryAfter;
|
|
25
|
+
}
|
|
26
|
+
return out;
|
|
27
|
+
}
|
|
28
|
+
/** Delay before the single `wait_retry` retry: server value, capped, with a default. */
|
|
29
|
+
export function waitRetryDelayMs(retryAfterSeconds) {
|
|
30
|
+
const seconds = typeof retryAfterSeconds === "number" && Number.isFinite(retryAfterSeconds) && retryAfterSeconds > 0
|
|
31
|
+
? Math.min(retryAfterSeconds, WAIT_RETRY_MAX_SECONDS)
|
|
32
|
+
: WAIT_RETRY_DEFAULT_SECONDS;
|
|
33
|
+
return Math.round(seconds * 1000);
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Error thrown by the invite-code login when `/v1/agents/connect` fails.
|
|
37
|
+
* The message keeps the historical `agents/connect failed (<kind>): <msg>`
|
|
38
|
+
* prefix; when the server sent guidance it is appended so an agent reading
|
|
39
|
+
* the CLI / chat-command output knows what to do next.
|
|
40
|
+
*/
|
|
41
|
+
export class ConnectFailedError extends Error {
|
|
42
|
+
kind;
|
|
43
|
+
code;
|
|
44
|
+
status;
|
|
45
|
+
hint;
|
|
46
|
+
nextAction;
|
|
47
|
+
retryAfterSeconds;
|
|
48
|
+
constructor(kind, serverMessage, meta = {}) {
|
|
49
|
+
super(formatConnectFailureMessage(kind, serverMessage, meta));
|
|
50
|
+
this.name = "ConnectFailedError";
|
|
51
|
+
this.kind = kind;
|
|
52
|
+
if (meta.code !== undefined)
|
|
53
|
+
this.code = meta.code;
|
|
54
|
+
if (meta.status !== undefined)
|
|
55
|
+
this.status = meta.status;
|
|
56
|
+
if (meta.hint !== undefined)
|
|
57
|
+
this.hint = meta.hint;
|
|
58
|
+
if (meta.nextAction !== undefined)
|
|
59
|
+
this.nextAction = meta.nextAction;
|
|
60
|
+
if (meta.retryAfterSeconds !== undefined)
|
|
61
|
+
this.retryAfterSeconds = meta.retryAfterSeconds;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
function formatConnectFailureMessage(kind, serverMessage, guidance) {
|
|
65
|
+
let text = `agents/connect failed (${kind}): ${serverMessage}`;
|
|
66
|
+
if (guidance.nextAction)
|
|
67
|
+
text += ` (next_action: ${guidance.nextAction})`;
|
|
68
|
+
if (guidance.hint)
|
|
69
|
+
text += `. ${guidance.hint}`;
|
|
70
|
+
return text;
|
|
71
|
+
}
|
|
72
|
+
export function connectFailedError(err) {
|
|
73
|
+
return new ConnectFailedError(err.kind, err.message, {
|
|
74
|
+
...(err.meta?.code !== undefined ? { code: err.meta.code } : {}),
|
|
75
|
+
...(err.meta?.status !== undefined ? { status: err.meta.status } : {}),
|
|
76
|
+
...readConnectFailureGuidance(err.meta?.data),
|
|
77
|
+
});
|
|
78
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { CHANNEL_ID, normalizeOpenclawClawlingAccountId, resolveOpenclawClawlingAccount } from "./config.js";
|
|
2
|
+
import { classifyChat, clawChatSessionAccountId, NON_OWNER_TOOL_ALLOWLIST, parseClawChatSessionKey, } from "./turn-boundary.js";
|
|
3
|
+
import { clawChatDbPathForStateDir, getClawChatStore } from "./storage.js";
|
|
4
|
+
import { resolveOwnerIdentity } from "./owner-identity.js";
|
|
5
|
+
import { getRegisteredClawChatToolNames } from "./tools.js";
|
|
6
|
+
export const BLOCK_MESSAGE = "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.";
|
|
7
|
+
/**
|
|
8
|
+
* Host tool-search controls (OpenClaw 2026.9.6+). The host adds them to every
|
|
9
|
+
* run on its own and builds their catalog from the run's allowed tools, so in
|
|
10
|
+
* a non-owner turn they reach only the allowlisted tools; the host fires this
|
|
11
|
+
* hook again for the inner tool a `tool_call` runs, and that inner call gets
|
|
12
|
+
* every check below. They are not part of the dispatch allowlist.
|
|
13
|
+
*/
|
|
14
|
+
export const HOST_TOOL_SEARCH_CONTROLS = Object.freeze(["tool_search", "tool_describe", "tool_call"]);
|
|
15
|
+
const ACCOUNT_SCOPED_TOOLS = ["clawchat_mention_message", "clawchat_react_message"];
|
|
16
|
+
const block = () => ({ block: true, blockReason: BLOCK_MESSAGE });
|
|
17
|
+
const CLAWCHAT_SESSION_PREFIX = new RegExp(`^agent:[^:]+:${CHANNEL_ID}:`, "i");
|
|
18
|
+
const PATH_FIELD = /^(?:filePath|media|iconPath|path|attachments)$/i;
|
|
19
|
+
/** D8: only the ClawChat session-key prefix ties a call to ClawChat; requester channel alone never does. */
|
|
20
|
+
export function isClawChatToolCall(input) {
|
|
21
|
+
return CLAWCHAT_SESSION_PREFIX.test(input.sessionKey?.trim() ?? "");
|
|
22
|
+
}
|
|
23
|
+
/** True when any argument looks like a local file path; only http(s) URLs are allowed. */
|
|
24
|
+
export function hasLocalPathArgument(value, field = "") {
|
|
25
|
+
if (typeof value === "string") {
|
|
26
|
+
const text = value.trim();
|
|
27
|
+
if (/^https?:\/\//i.test(text))
|
|
28
|
+
return false;
|
|
29
|
+
if (/^(?:\/|[a-z]:[\\/]|\\\\|file:|~|\.\.?[\\/])/i.test(text))
|
|
30
|
+
return true;
|
|
31
|
+
return PATH_FIELD.test(field) && text.length > 0;
|
|
32
|
+
}
|
|
33
|
+
if (Array.isArray(value))
|
|
34
|
+
return value.some(item => hasLocalPathArgument(item, field));
|
|
35
|
+
return Boolean(value && typeof value === "object" && Object.entries(value).some(([key, item]) => hasLocalPathArgument(item, PATH_FIELD.test(field) ? field : key)));
|
|
36
|
+
}
|
|
37
|
+
/** Pure decision for one tool call. `undefined` lets the call run. */
|
|
38
|
+
export function decideHostToolCall(input) {
|
|
39
|
+
if (!isClawChatToolCall(input))
|
|
40
|
+
return undefined;
|
|
41
|
+
const parsed = parseClawChatSessionKey(input.sessionKey);
|
|
42
|
+
if (!parsed)
|
|
43
|
+
return block();
|
|
44
|
+
if (classifyChat({ ...parsed, ownerDirectConversationId: input.ownerDirectConversationId }) === "owner") {
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
// Outer pre-check only. The inner tool call is checked again and that check
|
|
48
|
+
// is authoritative: the host also accepts dotted/flattened argument keys
|
|
49
|
+
// that this outer check may not recognise.
|
|
50
|
+
if (hasLocalPathArgument(input.params))
|
|
51
|
+
return block();
|
|
52
|
+
if (HOST_TOOL_SEARCH_CONTROLS.includes(input.toolName))
|
|
53
|
+
return undefined;
|
|
54
|
+
if (ACCOUNT_SCOPED_TOOLS.includes(input.toolName)) {
|
|
55
|
+
// D9: a non-owner turn may only act on its own chat, named explicitly.
|
|
56
|
+
const target = input.params.chatId;
|
|
57
|
+
if (typeof target !== "string" || !target.trim() || target.trim().toLowerCase() !== parsed.chatId) {
|
|
58
|
+
return {
|
|
59
|
+
block: true,
|
|
60
|
+
blockReason: input.toolName === "clawchat_react_message"
|
|
61
|
+
? "Pass this chat's chatId to react here."
|
|
62
|
+
: "Pass this chat's chatId to mention here.",
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (NON_OWNER_TOOL_ALLOWLIST.includes(input.toolName)) {
|
|
67
|
+
return input.toolName.startsWith("clawchat_") && !input.registeredTools.has(input.toolName)
|
|
68
|
+
? block()
|
|
69
|
+
: undefined;
|
|
70
|
+
}
|
|
71
|
+
return block(); // `off` and the reserved `approve` both block in this release.
|
|
72
|
+
}
|
|
73
|
+
export function registerHostToolsGuard(api, options = {}) {
|
|
74
|
+
const warned = new Set();
|
|
75
|
+
const warn = (failure) => {
|
|
76
|
+
if (warned.has(failure))
|
|
77
|
+
return;
|
|
78
|
+
warned.add(failure);
|
|
79
|
+
try {
|
|
80
|
+
api.logger.warn(`clawchat-plugin-openclaw: host-tool guard ${failure}; enforcing non-owner policy`);
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
// Logging cannot unlock a call.
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
const lookupOwnerConversation = (accountId) => {
|
|
87
|
+
if (options.ownerConversation)
|
|
88
|
+
return options.ownerConversation(accountId);
|
|
89
|
+
const account = resolveOpenclawClawlingAccount(api.config, accountId);
|
|
90
|
+
const stateDir = api.runtime.state?.resolveStateDir?.();
|
|
91
|
+
const store = getClawChatStore({
|
|
92
|
+
...(stateDir ? { dbPath: clawChatDbPathForStateDir(stateDir) } : {}),
|
|
93
|
+
log: { error: message => api.logger.error?.(message) },
|
|
94
|
+
});
|
|
95
|
+
return resolveOwnerIdentity(store, accountId, account.ownerUserId).conversationId;
|
|
96
|
+
};
|
|
97
|
+
api.on("before_tool_call", (event, sdkCtx) => {
|
|
98
|
+
// `requester` is host 2026.8.1+ (the floor); the development SDK types predate it.
|
|
99
|
+
const ctx = sdkCtx;
|
|
100
|
+
try {
|
|
101
|
+
if (!ctx) {
|
|
102
|
+
warn("missing-context");
|
|
103
|
+
return block();
|
|
104
|
+
}
|
|
105
|
+
const sessionKey = ctx.sessionKey;
|
|
106
|
+
if (!isClawChatToolCall({ sessionKey }))
|
|
107
|
+
return undefined;
|
|
108
|
+
const requester = ctx.requester;
|
|
109
|
+
const parsed = parseClawChatSessionKey(sessionKey);
|
|
110
|
+
if (!parsed || (requester?.channel && requester.channel !== CHANNEL_ID)) {
|
|
111
|
+
warn("unknown-session");
|
|
112
|
+
return block();
|
|
113
|
+
}
|
|
114
|
+
// Direct keys carry their account; group keys do not. A group turn is
|
|
115
|
+
// non-owner whoever speaks, so a group call without the requester
|
|
116
|
+
// account (host tool-search controls and the inner calls they run get
|
|
117
|
+
// a run-level ctx without one) still gets the non-owner policy; only
|
|
118
|
+
// the tools that act as an account need it. Conflicting ids block.
|
|
119
|
+
const keyAccountId = clawChatSessionAccountId(sessionKey);
|
|
120
|
+
const requesterAccountId = requester?.accountId?.trim()
|
|
121
|
+
? normalizeOpenclawClawlingAccountId(requester.accountId)
|
|
122
|
+
: undefined;
|
|
123
|
+
const accountId = requesterAccountId ?? keyAccountId;
|
|
124
|
+
if (keyAccountId && keyAccountId !== accountId) {
|
|
125
|
+
warn("account-identity");
|
|
126
|
+
return block();
|
|
127
|
+
}
|
|
128
|
+
if (!accountId) {
|
|
129
|
+
if (parsed.kind !== "group" || ACCOUNT_SCOPED_TOOLS.includes(event.toolName)) {
|
|
130
|
+
warn("account-identity");
|
|
131
|
+
return block();
|
|
132
|
+
}
|
|
133
|
+
return decideHostToolCall({
|
|
134
|
+
toolName: event.toolName,
|
|
135
|
+
params: event.params ?? {},
|
|
136
|
+
sessionKey,
|
|
137
|
+
ownerDirectConversationId: null,
|
|
138
|
+
registeredTools: getRegisteredClawChatToolNames(),
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
let ownerDirectConversationId = null;
|
|
142
|
+
try {
|
|
143
|
+
ownerDirectConversationId = lookupOwnerConversation(accountId);
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
// Treated as missing below; the allowlist still applies.
|
|
147
|
+
}
|
|
148
|
+
if (!ownerDirectConversationId?.trim())
|
|
149
|
+
warn("missing-owner-conversation");
|
|
150
|
+
return decideHostToolCall({
|
|
151
|
+
toolName: event.toolName,
|
|
152
|
+
params: event.params ?? {},
|
|
153
|
+
sessionKey,
|
|
154
|
+
ownerDirectConversationId,
|
|
155
|
+
registeredTools: getRegisteredClawChatToolNames(),
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
warn("handler-exception");
|
|
160
|
+
return block();
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export const HOST_FLOOR = "2026.8.1";
|
|
2
|
+
function parse(v) {
|
|
3
|
+
const m = /^(\d+)\.(\d+)\.(\d+)/.exec(v.trim());
|
|
4
|
+
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
|
|
5
|
+
}
|
|
6
|
+
/** Prerelease suffixes count as their base version (10.1-beta.2 ≥ 8.1). Unparseable ⇒ false. */
|
|
7
|
+
export function hostAtLeast(version, min) {
|
|
8
|
+
const a = version ? parse(version) : null;
|
|
9
|
+
const b = parse(min);
|
|
10
|
+
if (!a || !b)
|
|
11
|
+
return false;
|
|
12
|
+
for (let i = 0; i < 3; i++)
|
|
13
|
+
if (a[i] !== b[i])
|
|
14
|
+
return a[i] > b[i];
|
|
15
|
+
return true;
|
|
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
|
+
const isRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
20
|
+
/**
|
|
21
|
+
* Whether the host's `tools.toolSearch` setting leaves tool search on, read the
|
|
22
|
+
* way the host reads it (2026.9.6+): unset or `true` ⇒ on; `false` ⇒ off; an
|
|
23
|
+
* object ⇒ its `enabled` flag, else on only when it sets other options.
|
|
24
|
+
*/
|
|
25
|
+
export function hostToolSearchEnabled(config) {
|
|
26
|
+
const tools = isRecord(config) ? config.tools : undefined;
|
|
27
|
+
const raw = isRecord(tools) ? tools.toolSearch : undefined;
|
|
28
|
+
if (raw === undefined || raw === true)
|
|
29
|
+
return true;
|
|
30
|
+
if (!isRecord(raw))
|
|
31
|
+
return false;
|
|
32
|
+
if (typeof raw.enabled === "boolean")
|
|
33
|
+
return raw.enabled;
|
|
34
|
+
return Object.keys(raw).some(key => key !== "enabled");
|
|
35
|
+
}
|
|
@@ -8,10 +8,14 @@ import { CHANNEL_ID, mergeOpenclawClawchatRuntimePluginActivation, mergeOpenclaw
|
|
|
8
8
|
import { getClawChatStore } from "./storage.js";
|
|
9
9
|
import { buildOnboardingContext, RECONNECT_GUIDE_URL } from "./onboarding-context.js";
|
|
10
10
|
import { boundCodeNewIdentityMessage, preCheckConnectCode, unpairableMessage } from "./connect-check.js";
|
|
11
|
+
import { connectFailedError, readConnectFailureGuidance, waitRetryDelayMs } from "./connect-failure.js";
|
|
11
12
|
// Re-exported: `src/login.runtime.test.ts` and `src/commands.ts` import these
|
|
12
13
|
// from here, so the historical import path keeps working after the move.
|
|
13
14
|
import { AGENTS_CONNECT_PLATFORM, AGENTS_CONNECT_TYPE, AGENT_NOT_FOUND_CODE, } from "./onboarding-context.js";
|
|
14
15
|
export { AGENTS_CONNECT_PLATFORM, AGENTS_CONNECT_TYPE, AGENT_NOT_FOUND_CODE };
|
|
16
|
+
function defaultSleep(ms) {
|
|
17
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
18
|
+
}
|
|
15
19
|
/**
|
|
16
20
|
* Thrown when redeeming an invite code would silently replace a live activation.
|
|
17
21
|
*
|
|
@@ -284,8 +288,31 @@ export async function runOpenclawClawlingLogin(params) {
|
|
|
284
288
|
// A brand-new identity is precisely "do not replay" — the replay is the only
|
|
285
289
|
// thing that would bind this code to the incumbent agent.
|
|
286
290
|
const existingUserId = newAccount ? "" : storedUserId;
|
|
291
|
+
// Rate-limit (`next_action: wait_retry`) budget: one wait-and-retry per
|
|
292
|
+
// activation, shared across both requests below.
|
|
293
|
+
let waitRetryUsed = false;
|
|
294
|
+
const sleep = params.sleep ?? defaultSleep;
|
|
295
|
+
const redeem = async (body) => {
|
|
296
|
+
try {
|
|
297
|
+
return await apiClient.agentsConnect(body);
|
|
298
|
+
}
|
|
299
|
+
catch (err) {
|
|
300
|
+
if (!(err instanceof ClawlingApiError) || waitRetryUsed)
|
|
301
|
+
throw err;
|
|
302
|
+
const guidance = readConnectFailureGuidance(err.meta?.data);
|
|
303
|
+
// Only wait_retry is retried: a rate-limited request did not consume the
|
|
304
|
+
// code. Every other next_action (and any unknown one) is final.
|
|
305
|
+
if (guidance.nextAction !== "wait_retry")
|
|
306
|
+
throw err;
|
|
307
|
+
waitRetryUsed = true;
|
|
308
|
+
const delayMs = waitRetryDelayMs(guidance.retryAfterSeconds);
|
|
309
|
+
runtime.log(`Connect code redeem rate limited — retrying once in ${Math.round(delayMs / 1000)}s …`);
|
|
310
|
+
await sleep(delayMs);
|
|
311
|
+
return await apiClient.agentsConnect(body);
|
|
312
|
+
}
|
|
313
|
+
};
|
|
287
314
|
try {
|
|
288
|
-
result = await
|
|
315
|
+
result = await redeem({
|
|
289
316
|
code: inviteCode,
|
|
290
317
|
platform: AGENTS_CONNECT_PLATFORM,
|
|
291
318
|
type: AGENTS_CONNECT_TYPE,
|
|
@@ -306,7 +333,7 @@ export async function runOpenclawClawlingLogin(params) {
|
|
|
306
333
|
if (existingUserId && err.meta?.code === AGENT_NOT_FOUND_CODE) {
|
|
307
334
|
runtime.log(`Stored userId ${existingUserId} no longer exists on this server — retrying as a fresh pairing …`);
|
|
308
335
|
try {
|
|
309
|
-
result = await
|
|
336
|
+
result = await redeem({
|
|
310
337
|
code: inviteCode,
|
|
311
338
|
platform: AGENTS_CONNECT_PLATFORM,
|
|
312
339
|
type: AGENTS_CONNECT_TYPE,
|
|
@@ -315,13 +342,13 @@ export async function runOpenclawClawlingLogin(params) {
|
|
|
315
342
|
}
|
|
316
343
|
catch (retryErr) {
|
|
317
344
|
if (retryErr instanceof ClawlingApiError) {
|
|
318
|
-
throw
|
|
345
|
+
throw connectFailedError(retryErr);
|
|
319
346
|
}
|
|
320
347
|
throw retryErr;
|
|
321
348
|
}
|
|
322
349
|
}
|
|
323
350
|
else {
|
|
324
|
-
throw
|
|
351
|
+
throw connectFailedError(err);
|
|
325
352
|
}
|
|
326
353
|
}
|
|
327
354
|
else {
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// Which ClawChat notes the conversation behind a memory / metadata tool call
|
|
2
|
+
// may read. A tool result stays in the calling session's history, and a group
|
|
3
|
+
// is one session for everyone in it, so a note read there can surface to
|
|
4
|
+
// anyone present later. Scope therefore follows the conversation (tool
|
|
5
|
+
// factory ctx), never a parameter or the process environment:
|
|
6
|
+
//
|
|
7
|
+
// owner DM (incl. plugin-started turns there) → every note
|
|
8
|
+
// friend DM → only users/<requesterSenderId>.md
|
|
9
|
+
// group → groups/<this chat>.md + users/<id>.md of present members
|
|
10
|
+
// anything else (unknown session, missing store / peer, contradictions,
|
|
11
|
+
// ambiguous chat-id case, resolution errors) → nothing
|
|
12
|
+
//
|
|
13
|
+
// Chat ids compare case-insensitively (the host lowercases session keys);
|
|
14
|
+
// the original case is recovered before any group file / SQL lookup. Member
|
|
15
|
+
// user ids stay case-sensitive.
|
|
16
|
+
import fs from "node:fs/promises";
|
|
17
|
+
import path from "node:path";
|
|
18
|
+
import { readClawChatMemoryFile } from "./clawchat-memory.js";
|
|
19
|
+
import { resolveOwnerIdentity } from "./owner-identity.js";
|
|
20
|
+
import { classifyTurn, parseClawChatSessionKey } from "./turn-boundary.js";
|
|
21
|
+
/** Rows scanned for recent speakers when a group note has no participant list. */
|
|
22
|
+
const SPEAKER_FALLBACK_ROWS = 200;
|
|
23
|
+
const nothing = () => ({ kind: "unknown", chatId: "", members: new Set() });
|
|
24
|
+
async function groupNoteIds(root) {
|
|
25
|
+
try {
|
|
26
|
+
return (await fs.readdir(path.join(root, "groups")))
|
|
27
|
+
.filter((name) => name.endsWith(".md"))
|
|
28
|
+
.map((name) => name.slice(0, -3));
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
if (error.code === "ENOENT")
|
|
32
|
+
return [];
|
|
33
|
+
throw error;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function participantIds(metadata) {
|
|
37
|
+
return new Set((metadata.participant_ids ?? "").split(",").map((id) => id.trim()).filter(Boolean));
|
|
38
|
+
}
|
|
39
|
+
function inboundSenderId(rawJson) {
|
|
40
|
+
if (!rawJson)
|
|
41
|
+
return null;
|
|
42
|
+
const sender = JSON.parse(rawJson)?.sender?.id;
|
|
43
|
+
return typeof sender === "string" && sender.trim() ? sender.trim() : null;
|
|
44
|
+
}
|
|
45
|
+
export async function resolveMemoryScope(input) {
|
|
46
|
+
const { root, ctx, accountId, store, warn } = input;
|
|
47
|
+
try {
|
|
48
|
+
const parsed = parseClawChatSessionKey(ctx.sessionKey);
|
|
49
|
+
if (!parsed)
|
|
50
|
+
return nothing();
|
|
51
|
+
if (!store) {
|
|
52
|
+
warn?.("store-unavailable");
|
|
53
|
+
return nothing();
|
|
54
|
+
}
|
|
55
|
+
const lowered = parsed.chatId; // the parser lowercases
|
|
56
|
+
let chatId = lowered;
|
|
57
|
+
let caseKnown = false;
|
|
58
|
+
// 1. The trusted delivery route usually carries the original-case id.
|
|
59
|
+
const destination = ctx.deliveryContext?.to?.trim()
|
|
60
|
+
.replace(/^(?:clawchat-plugin-openclaw:)?(?:group|direct):/i, "");
|
|
61
|
+
if (destination && destination.toLowerCase() === lowered) {
|
|
62
|
+
chatId = destination;
|
|
63
|
+
caseKnown = true;
|
|
64
|
+
}
|
|
65
|
+
// 2. A group note filename, which wins: it is the file that will be read.
|
|
66
|
+
// Two notes differing only in case are ambiguous.
|
|
67
|
+
const fileMatches = (await groupNoteIds(root)).filter((id) => id.toLowerCase() === lowered);
|
|
68
|
+
if (fileMatches.length > 1) {
|
|
69
|
+
warn?.("ambiguous-chat-case");
|
|
70
|
+
return nothing();
|
|
71
|
+
}
|
|
72
|
+
const groupFileId = fileMatches[0];
|
|
73
|
+
if (groupFileId !== undefined) {
|
|
74
|
+
chatId = groupFileId;
|
|
75
|
+
caseKnown = true;
|
|
76
|
+
}
|
|
77
|
+
const members = groupFileId === undefined
|
|
78
|
+
? new Set()
|
|
79
|
+
: participantIds((await readClawChatMemoryFile(root, { targetType: "group", targetId: groupFileId })).metadata);
|
|
80
|
+
if (parsed.kind === "direct") {
|
|
81
|
+
// A chat the plugin knows as a group can never be a direct chat.
|
|
82
|
+
if (members.size) {
|
|
83
|
+
warn?.("contradictory-chat-kind");
|
|
84
|
+
return nothing();
|
|
85
|
+
}
|
|
86
|
+
const ownerConversationId = resolveOwnerIdentity(store, accountId, input.configOwnerUserId, warn).conversationId;
|
|
87
|
+
if (classifyTurn({ sessionKey: ctx.sessionKey, ownerDirectConversationId: ownerConversationId }) === "owner") {
|
|
88
|
+
return { kind: "owner", chatId: ownerConversationId, members: new Set() };
|
|
89
|
+
}
|
|
90
|
+
const peer = typeof ctx.requesterSenderId === "string" ? ctx.requesterSenderId.trim() : "";
|
|
91
|
+
if (!peer)
|
|
92
|
+
warn?.("missing-dm-peer");
|
|
93
|
+
return { kind: "direct", chatId: parsed.chatId, members: new Set(peer ? [peer] : []) };
|
|
94
|
+
}
|
|
95
|
+
// 3. No cached participant list: recent inbound speakers. When neither the
|
|
96
|
+
// route nor a filename supplied the case, the case-insensitive query is
|
|
97
|
+
// the recovery path and its stored chat_id supplies the original case.
|
|
98
|
+
if (!members.size) {
|
|
99
|
+
if (!store.listRecentGroupMessages) {
|
|
100
|
+
warn?.("store-unavailable");
|
|
101
|
+
return nothing();
|
|
102
|
+
}
|
|
103
|
+
const rows = store.listRecentGroupMessages("openclaw", accountId, chatId, SPEAKER_FALLBACK_ROWS, {
|
|
104
|
+
ignoreCase: !caseKnown,
|
|
105
|
+
});
|
|
106
|
+
if (!caseKnown) {
|
|
107
|
+
const storedIds = new Set(rows.map((row) => row.chat_id)
|
|
108
|
+
.filter((id) => typeof id === "string" && id.toLowerCase() === lowered));
|
|
109
|
+
if (storedIds.size > 1) {
|
|
110
|
+
warn?.("ambiguous-chat-case");
|
|
111
|
+
return nothing();
|
|
112
|
+
}
|
|
113
|
+
if (storedIds.size === 1)
|
|
114
|
+
chatId = [...storedIds][0];
|
|
115
|
+
}
|
|
116
|
+
for (const row of rows) {
|
|
117
|
+
if (row.direction !== "inbound")
|
|
118
|
+
continue;
|
|
119
|
+
try {
|
|
120
|
+
const sender = inboundSenderId(row.raw_json);
|
|
121
|
+
if (sender)
|
|
122
|
+
members.add(sender);
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
warn?.("malformed-group-row");
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return { kind: "group", chatId, members };
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
warn?.("scope-resolution");
|
|
133
|
+
return nothing();
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
export function canReadMemory(scope, target) {
|
|
137
|
+
if (scope.kind === "owner")
|
|
138
|
+
return true;
|
|
139
|
+
if (scope.kind === "unknown" || target.targetType === "owner")
|
|
140
|
+
return false;
|
|
141
|
+
if (target.targetType === "user")
|
|
142
|
+
return scope.members.has(target.targetId);
|
|
143
|
+
return scope.kind === "group" && target.targetType === "group" && target.targetId === scope.chatId;
|
|
144
|
+
}
|
|
145
|
+
export function memoryScopeRefusal(scope, target) {
|
|
146
|
+
const message = target.targetType === "owner"
|
|
147
|
+
? "owner.md can only be read in your owner's direct chat. Tool results stay in this conversation's history. Do not try to get the owner's notes another way."
|
|
148
|
+
: scope.kind === "direct"
|
|
149
|
+
? "In this direct chat you can only read the note about the person you are talking to."
|
|
150
|
+
: scope.kind === "group"
|
|
151
|
+
? "In a group you can only read this group's note and its members' notes."
|
|
152
|
+
: "This conversation could not be identified; no ClawChat notes are readable here.";
|
|
153
|
+
return { error: "not_readable_here", code: "memory_scope", message };
|
|
154
|
+
}
|
|
155
|
+
/** What a restricted search left out, for the model (never counts). */
|
|
156
|
+
export function memoryScopeNote(scope) {
|
|
157
|
+
if (scope.kind === "direct") {
|
|
158
|
+
return "In this direct chat only the note about the person you are talking to is searched; owner.md, other people's notes and group notes are left out.";
|
|
159
|
+
}
|
|
160
|
+
if (scope.kind === "group") {
|
|
161
|
+
return "In a group only this group's note and its members' notes are searched; owner.md, other groups' notes and notes about non-members are left out.";
|
|
162
|
+
}
|
|
163
|
+
if (scope.kind === "unknown")
|
|
164
|
+
return "This conversation could not be identified; no ClawChat notes were searched.";
|
|
165
|
+
return undefined;
|
|
166
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// The one definition of "who is the owner, and which chat is the owner's DM"
|
|
2
|
+
// shared by dispatch, the host-tool guard and memory scope. The SQLite
|
|
3
|
+
// activation row decides; the config `ownerUserId` mirror is only a fallback
|
|
4
|
+
// once a working store has proved there is no activation row at all. An
|
|
5
|
+
// unavailable store, a failed read, a cleared/rejected token or an incomplete
|
|
6
|
+
// row never falls back to the mirror — the caller sees no owner (fail closed).
|
|
7
|
+
export function resolveOwnerIdentity(store, accountId, configOwnerUserId, warn, rejectedActivationToken) {
|
|
8
|
+
const denied = { ownerUserId: null, conversationId: null };
|
|
9
|
+
try {
|
|
10
|
+
const lookup = store?.lookupActivationIdentity?.({ platform: "openclaw", accountId });
|
|
11
|
+
if (!lookup || lookup.status === "unavailable") {
|
|
12
|
+
warn?.("owner-identity");
|
|
13
|
+
return denied;
|
|
14
|
+
}
|
|
15
|
+
if (lookup.status === "absent") {
|
|
16
|
+
return { ownerUserId: configOwnerUserId?.trim() || null, conversationId: null };
|
|
17
|
+
}
|
|
18
|
+
const { row } = lookup;
|
|
19
|
+
// Mirror the runtime overlay: never trust the activation rejected during this lifecycle.
|
|
20
|
+
if (!row.accessToken || row.accessToken === rejectedActivationToken)
|
|
21
|
+
return denied;
|
|
22
|
+
const ownerUserId = row.ownerUserId?.trim() || null;
|
|
23
|
+
return { ownerUserId, conversationId: ownerUserId ? row.conversationId?.trim() || null : null };
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
warn?.("owner-identity");
|
|
27
|
+
return denied;
|
|
28
|
+
}
|
|
29
|
+
}
|