@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/dist/src/runtime.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { applyDmRecallDefault } from "./dm-recall-default.js";
|
|
2
2
|
import { HOST_FLOOR, HOST_TOOL_SEARCH_DEFAULT_ON, hostAtLeast, hostToolSearchEnabled } from "./host-version.js";
|
|
3
3
|
import { AckTimeoutError, AuthError, ProtocolError, StateError, TransportError, EVENT, recallTargetMessageId, } from "./protocol-types.js";
|
|
4
|
-
import { waitUntilAbort } from "openclaw/plugin-sdk/channel-
|
|
4
|
+
import { waitUntilAbort } from "openclaw/plugin-sdk/channel-outbound";
|
|
5
5
|
import { hasControlCommand } from "openclaw/plugin-sdk/command-detection";
|
|
6
6
|
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
|
|
7
7
|
import { createOpenclawClawlingClient, resolveOpenclawClawlingDeviceId } from "./client.js";
|
|
@@ -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
|
-
|
|
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
|
-
...(
|
|
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:
|
|
3154
|
+
nonOwnerTurn: turnClass === "non-owner",
|
|
3044
3155
|
log,
|
|
3045
3156
|
});
|
|
3046
3157
|
const agentsConfigured = Object.keys(cfg.agents ?? {});
|
package/dist/src/skill-update.js
CHANGED
|
@@ -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.
|
|
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
|
|
762
|
-
return jsonResponse(
|
|
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 (!
|
|
798
|
-
return jsonResponse(
|
|
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
package/skills/manifest.json
CHANGED
|
@@ -41,10 +41,10 @@
|
|
|
41
41
|
},
|
|
42
42
|
"hermes": {
|
|
43
43
|
"clawchat-core": {
|
|
44
|
-
"version": "1.17.
|
|
44
|
+
"version": "1.17.1",
|
|
45
45
|
"path": "hermes/clawchat-core/SKILL.md",
|
|
46
|
-
"sha256": "
|
|
47
|
-
"bytes":
|
|
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` (
|
|
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`
|
|
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>;
|