@clawling/clawchat-plugin-openclaw 2026.10.11-3 → 2026.10.19-1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/api-client.js +1 -0
- package/dist/src/approval-route.js +43 -9
- package/dist/src/channel.setup.js +1 -0
- package/dist/src/clawchat-memory.js +256 -30
- package/dist/src/clawchat-metadata.js +11 -7
- package/dist/src/client-ownership.js +147 -0
- package/dist/src/config.js +4 -0
- package/dist/src/host-tools-guard.js +277 -87
- package/dist/src/inbound.js +9 -6
- package/dist/src/memory-scope.js +33 -30
- package/dist/src/note-injection.js +10 -12
- package/dist/src/refresh-manager.js +23 -22
- package/dist/src/reply-dispatcher.js +61 -11
- package/dist/src/runtime.js +1110 -297
- package/dist/src/storage.js +451 -0
- package/dist/src/tool-capabilities.js +525 -0
- package/dist/src/tool-grants.js +406 -0
- package/dist/src/tool-requests.js +116 -0
- package/dist/src/tools-schema.js +29 -0
- package/dist/src/tools.js +445 -21
- package/dist/src/turn-artifacts.js +210 -0
- package/dist/src/turn-boundary.js +14 -4
- package/dist/src/turn-grants.js +238 -0
- package/dist/src/ws-client.js +21 -0
- package/openclaw.plugin.json +12 -0
- package/package.json +1 -1
- package/prompts/platform.md +1 -1
- package/src/api-client.ts +1 -0
- package/src/approval-route.ts +38 -10
- package/src/channel.setup.ts +1 -0
- package/src/clawchat-memory.ts +246 -35
- package/src/clawchat-metadata.ts +9 -6
- package/src/client-ownership.ts +172 -0
- package/src/config.ts +8 -0
- package/src/host-tools-guard.ts +261 -90
- package/src/inbound.ts +18 -14
- package/src/memory-scope.ts +28 -28
- package/src/note-injection.ts +12 -11
- package/src/refresh-manager.ts +30 -25
- package/src/reply-dispatcher.ts +54 -13
- package/src/runtime.ts +1060 -312
- package/src/storage.ts +410 -0
- package/src/tool-capabilities.ts +461 -0
- package/src/tool-grants.ts +401 -0
- package/src/tool-requests.ts +124 -0
- package/src/tools-schema.ts +35 -0
- package/src/tools.ts +375 -19
- package/src/turn-artifacts.ts +202 -0
- package/src/turn-boundary.ts +16 -4
- package/src/turn-grants.ts +235 -0
- package/src/ws-client.ts +18 -0
|
@@ -1,9 +1,23 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
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. With `non-owner-host-tools: approve` and a ready approval route
|
|
5
|
+
// (approval-route.ts), a group turn's other tools ask the owner instead:
|
|
6
|
+
// the hook returns `requireApproval` and the host waits for the owner's
|
|
7
|
+
// decision; anything but an approval blocks. Only calls whose session key
|
|
8
|
+
// carries the ClawChat prefix are in scope (D8); grant-management tools
|
|
9
|
+
// enforce their owner boundary on every channel. Tool arguments are never logged.
|
|
10
|
+
import { HOST_FLOOR, hostAtLeast } from "./host-version.js";
|
|
11
|
+
import { CHANNEL_ID, listOpenclawClawlingAccountIds, normalizeOpenclawClawlingAccountId, resolveOpenclawClawlingAccount } from "./config.js";
|
|
12
|
+
import { classifyChat, clawChatSessionAccountId, nonOwnerToolAllowlist, parseClawChatSessionKey, } from "./turn-boundary.js";
|
|
3
13
|
import { clawChatDbPathForStateDir, getClawChatStore } from "./storage.js";
|
|
4
14
|
import { resolveOwnerIdentity } from "./owner-identity.js";
|
|
5
15
|
import { getRegisteredClawChatToolNames } from "./tools.js";
|
|
6
16
|
import { APPROVAL_ROUTE_WARNINGS, listActiveAccountIds, resolveApprovalRoute, storedIdentityLookup, } from "./approval-route.js";
|
|
17
|
+
import { bindAwaitingTurnRun, grantsForRun, readTurnGrantRun, isOwnerSideRun, isNonOwnerSideSession, hasUnattributedTurn, requestOriginForRun, bindToolRequestCall } from "./turn-grants.js";
|
|
18
|
+
import { GRANT_BLOCK_MESSAGE, GRANT_INTERSECTION_BLOCK_MESSAGE, GRANT_ASK_DENIED_MESSAGE, GRANT_DENIED_MESSAGE, GRANT_ASK_UNAVAILABLE_MESSAGE, GRANT_NOT_AVAILABLE_MESSAGE, GRANT_CROSS_SESSION_MESSAGE, evaluateToolGrant, isCompiledGrants, } from "./tool-grants.js";
|
|
19
|
+
import { AUTHORIZATION_TOOLS, TOOL_CAPABILITIES, canonicalToolName, hostToolTargetsCurrentSession, isCrossChatCall, resolveCrossChatGate } from "./tool-capabilities.js";
|
|
20
|
+
import { recordTurnArtifactCall, registerTurnArtifacts } from "./turn-artifacts.js";
|
|
7
21
|
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.";
|
|
8
22
|
/** Told to the model when the owner denies, or does not answer in time. */
|
|
9
23
|
export const APPROVAL_TIMEOUT_MESSAGE = "Your owner did not approve this tool call in time, so it did not run. Do not call it again for this request; tell the group your owner did not approve it.";
|
|
@@ -19,7 +33,9 @@ const APPROVAL_DESCRIPTION_MAX = 500;
|
|
|
19
33
|
* hook again for the inner tool a `tool_call` runs, and that inner call gets
|
|
20
34
|
* every check below. They are not part of the dispatch allowlist.
|
|
21
35
|
*/
|
|
22
|
-
|
|
36
|
+
// Current host name: tmp/openclaw/src/agents/tool-search-types.ts,
|
|
37
|
+
// TOOL_CALL_RAW_TOOL_NAME (2026.10.3); retain the older tool_call name too.
|
|
38
|
+
export const HOST_TOOL_SEARCH_CONTROLS = Object.freeze(["tool_search", "tool_describe", "tool_call", "dispatch_action"]);
|
|
23
39
|
const ACCOUNT_SCOPED_TOOLS = ["clawchat_mention_message", "clawchat_react_message"];
|
|
24
40
|
const block = () => ({ block: true, blockReason: BLOCK_MESSAGE });
|
|
25
41
|
const CLAWCHAT_SESSION_PREFIX = new RegExp(`^agent:[^:]+:${CHANNEL_ID}:`, "i");
|
|
@@ -44,49 +60,124 @@ export function hasLocalPathArgument(value, field = "") {
|
|
|
44
60
|
}
|
|
45
61
|
/** Pure decision for one tool call. `undefined` lets the call run. */
|
|
46
62
|
export function decideHostToolCall(input) {
|
|
63
|
+
if (input.toolName === "clawchat_tool_request") {
|
|
64
|
+
const origin = input.sessionKey ? requestOriginForRun(input.sessionKey, input.runId) : undefined;
|
|
65
|
+
const parsed = parseClawChatSessionKey(input.sessionKey);
|
|
66
|
+
return input.toolGrants === "on" && origin && parsed && parsed.kind === origin.sourceChatType
|
|
67
|
+
&& parsed.chatId === origin.sourceChatId.trim().toLowerCase()
|
|
68
|
+
&& origin.sourceSessionKey.trim().toLowerCase() === input.sessionKey?.trim().toLowerCase()
|
|
69
|
+
&& input.senderId === origin.requesterUserId
|
|
70
|
+
&& input.ownerUserId && origin.requesterUserId !== input.ownerUserId
|
|
71
|
+
&& input.registeredTools.has(input.toolName) ? undefined : block();
|
|
72
|
+
}
|
|
73
|
+
if (AUTHORIZATION_TOOLS.includes(input.toolName)) {
|
|
74
|
+
const parsed = parseClawChatSessionKey(input.sessionKey);
|
|
75
|
+
return parsed && clawChatSessionAccountId(input.sessionKey)
|
|
76
|
+
&& classifyChat({ ...parsed, ownerDirectConversationId: input.ownerDirectConversationId }) === "owner"
|
|
77
|
+
&& input.toolGrants === "on" && Boolean(input.ownerUserId?.trim())
|
|
78
|
+
&& input.senderId === input.ownerUserId ? undefined : block();
|
|
79
|
+
}
|
|
47
80
|
if (!isClawChatToolCall(input))
|
|
48
81
|
return undefined;
|
|
49
82
|
const parsed = parseClawChatSessionKey(input.sessionKey);
|
|
50
83
|
if (!parsed)
|
|
51
84
|
return block();
|
|
52
|
-
|
|
85
|
+
const ownerTurn = classifyChat({ ...parsed, ownerDirectConversationId: input.ownerDirectConversationId }) === "owner";
|
|
86
|
+
// Owner DMs without a plugin snapshot keep owner access: non-owner calls
|
|
87
|
+
// cannot start local turns in other sessions (cross-session). Under that
|
|
88
|
+
// audited premise, only the owner can initiate a snapshot-free turn here.
|
|
89
|
+
// A new host tool missing from the audit could break it; keep it current.
|
|
90
|
+
if ((ownerTurn && !isNonOwnerSideSession(input.sessionKey))
|
|
91
|
+
|| (parsed.kind === "group" && isOwnerSideRun(input.sessionKey, input.runId)))
|
|
53
92
|
return undefined;
|
|
93
|
+
const publicTools = nonOwnerToolAllowlist(input.publicTools);
|
|
94
|
+
// Missing plugin registration takes precedence over missing caller access.
|
|
95
|
+
if ((input.toolGrants === "on" || publicTools.includes(input.toolName))
|
|
96
|
+
&& input.toolName.startsWith("clawchat_") && !input.registeredTools.has(input.toolName)) {
|
|
97
|
+
return input.toolGrants === "on"
|
|
98
|
+
? { block: true, reasonCode: "not-available", blockReason: GRANT_NOT_AVAILABLE_MESSAGE } : block();
|
|
54
99
|
}
|
|
55
100
|
// Approve mode: only group turns, and only once the route to the owner's
|
|
56
|
-
// direct chat is live. Friend direct chats
|
|
57
|
-
const approve = parsed.kind === "group" && input.
|
|
58
|
-
|
|
101
|
+
// direct chat is live. Friend direct chats ask only for an ask grant.
|
|
102
|
+
const approve = parsed.kind === "group" && input.nonOwnerHostTools !== "off"
|
|
103
|
+
&& input.approvalRoute?.ready === true;
|
|
104
|
+
const run = readTurnGrantRun(input.sessionKey, input.runId);
|
|
105
|
+
if (run.kind === "unavailable")
|
|
106
|
+
return block();
|
|
107
|
+
const details = run.kind === "bound" ? run.details : { speakers: [], anyGrantedTools: new Set() };
|
|
108
|
+
const hasGrantCaller = input.toolGrants === "on" && Boolean(input.senderId)
|
|
109
|
+
&& details.speakers.some(speaker => speaker.id === input.senderId);
|
|
110
|
+
// Catalog execution omits requester. A bound run must still restrict public
|
|
111
|
+
// defaults with its intersected ask/deny rules; it is not an unknown caller.
|
|
112
|
+
// Other tools retain their requirement for a matching requester identity.
|
|
113
|
+
const hasGrantContext = hasGrantCaller || (input.toolGrants === "on" && run.kind === "bound"
|
|
114
|
+
&& !input.senderId?.trim() && publicTools.includes(input.toolName));
|
|
115
|
+
const grants = hasGrantContext && run.kind === "bound" ? run.grants : new Map();
|
|
116
|
+
const anyGrantedTools = hasGrantContext ? details.anyGrantedTools : new Set();
|
|
117
|
+
const context = { chatId: parsed.chatId, sessionKey: input.sessionKey, accountId: input.accountId };
|
|
118
|
+
const grant = evaluateToolGrant(grants, input.toolName, input.params, context);
|
|
119
|
+
// A host-started run without a bound speaker cannot use approve fallback,
|
|
120
|
+
// even when per-person grants are off.
|
|
121
|
+
const effectiveApprove = approve && !hasUnattributedTurn(input.sessionKey)
|
|
122
|
+
&& (input.toolGrants === "on" ? hasGrantContext : details.speakers.length > 0);
|
|
123
|
+
const multipleCallers = (details.nonOwnerSpeakerIds?.size
|
|
124
|
+
?? details.speakers.filter(speaker => speaker.id !== input.ownerUserId).length) > 1;
|
|
125
|
+
const grantBlock = (denied = false) => ({ block: true,
|
|
126
|
+
reasonCode: multipleCallers ? "batch-intersection" : denied ? "denied" : "not-granted",
|
|
127
|
+
blockReason: multipleCallers ? GRANT_INTERSECTION_BLOCK_MESSAGE : denied ? GRANT_DENIED_MESSAGE : GRANT_BLOCK_MESSAGE,
|
|
128
|
+
});
|
|
129
|
+
// Forwarded arguments belong to the inner call, whose hook is authoritative.
|
|
130
|
+
if (grant === "deny")
|
|
131
|
+
return grantBlock(true);
|
|
132
|
+
if (HOST_TOOL_SEARCH_CONTROLS.includes(input.toolName) && (grants.size > 0 || grants.get("")))
|
|
133
|
+
return undefined;
|
|
134
|
+
const ask = grant === "ask" && input.approvalRoute?.ready === true;
|
|
135
|
+
const allowlisted = publicTools.includes(input.toolName);
|
|
59
136
|
// Outer pre-check only. The inner tool call is checked again and that check
|
|
60
137
|
// is authoritative: the host also accepts dotted/flattened argument keys
|
|
61
138
|
// that this outer check may not recognise. Under approve, tools the owner
|
|
62
139
|
// approves one by one (and the tool-search controls that reach them) show
|
|
63
140
|
// the owner their arguments instead; the allowlisted tools, which run
|
|
64
141
|
// without asking, keep the check.
|
|
65
|
-
|
|
142
|
+
const directLocalTool = grant === "direct" && [TOOL_CAPABILITIES.files, TOOL_CAPABILITIES.terminal, TOOL_CAPABILITIES.code].some(cap => cap.tools.includes(canonicalToolName(input.toolName)));
|
|
143
|
+
if (!directLocalTool && (grant === "direct" || (grant !== "ask" && (allowlisted || !effectiveApprove))) && hasLocalPathArgument(input.params))
|
|
66
144
|
return block();
|
|
67
145
|
if (HOST_TOOL_SEARCH_CONTROLS.includes(input.toolName))
|
|
68
146
|
return undefined;
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
if (!target || target !== parsed.chatId) {
|
|
75
|
-
return {
|
|
76
|
-
block: true,
|
|
77
|
-
blockReason: input.toolName === "clawchat_react_message"
|
|
78
|
-
? "Pass this chat's chatId to react here."
|
|
79
|
-
: "Pass this chat's chatId to mention here.",
|
|
80
|
-
};
|
|
81
|
-
}
|
|
147
|
+
// Check only audited cross-session host tools before direct/ask/approve exits.
|
|
148
|
+
// Other tools' business data is opaque here; D9 remains a separate check.
|
|
149
|
+
if ((grant === "direct" || grant === "ask" || allowlisted || effectiveApprove)
|
|
150
|
+
&& !hostToolTargetsCurrentSession(input.toolName, input.params, input.sessionKey)) {
|
|
151
|
+
return { block: true, reasonCode: "cross-session", blockReason: GRANT_CROSS_SESSION_MESSAGE };
|
|
82
152
|
}
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
153
|
+
// D9 follows authorization. Missing/opaque targets are cross-chat, never
|
|
154
|
+
// implicitly this chat. Defaults and approve fallback cannot open that gate.
|
|
155
|
+
const crossChatGate = resolveCrossChatGate({ crossChat: isCrossChatCall(input.toolName, input.params, context) }, grant);
|
|
156
|
+
if (crossChatGate === "block")
|
|
157
|
+
return grantBlock();
|
|
158
|
+
if (hasGrantContext && isCompiledGrants(grants) && !grant)
|
|
159
|
+
return grantBlock();
|
|
160
|
+
if (allowlisted && grant !== "ask")
|
|
161
|
+
return undefined;
|
|
162
|
+
if (grant === "direct")
|
|
163
|
+
return undefined;
|
|
164
|
+
if (grant === "ask") {
|
|
165
|
+
if (!ask)
|
|
166
|
+
return { block: true, reasonCode: "ask-unavailable", blockReason: GRANT_ASK_UNAVAILABLE_MESSAGE };
|
|
167
|
+
const approval = buildHostToolApproval(input.toolName, parsed.chatId, input.params, {
|
|
168
|
+
kind: parsed.kind, ...details,
|
|
169
|
+
});
|
|
170
|
+
return approval ? { requireApproval: approval } : { block: true, blockReason: APPROVAL_UNSHOWABLE_MESSAGE };
|
|
87
171
|
}
|
|
88
|
-
if (!
|
|
89
|
-
|
|
172
|
+
if (input.toolGrants === "on" && !effectiveApprove) {
|
|
173
|
+
if (multipleCallers && anyGrantedTools.has(input.toolName)) {
|
|
174
|
+
return { block: true, reasonCode: "batch-intersection", blockReason: GRANT_INTERSECTION_BLOCK_MESSAGE };
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
if (isCompiledGrants(grants))
|
|
178
|
+
return grantBlock();
|
|
179
|
+
if (!effectiveApprove)
|
|
180
|
+
return input.toolGrants === "on" ? grantBlock() : block();
|
|
90
181
|
const approval = buildHostToolApproval(input.toolName, parsed.chatId, input.params);
|
|
91
182
|
return approval ? { requireApproval: approval } : { block: true, blockReason: APPROVAL_UNSHOWABLE_MESSAGE };
|
|
92
183
|
}
|
|
@@ -109,7 +200,7 @@ export const APPROVAL_UNSHOWABLE_MESSAGE = "This call cannot be sent to your own
|
|
|
109
200
|
* holding characters the host would escape or hide): the owner must never
|
|
110
201
|
* approve a command they could not read, so such a call blocks.
|
|
111
202
|
*/
|
|
112
|
-
export function buildHostToolApproval(toolName, groupChatId, params) {
|
|
203
|
+
export function buildHostToolApproval(toolName, groupChatId, params, grantContext) {
|
|
113
204
|
const tool = cleanText(toolName, 48);
|
|
114
205
|
let args;
|
|
115
206
|
try {
|
|
@@ -123,17 +214,21 @@ export function buildHostToolApproval(toolName, groupChatId, params) {
|
|
|
123
214
|
const unattended = STARTS_UNATTENDED_WORK.test(toolName)
|
|
124
215
|
? " Approving starts work that later runs with every host tool and is not asked about again."
|
|
125
216
|
: "";
|
|
126
|
-
const
|
|
217
|
+
const conversation = grantContext?.kind === "direct" ? "friend direct chat" : "group";
|
|
218
|
+
const speakers = grantContext
|
|
219
|
+
? ` (requested by ${cleanText(grantContext.speakers.map(s => `${s.name} (${s.id})`).join(", "), 100)})`
|
|
220
|
+
: " (anyone in the group may have asked)";
|
|
221
|
+
const before = `A turn in ClawChat ${conversation} ${cleanText(groupChatId, 64)}${speakers} wants to run ` +
|
|
127
222
|
`the host tool ${tool} on this machine. Arguments: `;
|
|
128
223
|
const after = `.${unattended} Allow only if you would run it yourself.`;
|
|
129
224
|
if (before.length + args.length + after.length > APPROVAL_DESCRIPTION_MAX)
|
|
130
225
|
return null;
|
|
131
226
|
return {
|
|
132
|
-
title: cleanText(`ClawChat
|
|
227
|
+
title: cleanText(`ClawChat ${conversation} wants to run ${tool}`, APPROVAL_TITLE_MAX),
|
|
133
228
|
description: `${before}${args}${after}`,
|
|
134
229
|
severity: "warning",
|
|
135
230
|
timeoutMs: APPROVAL_TIMEOUT_MS,
|
|
136
|
-
timeoutReason: APPROVAL_TIMEOUT_MESSAGE,
|
|
231
|
+
timeoutReason: grantContext ? GRANT_ASK_DENIED_MESSAGE : APPROVAL_TIMEOUT_MESSAGE,
|
|
137
232
|
allowedDecisions: ["allow-once", "deny"],
|
|
138
233
|
};
|
|
139
234
|
}
|
|
@@ -171,16 +266,29 @@ export function registerHostToolsGuard(api, options = {}) {
|
|
|
171
266
|
const current = api.runtime.config?.current;
|
|
172
267
|
return (typeof current === "function" ? current.call(api.runtime.config) : api.config);
|
|
173
268
|
};
|
|
174
|
-
//
|
|
175
|
-
|
|
176
|
-
const groupApprovalRoute = (accountId) => {
|
|
269
|
+
// Ask grants also need the managed route for friend direct chats.
|
|
270
|
+
const approvalRoute = (accountId, hasAskSnapshot = false) => {
|
|
177
271
|
try {
|
|
178
272
|
const cfg = readLiveConfig();
|
|
179
273
|
const active = listActiveAccountIds(cfg, { knownActive: accountId, hasStoredIdentity });
|
|
180
274
|
const routeAccountId = accountId ?? (active.length === 1 ? active[0] : undefined);
|
|
181
|
-
const anyApprove = (routeAccountId ? [routeAccountId] : active).some(id =>
|
|
275
|
+
const anyApprove = (routeAccountId ? [routeAccountId] : active).some(id => {
|
|
276
|
+
const account = resolveOpenclawClawlingAccount(cfg, id);
|
|
277
|
+
if (account.nonOwnerHostTools === "approve")
|
|
278
|
+
return true;
|
|
279
|
+
if (account.toolGrants === "on" && hasAskSnapshot)
|
|
280
|
+
return true;
|
|
281
|
+
try {
|
|
282
|
+
return account.toolGrants === "on" && openStore().hasActiveAskGrant(id, Date.now());
|
|
283
|
+
}
|
|
284
|
+
catch {
|
|
285
|
+
return false;
|
|
286
|
+
}
|
|
287
|
+
});
|
|
182
288
|
if (!anyApprove)
|
|
183
289
|
return undefined;
|
|
290
|
+
if (!hostAtLeast(api.runtime.version, HOST_FLOOR))
|
|
291
|
+
return { ready: false, reason: "not-applied" };
|
|
184
292
|
const route = routeAccountId
|
|
185
293
|
? resolveApprovalRoute({
|
|
186
294
|
liveConfig: cfg,
|
|
@@ -202,69 +310,151 @@ export function registerHostToolsGuard(api, options = {}) {
|
|
|
202
310
|
api.on("before_tool_call", (event, sdkCtx) => {
|
|
203
311
|
// `requester` is host 2026.8.1+ (the floor); the development SDK types predate it.
|
|
204
312
|
const ctx = sdkCtx;
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
if (
|
|
313
|
+
const decision = (() => {
|
|
314
|
+
try {
|
|
315
|
+
if (!ctx) {
|
|
316
|
+
warn("missing-context");
|
|
317
|
+
return block();
|
|
318
|
+
}
|
|
319
|
+
const sessionKey = ctx.sessionKey;
|
|
320
|
+
const runId = ctx.runId ?? event.runId;
|
|
321
|
+
if (sessionKey && isClawChatToolCall({ sessionKey }) && ctx.requester?.senderId?.trim()
|
|
322
|
+
&& (!ctx.requester.channel || ctx.requester.channel === CHANNEL_ID)) {
|
|
323
|
+
bindAwaitingTurnRun(sessionKey, runId, ctx.requester.senderId);
|
|
324
|
+
}
|
|
325
|
+
if (event.toolName === "clawchat_tool_request") {
|
|
326
|
+
const origin = sessionKey ? requestOriginForRun(sessionKey, runId) : undefined;
|
|
327
|
+
if (!origin || !sessionKey || !ctx.requester?.senderId
|
|
328
|
+
|| (ctx.requester.channel && ctx.requester.channel !== CHANNEL_ID)
|
|
329
|
+
|| (ctx.requester.accountId && normalizeOpenclawClawlingAccountId(ctx.requester.accountId) !== origin.accountId)
|
|
330
|
+
|| (clawChatSessionAccountId(sessionKey) && clawChatSessionAccountId(sessionKey) !== origin.accountId))
|
|
331
|
+
return block();
|
|
332
|
+
const account = resolveOpenclawClawlingAccount(readLiveConfig(), origin.accountId);
|
|
333
|
+
const ownerUserId = options.ownerUserId ? options.ownerUserId(origin.accountId)
|
|
334
|
+
: resolveOwnerIdentity(openStore(), origin.accountId, account.ownerUserId).ownerUserId;
|
|
335
|
+
const decision = decideHostToolCall({ toolName: event.toolName, params: event.params ?? {}, sessionKey, runId,
|
|
336
|
+
senderId: ctx.requester.senderId, ownerUserId: ownerUserId ?? undefined, toolGrants: account.toolGrants,
|
|
337
|
+
registeredTools: getRegisteredClawChatToolNames() });
|
|
338
|
+
const callId = ctx.toolCallId
|
|
339
|
+
?? event.toolCallId;
|
|
340
|
+
return decision ?? (bindToolRequestCall(sessionKey, runId, callId) ? undefined : block());
|
|
341
|
+
}
|
|
342
|
+
if (AUTHORIZATION_TOOLS.includes(event.toolName)
|
|
343
|
+
&& (!clawChatSessionAccountId(sessionKey) || !ctx.requester?.senderId))
|
|
344
|
+
return block();
|
|
345
|
+
if (!isClawChatToolCall({ sessionKey }))
|
|
346
|
+
return undefined;
|
|
347
|
+
const requester = ctx.requester;
|
|
348
|
+
const parsed = parseClawChatSessionKey(sessionKey);
|
|
349
|
+
if (!parsed || (requester?.channel && requester.channel !== CHANNEL_ID)) {
|
|
350
|
+
warn("unknown-session");
|
|
351
|
+
return block();
|
|
352
|
+
}
|
|
353
|
+
// Direct keys carry their account; group keys do not. A group turn is
|
|
354
|
+
// classified as non-owner at the session level; owner-only runs carry
|
|
355
|
+
// a separate snapshot. A group call without the requester
|
|
356
|
+
// account (host tool-search controls and the inner calls they run get
|
|
357
|
+
// a run-level ctx without one) still gets the non-owner policy; only
|
|
358
|
+
// the tools that act as an account need it. Conflicting ids block.
|
|
359
|
+
const keyAccountId = clawChatSessionAccountId(sessionKey);
|
|
360
|
+
const requesterAccountId = requester?.accountId?.trim()
|
|
361
|
+
? normalizeOpenclawClawlingAccountId(requester.accountId)
|
|
362
|
+
: undefined;
|
|
363
|
+
const accountId = requesterAccountId ?? keyAccountId;
|
|
364
|
+
if (keyAccountId && keyAccountId !== accountId) {
|
|
235
365
|
warn("account-identity");
|
|
236
366
|
return block();
|
|
237
367
|
}
|
|
368
|
+
if (!accountId && parsed.kind === "group" && isOwnerSideRun(sessionKey, runId)) {
|
|
369
|
+
return decideHostToolCall({ toolName: event.toolName, params: event.params ?? {}, sessionKey, runId,
|
|
370
|
+
registeredTools: getRegisteredClawChatToolNames() });
|
|
371
|
+
}
|
|
372
|
+
if (!accountId) {
|
|
373
|
+
if (parsed.kind !== "group" || ACCOUNT_SCOPED_TOOLS.includes(event.toolName)) {
|
|
374
|
+
warn("account-identity");
|
|
375
|
+
return block();
|
|
376
|
+
}
|
|
377
|
+
const cfg = readLiveConfig();
|
|
378
|
+
const run = readTurnGrantRun(sessionKey, runId);
|
|
379
|
+
if (run.kind === "unavailable")
|
|
380
|
+
return block();
|
|
381
|
+
const snapshotAccountId = run.kind === "bound" ? run.details.accountId : undefined;
|
|
382
|
+
let account = null;
|
|
383
|
+
try {
|
|
384
|
+
if (snapshotAccountId) {
|
|
385
|
+
// Unknown IDs otherwise inherit root settings. A removed account
|
|
386
|
+
// must not acquire another account's public policy that way.
|
|
387
|
+
if (listOpenclawClawlingAccountIds(cfg).includes(snapshotAccountId)) {
|
|
388
|
+
account = resolveOpenclawClawlingAccount(cfg, snapshotAccountId);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
else {
|
|
392
|
+
const active = listActiveAccountIds(cfg, { hasStoredIdentity });
|
|
393
|
+
if (active.length === 1)
|
|
394
|
+
account = resolveOpenclawClawlingAccount(cfg, active[0]);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
catch { /* Missing/unreadable dispatch accounts keep the conservative fallback. */ }
|
|
398
|
+
nonOwnerToolAllowlist(account?.publicTools, api.logger);
|
|
399
|
+
return decideHostToolCall({
|
|
400
|
+
toolName: event.toolName,
|
|
401
|
+
params: event.params ?? {},
|
|
402
|
+
sessionKey,
|
|
403
|
+
runId,
|
|
404
|
+
ownerDirectConversationId: null,
|
|
405
|
+
registeredTools: getRegisteredClawChatToolNames(),
|
|
406
|
+
senderId: requester?.senderId,
|
|
407
|
+
approvalRoute: approvalRoute(account?.accountId, [...grantsForRun(sessionKey, runId).values()].includes("ask")),
|
|
408
|
+
accountId: account?.accountId,
|
|
409
|
+
publicTools: account?.publicTools,
|
|
410
|
+
// A bound run can have grants disabled. Use its recorded mode;
|
|
411
|
+
// older/ambiguous snapshots keep the conservative grants-on path.
|
|
412
|
+
toolGrants: account?.toolGrants ?? (run.kind === "bound" ? run.details.toolGrants ?? "on" : "off"),
|
|
413
|
+
nonOwnerHostTools: account?.nonOwnerHostTools ?? "off",
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
let ownerDirectConversationId = null;
|
|
417
|
+
try {
|
|
418
|
+
ownerDirectConversationId = lookupOwnerConversation(accountId);
|
|
419
|
+
}
|
|
420
|
+
catch {
|
|
421
|
+
// Treated as missing below; the allowlist still applies.
|
|
422
|
+
}
|
|
423
|
+
if (!ownerDirectConversationId?.trim())
|
|
424
|
+
warn("missing-owner-conversation");
|
|
425
|
+
const account = resolveOpenclawClawlingAccount(readLiveConfig(), accountId);
|
|
426
|
+
nonOwnerToolAllowlist(account.publicTools, api.logger);
|
|
427
|
+
let ownerUserId = null;
|
|
428
|
+
if (AUTHORIZATION_TOOLS.includes(event.toolName)) {
|
|
429
|
+
try {
|
|
430
|
+
ownerUserId = options.ownerUserId ? options.ownerUserId(accountId)
|
|
431
|
+
: resolveOwnerIdentity(openStore(), accountId, account.ownerUserId).ownerUserId;
|
|
432
|
+
}
|
|
433
|
+
catch { /* A missing owner identity cannot authorize grant tools. */ }
|
|
434
|
+
}
|
|
238
435
|
return decideHostToolCall({
|
|
239
436
|
toolName: event.toolName,
|
|
240
437
|
params: event.params ?? {},
|
|
241
438
|
sessionKey,
|
|
242
|
-
|
|
439
|
+
runId,
|
|
440
|
+
ownerDirectConversationId,
|
|
243
441
|
registeredTools: getRegisteredClawChatToolNames(),
|
|
244
|
-
|
|
442
|
+
accountId,
|
|
443
|
+
publicTools: account.publicTools,
|
|
444
|
+
toolGrants: account.toolGrants,
|
|
445
|
+
nonOwnerHostTools: account.nonOwnerHostTools,
|
|
446
|
+
ownerUserId: ownerUserId ?? undefined,
|
|
447
|
+
senderId: requester?.senderId,
|
|
448
|
+
approvalRoute: approvalRoute(accountId, [...grantsForRun(sessionKey, runId).values()].includes("ask")),
|
|
245
449
|
});
|
|
246
450
|
}
|
|
247
|
-
let ownerDirectConversationId = null;
|
|
248
|
-
try {
|
|
249
|
-
ownerDirectConversationId = lookupOwnerConversation(accountId);
|
|
250
|
-
}
|
|
251
451
|
catch {
|
|
252
|
-
|
|
452
|
+
warn("handler-exception");
|
|
453
|
+
return block();
|
|
253
454
|
}
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
toolName: event.toolName,
|
|
258
|
-
params: event.params ?? {},
|
|
259
|
-
sessionKey,
|
|
260
|
-
ownerDirectConversationId,
|
|
261
|
-
registeredTools: getRegisteredClawChatToolNames(),
|
|
262
|
-
...(parsed.kind === "group" ? { approvalRoute: groupApprovalRoute(accountId) } : {}),
|
|
263
|
-
});
|
|
264
|
-
}
|
|
265
|
-
catch {
|
|
266
|
-
warn("handler-exception");
|
|
267
|
-
return block();
|
|
268
|
-
}
|
|
455
|
+
})();
|
|
456
|
+
recordTurnArtifactCall(event, ctx, decision === undefined || "requireApproval" in decision);
|
|
457
|
+
return decision;
|
|
269
458
|
});
|
|
459
|
+
api.on("after_tool_call", (event, ctx) => registerTurnArtifacts(event, ctx));
|
|
270
460
|
}
|
package/dist/src/inbound.js
CHANGED
|
@@ -67,6 +67,8 @@ function extractMentionUsers(fragments) {
|
|
|
67
67
|
.filter((mention) => mention !== null);
|
|
68
68
|
}
|
|
69
69
|
function normalizeMentionUsers(mentions) {
|
|
70
|
+
if (!Array.isArray(mentions))
|
|
71
|
+
return [];
|
|
70
72
|
return mentions
|
|
71
73
|
.map((mention) => {
|
|
72
74
|
if (typeof mention === "string" && mention.length > 0)
|
|
@@ -181,7 +183,7 @@ export async function dispatchOpenclawClawlingInbound(params) {
|
|
|
181
183
|
return;
|
|
182
184
|
}
|
|
183
185
|
const fragmentMentionUsers = extractMentionUsers(message.body.fragments);
|
|
184
|
-
const contextMentionUsers = normalizeMentionUsers(message.context
|
|
186
|
+
const contextMentionUsers = normalizeMentionUsers(message.context?.mentions);
|
|
185
187
|
const mentionedUsers = mergeMentionUsers(fragmentMentionUsers, contextMentionUsers);
|
|
186
188
|
const mentionIds = mentionedUserIds(mentionedUsers);
|
|
187
189
|
const rawBody = fragmentsToText(message.body.fragments, {
|
|
@@ -225,13 +227,14 @@ export async function dispatchOpenclawClawlingInbound(params) {
|
|
|
225
227
|
// when a backend reply_mode="all" row overrides the static config.
|
|
226
228
|
// Direct chats are unaffected (detectMention returns true for direct).
|
|
227
229
|
log?.info?.(`[${account.accountId}] clawchat-plugin-openclaw inbound event=${envelope.event} msg=${payload.message_id} from=${sender.id} text_len=${rawBody.length} mentioned=${wasMentioned}`);
|
|
228
|
-
const
|
|
230
|
+
const reply = message.context?.reply;
|
|
231
|
+
const replyCtx = reply
|
|
229
232
|
? {
|
|
230
|
-
replyToMessageId:
|
|
233
|
+
replyToMessageId: reply.reply_to_msg_id,
|
|
231
234
|
replyPreviewChatId: chatId,
|
|
232
|
-
replyPreviewSenderId:
|
|
233
|
-
replyPreviewNickName:
|
|
234
|
-
replyPreviewText: fragmentsToText(
|
|
235
|
+
replyPreviewSenderId: reply.reply_preview?.id ?? "",
|
|
236
|
+
replyPreviewNickName: reply.reply_preview?.nick_name ?? "",
|
|
237
|
+
replyPreviewText: fragmentsToText((Array.isArray(reply.reply_preview?.fragments) ? reply.reply_preview.fragments : [])),
|
|
235
238
|
}
|
|
236
239
|
: undefined;
|
|
237
240
|
await params.ingest({
|
package/dist/src/memory-scope.js
CHANGED
|
@@ -5,15 +5,17 @@
|
|
|
5
5
|
// factory ctx), never a parameter or the process environment:
|
|
6
6
|
//
|
|
7
7
|
// owner DM (incl. plugin-started turns there) → every note
|
|
8
|
-
// friend DM →
|
|
9
|
-
// group → groups/<this chat>.md + users/<id>.md of present members
|
|
8
|
+
// friend DM → owner.md + users/<requesterSenderId>.md
|
|
9
|
+
// group → owner.md + groups/<this chat>.md + users/<id>.md of present members
|
|
10
10
|
// anything else (unknown session, missing store / peer, contradictions,
|
|
11
11
|
// ambiguous chat-id case, resolution errors) → nothing
|
|
12
12
|
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
13
|
+
// owner.md body is also readable in a friend DM and a group: it is injected
|
|
14
|
+
// into every turn, and what to say from it is left to the agent (platform
|
|
15
|
+
// prompt). Its metadata block (behavior, profile) stays owner-DM-only for
|
|
16
|
+
// the metadata tools.
|
|
17
|
+
//
|
|
18
|
+
// Writes: replace / edit need a readable note; append may go to any note.
|
|
17
19
|
//
|
|
18
20
|
// Chat ids compare case-insensitively (the host lowercases session keys);
|
|
19
21
|
// the original case is recovered before any group file / SQL lookup. Member
|
|
@@ -138,6 +140,7 @@ export async function resolveMemoryScope(input) {
|
|
|
138
140
|
return nothing();
|
|
139
141
|
}
|
|
140
142
|
}
|
|
143
|
+
/** Whether the metadata tools may read or change the target (owner.md: owner DM only). */
|
|
141
144
|
export function canReadMemory(scope, target) {
|
|
142
145
|
if (scope.kind === "owner")
|
|
143
146
|
return true;
|
|
@@ -147,41 +150,41 @@ export function canReadMemory(scope, target) {
|
|
|
147
150
|
return scope.members.has(target.targetId);
|
|
148
151
|
return scope.kind === "group" && target.targetType === "group" && target.targetId === scope.chatId;
|
|
149
152
|
}
|
|
150
|
-
/** Whether a
|
|
151
|
-
export function
|
|
153
|
+
/** Whether a note body may be read, searched, replaced or edited. */
|
|
154
|
+
export function canReadNote(scope, target) {
|
|
155
|
+
// A direct chat with no trusted peer reads nothing, owner.md included.
|
|
152
156
|
if (target.targetType === "owner")
|
|
153
|
-
return scope.kind
|
|
154
|
-
return
|
|
157
|
+
return scope.kind !== "unknown" && (scope.kind !== "direct" || scope.members.size > 0);
|
|
158
|
+
return canReadMemory(scope, target);
|
|
155
159
|
}
|
|
156
|
-
/**
|
|
157
|
-
export function
|
|
158
|
-
|
|
159
|
-
const message = scope.kind === "direct"
|
|
160
|
-
? `${base} Record this in the note about the person you are talking to (their users/<id>.md) instead.`
|
|
161
|
-
: scope.kind === "group"
|
|
162
|
-
? `${base} Record this in the speaker's own note (users/<id>.md) or this group's note instead; if the owner wants it in owner.md, they can tell you in their direct chat.`
|
|
163
|
-
: base;
|
|
164
|
-
return { error: "not_writable_here", code: "memory_scope", message };
|
|
160
|
+
/** Whether a write (`append` when `append` is true) may touch the target. */
|
|
161
|
+
export function canWriteMemory(scope, target, append) {
|
|
162
|
+
return append || canReadNote(scope, target);
|
|
165
163
|
}
|
|
166
164
|
export function memoryScopeRefusal(scope, target) {
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
165
|
+
// For a known peer, owner-body reads are allowed; only metadata can reach
|
|
166
|
+
// the owner-target refusal below. A missing peer blocks every note body.
|
|
167
|
+
const message = scope.kind === "direct" && scope.members.size === 0
|
|
168
|
+
? "The other person's identity could not be confirmed; no ClawChat notes are readable in this direct chat."
|
|
169
|
+
: target.targetType === "owner" && scope.kind !== "unknown"
|
|
170
|
+
? "owner.md metadata can only be read or changed in your owner's direct chat."
|
|
171
|
+
: scope.kind === "direct"
|
|
172
|
+
? "In this direct chat you can only read the note about the person you are talking to."
|
|
173
|
+
: scope.kind === "group"
|
|
174
|
+
? "In a group you can only read this group's note and its members' notes."
|
|
175
|
+
: "This conversation could not be identified; no ClawChat notes are readable here.";
|
|
174
176
|
return { error: "not_readable_here", code: "memory_scope", message };
|
|
175
177
|
}
|
|
176
178
|
/** What a restricted search left out, for the model (never counts). */
|
|
177
179
|
export function memoryScopeNote(scope) {
|
|
180
|
+
if (scope.kind === "unknown" || (scope.kind === "direct" && scope.members.size === 0)) {
|
|
181
|
+
return "This conversation could not be identified; no ClawChat notes were searched.";
|
|
182
|
+
}
|
|
178
183
|
if (scope.kind === "direct") {
|
|
179
|
-
return "In this direct chat only the note about the person you are talking to
|
|
184
|
+
return "In this direct chat only owner.md and the note about the person you are talking to are searched; other people's notes and group notes are left out.";
|
|
180
185
|
}
|
|
181
186
|
if (scope.kind === "group") {
|
|
182
|
-
return "In a group only
|
|
187
|
+
return "In a group only owner.md, that group's note and its members' notes are searched; other groups' notes and notes about non-members are left out.";
|
|
183
188
|
}
|
|
184
|
-
if (scope.kind === "unknown")
|
|
185
|
-
return "This conversation could not be identified; no ClawChat notes were searched.";
|
|
186
189
|
return undefined;
|
|
187
190
|
}
|