@clawling/clawchat-plugin-openclaw 2026.10.9-4 → 2026.10.11-2
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/index.js +12 -0
- package/dist/src/approval-route.js +282 -0
- package/dist/src/host-tools-guard.js +115 -10
- package/dist/src/memory-scope.js +21 -0
- package/dist/src/outbound.js +14 -0
- package/dist/src/runtime.js +119 -8
- package/dist/src/skill-update.js +1 -1
- package/dist/src/tools.js +13 -7
- package/index.ts +12 -0
- package/package.json +1 -1
- package/skills/manifest.json +3 -3
- package/src/approval-route.ts +346 -0
- package/src/config.ts +2 -2
- package/src/host-tools-guard.ts +155 -15
- package/src/memory-scope.ts +26 -0
- package/src/outbound.ts +14 -0
- package/src/runtime.ts +117 -9
- package/src/skill-update.ts +1 -1
- package/src/tools.ts +14 -4
package/src/host-tools-guard.ts
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
// Layer 2 of the non-owner turn boundary: a fail-closed `before_tool_call`
|
|
2
2
|
// hook. Dispatch (layer 1) already narrows the offered tools for turns the
|
|
3
3
|
// owner did not start; this hook blocks anything else that still reaches
|
|
4
|
-
// execution.
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
|
|
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); everything else passes
|
|
9
|
+
// untouched. Tool arguments are never logged.
|
|
10
|
+
import type { OpenClawConfig, OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
8
11
|
import { CHANNEL_ID, normalizeOpenclawClawlingAccountId, resolveOpenclawClawlingAccount } from "./config.ts";
|
|
9
12
|
import {
|
|
10
13
|
classifyChat,
|
|
@@ -16,17 +19,46 @@ import {
|
|
|
16
19
|
import { clawChatDbPathForStateDir, getClawChatStore } from "./storage.ts";
|
|
17
20
|
import { resolveOwnerIdentity } from "./owner-identity.ts";
|
|
18
21
|
import { getRegisteredClawChatToolNames } from "./tools.ts";
|
|
22
|
+
import {
|
|
23
|
+
APPROVAL_ROUTE_WARNINGS,
|
|
24
|
+
listActiveAccountIds,
|
|
25
|
+
resolveApprovalRoute,
|
|
26
|
+
storedIdentityLookup,
|
|
27
|
+
type ApprovalRoute,
|
|
28
|
+
} from "./approval-route.ts";
|
|
19
29
|
|
|
20
30
|
export const BLOCK_MESSAGE =
|
|
21
31
|
"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.";
|
|
22
32
|
|
|
23
|
-
|
|
33
|
+
/** Told to the model when the owner denies, or does not answer in time. */
|
|
34
|
+
export const APPROVAL_TIMEOUT_MESSAGE =
|
|
35
|
+
"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.";
|
|
36
|
+
/** Owner approval wait for one call; the host caps plugin approvals at 600 s. */
|
|
37
|
+
export const APPROVAL_TIMEOUT_MS = 300_000;
|
|
38
|
+
const APPROVAL_TITLE_MAX = 80;
|
|
39
|
+
/** The host rejects (and so blocks) a description over 512 characters. */
|
|
40
|
+
const APPROVAL_DESCRIPTION_MAX = 500;
|
|
41
|
+
|
|
42
|
+
export type HostToolApproval = {
|
|
43
|
+
title: string;
|
|
44
|
+
description: string;
|
|
45
|
+
severity: "warning";
|
|
46
|
+
timeoutMs: number;
|
|
47
|
+
timeoutReason: string;
|
|
48
|
+
allowedDecisions: ["allow-once", "deny"];
|
|
49
|
+
};
|
|
50
|
+
export type HostToolDecision =
|
|
51
|
+
| { block: true; blockReason: string }
|
|
52
|
+
| { requireApproval: HostToolApproval }
|
|
53
|
+
| undefined;
|
|
24
54
|
export type HostToolInput = {
|
|
25
55
|
toolName: string;
|
|
26
56
|
params: Record<string, unknown>;
|
|
27
57
|
sessionKey?: string;
|
|
28
58
|
ownerDirectConversationId?: string | null;
|
|
29
59
|
registeredTools: ReadonlySet<string>;
|
|
60
|
+
/** Group turns only; a ready route sends non-allowlisted tools to the owner for approval. */
|
|
61
|
+
approvalRoute?: ApprovalRoute;
|
|
30
62
|
};
|
|
31
63
|
|
|
32
64
|
/**
|
|
@@ -70,10 +102,17 @@ export function decideHostToolCall(input: HostToolInput): HostToolDecision {
|
|
|
70
102
|
if (classifyChat({ ...parsed, ownerDirectConversationId: input.ownerDirectConversationId }) === "owner") {
|
|
71
103
|
return undefined;
|
|
72
104
|
}
|
|
105
|
+
// Approve mode: only group turns, and only once the route to the owner's
|
|
106
|
+
// direct chat is live. Friend direct chats never ask; they block below.
|
|
107
|
+
const approve = parsed.kind === "group" && input.approvalRoute?.ready === true;
|
|
108
|
+
const allowlisted = NON_OWNER_TOOL_ALLOWLIST.includes(input.toolName);
|
|
73
109
|
// Outer pre-check only. The inner tool call is checked again and that check
|
|
74
110
|
// is authoritative: the host also accepts dotted/flattened argument keys
|
|
75
|
-
// that this outer check may not recognise.
|
|
76
|
-
|
|
111
|
+
// that this outer check may not recognise. Under approve, tools the owner
|
|
112
|
+
// approves one by one (and the tool-search controls that reach them) show
|
|
113
|
+
// the owner their arguments instead; the allowlisted tools, which run
|
|
114
|
+
// without asking, keep the check.
|
|
115
|
+
if ((!approve || allowlisted) && hasLocalPathArgument(input.params)) return block();
|
|
77
116
|
if (HOST_TOOL_SEARCH_CONTROLS.includes(input.toolName)) return undefined;
|
|
78
117
|
if (ACCOUNT_SCOPED_TOOLS.includes(input.toolName)) {
|
|
79
118
|
// D9: a non-owner turn may only act on its own chat, named explicitly.
|
|
@@ -89,19 +128,81 @@ export function decideHostToolCall(input: HostToolInput): HostToolDecision {
|
|
|
89
128
|
};
|
|
90
129
|
}
|
|
91
130
|
}
|
|
92
|
-
if (
|
|
131
|
+
if (allowlisted) {
|
|
93
132
|
return input.toolName.startsWith("clawchat_") && !input.registeredTools.has(input.toolName)
|
|
94
133
|
? block()
|
|
95
134
|
: undefined;
|
|
96
135
|
}
|
|
97
|
-
return block();
|
|
136
|
+
if (!approve) return block();
|
|
137
|
+
const approval = buildHostToolApproval(input.toolName, parsed.chatId, input.params);
|
|
138
|
+
return approval ? { requireApproval: approval } : { block: true, blockReason: APPROVAL_UNSHOWABLE_MESSAGE };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Drop what the host would escape (controls, zero-width, bidi) from the names
|
|
142
|
+
// shown on the card, and collapse whitespace. Arguments are never cleaned:
|
|
143
|
+
// they are shown exactly, or the call is not offered for approval.
|
|
144
|
+
const UNSAFE_TEXT = /[\u0000-\u001f\u007f-\u009f\u00ad\u200b-\u200f\u202a-\u202e\u2060-\u206f\ufeff]/g;
|
|
145
|
+
function cleanText(text: string, max: number): string {
|
|
146
|
+
const flat = text.replace(UNSAFE_TEXT, " ").replace(/\s+/g, " ").trim();
|
|
147
|
+
return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Tools whose approval starts work that later runs on its own (own session key, no further asking). */
|
|
151
|
+
const STARTS_UNATTENDED_WORK = /spawn|subagent|sessions_send|cron|automation|schedul/i;
|
|
152
|
+
|
|
153
|
+
/** Told to the model when a call's arguments cannot be shown to the owner exactly. */
|
|
154
|
+
export const APPROVAL_UNSHOWABLE_MESSAGE =
|
|
155
|
+
"This call cannot be sent to your owner for approval: its arguments are too long or contain hidden characters, and your owner must see exactly what would run. Do not retry it as is; use a much shorter call, or tell the person to ask your owner directly.";
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* The approval card for one group host-tool call; it goes only to the owner's
|
|
159
|
+
* direct chat. `null` when the arguments cannot be shown in full and exactly
|
|
160
|
+
* (too long for the host's 512-character description, unserializable, or
|
|
161
|
+
* holding characters the host would escape or hide): the owner must never
|
|
162
|
+
* approve a command they could not read, so such a call blocks.
|
|
163
|
+
*/
|
|
164
|
+
export function buildHostToolApproval(
|
|
165
|
+
toolName: string,
|
|
166
|
+
groupChatId: string,
|
|
167
|
+
params: Record<string, unknown>,
|
|
168
|
+
): HostToolApproval | null {
|
|
169
|
+
const tool = cleanText(toolName, 48);
|
|
170
|
+
let args: string;
|
|
171
|
+
try {
|
|
172
|
+
args = JSON.stringify(params) ?? "{}";
|
|
173
|
+
} catch {
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
if (new RegExp(UNSAFE_TEXT.source).test(args)) return null;
|
|
177
|
+
const unattended = STARTS_UNATTENDED_WORK.test(toolName)
|
|
178
|
+
? " Approving starts work that later runs with every host tool and is not asked about again."
|
|
179
|
+
: "";
|
|
180
|
+
const before =
|
|
181
|
+
`A turn in ClawChat group ${cleanText(groupChatId, 64)} (anyone in the group may have asked) wants to run ` +
|
|
182
|
+
`the host tool ${tool} on this machine. Arguments: `;
|
|
183
|
+
const after = `.${unattended} Allow only if you would run it yourself.`;
|
|
184
|
+
if (before.length + args.length + after.length > APPROVAL_DESCRIPTION_MAX) return null;
|
|
185
|
+
return {
|
|
186
|
+
title: cleanText(`ClawChat group wants to run ${tool}`, APPROVAL_TITLE_MAX),
|
|
187
|
+
description: `${before}${args}${after}`,
|
|
188
|
+
severity: "warning",
|
|
189
|
+
timeoutMs: APPROVAL_TIMEOUT_MS,
|
|
190
|
+
timeoutReason: APPROVAL_TIMEOUT_MESSAGE,
|
|
191
|
+
allowedDecisions: ["allow-once", "deny"],
|
|
192
|
+
};
|
|
98
193
|
}
|
|
99
194
|
|
|
100
195
|
type RequesterContext = { channel?: string; accountId?: string };
|
|
101
196
|
|
|
102
197
|
export function registerHostToolsGuard(
|
|
103
198
|
api: OpenClawPluginApi,
|
|
104
|
-
options: {
|
|
199
|
+
options: {
|
|
200
|
+
ownerConversation?: (accountId: string) => string | null;
|
|
201
|
+
/** The config the gateway runs with now; defaults to the runtime's live config. */
|
|
202
|
+
liveConfig?: () => unknown;
|
|
203
|
+
/** Whether an account has a stored activation; defaults to the plugin store. */
|
|
204
|
+
hasStoredIdentity?: (accountId: string) => boolean;
|
|
205
|
+
} = {},
|
|
105
206
|
): void {
|
|
106
207
|
const warned = new Set<string>();
|
|
107
208
|
const warn = (failure: string) => {
|
|
@@ -113,15 +214,52 @@ export function registerHostToolsGuard(
|
|
|
113
214
|
// Logging cannot unlock a call.
|
|
114
215
|
}
|
|
115
216
|
};
|
|
116
|
-
const
|
|
117
|
-
if (options.ownerConversation) return options.ownerConversation(accountId);
|
|
118
|
-
const account = resolveOpenclawClawlingAccount(api.config, accountId);
|
|
217
|
+
const openStore = () => {
|
|
119
218
|
const stateDir = api.runtime.state?.resolveStateDir?.();
|
|
120
|
-
|
|
219
|
+
return getClawChatStore({
|
|
121
220
|
...(stateDir ? { dbPath: clawChatDbPathForStateDir(stateDir) } : {}),
|
|
122
221
|
log: { error: message => api.logger.error?.(message) },
|
|
123
222
|
});
|
|
124
|
-
|
|
223
|
+
};
|
|
224
|
+
const lookupOwnerConversation = (accountId: string): string | null => {
|
|
225
|
+
if (options.ownerConversation) return options.ownerConversation(accountId);
|
|
226
|
+
const account = resolveOpenclawClawlingAccount(api.config, accountId);
|
|
227
|
+
return resolveOwnerIdentity(openStore(), accountId, account.ownerUserId).conversationId;
|
|
228
|
+
};
|
|
229
|
+
const hasStoredIdentity = options.hasStoredIdentity
|
|
230
|
+
?? ((accountId: string) => storedIdentityLookup(openStore())(accountId));
|
|
231
|
+
|
|
232
|
+
const readLiveConfig = (): OpenClawConfig => {
|
|
233
|
+
if (options.liveConfig) return options.liveConfig() as OpenClawConfig;
|
|
234
|
+
const current = (api.runtime.config as { current?: () => unknown } | undefined)?.current;
|
|
235
|
+
return (typeof current === "function" ? current.call(api.runtime.config) : api.config) as OpenClawConfig;
|
|
236
|
+
};
|
|
237
|
+
// Only consulted for group calls. `undefined` = the account is not in
|
|
238
|
+
// approve mode; a route that is not ready blocks like `off`.
|
|
239
|
+
const groupApprovalRoute = (accountId: string | undefined): ApprovalRoute | undefined => {
|
|
240
|
+
try {
|
|
241
|
+
const cfg = readLiveConfig();
|
|
242
|
+
const active = listActiveAccountIds(cfg, { knownActive: accountId, hasStoredIdentity });
|
|
243
|
+
const routeAccountId = accountId ?? (active.length === 1 ? active[0] : undefined);
|
|
244
|
+
const anyApprove = (routeAccountId ? [routeAccountId] : active).some(
|
|
245
|
+
id => resolveOpenclawClawlingAccount(cfg, id).nonOwnerHostTools === "approve",
|
|
246
|
+
);
|
|
247
|
+
if (!anyApprove) return undefined;
|
|
248
|
+
const route = routeAccountId
|
|
249
|
+
? resolveApprovalRoute({
|
|
250
|
+
liveConfig: cfg,
|
|
251
|
+
accountId: routeAccountId,
|
|
252
|
+
activeAccountIds: active,
|
|
253
|
+
approve: true,
|
|
254
|
+
ownerConversationId: lookupOwnerConversation(routeAccountId),
|
|
255
|
+
})
|
|
256
|
+
: { ready: false as const, reason: "multiple-accounts" as const };
|
|
257
|
+
if (!route.ready) warn(`approve unavailable (${route.reason}): ${APPROVAL_ROUTE_WARNINGS[route.reason]}`);
|
|
258
|
+
return route;
|
|
259
|
+
} catch {
|
|
260
|
+
warn("approve unavailable (route lookup failed)");
|
|
261
|
+
return { ready: false, reason: "not-applied" };
|
|
262
|
+
}
|
|
125
263
|
};
|
|
126
264
|
|
|
127
265
|
api.on("before_tool_call", (event, sdkCtx) => {
|
|
@@ -165,6 +303,7 @@ export function registerHostToolsGuard(
|
|
|
165
303
|
sessionKey,
|
|
166
304
|
ownerDirectConversationId: null,
|
|
167
305
|
registeredTools: getRegisteredClawChatToolNames(),
|
|
306
|
+
approvalRoute: groupApprovalRoute(undefined),
|
|
168
307
|
});
|
|
169
308
|
}
|
|
170
309
|
let ownerDirectConversationId: string | null = null;
|
|
@@ -180,6 +319,7 @@ export function registerHostToolsGuard(
|
|
|
180
319
|
sessionKey,
|
|
181
320
|
ownerDirectConversationId,
|
|
182
321
|
registeredTools: getRegisteredClawChatToolNames(),
|
|
322
|
+
...(parsed.kind === "group" ? { approvalRoute: groupApprovalRoute(accountId) } : {}),
|
|
183
323
|
});
|
|
184
324
|
} catch {
|
|
185
325
|
warn("handler-exception");
|
package/src/memory-scope.ts
CHANGED
|
@@ -10,6 +10,11 @@
|
|
|
10
10
|
// anything else (unknown session, missing store / peer, contradictions,
|
|
11
11
|
// ambiguous chat-id case, resolution errors) → nothing
|
|
12
12
|
//
|
|
13
|
+
// Writes: replace / edit need a readable note; append may go to any note
|
|
14
|
+
// except owner.md, which only the owner DM writes. owner.md is injected into
|
|
15
|
+
// the owner DM, where the agent has every tool, so text appended from a
|
|
16
|
+
// friend or group turn would read there as the owner's own note.
|
|
17
|
+
//
|
|
13
18
|
// Chat ids compare case-insensitively (the host lowercases session keys);
|
|
14
19
|
// the original case is recovered before any group file / SQL lookup. Member
|
|
15
20
|
// user ids stay case-sensitive.
|
|
@@ -163,6 +168,27 @@ export function canReadMemory(scope: MemoryScope, target: ClawChatMemoryTarget):
|
|
|
163
168
|
return scope.kind === "group" && target.targetType === "group" && target.targetId === scope.chatId;
|
|
164
169
|
}
|
|
165
170
|
|
|
171
|
+
/** Whether a write (`append` when `append` is true) may touch the target. */
|
|
172
|
+
export function canWriteMemory(scope: MemoryScope, target: ClawChatMemoryTarget, append: boolean): boolean {
|
|
173
|
+
if (target.targetType === "owner") return scope.kind === "owner";
|
|
174
|
+
return append || canReadMemory(scope, target);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Refusal for an owner.md write outside the owner DM, with where to write instead. */
|
|
178
|
+
export function ownerNoteWriteRefusal(scope: MemoryScope): {
|
|
179
|
+
error: "not_writable_here";
|
|
180
|
+
code: "memory_scope";
|
|
181
|
+
message: string;
|
|
182
|
+
} {
|
|
183
|
+
const base = "owner.md can only be changed in your owner's direct chat.";
|
|
184
|
+
const message = scope.kind === "direct"
|
|
185
|
+
? `${base} Record this in the note about the person you are talking to (their users/<id>.md) instead.`
|
|
186
|
+
: scope.kind === "group"
|
|
187
|
+
? `${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.`
|
|
188
|
+
: base;
|
|
189
|
+
return { error: "not_writable_here", code: "memory_scope", message };
|
|
190
|
+
}
|
|
191
|
+
|
|
166
192
|
export function memoryScopeRefusal(scope: MemoryScope, target: ClawChatMemoryTarget): {
|
|
167
193
|
error: "not_readable_here";
|
|
168
194
|
code: "memory_scope";
|
package/src/outbound.ts
CHANGED
|
@@ -979,8 +979,22 @@ function markChannelOutboundAcknowledged(params: {
|
|
|
979
979
|
}
|
|
980
980
|
}
|
|
981
981
|
|
|
982
|
+
/**
|
|
983
|
+
* Host approval prompts (exec and plugin approvals forwarded by
|
|
984
|
+
* `approvals.exec` / `approvals.plugin`) never go into a ClawChat group: anyone
|
|
985
|
+
* there could read the request, and only the owner may answer it. The host
|
|
986
|
+
* calls this before each forwarded prompt; throwing fails that one delivery.
|
|
987
|
+
*/
|
|
988
|
+
export function refuseGroupApprovalPrompt(params: { target: { to: string }; hint?: { kind?: string } }): void {
|
|
989
|
+
if (params.hint?.kind !== "approval-pending") return;
|
|
990
|
+
if (parseOpenclawRecipient(params.target.to).chatType === "group") {
|
|
991
|
+
throw new Error("clawchat-plugin-openclaw: approval prompts are not sent to ClawChat groups");
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
|
|
982
995
|
export const openclawClawlingOutbound: ChannelOutboundAdapter = {
|
|
983
996
|
deliveryMode: "direct",
|
|
997
|
+
beforeDeliverPayload: (params) => refuseGroupApprovalPrompt(params),
|
|
984
998
|
chunker: (text, limit) => chunkMarkdownText(text, limit),
|
|
985
999
|
chunkerMode: "markdown",
|
|
986
1000
|
textChunkLimit: 4000,
|
package/src/runtime.ts
CHANGED
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
type Transport,
|
|
13
13
|
} from "./protocol-types.ts";
|
|
14
14
|
import type { ChannelAccountSnapshot } from "openclaw/plugin-sdk/channel-contract";
|
|
15
|
-
import { waitUntilAbort } from "openclaw/plugin-sdk/channel-
|
|
15
|
+
import { waitUntilAbort } from "openclaw/plugin-sdk/channel-outbound";
|
|
16
16
|
import { hasControlCommand } from "openclaw/plugin-sdk/command-detection";
|
|
17
17
|
import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk/core";
|
|
18
18
|
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
|
|
@@ -90,6 +90,13 @@ import {
|
|
|
90
90
|
import { getClawChatGroupPrompt, getClawChatPlatformPrompt, getClawChatUserPrompt } from "./plugin-prompts.ts";
|
|
91
91
|
import { classifyChat, NON_OWNER_TOOL_ALLOWLIST } from "./turn-boundary.ts";
|
|
92
92
|
import { resolveOwnerIdentity } from "./owner-identity.ts";
|
|
93
|
+
import {
|
|
94
|
+
createApprovalsWriteGuard,
|
|
95
|
+
listActiveAccountIds,
|
|
96
|
+
reconcileApprovalsPlugin,
|
|
97
|
+
resolveApprovalRoute,
|
|
98
|
+
storedIdentityLookup,
|
|
99
|
+
} from "./approval-route.ts";
|
|
93
100
|
import {
|
|
94
101
|
loadClawChatPromptMetadata,
|
|
95
102
|
type ClawChatGroupParticipantPrompt,
|
|
@@ -221,6 +228,11 @@ const OPENCLAW_CONFIRM_SLASH_COMMANDS = new Set([
|
|
|
221
228
|
"nevermind",
|
|
222
229
|
]);
|
|
223
230
|
const GROUP_OWNER_ATTENTION_TITLE = "requires owner attention";
|
|
231
|
+
/** Group turns under `non-owner-host-tools: approve` (route live). */
|
|
232
|
+
const GROUP_APPROVAL_GUIDANCE =
|
|
233
|
+
"Host tools here (commands, files, browser, sub-agents, scheduling and the like) run only if your owner approves each call: " +
|
|
234
|
+
"the request goes to your owner's direct chat and the call waits up to 5 minutes. " +
|
|
235
|
+
"Do not ask anyone in this group to approve it. If it is denied or times out, do not retry; say your owner did not approve it.";
|
|
224
236
|
// §C.1 — user-visible message emitted on permanent token expiry. Kept
|
|
225
237
|
// byte-identical to the Hermes plugin (parity spec §C.1.4). Points the owner
|
|
226
238
|
// at the reconnect prompt (a code bound to this identity) rather than at a
|
|
@@ -590,7 +602,7 @@ export interface StartGatewayParams {
|
|
|
590
602
|
setStatus: (next: ChannelAccountSnapshot) => void;
|
|
591
603
|
getStatus: () => ChannelAccountSnapshot;
|
|
592
604
|
log?: Log;
|
|
593
|
-
/** Internal — set on recursive re-entry so the approve-
|
|
605
|
+
/** Internal — set on recursive re-entry so the approve-route check at start runs once per lifecycle. */
|
|
594
606
|
boundaryWarningIssued?: boolean;
|
|
595
607
|
/** Test hook only. */
|
|
596
608
|
store?: RuntimeConnectionStore | null;
|
|
@@ -1084,10 +1096,7 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
|
|
|
1084
1096
|
hostToolSearchEnabled(cfg)
|
|
1085
1097
|
) {
|
|
1086
1098
|
loggedHostToolSearch = true;
|
|
1087
|
-
log?.info?.("clawchat-plugin-openclaw: host tool search is on; turns your owner did not start can still use only the allowlisted tools (set tools.toolSearch.enabled=false to turn tool search off; this affects every turn)");
|
|
1088
|
-
}
|
|
1089
|
-
if (!params.boundaryWarningIssued && account.nonOwnerHostTools === "approve") {
|
|
1090
|
-
log?.warn?.("clawchat-plugin-openclaw: non-owner-host-tools approve is not supported yet; using off");
|
|
1099
|
+
log?.info?.("clawchat-plugin-openclaw: host tool search is on; turns your owner did not start can still use only the allowlisted tools without asking (approve mode: other tools in group turns still need the owner's approval each call) (set tools.toolSearch.enabled=false to turn tool search off; this affects every turn)");
|
|
1091
1100
|
}
|
|
1092
1101
|
log?.info?.(
|
|
1093
1102
|
`[${accountId}] clawchat-plugin-openclaw runtime start entered configured=${account.configured} enabled=${account.enabled} hasToken=${Boolean(account.token)} hasUserId=${Boolean(account.userId)} hasOwnerUserId=${Boolean(account.ownerUserId)} websocketUrl=${account.websocketUrl || "(empty)"}`,
|
|
@@ -2603,6 +2612,98 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
|
|
|
2603
2612
|
// forwards) would be unreachable. Registered lazily — the activation
|
|
2604
2613
|
// conversation may be recorded after the client is constructed.
|
|
2605
2614
|
client.setOwnerDirectChatResolver(resolveOwnerDirectConversationId);
|
|
2615
|
+
// `non-owner-host-tools: approve` routes group host-tool approvals to the
|
|
2616
|
+
// owner's direct chat through the host's `approvals.plugin`
|
|
2617
|
+
// (approval-route.ts). Checked once at start and again from a group turn
|
|
2618
|
+
// that finds the route missing (the owner chat may be recorded later). One
|
|
2619
|
+
// attempt per owner chat per lifecycle, so a failing write cannot loop.
|
|
2620
|
+
const readLiveConfig = (): OpenClawConfig => {
|
|
2621
|
+
const current = (runtime.config as { current?: () => unknown } | undefined)?.current;
|
|
2622
|
+
return (typeof current === "function" ? current.call(runtime.config) : cfg) as OpenClawConfig;
|
|
2623
|
+
};
|
|
2624
|
+
const approvalReconcileAttempts = new Set<string>();
|
|
2625
|
+
const hasStoredIdentity = storedIdentityLookup(store);
|
|
2626
|
+
const approvalsStateDir = (() => {
|
|
2627
|
+
try {
|
|
2628
|
+
return runtime.state?.resolveStateDir?.();
|
|
2629
|
+
} catch {
|
|
2630
|
+
return undefined;
|
|
2631
|
+
}
|
|
2632
|
+
})();
|
|
2633
|
+
const reconcileApprovalRoute = (): void => {
|
|
2634
|
+
const mutateConfigFile = resolveMutateConfigFile();
|
|
2635
|
+
if (!mutateConfigFile) {
|
|
2636
|
+
if (approvalReconcileAttempts.has("no-config-writes")) return;
|
|
2637
|
+
approvalReconcileAttempts.add("no-config-writes");
|
|
2638
|
+
try {
|
|
2639
|
+
const live = readLiveConfig();
|
|
2640
|
+
if (resolveOpenclawClawlingAccount(live, accountId).nonOwnerHostTools !== "approve") return;
|
|
2641
|
+
const route = resolveApprovalRoute({
|
|
2642
|
+
liveConfig: live,
|
|
2643
|
+
accountId,
|
|
2644
|
+
activeAccountIds: listActiveAccountIds(live, { knownActive: accountId, hasStoredIdentity }),
|
|
2645
|
+
approve: true,
|
|
2646
|
+
ownerConversationId: resolveOwnerDirectConversationId(),
|
|
2647
|
+
});
|
|
2648
|
+
if (route.ready) return;
|
|
2649
|
+
} catch {
|
|
2650
|
+
// Fall through: say why approve is not routing.
|
|
2651
|
+
}
|
|
2652
|
+
log?.warn?.(
|
|
2653
|
+
`[${accountId}] clawchat-plugin-openclaw: non-owner-host-tools approve cannot write approvals.plugin (host config writes unavailable); group host-tool calls stay blocked`,
|
|
2654
|
+
);
|
|
2655
|
+
return;
|
|
2656
|
+
}
|
|
2657
|
+
let ownerChat: string | null = null;
|
|
2658
|
+
try {
|
|
2659
|
+
ownerChat = resolveOwnerDirectConversationId();
|
|
2660
|
+
} catch {
|
|
2661
|
+
ownerChat = null;
|
|
2662
|
+
}
|
|
2663
|
+
const attemptKey = ownerChat ?? "";
|
|
2664
|
+
if (approvalReconcileAttempts.has(attemptKey)) return;
|
|
2665
|
+
approvalReconcileAttempts.add(attemptKey);
|
|
2666
|
+
void reconcileApprovalsPlugin({
|
|
2667
|
+
readConfig: readLiveConfig,
|
|
2668
|
+
mutateConfigFile: mutateConfigFile as never,
|
|
2669
|
+
ownerConversation: (id) => (id === accountId ? ownerChat : null),
|
|
2670
|
+
knownActive: accountId,
|
|
2671
|
+
hasStoredIdentity,
|
|
2672
|
+
...(approvalsStateDir ? { writeGuard: createApprovalsWriteGuard(approvalsStateDir) } : {}),
|
|
2673
|
+
log: {
|
|
2674
|
+
info: (m) => log?.info?.(`[${accountId}] ${m}`),
|
|
2675
|
+
warn: (m) => {
|
|
2676
|
+
const warn = (log as { warn?: (msg: string) => void } | undefined)?.warn;
|
|
2677
|
+
if (warn) warn(`[${accountId}] ${m}`);
|
|
2678
|
+
else log?.info?.(`[${accountId}] WARNING ${m}`);
|
|
2679
|
+
},
|
|
2680
|
+
error: (m) => log?.error?.(`[${accountId}] ${m}`),
|
|
2681
|
+
},
|
|
2682
|
+
}).catch(() => {
|
|
2683
|
+
// Logged inside; the route simply stays not ready (calls block).
|
|
2684
|
+
});
|
|
2685
|
+
};
|
|
2686
|
+
const groupApprovalRouteReady = (ownerChat: string | null): boolean => {
|
|
2687
|
+
try {
|
|
2688
|
+
const live = readLiveConfig();
|
|
2689
|
+
const approve = resolveOpenclawClawlingAccount(live, accountId).nonOwnerHostTools === "approve";
|
|
2690
|
+
if (!approve) return false;
|
|
2691
|
+
const route = resolveApprovalRoute({
|
|
2692
|
+
liveConfig: live,
|
|
2693
|
+
accountId,
|
|
2694
|
+
activeAccountIds: listActiveAccountIds(live, { knownActive: accountId, hasStoredIdentity }),
|
|
2695
|
+
approve,
|
|
2696
|
+
ownerConversationId: ownerChat,
|
|
2697
|
+
});
|
|
2698
|
+
if (!route.ready && (route.reason === "not-applied" || route.reason === "no-owner-chat")) {
|
|
2699
|
+
reconcileApprovalRoute();
|
|
2700
|
+
}
|
|
2701
|
+
return route.ready;
|
|
2702
|
+
} catch {
|
|
2703
|
+
return false;
|
|
2704
|
+
}
|
|
2705
|
+
};
|
|
2706
|
+
if (!params.boundaryWarningIssued) reconcileApprovalRoute();
|
|
2606
2707
|
const sendOwnerSkillMessage = async (text: string): Promise<void> => {
|
|
2607
2708
|
const conversationId = resolveOwnerDirectConversationId();
|
|
2608
2709
|
if (!conversationId) {
|
|
@@ -3389,12 +3490,19 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
|
|
|
3389
3490
|
chatId: turn.peer.id,
|
|
3390
3491
|
ownerDirectConversationId: ownerConversationId,
|
|
3391
3492
|
});
|
|
3392
|
-
|
|
3493
|
+
// `non-owner-host-tools: approve`: a group turn keeps the host tools once
|
|
3494
|
+
// the approval route to the owner's direct chat is live; the hook then
|
|
3495
|
+
// asks the owner for each call. Friend direct chats always narrow.
|
|
3496
|
+
const groupApproval = turnClass === "non-owner"
|
|
3497
|
+
&& turn.peer.kind === "group"
|
|
3498
|
+
&& groupApprovalRouteReady(ownerConversationId);
|
|
3499
|
+
const narrowHostTools = turnClass === "non-owner" && !groupApproval;
|
|
3393
3500
|
const baseTurnPrompt = await buildPromptForTurn(turn, memoryRoot);
|
|
3394
3501
|
const nonOwnerMediaGuidance =
|
|
3395
3502
|
"To send an image or file here, include it in your final reply as an http(s) URL; local files cannot be sent from this chat.";
|
|
3396
3503
|
const turnPrompt = [
|
|
3397
|
-
...(
|
|
3504
|
+
...(turnClass === "non-owner" ? [getClawChatPlatformPrompt(), nonOwnerMediaGuidance] : []),
|
|
3505
|
+
...(groupApproval ? [GROUP_APPROVAL_GUIDANCE] : []),
|
|
3398
3506
|
baseTurnPrompt,
|
|
3399
3507
|
].filter(Boolean).join("\n\n");
|
|
3400
3508
|
const ctxPayload = resolveChannelContextBuilder(rt)({
|
|
@@ -3560,7 +3668,7 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
|
|
|
3560
3668
|
// conversation, not `ownerUserId` (a usr_… chat_id is rejected by
|
|
3561
3669
|
// member-backend). Passed as a thunk so it is resolved at send time.
|
|
3562
3670
|
resolveOwnerConversationId: resolveOwnerDirectConversationId,
|
|
3563
|
-
nonOwnerTurn:
|
|
3671
|
+
nonOwnerTurn: turnClass === "non-owner",
|
|
3564
3672
|
log,
|
|
3565
3673
|
});
|
|
3566
3674
|
|
package/src/skill-update.ts
CHANGED
|
@@ -71,7 +71,7 @@ export const OFFICIAL_SKILLS_BASE =
|
|
|
71
71
|
* in the install-cli repo, bump this constant, ship it. `liveware-sample.ts`
|
|
72
72
|
* imports the same ref, so the `livewares` tree at that tag is pinned too.
|
|
73
73
|
*/
|
|
74
|
-
export const DEFAULT_SKILLS_REF = "skills-v1.17.
|
|
74
|
+
export const DEFAULT_SKILLS_REF = "skills-v1.17.1";
|
|
75
75
|
|
|
76
76
|
/** Refuse to treat an absurdly large response as a skill file (defence in depth). */
|
|
77
77
|
export const MAX_SKILL_BYTES = 256 * 1024;
|
package/src/tools.ts
CHANGED
|
@@ -40,8 +40,10 @@ import {
|
|
|
40
40
|
} from "./clawchat-memory.ts";
|
|
41
41
|
import {
|
|
42
42
|
canReadMemory,
|
|
43
|
+
canWriteMemory,
|
|
43
44
|
memoryScopeNote,
|
|
44
45
|
memoryScopeRefusal,
|
|
46
|
+
ownerNoteWriteRefusal,
|
|
45
47
|
resolveMemoryScope,
|
|
46
48
|
type MemoryScope,
|
|
47
49
|
} from "./memory-scope.ts";
|
|
@@ -290,7 +292,7 @@ function toolDescription(...parts: string[]): string {
|
|
|
290
292
|
}
|
|
291
293
|
|
|
292
294
|
const MEMORY_SCOPE_DESCRIPTION =
|
|
293
|
-
"In a non-owner direct chat only that person's note is readable; in a group only that group's note and its members' notes are readable. owner.md is readable only in the owner's direct chat. Replace, edit and metadata operations require a readable target; append may add facts to any target without revealing existing content.";
|
|
295
|
+
"In a non-owner direct chat only that person's note is readable; in a group only that group's note and its members' notes are readable. owner.md is readable only in the owner's direct chat. Replace, edit and metadata operations require a readable target; append may add facts to any other target without revealing existing content. owner.md can only be written (append included) in the owner's direct chat; elsewhere, record facts about the owner in the speaker's own users/<id>.md or this group's note.";
|
|
294
296
|
|
|
295
297
|
function memoryToolDescription(...parts: string[]): string {
|
|
296
298
|
return `${parts.join("")} ${MEMORY_SCOPE_DESCRIPTION}`;
|
|
@@ -982,8 +984,12 @@ export function registerOpenclawClawlingTools(
|
|
|
982
984
|
const target = targetFromParams(p);
|
|
983
985
|
const scope = await memoryScopeFor(ctx, root);
|
|
984
986
|
readable = canReadMemory(scope, target);
|
|
985
|
-
// Replacing wipes what is there; append may go to any note.
|
|
986
|
-
if (p.mode
|
|
987
|
+
// Replacing wipes what is there; append may go to any note but owner.md.
|
|
988
|
+
if (!canWriteMemory(scope, target, p.mode === "append")) {
|
|
989
|
+
return jsonResponse(target.targetType === "owner"
|
|
990
|
+
? ownerNoteWriteRefusal(scope)
|
|
991
|
+
: memoryScopeRefusal(scope, target));
|
|
992
|
+
}
|
|
987
993
|
await writeClawChatMemoryBody(root, target, p.mode, p.content);
|
|
988
994
|
return jsonResponse({ ok: true, targetType: p.targetType, targetId: p.targetId, mode: p.mode });
|
|
989
995
|
} catch (err) {
|
|
@@ -1022,7 +1028,11 @@ export function registerOpenclawClawlingTools(
|
|
|
1022
1028
|
if (targetError) return memoryToolError(new Error(targetError));
|
|
1023
1029
|
const target = targetFromParams(p);
|
|
1024
1030
|
const scope = await memoryScopeFor(ctx, root);
|
|
1025
|
-
if (!
|
|
1031
|
+
if (!canWriteMemory(scope, target, false)) {
|
|
1032
|
+
return jsonResponse(target.targetType === "owner"
|
|
1033
|
+
? ownerNoteWriteRefusal(scope)
|
|
1034
|
+
: memoryScopeRefusal(scope, target));
|
|
1035
|
+
}
|
|
1026
1036
|
await editClawChatMemoryBody(root, target, p.oldText, p.newText);
|
|
1027
1037
|
return jsonResponse({ ok: true, targetType: p.targetType, targetId: p.targetId });
|
|
1028
1038
|
} catch (err) {
|