@clawling/clawchat-plugin-openclaw 2026.10.9-4 → 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.
@@ -33,6 +33,7 @@ import { clawChatDbPathForStateDir, getClawChatStore, makeOwnerProfilePersister,
33
33
  import { getClawChatGroupPrompt, getClawChatPlatformPrompt, getClawChatUserPrompt } from "./plugin-prompts.js";
34
34
  import { classifyChat, NON_OWNER_TOOL_ALLOWLIST } from "./turn-boundary.js";
35
35
  import { resolveOwnerIdentity } from "./owner-identity.js";
36
+ import { createApprovalsWriteGuard, listActiveAccountIds, reconcileApprovalsPlugin, resolveApprovalRoute, storedIdentityLookup, } from "./approval-route.js";
36
37
  import { loadClawChatPromptMetadata, renderClawChatProfilePrompt, resolveSenderRelation, } from "./profile-prompt.js";
37
38
  import { buildNoteMemorySection } from "./note-injection.js";
38
39
  import { buildGroupCatchUp, CATCH_UP_FETCH_ROWS, DeliveredGroupMessages } from "./group-catch-up.js";
@@ -110,6 +111,10 @@ const OPENCLAW_CONFIRM_SLASH_COMMANDS = new Set([
110
111
  "nevermind",
111
112
  ]);
112
113
  const GROUP_OWNER_ATTENTION_TITLE = "requires owner attention";
114
+ /** Group turns under `non-owner-host-tools: approve` (route live). */
115
+ const GROUP_APPROVAL_GUIDANCE = "Host tools here (commands, files, browser, sub-agents, scheduling and the like) run only if your owner approves each call: " +
116
+ "the request goes to your owner's direct chat and the call waits up to 5 minutes. " +
117
+ "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.";
113
118
  // §C.1 — user-visible message emitted on permanent token expiry. Kept
114
119
  // byte-identical to the Hermes plugin (parity spec §C.1.4). Points the owner
115
120
  // at the reconnect prompt (a code bound to this identity) rather than at a
@@ -733,10 +738,7 @@ export async function startOpenclawClawlingGateway(params) {
733
738
  hostAtLeast(runtime.version, HOST_TOOL_SEARCH_DEFAULT_ON) &&
734
739
  hostToolSearchEnabled(cfg)) {
735
740
  loggedHostToolSearch = true;
736
- 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)");
737
- }
738
- if (!params.boundaryWarningIssued && account.nonOwnerHostTools === "approve") {
739
- log?.warn?.("clawchat-plugin-openclaw: non-owner-host-tools approve is not supported yet; using off");
741
+ 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)");
740
742
  }
741
743
  log?.info?.(`[${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)"}`);
742
744
  // Freeze one report device_id for this process; reused for the paired report
@@ -2161,6 +2163,108 @@ export async function startOpenclawClawlingGateway(params) {
2161
2163
  // forwards) would be unreachable. Registered lazily — the activation
2162
2164
  // conversation may be recorded after the client is constructed.
2163
2165
  client.setOwnerDirectChatResolver(resolveOwnerDirectConversationId);
2166
+ // `non-owner-host-tools: approve` routes group host-tool approvals to the
2167
+ // owner's direct chat through the host's `approvals.plugin`
2168
+ // (approval-route.ts). Checked once at start and again from a group turn
2169
+ // that finds the route missing (the owner chat may be recorded later). One
2170
+ // attempt per owner chat per lifecycle, so a failing write cannot loop.
2171
+ const readLiveConfig = () => {
2172
+ const current = runtime.config?.current;
2173
+ return (typeof current === "function" ? current.call(runtime.config) : cfg);
2174
+ };
2175
+ const approvalReconcileAttempts = new Set();
2176
+ const hasStoredIdentity = storedIdentityLookup(store);
2177
+ const approvalsStateDir = (() => {
2178
+ try {
2179
+ return runtime.state?.resolveStateDir?.();
2180
+ }
2181
+ catch {
2182
+ return undefined;
2183
+ }
2184
+ })();
2185
+ const reconcileApprovalRoute = () => {
2186
+ const mutateConfigFile = resolveMutateConfigFile();
2187
+ if (!mutateConfigFile) {
2188
+ if (approvalReconcileAttempts.has("no-config-writes"))
2189
+ return;
2190
+ approvalReconcileAttempts.add("no-config-writes");
2191
+ try {
2192
+ const live = readLiveConfig();
2193
+ if (resolveOpenclawClawlingAccount(live, accountId).nonOwnerHostTools !== "approve")
2194
+ return;
2195
+ const route = resolveApprovalRoute({
2196
+ liveConfig: live,
2197
+ accountId,
2198
+ activeAccountIds: listActiveAccountIds(live, { knownActive: accountId, hasStoredIdentity }),
2199
+ approve: true,
2200
+ ownerConversationId: resolveOwnerDirectConversationId(),
2201
+ });
2202
+ if (route.ready)
2203
+ return;
2204
+ }
2205
+ catch {
2206
+ // Fall through: say why approve is not routing.
2207
+ }
2208
+ log?.warn?.(`[${accountId}] clawchat-plugin-openclaw: non-owner-host-tools approve cannot write approvals.plugin (host config writes unavailable); group host-tool calls stay blocked`);
2209
+ return;
2210
+ }
2211
+ let ownerChat = null;
2212
+ try {
2213
+ ownerChat = resolveOwnerDirectConversationId();
2214
+ }
2215
+ catch {
2216
+ ownerChat = null;
2217
+ }
2218
+ const attemptKey = ownerChat ?? "";
2219
+ if (approvalReconcileAttempts.has(attemptKey))
2220
+ return;
2221
+ approvalReconcileAttempts.add(attemptKey);
2222
+ void reconcileApprovalsPlugin({
2223
+ readConfig: readLiveConfig,
2224
+ mutateConfigFile: mutateConfigFile,
2225
+ ownerConversation: (id) => (id === accountId ? ownerChat : null),
2226
+ knownActive: accountId,
2227
+ hasStoredIdentity,
2228
+ ...(approvalsStateDir ? { writeGuard: createApprovalsWriteGuard(approvalsStateDir) } : {}),
2229
+ log: {
2230
+ info: (m) => log?.info?.(`[${accountId}] ${m}`),
2231
+ warn: (m) => {
2232
+ const warn = log?.warn;
2233
+ if (warn)
2234
+ warn(`[${accountId}] ${m}`);
2235
+ else
2236
+ log?.info?.(`[${accountId}] WARNING ${m}`);
2237
+ },
2238
+ error: (m) => log?.error?.(`[${accountId}] ${m}`),
2239
+ },
2240
+ }).catch(() => {
2241
+ // Logged inside; the route simply stays not ready (calls block).
2242
+ });
2243
+ };
2244
+ const groupApprovalRouteReady = (ownerChat) => {
2245
+ try {
2246
+ const live = readLiveConfig();
2247
+ const approve = resolveOpenclawClawlingAccount(live, accountId).nonOwnerHostTools === "approve";
2248
+ if (!approve)
2249
+ return false;
2250
+ const route = resolveApprovalRoute({
2251
+ liveConfig: live,
2252
+ accountId,
2253
+ activeAccountIds: listActiveAccountIds(live, { knownActive: accountId, hasStoredIdentity }),
2254
+ approve,
2255
+ ownerConversationId: ownerChat,
2256
+ });
2257
+ if (!route.ready && (route.reason === "not-applied" || route.reason === "no-owner-chat")) {
2258
+ reconcileApprovalRoute();
2259
+ }
2260
+ return route.ready;
2261
+ }
2262
+ catch {
2263
+ return false;
2264
+ }
2265
+ };
2266
+ if (!params.boundaryWarningIssued)
2267
+ reconcileApprovalRoute();
2164
2268
  const sendOwnerSkillMessage = async (text) => {
2165
2269
  const conversationId = resolveOwnerDirectConversationId();
2166
2270
  if (!conversationId) {
@@ -2881,11 +2985,18 @@ export async function startOpenclawClawlingGateway(params) {
2881
2985
  chatId: turn.peer.id,
2882
2986
  ownerDirectConversationId: ownerConversationId,
2883
2987
  });
2884
- const narrowHostTools = turnClass === "non-owner";
2988
+ // `non-owner-host-tools: approve`: a group turn keeps the host tools once
2989
+ // the approval route to the owner's direct chat is live; the hook then
2990
+ // asks the owner for each call. Friend direct chats always narrow.
2991
+ const groupApproval = turnClass === "non-owner"
2992
+ && turn.peer.kind === "group"
2993
+ && groupApprovalRouteReady(ownerConversationId);
2994
+ const narrowHostTools = turnClass === "non-owner" && !groupApproval;
2885
2995
  const baseTurnPrompt = await buildPromptForTurn(turn, memoryRoot);
2886
2996
  const nonOwnerMediaGuidance = "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.";
2887
2997
  const turnPrompt = [
2888
- ...(narrowHostTools ? [getClawChatPlatformPrompt(), nonOwnerMediaGuidance] : []),
2998
+ ...(turnClass === "non-owner" ? [getClawChatPlatformPrompt(), nonOwnerMediaGuidance] : []),
2999
+ ...(groupApproval ? [GROUP_APPROVAL_GUIDANCE] : []),
2889
3000
  baseTurnPrompt,
2890
3001
  ].filter(Boolean).join("\n\n");
2891
3002
  const ctxPayload = resolveChannelContextBuilder(rt)({
@@ -3040,7 +3151,7 @@ export async function startOpenclawClawlingGateway(params) {
3040
3151
  // conversation, not `ownerUserId` (a usr_… chat_id is rejected by
3041
3152
  // member-backend). Passed as a thunk so it is resolved at send time.
3042
3153
  resolveOwnerConversationId: resolveOwnerDirectConversationId,
3043
- nonOwnerTurn: narrowHostTools,
3154
+ nonOwnerTurn: turnClass === "non-owner",
3044
3155
  log,
3045
3156
  });
3046
3157
  const agentsConfigured = Object.keys(cfg.agents ?? {});
@@ -66,7 +66,7 @@ export const OFFICIAL_SKILLS_BASE = "https://raw.githubusercontent.com/clawling/
66
66
  * in the install-cli repo, bump this constant, ship it. `liveware-sample.ts`
67
67
  * imports the same ref, so the `livewares` tree at that tag is pinned too.
68
68
  */
69
- export const DEFAULT_SKILLS_REF = "skills-v1.17.0";
69
+ export const DEFAULT_SKILLS_REF = "skills-v1.17.1";
70
70
  /** Refuse to treat an absurdly large response as a skill file (defence in depth). */
71
71
  export const MAX_SKILL_BYTES = 256 * 1024;
72
72
  /** This adapter's host target inside `skills/manifest.json`. */
package/dist/src/tools.js CHANGED
@@ -13,7 +13,7 @@ import { isValidChatId } from "./ws-client.js";
13
13
  import { getOpenclawClawlingClient, } from "./runtime.js";
14
14
  import { markTerminalClawChatSend, getCurrentTerminalSendScope } from "./terminal-send.js";
15
15
  import { editClawChatMemoryBody, readClawChatMemoryFile, resolveClawChatMemoryPath, searchClawChatMemory, writeClawChatMemoryBody, } from "./clawchat-memory.js";
16
- import { canReadMemory, memoryScopeNote, memoryScopeRefusal, resolveMemoryScope, } from "./memory-scope.js";
16
+ import { canReadMemory, canWriteMemory, memoryScopeNote, memoryScopeRefusal, ownerNoteWriteRefusal, resolveMemoryScope, } from "./memory-scope.js";
17
17
  import { clawChatSessionAccountId, NON_OWNER_TOOL_ALLOWLIST, normalizeToolChatId } from "./turn-boundary.js";
18
18
  import { pullGroupMetadata, pullOwnerMetadata, pullUserMetadata, pushMetadata, updateMetadata, } from "./clawchat-metadata.js";
19
19
  import { ClawchatGetAccountProfileSchema, ClawchatGetConversationSchema, ClawchatGetDirectConversationSchema, ClawchatGetMomentSchema, ClawchatGetUserProfileSchema, ClawchatLeaveGroupSchema, ClawchatAddGroupMemberSchema, ClawchatAcceptFriendRequestSchema, ClawchatMemoryEditSchema, ClawchatMemoryReadSchema, ClawchatMemorySearchSchema, ClawchatMemoryWriteSchema, ClawchatMetadataSyncSchema, ClawchatMetadataUpdateSchema, ClawchatCreateMomentCommentSchema, ClawchatCreateMomentSchema, ClawchatDeleteMomentCommentSchema, ClawchatDeleteMomentSchema, ClawchatListAccountFriendsSchema, ClawchatListFriendRequestsSchema, ClawchatListMomentsSchema, ClawchatMentionMessageSchema, ClawchatReactMessageSchema, ClawchatReplyMomentCommentSchema, ClawchatRejectFriendRequestSchema, ClawchatRemoveFriendSchema, ClawchatSearchUsersSchema, ClawchatSendFriendRequestSchema, ClawchatToggleMomentReactionSchema, ClawchatUpdateAccountProfileSchema, ClawchatUploadAvatarImageSchema, ClawchatRegisterAppSchema, ClawchatListAppsSchema, ClawchatUnregisterAppSchema, ClawchatLivewareLoginSchema, ClawchatOrchListAgentsSchema, ClawchatOrchGetAgentSchema, ClawchatOrchSetAgentBehaviorSchema, ClawchatOrchListGroupsSchema, ClawchatOrchGetGroupSchema, ClawchatOrchSetGroupPromptSchema, ClawchatOrchCreateGroupSchema, ClawchatOrchGroupMemberSchema, ClawchatOrchSetGroupAgentSettingsSchema, ClawchatOrchCreateConnectCodeSchema, ClawchatOrchGetConnectCodeSchema, } from "./tools-schema.js";
@@ -135,7 +135,7 @@ const MEMORY_READ_MAX_LIMIT = 100_000;
135
135
  function toolDescription(...parts) {
136
136
  return `${parts.join("")} ${DIRECT_TOOL_GUARD}`;
137
137
  }
138
- const MEMORY_SCOPE_DESCRIPTION = "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.";
138
+ const MEMORY_SCOPE_DESCRIPTION = "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.";
139
139
  function memoryToolDescription(...parts) {
140
140
  return `${parts.join("")} ${MEMORY_SCOPE_DESCRIPTION}`;
141
141
  }
@@ -757,9 +757,12 @@ export function registerOpenclawClawlingTools(api, options = {}) {
757
757
  const target = targetFromParams(p);
758
758
  const scope = await memoryScopeFor(ctx, root);
759
759
  readable = canReadMemory(scope, target);
760
- // Replacing wipes what is there; append may go to any note.
761
- if (p.mode !== "append" && !readable)
762
- return jsonResponse(memoryScopeRefusal(scope, target));
760
+ // Replacing wipes what is there; append may go to any note but owner.md.
761
+ if (!canWriteMemory(scope, target, p.mode === "append")) {
762
+ return jsonResponse(target.targetType === "owner"
763
+ ? ownerNoteWriteRefusal(scope)
764
+ : memoryScopeRefusal(scope, target));
765
+ }
763
766
  await writeClawChatMemoryBody(root, target, p.mode, p.content);
764
767
  return jsonResponse({ ok: true, targetType: p.targetType, targetId: p.targetId, mode: p.mode });
765
768
  }
@@ -794,8 +797,11 @@ export function registerOpenclawClawlingTools(api, options = {}) {
794
797
  return memoryToolError(new Error(targetError));
795
798
  const target = targetFromParams(p);
796
799
  const scope = await memoryScopeFor(ctx, root);
797
- if (!canReadMemory(scope, target))
798
- return jsonResponse(memoryScopeRefusal(scope, target));
800
+ if (!canWriteMemory(scope, target, false)) {
801
+ return jsonResponse(target.targetType === "owner"
802
+ ? ownerNoteWriteRefusal(scope)
803
+ : memoryScopeRefusal(scope, target));
804
+ }
799
805
  await editClawChatMemoryBody(root, target, p.oldText, p.newText);
800
806
  return jsonResponse({ ok: true, targetType: p.targetType, targetId: p.targetId });
801
807
  }
package/index.ts CHANGED
@@ -49,6 +49,17 @@ const channelEntry = defineChannelPluginEntry({
49
49
  * instead. Each load registers the commands once; a duplicate registration
50
50
  * would mark the plugin as errored on the host side.
51
51
  */
52
+ /**
53
+ * The host-tools guard (`before_tool_call`) follows the same rule, for the
54
+ * same reason: on OpenClaw 2026.8.x the agent run uses the hook runner of the
55
+ * request-scoped `discovery` registry, so a guard registered only in
56
+ * `registerFull` never fires there (measured on 2026.8.2: the call ran with no
57
+ * hook invocation). Every load except `cli-metadata` gets the guard exactly
58
+ * once: `registerFull` covers `full` / `tool-discovery`, the wrapper covers
59
+ * the rest.
60
+ */
61
+ const FULL_REGISTRATION_MODES = new Set(["full", "tool-discovery"]);
62
+
52
63
  export default {
53
64
  // Newer hosts expose `configSchema` as a lazy getter; the spread evaluates it
54
65
  // once here, which is fine because the schema is a static object.
@@ -57,6 +68,7 @@ export default {
57
68
  channelEntry.register(api);
58
69
  if (api.registrationMode !== "cli-metadata") {
59
70
  registerOpenclawClawlingCommands(api);
71
+ if (!FULL_REGISTRATION_MODES.has(String(api.registrationMode))) registerHostToolsGuard(api);
60
72
  }
61
73
  },
62
74
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clawling/clawchat-plugin-openclaw",
3
- "version": "2026.10.9-4",
3
+ "version": "2026.10.11-1",
4
4
  "description": "OpenClaw ClawChat channel plugin",
5
5
  "license": "MIT",
6
6
  "author": "CLAWLING PTE. LTD.",
@@ -41,10 +41,10 @@
41
41
  },
42
42
  "hermes": {
43
43
  "clawchat-core": {
44
- "version": "1.17.0",
44
+ "version": "1.17.1",
45
45
  "path": "hermes/clawchat-core/SKILL.md",
46
- "sha256": "b3038b3a540645a19224ce198fdf77f1fbb109b843a3a80fe803a955d027d689",
47
- "bytes": 27668
46
+ "sha256": "2c235cdca892c3553ce883494e0af71b1bb91c04e7f63bc544788f90951a3484",
47
+ "bytes": 28209
48
48
  },
49
49
  "clawchat-liveware": {
50
50
  "version": "1.3.0",
@@ -0,0 +1,346 @@
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 type { OpenClawConfig } from "openclaw/plugin-sdk";
20
+ import { CHANNEL_ID, listOpenclawClawlingAccountIds, resolveOpenclawClawlingAccount } from "./config.ts";
21
+
22
+ type Rec = Record<string, unknown>;
23
+
24
+ /** Substring of every ClawChat group session key (`agent:<id>:<channel>:group:<chat>`). */
25
+ export const APPROVAL_SESSION_FILTER = `:${CHANNEL_ID}:group:`;
26
+
27
+ export type ApprovalTarget = { accountId: string; to: string };
28
+
29
+ export type ApprovalsPluginState =
30
+ | { kind: "absent" }
31
+ | { kind: "managed"; target: ApprovalTarget }
32
+ | { kind: "foreign" };
33
+
34
+ export type ApprovalRouteBlockReason =
35
+ | "not-approve"
36
+ | "multiple-accounts"
37
+ | "no-owner-chat"
38
+ | "foreign-config"
39
+ | "not-applied";
40
+
41
+ export type ApprovalRoute =
42
+ | { ready: true; target: ApprovalTarget }
43
+ | { ready: false; reason: ApprovalRouteBlockReason };
44
+
45
+ function isRec(value: unknown): value is Rec {
46
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
47
+ }
48
+
49
+ const norm = (value: unknown) => (typeof value === "string" ? value.trim().toLowerCase() : "");
50
+ /** Keys holding a value; a loader may add `undefined` placeholders. */
51
+ const definedKeys = (rec: Rec) => Object.keys(rec).filter((key) => rec[key] !== undefined).sort().join(",");
52
+
53
+ /**
54
+ * Enabled ClawChat accounts in use: those whose config section carries an
55
+ * identity (token or user id), those with a stored activation
56
+ * (`hasStoredIdentity`, the SQLite row; credentials may live only there), plus
57
+ * `knownActive` (an account the caller knows is running). Every caller must
58
+ * count the same way, or two accounts' runtimes would disagree about the one
59
+ * global block. The root section always lists as an account, so an empty root
60
+ * next to named accounts does not count.
61
+ */
62
+ export function listActiveAccountIds(
63
+ cfg: OpenClawConfig,
64
+ options: { knownActive?: string; hasStoredIdentity?: (accountId: string) => boolean } = {},
65
+ ): string[] {
66
+ const { knownActive, hasStoredIdentity } = options;
67
+ const ids = new Set<string>();
68
+ for (const id of listOpenclawClawlingAccountIds(cfg)) {
69
+ const account = resolveOpenclawClawlingAccount(cfg, id);
70
+ if (!account.enabled) continue;
71
+ let stored = false;
72
+ try {
73
+ stored = hasStoredIdentity?.(account.accountId) === true;
74
+ } catch {
75
+ // Unknown counts as in use: more accounts only ever blocks.
76
+ stored = true;
77
+ }
78
+ if (account.token || account.userId || stored || norm(id) === norm(knownActive)) ids.add(account.accountId);
79
+ }
80
+ if (knownActive?.trim() && ![...ids].some((id) => norm(id) === norm(knownActive))) ids.add(knownActive.trim());
81
+ return [...ids];
82
+ }
83
+
84
+ /** The exact `approvals.plugin` block the plugin writes for `target`. */
85
+ export function buildManagedApprovalsPlugin(target: ApprovalTarget): Rec {
86
+ return {
87
+ enabled: true,
88
+ mode: "targets",
89
+ sessionFilter: [APPROVAL_SESSION_FILTER],
90
+ targets: [{ channel: CHANNEL_ID, to: target.to, accountId: target.accountId }],
91
+ };
92
+ }
93
+
94
+ /** Classify the host's `approvals.plugin`; only the exact managed shape is ours. */
95
+ export function readApprovalsPluginState(cfg: unknown): ApprovalsPluginState {
96
+ const approvals = isRec(cfg) ? cfg.approvals : undefined;
97
+ if (approvals === undefined || (isRec(approvals) && approvals.plugin === undefined)) {
98
+ return { kind: "absent" };
99
+ }
100
+ if (!isRec(approvals) || !isRec(approvals.plugin)) return { kind: "foreign" };
101
+ const block = approvals.plugin;
102
+ if (definedKeys(block) !== "enabled,mode,sessionFilter,targets") return { kind: "foreign" };
103
+ if (block.enabled !== true || block.mode !== "targets") return { kind: "foreign" };
104
+ const filter = block.sessionFilter;
105
+ if (!Array.isArray(filter) || filter.length !== 1 || filter[0] !== APPROVAL_SESSION_FILTER) {
106
+ return { kind: "foreign" };
107
+ }
108
+ const targets = block.targets;
109
+ if (!Array.isArray(targets) || targets.length !== 1 || !isRec(targets[0])) return { kind: "foreign" };
110
+ const target = targets[0];
111
+ if (definedKeys(target) !== "accountId,channel,to") return { kind: "foreign" };
112
+ if (target.channel !== CHANNEL_ID) return { kind: "foreign" };
113
+ const to = typeof target.to === "string" ? target.to.trim() : "";
114
+ const accountId = typeof target.accountId === "string" ? target.accountId.trim() : "";
115
+ if (!to || !accountId) return { kind: "foreign" };
116
+ return { kind: "managed", target: { to, accountId } };
117
+ }
118
+
119
+ /**
120
+ * The target the managed block should hold now, or `null` when none should
121
+ * exist. Approve routes only with exactly one active ClawChat account: the
122
+ * block is global, so a second account's owner would see the first one's
123
+ * requests.
124
+ */
125
+ export function desiredApprovalTarget(input: {
126
+ activeAccountIds: readonly string[];
127
+ approveAccountIds: readonly string[];
128
+ ownerConversation: (accountId: string) => string | null;
129
+ }): { target: ApprovalTarget | null; reason?: ApprovalRouteBlockReason } {
130
+ if (input.approveAccountIds.length === 0) return { target: null, reason: "not-approve" };
131
+ if (input.activeAccountIds.length !== 1) return { target: null, reason: "multiple-accounts" };
132
+ const accountId = input.activeAccountIds[0]!;
133
+ if (!input.approveAccountIds.includes(accountId)) return { target: null, reason: "not-approve" };
134
+ const to = input.ownerConversation(accountId)?.trim();
135
+ if (!to) return { target: null, reason: "no-owner-chat" };
136
+ return { target: { accountId, to } };
137
+ }
138
+
139
+ export type ApprovalsPluginWrite =
140
+ | { action: "none" }
141
+ | { action: "set"; target: ApprovalTarget }
142
+ | { action: "remove" };
143
+
144
+ /** What to write so `approvals.plugin` matches `desired`, never touching a foreign block. */
145
+ export function planApprovalsPluginWrite(
146
+ current: ApprovalsPluginState,
147
+ desired: ApprovalTarget | null,
148
+ ): ApprovalsPluginWrite {
149
+ if (current.kind === "foreign") return { action: "none" };
150
+ if (!desired) return current.kind === "managed" ? { action: "remove" } : { action: "none" };
151
+ // Never take the block over from another account: that account's runtime
152
+ // would take it back (each write restarts a pre-9.2 gateway). Drop it.
153
+ if (current.kind === "managed" && norm(current.target.accountId) !== norm(desired.accountId)) {
154
+ return { action: "remove" };
155
+ }
156
+ if (
157
+ current.kind === "managed" &&
158
+ norm(current.target.to) === norm(desired.to) &&
159
+ norm(current.target.accountId) === norm(desired.accountId)
160
+ ) {
161
+ return { action: "none" };
162
+ }
163
+ return { action: "set", target: desired };
164
+ }
165
+
166
+ /** Apply `plan` to a config draft in place. */
167
+ export function applyApprovalsPluginWrite(draft: Rec, plan: ApprovalsPluginWrite): void {
168
+ if (plan.action === "none") return;
169
+ const approvals = isRec(draft.approvals) ? { ...draft.approvals } : {};
170
+ if (plan.action === "set") {
171
+ approvals.plugin = buildManagedApprovalsPlugin(plan.target);
172
+ draft.approvals = approvals;
173
+ return;
174
+ }
175
+ delete approvals.plugin;
176
+ if (Object.keys(approvals).length === 0) delete draft.approvals;
177
+ else draft.approvals = approvals;
178
+ }
179
+
180
+ /**
181
+ * Whether a group call on `accountId` can be sent for approval now: the live
182
+ * config must already hold the managed block for exactly this account and its
183
+ * current owner chat. Everything else blocks.
184
+ */
185
+ export function resolveApprovalRoute(input: {
186
+ liveConfig: unknown;
187
+ accountId: string;
188
+ activeAccountIds: readonly string[];
189
+ approve: boolean;
190
+ ownerConversationId: string | null;
191
+ }): ApprovalRoute {
192
+ if (!input.approve) return { ready: false, reason: "not-approve" };
193
+ if (input.activeAccountIds.length !== 1 || norm(input.activeAccountIds[0]) !== norm(input.accountId)) {
194
+ return { ready: false, reason: "multiple-accounts" };
195
+ }
196
+ const owner = input.ownerConversationId?.trim();
197
+ if (!owner) return { ready: false, reason: "no-owner-chat" };
198
+ const state = readApprovalsPluginState(input.liveConfig);
199
+ if (state.kind === "foreign") return { ready: false, reason: "foreign-config" };
200
+ if (
201
+ state.kind !== "managed" ||
202
+ norm(state.target.accountId) !== norm(input.accountId) ||
203
+ norm(state.target.to) !== norm(owner)
204
+ ) {
205
+ return { ready: false, reason: "not-applied" };
206
+ }
207
+ return { ready: true, target: state.target };
208
+ }
209
+
210
+ export const APPROVAL_ROUTE_WARNINGS: Record<ApprovalRouteBlockReason, string> = {
211
+ "not-approve": "",
212
+ "multiple-accounts":
213
+ "non-owner-host-tools approve needs exactly one ClawChat account on this gateway; group host-tool calls stay blocked",
214
+ "no-owner-chat":
215
+ "non-owner-host-tools approve has no owner direct chat yet (activate first); group host-tool calls stay blocked",
216
+ "foreign-config":
217
+ "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",
218
+ "not-applied":
219
+ "non-owner-host-tools approve is waiting for the gateway to load approvals.plugin; group host-tool calls stay blocked until it does",
220
+ };
221
+
222
+ type ReconcileLog = { info?: (msg: string) => void; warn?: (msg: string) => void; error?: (msg: string) => void };
223
+ type MutateConfigFile = (params: {
224
+ afterWrite: { mode: "auto" };
225
+ mutate: (draft: OpenClawConfig) => void;
226
+ }) => Promise<unknown>;
227
+
228
+ /**
229
+ * Bring `approvals.plugin` in line with the accounts' `non-owner-host-tools`:
230
+ * write the managed block when approve can route, remove a managed block that
231
+ * no longer applies, and never touch the owner's own block. The write lets the
232
+ * host decide how to load it (`afterWrite: auto`): OpenClaw 2026.9.2+ applies
233
+ * `approvals.plugin` without a restart, earlier hosts restart the gateway once.
234
+ */
235
+ export async function reconcileApprovalsPlugin(params: {
236
+ readConfig: () => OpenClawConfig;
237
+ mutateConfigFile: MutateConfigFile;
238
+ ownerConversation: (accountId: string) => string | null;
239
+ /** The account whose runtime is calling; it counts as in use. */
240
+ knownActive?: string;
241
+ hasStoredIdentity?: (accountId: string) => boolean;
242
+ /**
243
+ * Durable guard against write loops: `seen(key)` is true when the same write
244
+ * was already made recently (a pre-9.2 gateway restarts after each write, so
245
+ * an in-memory guard would not survive). `record(key)` remembers one.
246
+ */
247
+ writeGuard?: { seen: (key: string) => boolean; record: (key: string) => void };
248
+ log?: ReconcileLog;
249
+ }): Promise<ApprovalsPluginWrite["action"] | "failed"> {
250
+ const cfg = params.readConfig();
251
+ const active = listActiveAccountIds(cfg, {
252
+ knownActive: params.knownActive,
253
+ hasStoredIdentity: params.hasStoredIdentity,
254
+ });
255
+ const approveIds = active.filter((id) => resolveOpenclawClawlingAccount(cfg, id).nonOwnerHostTools === "approve");
256
+ const desired = desiredApprovalTarget({
257
+ activeAccountIds: active,
258
+ approveAccountIds: approveIds,
259
+ ownerConversation: params.ownerConversation,
260
+ });
261
+ const current = readApprovalsPluginState(cfg);
262
+ if (approveIds.length > 0) {
263
+ const reason = current.kind === "foreign" ? "foreign-config" : desired.reason;
264
+ if (reason && reason !== "not-approve") params.log?.warn?.(`clawchat-plugin-openclaw: ${APPROVAL_ROUTE_WARNINGS[reason]}`);
265
+ }
266
+ const planned = planApprovalsPluginWrite(current, desired.target);
267
+ if (planned.action === "none") return "none";
268
+ const guardKey = planned.action === "set"
269
+ ? `set:${norm(planned.target.accountId)}:${norm(planned.target.to)}`
270
+ : "remove";
271
+ try {
272
+ if (params.writeGuard?.seen(guardKey)) {
273
+ params.log?.warn?.(
274
+ "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)",
275
+ );
276
+ return "none";
277
+ }
278
+ params.writeGuard?.record(guardKey);
279
+ } catch {
280
+ // A broken guard must not stop the write; the per-lifecycle guard still applies.
281
+ }
282
+ let action = "none" as ApprovalsPluginWrite["action"];
283
+ try {
284
+ await params.mutateConfigFile({
285
+ afterWrite: { mode: "auto" },
286
+ mutate(draft) {
287
+ // Re-plan against the file as it is now, not the snapshot read above.
288
+ const plan = planApprovalsPluginWrite(readApprovalsPluginState(draft), desired.target);
289
+ action = plan.action;
290
+ applyApprovalsPluginWrite(draft as unknown as Rec, plan);
291
+ },
292
+ });
293
+ } catch (err) {
294
+ params.log?.error?.(
295
+ `clawchat-plugin-openclaw: approvals.plugin update failed; group host-tool calls stay blocked (${err instanceof Error ? err.message : String(err)})`,
296
+ );
297
+ return "failed";
298
+ }
299
+ if (action === "set") {
300
+ params.log?.info?.(
301
+ "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",
302
+ );
303
+ } else if (action === "remove") {
304
+ params.log?.info?.("clawchat-plugin-openclaw: removed the plugin-managed approvals.plugin (approve is no longer in use)");
305
+ }
306
+ return action;
307
+ }
308
+
309
+ export const APPROVALS_WRITE_MARKER_FILENAME = "clawchat-approvals-plugin-write.json";
310
+ const WRITE_GUARD_WINDOW_MS = 10 * 60_000;
311
+
312
+ /**
313
+ * File-backed `writeGuard` in the plugin's state dir: the same write is made
314
+ * at most once per 10 minutes, across gateway restarts.
315
+ */
316
+ export function createApprovalsWriteGuard(stateDir: string, now: () => number = Date.now) {
317
+ const file = path.join(stateDir, APPROVALS_WRITE_MARKER_FILENAME);
318
+ const read = (): { key?: unknown; at?: unknown } => {
319
+ try {
320
+ const parsed = JSON.parse(fs.readFileSync(file, "utf8")) as unknown;
321
+ return isRec(parsed) ? parsed : {};
322
+ } catch {
323
+ return {};
324
+ }
325
+ };
326
+ return {
327
+ seen: (key: string) => {
328
+ const last = read();
329
+ return last.key === key && typeof last.at === "number" && now() - last.at < WRITE_GUARD_WINDOW_MS;
330
+ },
331
+ record: (key: string) => {
332
+ fs.mkdirSync(stateDir, { recursive: true });
333
+ fs.writeFileSync(file, JSON.stringify({ key, at: now() }));
334
+ },
335
+ };
336
+ }
337
+
338
+ /** `hasStoredIdentity` from the plugin store; an unavailable store counts every account. */
339
+ export function storedIdentityLookup(
340
+ store: { lookupActivationIdentity?: (input: { platform: string; accountId: string }) => { status: string } } | null | undefined,
341
+ ): (accountId: string) => boolean {
342
+ return (accountId) => {
343
+ const lookup = store?.lookupActivationIdentity?.({ platform: "openclaw", accountId });
344
+ return !lookup || lookup.status !== "absent";
345
+ };
346
+ }
package/src/config.ts CHANGED
@@ -125,7 +125,7 @@ export type OpenclawClawlingAccountConfig = {
125
125
  "note-cap-group"?: number;
126
126
  "note-cap-turn"?: number;
127
127
  "delta-budget-chars"?: number;
128
- /** Host tools in turns the owner did not start: `off` | `approve` (approve currently behaves as off). */
128
+ /** Host tools in turns the owner did not start: `off` | `approve` (group calls ask the owner; see approval-route.ts). */
129
129
  "non-owner-host-tools"?: string;
130
130
  reconnect?: OpenclawClawlingReconnectConfig;
131
131
  heartbeat?: OpenclawClawlingHeartbeatConfig;
@@ -340,7 +340,7 @@ export type ResolvedOpenclawClawlingAccount = {
340
340
  noteCapTurn: number;
341
341
  /** `delta-budget-chars`: characters of unseen group messages an @-mention adds. */
342
342
  deltaBudgetChars: number;
343
- /** `non-owner-host-tools`: parsed mode; `approve` is reserved and currently behaves like `off`. */
343
+ /** `non-owner-host-tools`: parsed mode; `approve` sends group host-tool calls to the owner for approval. */
344
344
  nonOwnerHostTools: "off" | "approve";
345
345
  allowFrom: string[];
346
346
  reconnect: Required<OpenclawClawlingReconnectConfig>;