@clawling/clawchat-plugin-openclaw 2026.10.9-3 → 2026.10.11-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/index.js +12 -0
- package/dist/src/approval-route.js +282 -0
- package/dist/src/host-tools-guard.js +120 -13
- package/dist/src/memory-scope.js +21 -0
- package/dist/src/outbound.js +42 -2
- package/dist/src/runtime.js +119 -8
- package/dist/src/skill-update.js +1 -1
- package/dist/src/storage.js +112 -13
- package/dist/src/terminal-send.js +69 -12
- package/dist/src/tools.js +216 -28
- package/dist/src/turn-boundary.js +17 -0
- 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 +160 -17
- package/src/memory-scope.ts +26 -0
- package/src/outbound.ts +53 -3
- package/src/runtime.ts +117 -9
- package/src/skill-update.ts +1 -1
- package/src/storage.ts +134 -15
- package/src/terminal-send.ts +97 -13
- package/src/tools.ts +230 -26
- package/src/turn-boundary.ts +18 -0
package/dist/index.js
CHANGED
|
@@ -43,6 +43,16 @@ const channelEntry = defineChannelPluginEntry({
|
|
|
43
43
|
* instead. Each load registers the commands once; a duplicate registration
|
|
44
44
|
* would mark the plugin as errored on the host side.
|
|
45
45
|
*/
|
|
46
|
+
/**
|
|
47
|
+
* The host-tools guard (`before_tool_call`) follows the same rule, for the
|
|
48
|
+
* same reason: on OpenClaw 2026.8.x the agent run uses the hook runner of the
|
|
49
|
+
* request-scoped `discovery` registry, so a guard registered only in
|
|
50
|
+
* `registerFull` never fires there (measured on 2026.8.2: the call ran with no
|
|
51
|
+
* hook invocation). Every load except `cli-metadata` gets the guard exactly
|
|
52
|
+
* once: `registerFull` covers `full` / `tool-discovery`, the wrapper covers
|
|
53
|
+
* the rest.
|
|
54
|
+
*/
|
|
55
|
+
const FULL_REGISTRATION_MODES = new Set(["full", "tool-discovery"]);
|
|
46
56
|
export default {
|
|
47
57
|
// Newer hosts expose `configSchema` as a lazy getter; the spread evaluates it
|
|
48
58
|
// once here, which is fine because the schema is a static object.
|
|
@@ -51,6 +61,8 @@ export default {
|
|
|
51
61
|
channelEntry.register(api);
|
|
52
62
|
if (api.registrationMode !== "cli-metadata") {
|
|
53
63
|
registerOpenclawClawlingCommands(api);
|
|
64
|
+
if (!FULL_REGISTRATION_MODES.has(String(api.registrationMode)))
|
|
65
|
+
registerHostToolsGuard(api);
|
|
54
66
|
}
|
|
55
67
|
},
|
|
56
68
|
};
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
// Where `non-owner-host-tools: approve` sends a group turn's host-tool request.
|
|
2
|
+
//
|
|
3
|
+
// The host asks for a plugin approval when `before_tool_call` returns
|
|
4
|
+
// `requireApproval`; it shows that request in a chat only through the global
|
|
5
|
+
// `approvals.plugin` forwarding config (OpenClaw 2026.8.1+). Without it the
|
|
6
|
+
// request is posted nowhere and simply times out. Its default `session` mode
|
|
7
|
+
// would answer in the chat the turn came from — the group — so the plugin
|
|
8
|
+
// manages one exact block instead: `mode: "targets"`, one target (the owner's
|
|
9
|
+
// direct chat on the one ClawChat account), and a `sessionFilter` that matches
|
|
10
|
+
// ClawChat group sessions only.
|
|
11
|
+
//
|
|
12
|
+
// The plugin writes that block only when the host has none, and later rewrites
|
|
13
|
+
// or removes only a block of exactly that shape. Any other `approvals.plugin`
|
|
14
|
+
// is the owner's own and is never touched; approve then cannot route and group
|
|
15
|
+
// calls stay blocked. A group call asks for approval only while the LIVE
|
|
16
|
+
// config holds the managed block for this account and its current owner chat.
|
|
17
|
+
import fs from "node:fs";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
import { CHANNEL_ID, listOpenclawClawlingAccountIds, resolveOpenclawClawlingAccount } from "./config.js";
|
|
20
|
+
/** Substring of every ClawChat group session key (`agent:<id>:<channel>:group:<chat>`). */
|
|
21
|
+
export const APPROVAL_SESSION_FILTER = `:${CHANNEL_ID}:group:`;
|
|
22
|
+
function isRec(value) {
|
|
23
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
24
|
+
}
|
|
25
|
+
const norm = (value) => (typeof value === "string" ? value.trim().toLowerCase() : "");
|
|
26
|
+
/** Keys holding a value; a loader may add `undefined` placeholders. */
|
|
27
|
+
const definedKeys = (rec) => Object.keys(rec).filter((key) => rec[key] !== undefined).sort().join(",");
|
|
28
|
+
/**
|
|
29
|
+
* Enabled ClawChat accounts in use: those whose config section carries an
|
|
30
|
+
* identity (token or user id), those with a stored activation
|
|
31
|
+
* (`hasStoredIdentity`, the SQLite row; credentials may live only there), plus
|
|
32
|
+
* `knownActive` (an account the caller knows is running). Every caller must
|
|
33
|
+
* count the same way, or two accounts' runtimes would disagree about the one
|
|
34
|
+
* global block. The root section always lists as an account, so an empty root
|
|
35
|
+
* next to named accounts does not count.
|
|
36
|
+
*/
|
|
37
|
+
export function listActiveAccountIds(cfg, options = {}) {
|
|
38
|
+
const { knownActive, hasStoredIdentity } = options;
|
|
39
|
+
const ids = new Set();
|
|
40
|
+
for (const id of listOpenclawClawlingAccountIds(cfg)) {
|
|
41
|
+
const account = resolveOpenclawClawlingAccount(cfg, id);
|
|
42
|
+
if (!account.enabled)
|
|
43
|
+
continue;
|
|
44
|
+
let stored = false;
|
|
45
|
+
try {
|
|
46
|
+
stored = hasStoredIdentity?.(account.accountId) === true;
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
// Unknown counts as in use: more accounts only ever blocks.
|
|
50
|
+
stored = true;
|
|
51
|
+
}
|
|
52
|
+
if (account.token || account.userId || stored || norm(id) === norm(knownActive))
|
|
53
|
+
ids.add(account.accountId);
|
|
54
|
+
}
|
|
55
|
+
if (knownActive?.trim() && ![...ids].some((id) => norm(id) === norm(knownActive)))
|
|
56
|
+
ids.add(knownActive.trim());
|
|
57
|
+
return [...ids];
|
|
58
|
+
}
|
|
59
|
+
/** The exact `approvals.plugin` block the plugin writes for `target`. */
|
|
60
|
+
export function buildManagedApprovalsPlugin(target) {
|
|
61
|
+
return {
|
|
62
|
+
enabled: true,
|
|
63
|
+
mode: "targets",
|
|
64
|
+
sessionFilter: [APPROVAL_SESSION_FILTER],
|
|
65
|
+
targets: [{ channel: CHANNEL_ID, to: target.to, accountId: target.accountId }],
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
/** Classify the host's `approvals.plugin`; only the exact managed shape is ours. */
|
|
69
|
+
export function readApprovalsPluginState(cfg) {
|
|
70
|
+
const approvals = isRec(cfg) ? cfg.approvals : undefined;
|
|
71
|
+
if (approvals === undefined || (isRec(approvals) && approvals.plugin === undefined)) {
|
|
72
|
+
return { kind: "absent" };
|
|
73
|
+
}
|
|
74
|
+
if (!isRec(approvals) || !isRec(approvals.plugin))
|
|
75
|
+
return { kind: "foreign" };
|
|
76
|
+
const block = approvals.plugin;
|
|
77
|
+
if (definedKeys(block) !== "enabled,mode,sessionFilter,targets")
|
|
78
|
+
return { kind: "foreign" };
|
|
79
|
+
if (block.enabled !== true || block.mode !== "targets")
|
|
80
|
+
return { kind: "foreign" };
|
|
81
|
+
const filter = block.sessionFilter;
|
|
82
|
+
if (!Array.isArray(filter) || filter.length !== 1 || filter[0] !== APPROVAL_SESSION_FILTER) {
|
|
83
|
+
return { kind: "foreign" };
|
|
84
|
+
}
|
|
85
|
+
const targets = block.targets;
|
|
86
|
+
if (!Array.isArray(targets) || targets.length !== 1 || !isRec(targets[0]))
|
|
87
|
+
return { kind: "foreign" };
|
|
88
|
+
const target = targets[0];
|
|
89
|
+
if (definedKeys(target) !== "accountId,channel,to")
|
|
90
|
+
return { kind: "foreign" };
|
|
91
|
+
if (target.channel !== CHANNEL_ID)
|
|
92
|
+
return { kind: "foreign" };
|
|
93
|
+
const to = typeof target.to === "string" ? target.to.trim() : "";
|
|
94
|
+
const accountId = typeof target.accountId === "string" ? target.accountId.trim() : "";
|
|
95
|
+
if (!to || !accountId)
|
|
96
|
+
return { kind: "foreign" };
|
|
97
|
+
return { kind: "managed", target: { to, accountId } };
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* The target the managed block should hold now, or `null` when none should
|
|
101
|
+
* exist. Approve routes only with exactly one active ClawChat account: the
|
|
102
|
+
* block is global, so a second account's owner would see the first one's
|
|
103
|
+
* requests.
|
|
104
|
+
*/
|
|
105
|
+
export function desiredApprovalTarget(input) {
|
|
106
|
+
if (input.approveAccountIds.length === 0)
|
|
107
|
+
return { target: null, reason: "not-approve" };
|
|
108
|
+
if (input.activeAccountIds.length !== 1)
|
|
109
|
+
return { target: null, reason: "multiple-accounts" };
|
|
110
|
+
const accountId = input.activeAccountIds[0];
|
|
111
|
+
if (!input.approveAccountIds.includes(accountId))
|
|
112
|
+
return { target: null, reason: "not-approve" };
|
|
113
|
+
const to = input.ownerConversation(accountId)?.trim();
|
|
114
|
+
if (!to)
|
|
115
|
+
return { target: null, reason: "no-owner-chat" };
|
|
116
|
+
return { target: { accountId, to } };
|
|
117
|
+
}
|
|
118
|
+
/** What to write so `approvals.plugin` matches `desired`, never touching a foreign block. */
|
|
119
|
+
export function planApprovalsPluginWrite(current, desired) {
|
|
120
|
+
if (current.kind === "foreign")
|
|
121
|
+
return { action: "none" };
|
|
122
|
+
if (!desired)
|
|
123
|
+
return current.kind === "managed" ? { action: "remove" } : { action: "none" };
|
|
124
|
+
// Never take the block over from another account: that account's runtime
|
|
125
|
+
// would take it back (each write restarts a pre-9.2 gateway). Drop it.
|
|
126
|
+
if (current.kind === "managed" && norm(current.target.accountId) !== norm(desired.accountId)) {
|
|
127
|
+
return { action: "remove" };
|
|
128
|
+
}
|
|
129
|
+
if (current.kind === "managed" &&
|
|
130
|
+
norm(current.target.to) === norm(desired.to) &&
|
|
131
|
+
norm(current.target.accountId) === norm(desired.accountId)) {
|
|
132
|
+
return { action: "none" };
|
|
133
|
+
}
|
|
134
|
+
return { action: "set", target: desired };
|
|
135
|
+
}
|
|
136
|
+
/** Apply `plan` to a config draft in place. */
|
|
137
|
+
export function applyApprovalsPluginWrite(draft, plan) {
|
|
138
|
+
if (plan.action === "none")
|
|
139
|
+
return;
|
|
140
|
+
const approvals = isRec(draft.approvals) ? { ...draft.approvals } : {};
|
|
141
|
+
if (plan.action === "set") {
|
|
142
|
+
approvals.plugin = buildManagedApprovalsPlugin(plan.target);
|
|
143
|
+
draft.approvals = approvals;
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
delete approvals.plugin;
|
|
147
|
+
if (Object.keys(approvals).length === 0)
|
|
148
|
+
delete draft.approvals;
|
|
149
|
+
else
|
|
150
|
+
draft.approvals = approvals;
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Whether a group call on `accountId` can be sent for approval now: the live
|
|
154
|
+
* config must already hold the managed block for exactly this account and its
|
|
155
|
+
* current owner chat. Everything else blocks.
|
|
156
|
+
*/
|
|
157
|
+
export function resolveApprovalRoute(input) {
|
|
158
|
+
if (!input.approve)
|
|
159
|
+
return { ready: false, reason: "not-approve" };
|
|
160
|
+
if (input.activeAccountIds.length !== 1 || norm(input.activeAccountIds[0]) !== norm(input.accountId)) {
|
|
161
|
+
return { ready: false, reason: "multiple-accounts" };
|
|
162
|
+
}
|
|
163
|
+
const owner = input.ownerConversationId?.trim();
|
|
164
|
+
if (!owner)
|
|
165
|
+
return { ready: false, reason: "no-owner-chat" };
|
|
166
|
+
const state = readApprovalsPluginState(input.liveConfig);
|
|
167
|
+
if (state.kind === "foreign")
|
|
168
|
+
return { ready: false, reason: "foreign-config" };
|
|
169
|
+
if (state.kind !== "managed" ||
|
|
170
|
+
norm(state.target.accountId) !== norm(input.accountId) ||
|
|
171
|
+
norm(state.target.to) !== norm(owner)) {
|
|
172
|
+
return { ready: false, reason: "not-applied" };
|
|
173
|
+
}
|
|
174
|
+
return { ready: true, target: state.target };
|
|
175
|
+
}
|
|
176
|
+
export const APPROVAL_ROUTE_WARNINGS = {
|
|
177
|
+
"not-approve": "",
|
|
178
|
+
"multiple-accounts": "non-owner-host-tools approve needs exactly one ClawChat account on this gateway; group host-tool calls stay blocked",
|
|
179
|
+
"no-owner-chat": "non-owner-host-tools approve has no owner direct chat yet (activate first); group host-tool calls stay blocked",
|
|
180
|
+
"foreign-config": "non-owner-host-tools approve cannot route: approvals.plugin is already set in the OpenClaw config and the plugin will not change it; group host-tool calls stay blocked",
|
|
181
|
+
"not-applied": "non-owner-host-tools approve is waiting for the gateway to load approvals.plugin; group host-tool calls stay blocked until it does",
|
|
182
|
+
};
|
|
183
|
+
/**
|
|
184
|
+
* Bring `approvals.plugin` in line with the accounts' `non-owner-host-tools`:
|
|
185
|
+
* write the managed block when approve can route, remove a managed block that
|
|
186
|
+
* no longer applies, and never touch the owner's own block. The write lets the
|
|
187
|
+
* host decide how to load it (`afterWrite: auto`): OpenClaw 2026.9.2+ applies
|
|
188
|
+
* `approvals.plugin` without a restart, earlier hosts restart the gateway once.
|
|
189
|
+
*/
|
|
190
|
+
export async function reconcileApprovalsPlugin(params) {
|
|
191
|
+
const cfg = params.readConfig();
|
|
192
|
+
const active = listActiveAccountIds(cfg, {
|
|
193
|
+
knownActive: params.knownActive,
|
|
194
|
+
hasStoredIdentity: params.hasStoredIdentity,
|
|
195
|
+
});
|
|
196
|
+
const approveIds = active.filter((id) => resolveOpenclawClawlingAccount(cfg, id).nonOwnerHostTools === "approve");
|
|
197
|
+
const desired = desiredApprovalTarget({
|
|
198
|
+
activeAccountIds: active,
|
|
199
|
+
approveAccountIds: approveIds,
|
|
200
|
+
ownerConversation: params.ownerConversation,
|
|
201
|
+
});
|
|
202
|
+
const current = readApprovalsPluginState(cfg);
|
|
203
|
+
if (approveIds.length > 0) {
|
|
204
|
+
const reason = current.kind === "foreign" ? "foreign-config" : desired.reason;
|
|
205
|
+
if (reason && reason !== "not-approve")
|
|
206
|
+
params.log?.warn?.(`clawchat-plugin-openclaw: ${APPROVAL_ROUTE_WARNINGS[reason]}`);
|
|
207
|
+
}
|
|
208
|
+
const planned = planApprovalsPluginWrite(current, desired.target);
|
|
209
|
+
if (planned.action === "none")
|
|
210
|
+
return "none";
|
|
211
|
+
const guardKey = planned.action === "set"
|
|
212
|
+
? `set:${norm(planned.target.accountId)}:${norm(planned.target.to)}`
|
|
213
|
+
: "remove";
|
|
214
|
+
try {
|
|
215
|
+
if (params.writeGuard?.seen(guardKey)) {
|
|
216
|
+
params.log?.warn?.("clawchat-plugin-openclaw: approvals.plugin was already updated recently but the gateway does not show it; not writing again (group host-tool calls stay blocked)");
|
|
217
|
+
return "none";
|
|
218
|
+
}
|
|
219
|
+
params.writeGuard?.record(guardKey);
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
// A broken guard must not stop the write; the per-lifecycle guard still applies.
|
|
223
|
+
}
|
|
224
|
+
let action = "none";
|
|
225
|
+
try {
|
|
226
|
+
await params.mutateConfigFile({
|
|
227
|
+
afterWrite: { mode: "auto" },
|
|
228
|
+
mutate(draft) {
|
|
229
|
+
// Re-plan against the file as it is now, not the snapshot read above.
|
|
230
|
+
const plan = planApprovalsPluginWrite(readApprovalsPluginState(draft), desired.target);
|
|
231
|
+
action = plan.action;
|
|
232
|
+
applyApprovalsPluginWrite(draft, plan);
|
|
233
|
+
},
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
catch (err) {
|
|
237
|
+
params.log?.error?.(`clawchat-plugin-openclaw: approvals.plugin update failed; group host-tool calls stay blocked (${err instanceof Error ? err.message : String(err)})`);
|
|
238
|
+
return "failed";
|
|
239
|
+
}
|
|
240
|
+
if (action === "set") {
|
|
241
|
+
params.log?.info?.("clawchat-plugin-openclaw: non-owner-host-tools approve: wrote approvals.plugin so group host-tool approvals go only to the owner's ClawChat direct chat");
|
|
242
|
+
}
|
|
243
|
+
else if (action === "remove") {
|
|
244
|
+
params.log?.info?.("clawchat-plugin-openclaw: removed the plugin-managed approvals.plugin (approve is no longer in use)");
|
|
245
|
+
}
|
|
246
|
+
return action;
|
|
247
|
+
}
|
|
248
|
+
export const APPROVALS_WRITE_MARKER_FILENAME = "clawchat-approvals-plugin-write.json";
|
|
249
|
+
const WRITE_GUARD_WINDOW_MS = 10 * 60_000;
|
|
250
|
+
/**
|
|
251
|
+
* File-backed `writeGuard` in the plugin's state dir: the same write is made
|
|
252
|
+
* at most once per 10 minutes, across gateway restarts.
|
|
253
|
+
*/
|
|
254
|
+
export function createApprovalsWriteGuard(stateDir, now = Date.now) {
|
|
255
|
+
const file = path.join(stateDir, APPROVALS_WRITE_MARKER_FILENAME);
|
|
256
|
+
const read = () => {
|
|
257
|
+
try {
|
|
258
|
+
const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
259
|
+
return isRec(parsed) ? parsed : {};
|
|
260
|
+
}
|
|
261
|
+
catch {
|
|
262
|
+
return {};
|
|
263
|
+
}
|
|
264
|
+
};
|
|
265
|
+
return {
|
|
266
|
+
seen: (key) => {
|
|
267
|
+
const last = read();
|
|
268
|
+
return last.key === key && typeof last.at === "number" && now() - last.at < WRITE_GUARD_WINDOW_MS;
|
|
269
|
+
},
|
|
270
|
+
record: (key) => {
|
|
271
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
272
|
+
fs.writeFileSync(file, JSON.stringify({ key, at: now() }));
|
|
273
|
+
},
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
/** `hasStoredIdentity` from the plugin store; an unavailable store counts every account. */
|
|
277
|
+
export function storedIdentityLookup(store) {
|
|
278
|
+
return (accountId) => {
|
|
279
|
+
const lookup = store?.lookupActivationIdentity?.({ platform: "openclaw", accountId });
|
|
280
|
+
return !lookup || lookup.status !== "absent";
|
|
281
|
+
};
|
|
282
|
+
}
|
|
@@ -1,9 +1,17 @@
|
|
|
1
1
|
import { CHANNEL_ID, normalizeOpenclawClawlingAccountId, resolveOpenclawClawlingAccount } from "./config.js";
|
|
2
|
-
import { classifyChat, clawChatSessionAccountId, NON_OWNER_TOOL_ALLOWLIST, parseClawChatSessionKey, } from "./turn-boundary.js";
|
|
2
|
+
import { classifyChat, clawChatSessionAccountId, NON_OWNER_TOOL_ALLOWLIST, normalizeToolChatId, parseClawChatSessionKey, } from "./turn-boundary.js";
|
|
3
3
|
import { clawChatDbPathForStateDir, getClawChatStore } from "./storage.js";
|
|
4
4
|
import { resolveOwnerIdentity } from "./owner-identity.js";
|
|
5
5
|
import { getRegisteredClawChatToolNames } from "./tools.js";
|
|
6
|
+
import { APPROVAL_ROUTE_WARNINGS, listActiveAccountIds, resolveApprovalRoute, storedIdentityLookup, } from "./approval-route.js";
|
|
6
7
|
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
|
+
/** Told to the model when the owner denies, or does not answer in time. */
|
|
9
|
+
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.";
|
|
10
|
+
/** Owner approval wait for one call; the host caps plugin approvals at 600 s. */
|
|
11
|
+
export const APPROVAL_TIMEOUT_MS = 300_000;
|
|
12
|
+
const APPROVAL_TITLE_MAX = 80;
|
|
13
|
+
/** The host rejects (and so blocks) a description over 512 characters. */
|
|
14
|
+
const APPROVAL_DESCRIPTION_MAX = 500;
|
|
7
15
|
/**
|
|
8
16
|
* Host tool-search controls (OpenClaw 2026.9.6+). The host adds them to every
|
|
9
17
|
* run on its own and builds their catalog from the run's allowed tools, so in
|
|
@@ -44,17 +52,26 @@ export function decideHostToolCall(input) {
|
|
|
44
52
|
if (classifyChat({ ...parsed, ownerDirectConversationId: input.ownerDirectConversationId }) === "owner") {
|
|
45
53
|
return undefined;
|
|
46
54
|
}
|
|
55
|
+
// Approve mode: only group turns, and only once the route to the owner's
|
|
56
|
+
// direct chat is live. Friend direct chats never ask; they block below.
|
|
57
|
+
const approve = parsed.kind === "group" && input.approvalRoute?.ready === true;
|
|
58
|
+
const allowlisted = NON_OWNER_TOOL_ALLOWLIST.includes(input.toolName);
|
|
47
59
|
// Outer pre-check only. The inner tool call is checked again and that check
|
|
48
60
|
// is authoritative: the host also accepts dotted/flattened argument keys
|
|
49
|
-
// that this outer check may not recognise.
|
|
50
|
-
|
|
61
|
+
// that this outer check may not recognise. Under approve, tools the owner
|
|
62
|
+
// approves one by one (and the tool-search controls that reach them) show
|
|
63
|
+
// the owner their arguments instead; the allowlisted tools, which run
|
|
64
|
+
// without asking, keep the check.
|
|
65
|
+
if ((!approve || allowlisted) && hasLocalPathArgument(input.params))
|
|
51
66
|
return block();
|
|
52
67
|
if (HOST_TOOL_SEARCH_CONTROLS.includes(input.toolName))
|
|
53
68
|
return undefined;
|
|
54
69
|
if (ACCOUNT_SCOPED_TOOLS.includes(input.toolName)) {
|
|
55
70
|
// D9: a non-owner turn may only act on its own chat, named explicitly.
|
|
56
|
-
|
|
57
|
-
|
|
71
|
+
// The tool resolves the same argument with the same helper, so the chat
|
|
72
|
+
// checked here is the chat the call reaches.
|
|
73
|
+
const target = normalizeToolChatId(input.params.chatId).toLowerCase();
|
|
74
|
+
if (!target || target !== parsed.chatId) {
|
|
58
75
|
return {
|
|
59
76
|
block: true,
|
|
60
77
|
blockReason: input.toolName === "clawchat_react_message"
|
|
@@ -63,12 +80,62 @@ export function decideHostToolCall(input) {
|
|
|
63
80
|
};
|
|
64
81
|
}
|
|
65
82
|
}
|
|
66
|
-
if (
|
|
83
|
+
if (allowlisted) {
|
|
67
84
|
return input.toolName.startsWith("clawchat_") && !input.registeredTools.has(input.toolName)
|
|
68
85
|
? block()
|
|
69
86
|
: undefined;
|
|
70
87
|
}
|
|
71
|
-
|
|
88
|
+
if (!approve)
|
|
89
|
+
return block();
|
|
90
|
+
const approval = buildHostToolApproval(input.toolName, parsed.chatId, input.params);
|
|
91
|
+
return approval ? { requireApproval: approval } : { block: true, blockReason: APPROVAL_UNSHOWABLE_MESSAGE };
|
|
92
|
+
}
|
|
93
|
+
// Drop what the host would escape (controls, zero-width, bidi) from the names
|
|
94
|
+
// shown on the card, and collapse whitespace. Arguments are never cleaned:
|
|
95
|
+
// they are shown exactly, or the call is not offered for approval.
|
|
96
|
+
const UNSAFE_TEXT = /[\u0000-\u001f\u007f-\u009f\u00ad\u200b-\u200f\u202a-\u202e\u2060-\u206f\ufeff]/g;
|
|
97
|
+
function cleanText(text, max) {
|
|
98
|
+
const flat = text.replace(UNSAFE_TEXT, " ").replace(/\s+/g, " ").trim();
|
|
99
|
+
return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
|
|
100
|
+
}
|
|
101
|
+
/** Tools whose approval starts work that later runs on its own (own session key, no further asking). */
|
|
102
|
+
const STARTS_UNATTENDED_WORK = /spawn|subagent|sessions_send|cron|automation|schedul/i;
|
|
103
|
+
/** Told to the model when a call's arguments cannot be shown to the owner exactly. */
|
|
104
|
+
export const APPROVAL_UNSHOWABLE_MESSAGE = "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.";
|
|
105
|
+
/**
|
|
106
|
+
* The approval card for one group host-tool call; it goes only to the owner's
|
|
107
|
+
* direct chat. `null` when the arguments cannot be shown in full and exactly
|
|
108
|
+
* (too long for the host's 512-character description, unserializable, or
|
|
109
|
+
* holding characters the host would escape or hide): the owner must never
|
|
110
|
+
* approve a command they could not read, so such a call blocks.
|
|
111
|
+
*/
|
|
112
|
+
export function buildHostToolApproval(toolName, groupChatId, params) {
|
|
113
|
+
const tool = cleanText(toolName, 48);
|
|
114
|
+
let args;
|
|
115
|
+
try {
|
|
116
|
+
args = JSON.stringify(params) ?? "{}";
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
if (new RegExp(UNSAFE_TEXT.source).test(args))
|
|
122
|
+
return null;
|
|
123
|
+
const unattended = STARTS_UNATTENDED_WORK.test(toolName)
|
|
124
|
+
? " Approving starts work that later runs with every host tool and is not asked about again."
|
|
125
|
+
: "";
|
|
126
|
+
const before = `A turn in ClawChat group ${cleanText(groupChatId, 64)} (anyone in the group may have asked) wants to run ` +
|
|
127
|
+
`the host tool ${tool} on this machine. Arguments: `;
|
|
128
|
+
const after = `.${unattended} Allow only if you would run it yourself.`;
|
|
129
|
+
if (before.length + args.length + after.length > APPROVAL_DESCRIPTION_MAX)
|
|
130
|
+
return null;
|
|
131
|
+
return {
|
|
132
|
+
title: cleanText(`ClawChat group wants to run ${tool}`, APPROVAL_TITLE_MAX),
|
|
133
|
+
description: `${before}${args}${after}`,
|
|
134
|
+
severity: "warning",
|
|
135
|
+
timeoutMs: APPROVAL_TIMEOUT_MS,
|
|
136
|
+
timeoutReason: APPROVAL_TIMEOUT_MESSAGE,
|
|
137
|
+
allowedDecisions: ["allow-once", "deny"],
|
|
138
|
+
};
|
|
72
139
|
}
|
|
73
140
|
export function registerHostToolsGuard(api, options = {}) {
|
|
74
141
|
const warned = new Set();
|
|
@@ -83,16 +150,54 @@ export function registerHostToolsGuard(api, options = {}) {
|
|
|
83
150
|
// Logging cannot unlock a call.
|
|
84
151
|
}
|
|
85
152
|
};
|
|
86
|
-
const
|
|
87
|
-
if (options.ownerConversation)
|
|
88
|
-
return options.ownerConversation(accountId);
|
|
89
|
-
const account = resolveOpenclawClawlingAccount(api.config, accountId);
|
|
153
|
+
const openStore = () => {
|
|
90
154
|
const stateDir = api.runtime.state?.resolveStateDir?.();
|
|
91
|
-
|
|
155
|
+
return getClawChatStore({
|
|
92
156
|
...(stateDir ? { dbPath: clawChatDbPathForStateDir(stateDir) } : {}),
|
|
93
157
|
log: { error: message => api.logger.error?.(message) },
|
|
94
158
|
});
|
|
95
|
-
|
|
159
|
+
};
|
|
160
|
+
const lookupOwnerConversation = (accountId) => {
|
|
161
|
+
if (options.ownerConversation)
|
|
162
|
+
return options.ownerConversation(accountId);
|
|
163
|
+
const account = resolveOpenclawClawlingAccount(api.config, accountId);
|
|
164
|
+
return resolveOwnerIdentity(openStore(), accountId, account.ownerUserId).conversationId;
|
|
165
|
+
};
|
|
166
|
+
const hasStoredIdentity = options.hasStoredIdentity
|
|
167
|
+
?? ((accountId) => storedIdentityLookup(openStore())(accountId));
|
|
168
|
+
const readLiveConfig = () => {
|
|
169
|
+
if (options.liveConfig)
|
|
170
|
+
return options.liveConfig();
|
|
171
|
+
const current = api.runtime.config?.current;
|
|
172
|
+
return (typeof current === "function" ? current.call(api.runtime.config) : api.config);
|
|
173
|
+
};
|
|
174
|
+
// Only consulted for group calls. `undefined` = the account is not in
|
|
175
|
+
// approve mode; a route that is not ready blocks like `off`.
|
|
176
|
+
const groupApprovalRoute = (accountId) => {
|
|
177
|
+
try {
|
|
178
|
+
const cfg = readLiveConfig();
|
|
179
|
+
const active = listActiveAccountIds(cfg, { knownActive: accountId, hasStoredIdentity });
|
|
180
|
+
const routeAccountId = accountId ?? (active.length === 1 ? active[0] : undefined);
|
|
181
|
+
const anyApprove = (routeAccountId ? [routeAccountId] : active).some(id => resolveOpenclawClawlingAccount(cfg, id).nonOwnerHostTools === "approve");
|
|
182
|
+
if (!anyApprove)
|
|
183
|
+
return undefined;
|
|
184
|
+
const route = routeAccountId
|
|
185
|
+
? resolveApprovalRoute({
|
|
186
|
+
liveConfig: cfg,
|
|
187
|
+
accountId: routeAccountId,
|
|
188
|
+
activeAccountIds: active,
|
|
189
|
+
approve: true,
|
|
190
|
+
ownerConversationId: lookupOwnerConversation(routeAccountId),
|
|
191
|
+
})
|
|
192
|
+
: { ready: false, reason: "multiple-accounts" };
|
|
193
|
+
if (!route.ready)
|
|
194
|
+
warn(`approve unavailable (${route.reason}): ${APPROVAL_ROUTE_WARNINGS[route.reason]}`);
|
|
195
|
+
return route;
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
warn("approve unavailable (route lookup failed)");
|
|
199
|
+
return { ready: false, reason: "not-applied" };
|
|
200
|
+
}
|
|
96
201
|
};
|
|
97
202
|
api.on("before_tool_call", (event, sdkCtx) => {
|
|
98
203
|
// `requester` is host 2026.8.1+ (the floor); the development SDK types predate it.
|
|
@@ -136,6 +241,7 @@ export function registerHostToolsGuard(api, options = {}) {
|
|
|
136
241
|
sessionKey,
|
|
137
242
|
ownerDirectConversationId: null,
|
|
138
243
|
registeredTools: getRegisteredClawChatToolNames(),
|
|
244
|
+
approvalRoute: groupApprovalRoute(undefined),
|
|
139
245
|
});
|
|
140
246
|
}
|
|
141
247
|
let ownerDirectConversationId = null;
|
|
@@ -153,6 +259,7 @@ export function registerHostToolsGuard(api, options = {}) {
|
|
|
153
259
|
sessionKey,
|
|
154
260
|
ownerDirectConversationId,
|
|
155
261
|
registeredTools: getRegisteredClawChatToolNames(),
|
|
262
|
+
...(parsed.kind === "group" ? { approvalRoute: groupApprovalRoute(accountId) } : {}),
|
|
156
263
|
});
|
|
157
264
|
}
|
|
158
265
|
catch {
|
package/dist/src/memory-scope.js
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.
|
|
@@ -142,6 +147,22 @@ export function canReadMemory(scope, target) {
|
|
|
142
147
|
return scope.members.has(target.targetId);
|
|
143
148
|
return scope.kind === "group" && target.targetType === "group" && target.targetId === scope.chatId;
|
|
144
149
|
}
|
|
150
|
+
/** Whether a write (`append` when `append` is true) may touch the target. */
|
|
151
|
+
export function canWriteMemory(scope, target, append) {
|
|
152
|
+
if (target.targetType === "owner")
|
|
153
|
+
return scope.kind === "owner";
|
|
154
|
+
return append || canReadMemory(scope, target);
|
|
155
|
+
}
|
|
156
|
+
/** Refusal for an owner.md write outside the owner DM, with where to write instead. */
|
|
157
|
+
export function ownerNoteWriteRefusal(scope) {
|
|
158
|
+
const base = "owner.md can only be changed in your owner's direct chat.";
|
|
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 };
|
|
165
|
+
}
|
|
145
166
|
export function memoryScopeRefusal(scope, target) {
|
|
146
167
|
const message = target.targetType === "owner"
|
|
147
168
|
? "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."
|
package/dist/src/outbound.js
CHANGED
|
@@ -583,15 +583,16 @@ export async function sendOpenclawClawlingMedia(params) {
|
|
|
583
583
|
* Emit a Protocol-v2 `message.reaction`. Fire-and-forget: reactions are NOT
|
|
584
584
|
* ack-aligned (the server mints the slot-key message_id, so the ack-by-trace_id
|
|
585
585
|
* path used for message.send/reply does not apply). Matches the mobile client.
|
|
586
|
+
* Local refusals are returned, not thrown; a transport failure still throws.
|
|
586
587
|
*/
|
|
587
588
|
export function sendOpenclawClawlingReaction(params) {
|
|
588
589
|
if (!isValidChatId(params.chatId)) {
|
|
589
590
|
params.log?.info?.(`clawchat reaction blocked chat_id=${params.chatId} reason=invalid_chat_id`);
|
|
590
|
-
return;
|
|
591
|
+
return { sent: false, reason: "invalid_chat_id" };
|
|
591
592
|
}
|
|
592
593
|
if (isOutboundBlocked(params.client, params.chatId)) {
|
|
593
594
|
params.log?.info?.(`clawchat reaction blocked chat_id=${params.chatId} reason=chat_rejected_by_server`);
|
|
594
|
-
return;
|
|
595
|
+
return { sent: false, reason: "chat_rejected_by_server" };
|
|
595
596
|
}
|
|
596
597
|
const payload = {
|
|
597
598
|
target_message_id: params.targetMessageId,
|
|
@@ -608,6 +609,7 @@ export function sendOpenclawClawlingReaction(params) {
|
|
|
608
609
|
};
|
|
609
610
|
params.client.sendWire(JSON.stringify(env));
|
|
610
611
|
params.log?.info?.(`clawchat reaction sent chat_id=${params.chatId} target=${params.targetMessageId} emoji=${params.emoji} removed=${params.removed}`);
|
|
612
|
+
return { sent: true };
|
|
611
613
|
}
|
|
612
614
|
// Crockford base32 alphabet (ULID spec).
|
|
613
615
|
const ULID_ENCODING = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
|
|
@@ -629,6 +631,30 @@ function ulid(now = Date.now()) {
|
|
|
629
631
|
}
|
|
630
632
|
return out.join("");
|
|
631
633
|
}
|
|
634
|
+
// Every message id shape a ClawChat message can carry. The server preserves a
|
|
635
|
+
// client-supplied id verbatim and never parses it, so this is deliberately a
|
|
636
|
+
// plausibility check, not the mint contract:
|
|
637
|
+
// - `msg-<ULID>`: clients (this plugin, mobile, hermes) and the server's own
|
|
638
|
+
// fallback mint; `msg_…` / short `msg-…` forms are kept for older ids;
|
|
639
|
+
// - `sys-<ULID>` / `sys-<request_id>`: server system injections;
|
|
640
|
+
// - a bare ULID: the server's user-message publisher;
|
|
641
|
+
// - `clawchat-plugin-openclaw-…`: this plugin's synthetic turn ids.
|
|
642
|
+
// UUIDs are not accepted: no ClawChat message id the model sees has that
|
|
643
|
+
// shape, while host transcript ids (e.g. from `sessions_search`) do.
|
|
644
|
+
const PREFIXED_MESSAGE_ID = /^(?:msg[-_]|sys-|clawchat-plugin-openclaw-)\S+$/i;
|
|
645
|
+
const BARE_ULID = /^[0-9A-HJKMNP-TV-Z]{26}$/i;
|
|
646
|
+
/**
|
|
647
|
+
* Whether `id` can be a real ClawChat message id. Rejects what a model makes
|
|
648
|
+
* up when it has no id (a label like `B`, `msg`, prose); the server only checks
|
|
649
|
+
* that a reaction target is non-empty, so such a value would "succeed" on no
|
|
650
|
+
* message. Callers trim first. Tools use this only as the fallback when the
|
|
651
|
+
* plugin store cannot answer whether it has seen the id in the chat.
|
|
652
|
+
*/
|
|
653
|
+
export function isPlausibleMessageId(id) {
|
|
654
|
+
if (typeof id !== "string")
|
|
655
|
+
return false;
|
|
656
|
+
return PREFIXED_MESSAGE_ID.test(id) || BARE_ULID.test(id);
|
|
657
|
+
}
|
|
632
658
|
/** Client-minted message id: `msg-` + ULID (protocol §7.6 format contract). */
|
|
633
659
|
export function mintMessageId() {
|
|
634
660
|
return `msg-${ulid()}`;
|
|
@@ -687,8 +713,22 @@ function markChannelOutboundAcknowledged(params) {
|
|
|
687
713
|
// Best-effort diagnostics only; ack has already succeeded.
|
|
688
714
|
}
|
|
689
715
|
}
|
|
716
|
+
/**
|
|
717
|
+
* Host approval prompts (exec and plugin approvals forwarded by
|
|
718
|
+
* `approvals.exec` / `approvals.plugin`) never go into a ClawChat group: anyone
|
|
719
|
+
* there could read the request, and only the owner may answer it. The host
|
|
720
|
+
* calls this before each forwarded prompt; throwing fails that one delivery.
|
|
721
|
+
*/
|
|
722
|
+
export function refuseGroupApprovalPrompt(params) {
|
|
723
|
+
if (params.hint?.kind !== "approval-pending")
|
|
724
|
+
return;
|
|
725
|
+
if (parseOpenclawRecipient(params.target.to).chatType === "group") {
|
|
726
|
+
throw new Error("clawchat-plugin-openclaw: approval prompts are not sent to ClawChat groups");
|
|
727
|
+
}
|
|
728
|
+
}
|
|
690
729
|
export const openclawClawlingOutbound = {
|
|
691
730
|
deliveryMode: "direct",
|
|
731
|
+
beforeDeliverPayload: (params) => refuseGroupApprovalPrompt(params),
|
|
692
732
|
chunker: (text, limit) => chunkMarkdownText(text, limit),
|
|
693
733
|
chunkerMode: "markdown",
|
|
694
734
|
textChunkLimit: 4000,
|