@clawling/clawchat-plugin-openclaw 2026.10.9-2 → 2026.10.9-4

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/README.md CHANGED
@@ -18,7 +18,7 @@ OpenClaw channel plugin that connects an agent to ClawChat over ClawChat Protoco
18
18
  npm i @clawling/clawchat-plugin-openclaw
19
19
  ```
20
20
 
21
- Requires `openclaw >= 2026.5.4` as a peer host.
21
+ Requires `openclaw >= 2026.8.1` as a peer host.
22
22
 
23
23
  Example LLM prompt:
24
24
 
package/dist/index.js CHANGED
@@ -2,6 +2,7 @@ import { defineChannelPluginEntry } from "openclaw/plugin-sdk/channel-core";
2
2
  import { openclawClawlingPlugin } from "./src/channel.js";
3
3
  import { registerOpenclawClawlingCommands } from "./src/commands.js";
4
4
  import { openclawClawlingConfigSchema } from "./src/config.js";
5
+ import { registerHostToolsGuard } from "./src/host-tools-guard.js";
5
6
  import { registerClawChatPromptInjection, } from "./src/prompt-injection.js";
6
7
  import { setOpenclawClawlingRuntime } from "./src/runtime.js";
7
8
  import { registerOpenclawClawlingTools } from "./src/tools.js";
@@ -15,6 +16,9 @@ const channelEntry = defineChannelPluginEntry({
15
16
  registerFull(api) {
16
17
  registerClawChatPromptInjection(api);
17
18
  registerOpenclawClawlingTools(api);
19
+ // Fail-closed host-tool guard for turns the owner did not start. After the
20
+ // tools so their names are recorded; not gated by conversation access.
21
+ registerHostToolsGuard(api);
18
22
  // NOTE: the legacy-rename config migration is intentionally NOT registered
19
23
  // here. The host's setup-migration runner only loads a plugin's SETUP source
20
24
  // (`openclaw.setupEntry`/`runtimeSetupEntry` → setup-entry.ts) and calls its
@@ -332,7 +332,7 @@ function buildSearchMatch(target, file, queryLower) {
332
332
  }
333
333
  return { ...target, matchedFields, snippets: snippets.slice(0, 3) };
334
334
  }
335
- export async function searchClawChatMemory(root, params) {
335
+ export async function searchClawChatMemory(root, params, options = {}) {
336
336
  const { query, queryLower, targetTypes, maxResults } = normalizeSearchParams(params);
337
337
  const rootPath = path.resolve(root);
338
338
  const rootRealPath = (await pathExists(rootPath)) ? await fs.realpath(rootPath) : rootPath;
@@ -340,6 +340,11 @@ export async function searchClawChatMemory(root, params) {
340
340
  for (const targetType of targetTypes) {
341
341
  const targets = await listSafeMemoryFiles(rootPath, rootRealPath, targetType);
342
342
  for (const target of targets) {
343
+ // Filter before reading, matching and counting so unreadable notes
344
+ // leave no trace in matches or `truncated`.
345
+ if (options.readable && !options.readable(target)) {
346
+ continue;
347
+ }
343
348
  const file = await readClawChatMemoryFile(rootPath, target);
344
349
  if (!file.exists) {
345
350
  continue;
@@ -90,6 +90,7 @@ export const openclawClawlingAccountConfigSchema = {
90
90
  "note-cap-group": { type: "integer" },
91
91
  "note-cap-turn": { type: "integer" },
92
92
  "delta-budget-chars": { type: "integer" },
93
+ "non-owner-host-tools": { type: "string", enum: ["off", "approve"] },
93
94
  reconnect: {
94
95
  type: "object",
95
96
  additionalProperties: false,
@@ -402,6 +403,10 @@ export const SESSION_INT_KEYS = {
402
403
  "note-cap-turn": [4000, 1000, 16000],
403
404
  "delta-budget-chars": [8000, 2000, 32000],
404
405
  };
406
+ /** `approve` only when explicitly set; any other value (typo, `on`, boolean) means `off`. */
407
+ export function parseNonOwnerHostTools(raw) {
408
+ return typeof raw === "string" && raw.trim().toLowerCase() === "approve" ? "approve" : "off";
409
+ }
405
410
  function readSessionInt(channel, key) {
406
411
  const [factory, minimum, maximum] = SESSION_INT_KEYS[key];
407
412
  const raw = channel[key];
@@ -513,6 +518,7 @@ export function resolveOpenclawClawlingAccount(cfg, accountId, env = process.env
513
518
  noteCapGroup: readSessionInt(channel, "note-cap-group"),
514
519
  noteCapTurn: readSessionInt(channel, "note-cap-turn"),
515
520
  deltaBudgetChars: readSessionInt(channel, "delta-budget-chars"),
521
+ nonOwnerHostTools: parseNonOwnerHostTools(channel["non-owner-host-tools"]),
516
522
  allowFrom: [],
517
523
  reconnect: readReconnect(channel.reconnect),
518
524
  heartbeat: readHeartbeat(channel.heartbeat),
@@ -0,0 +1,165 @@
1
+ import { CHANNEL_ID, normalizeOpenclawClawlingAccountId, resolveOpenclawClawlingAccount } from "./config.js";
2
+ import { classifyChat, clawChatSessionAccountId, NON_OWNER_TOOL_ALLOWLIST, normalizeToolChatId, parseClawChatSessionKey, } from "./turn-boundary.js";
3
+ import { clawChatDbPathForStateDir, getClawChatStore } from "./storage.js";
4
+ import { resolveOwnerIdentity } from "./owner-identity.js";
5
+ import { getRegisteredClawChatToolNames } from "./tools.js";
6
+ 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.";
7
+ /**
8
+ * Host tool-search controls (OpenClaw 2026.9.6+). The host adds them to every
9
+ * run on its own and builds their catalog from the run's allowed tools, so in
10
+ * a non-owner turn they reach only the allowlisted tools; the host fires this
11
+ * hook again for the inner tool a `tool_call` runs, and that inner call gets
12
+ * every check below. They are not part of the dispatch allowlist.
13
+ */
14
+ export const HOST_TOOL_SEARCH_CONTROLS = Object.freeze(["tool_search", "tool_describe", "tool_call"]);
15
+ const ACCOUNT_SCOPED_TOOLS = ["clawchat_mention_message", "clawchat_react_message"];
16
+ const block = () => ({ block: true, blockReason: BLOCK_MESSAGE });
17
+ const CLAWCHAT_SESSION_PREFIX = new RegExp(`^agent:[^:]+:${CHANNEL_ID}:`, "i");
18
+ const PATH_FIELD = /^(?:filePath|media|iconPath|path|attachments)$/i;
19
+ /** D8: only the ClawChat session-key prefix ties a call to ClawChat; requester channel alone never does. */
20
+ export function isClawChatToolCall(input) {
21
+ return CLAWCHAT_SESSION_PREFIX.test(input.sessionKey?.trim() ?? "");
22
+ }
23
+ /** True when any argument looks like a local file path; only http(s) URLs are allowed. */
24
+ export function hasLocalPathArgument(value, field = "") {
25
+ if (typeof value === "string") {
26
+ const text = value.trim();
27
+ if (/^https?:\/\//i.test(text))
28
+ return false;
29
+ if (/^(?:\/|[a-z]:[\\/]|\\\\|file:|~|\.\.?[\\/])/i.test(text))
30
+ return true;
31
+ return PATH_FIELD.test(field) && text.length > 0;
32
+ }
33
+ if (Array.isArray(value))
34
+ return value.some(item => hasLocalPathArgument(item, field));
35
+ return Boolean(value && typeof value === "object" && Object.entries(value).some(([key, item]) => hasLocalPathArgument(item, PATH_FIELD.test(field) ? field : key)));
36
+ }
37
+ /** Pure decision for one tool call. `undefined` lets the call run. */
38
+ export function decideHostToolCall(input) {
39
+ if (!isClawChatToolCall(input))
40
+ return undefined;
41
+ const parsed = parseClawChatSessionKey(input.sessionKey);
42
+ if (!parsed)
43
+ return block();
44
+ if (classifyChat({ ...parsed, ownerDirectConversationId: input.ownerDirectConversationId }) === "owner") {
45
+ return undefined;
46
+ }
47
+ // Outer pre-check only. The inner tool call is checked again and that check
48
+ // is authoritative: the host also accepts dotted/flattened argument keys
49
+ // that this outer check may not recognise.
50
+ if (hasLocalPathArgument(input.params))
51
+ return block();
52
+ if (HOST_TOOL_SEARCH_CONTROLS.includes(input.toolName))
53
+ return undefined;
54
+ if (ACCOUNT_SCOPED_TOOLS.includes(input.toolName)) {
55
+ // D9: a non-owner turn may only act on its own chat, named explicitly.
56
+ // The tool resolves the same argument with the same helper, so the chat
57
+ // checked here is the chat the call reaches.
58
+ const target = normalizeToolChatId(input.params.chatId).toLowerCase();
59
+ if (!target || target !== parsed.chatId) {
60
+ return {
61
+ block: true,
62
+ blockReason: input.toolName === "clawchat_react_message"
63
+ ? "Pass this chat's chatId to react here."
64
+ : "Pass this chat's chatId to mention here.",
65
+ };
66
+ }
67
+ }
68
+ if (NON_OWNER_TOOL_ALLOWLIST.includes(input.toolName)) {
69
+ return input.toolName.startsWith("clawchat_") && !input.registeredTools.has(input.toolName)
70
+ ? block()
71
+ : undefined;
72
+ }
73
+ return block(); // `off` and the reserved `approve` both block in this release.
74
+ }
75
+ export function registerHostToolsGuard(api, options = {}) {
76
+ const warned = new Set();
77
+ const warn = (failure) => {
78
+ if (warned.has(failure))
79
+ return;
80
+ warned.add(failure);
81
+ try {
82
+ api.logger.warn(`clawchat-plugin-openclaw: host-tool guard ${failure}; enforcing non-owner policy`);
83
+ }
84
+ catch {
85
+ // Logging cannot unlock a call.
86
+ }
87
+ };
88
+ const lookupOwnerConversation = (accountId) => {
89
+ if (options.ownerConversation)
90
+ return options.ownerConversation(accountId);
91
+ const account = resolveOpenclawClawlingAccount(api.config, accountId);
92
+ const stateDir = api.runtime.state?.resolveStateDir?.();
93
+ const store = getClawChatStore({
94
+ ...(stateDir ? { dbPath: clawChatDbPathForStateDir(stateDir) } : {}),
95
+ log: { error: message => api.logger.error?.(message) },
96
+ });
97
+ return resolveOwnerIdentity(store, accountId, account.ownerUserId).conversationId;
98
+ };
99
+ api.on("before_tool_call", (event, sdkCtx) => {
100
+ // `requester` is host 2026.8.1+ (the floor); the development SDK types predate it.
101
+ const ctx = sdkCtx;
102
+ try {
103
+ if (!ctx) {
104
+ warn("missing-context");
105
+ return block();
106
+ }
107
+ const sessionKey = ctx.sessionKey;
108
+ if (!isClawChatToolCall({ sessionKey }))
109
+ return undefined;
110
+ const requester = ctx.requester;
111
+ const parsed = parseClawChatSessionKey(sessionKey);
112
+ if (!parsed || (requester?.channel && requester.channel !== CHANNEL_ID)) {
113
+ warn("unknown-session");
114
+ return block();
115
+ }
116
+ // Direct keys carry their account; group keys do not. A group turn is
117
+ // non-owner whoever speaks, so a group call without the requester
118
+ // account (host tool-search controls and the inner calls they run get
119
+ // a run-level ctx without one) still gets the non-owner policy; only
120
+ // the tools that act as an account need it. Conflicting ids block.
121
+ const keyAccountId = clawChatSessionAccountId(sessionKey);
122
+ const requesterAccountId = requester?.accountId?.trim()
123
+ ? normalizeOpenclawClawlingAccountId(requester.accountId)
124
+ : undefined;
125
+ const accountId = requesterAccountId ?? keyAccountId;
126
+ if (keyAccountId && keyAccountId !== accountId) {
127
+ warn("account-identity");
128
+ return block();
129
+ }
130
+ if (!accountId) {
131
+ if (parsed.kind !== "group" || ACCOUNT_SCOPED_TOOLS.includes(event.toolName)) {
132
+ warn("account-identity");
133
+ return block();
134
+ }
135
+ return decideHostToolCall({
136
+ toolName: event.toolName,
137
+ params: event.params ?? {},
138
+ sessionKey,
139
+ ownerDirectConversationId: null,
140
+ registeredTools: getRegisteredClawChatToolNames(),
141
+ });
142
+ }
143
+ let ownerDirectConversationId = null;
144
+ try {
145
+ ownerDirectConversationId = lookupOwnerConversation(accountId);
146
+ }
147
+ catch {
148
+ // Treated as missing below; the allowlist still applies.
149
+ }
150
+ if (!ownerDirectConversationId?.trim())
151
+ warn("missing-owner-conversation");
152
+ return decideHostToolCall({
153
+ toolName: event.toolName,
154
+ params: event.params ?? {},
155
+ sessionKey,
156
+ ownerDirectConversationId,
157
+ registeredTools: getRegisteredClawChatToolNames(),
158
+ });
159
+ }
160
+ catch {
161
+ warn("handler-exception");
162
+ return block();
163
+ }
164
+ });
165
+ }
@@ -0,0 +1,35 @@
1
+ export const HOST_FLOOR = "2026.8.1";
2
+ function parse(v) {
3
+ const m = /^(\d+)\.(\d+)\.(\d+)/.exec(v.trim());
4
+ return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
5
+ }
6
+ /** Prerelease suffixes count as their base version (10.1-beta.2 ≥ 8.1). Unparseable ⇒ false. */
7
+ export function hostAtLeast(version, min) {
8
+ const a = version ? parse(version) : null;
9
+ const b = parse(min);
10
+ if (!a || !b)
11
+ return false;
12
+ for (let i = 0; i < 3; i++)
13
+ if (a[i] !== b[i])
14
+ return a[i] > b[i];
15
+ return true;
16
+ }
17
+ /** First host release where tool search is on unless `tools.toolSearch` turns it off. */
18
+ export const HOST_TOOL_SEARCH_DEFAULT_ON = "2026.9.6";
19
+ const isRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
20
+ /**
21
+ * Whether the host's `tools.toolSearch` setting leaves tool search on, read the
22
+ * way the host reads it (2026.9.6+): unset or `true` ⇒ on; `false` ⇒ off; an
23
+ * object ⇒ its `enabled` flag, else on only when it sets other options.
24
+ */
25
+ export function hostToolSearchEnabled(config) {
26
+ const tools = isRecord(config) ? config.tools : undefined;
27
+ const raw = isRecord(tools) ? tools.toolSearch : undefined;
28
+ if (raw === undefined || raw === true)
29
+ return true;
30
+ if (!isRecord(raw))
31
+ return false;
32
+ if (typeof raw.enabled === "boolean")
33
+ return raw.enabled;
34
+ return Object.keys(raw).some(key => key !== "enabled");
35
+ }
@@ -0,0 +1,166 @@
1
+ // Which ClawChat notes the conversation behind a memory / metadata tool call
2
+ // may read. A tool result stays in the calling session's history, and a group
3
+ // is one session for everyone in it, so a note read there can surface to
4
+ // anyone present later. Scope therefore follows the conversation (tool
5
+ // factory ctx), never a parameter or the process environment:
6
+ //
7
+ // owner DM (incl. plugin-started turns there) → every note
8
+ // friend DM → only users/<requesterSenderId>.md
9
+ // group → groups/<this chat>.md + users/<id>.md of present members
10
+ // anything else (unknown session, missing store / peer, contradictions,
11
+ // ambiguous chat-id case, resolution errors) → nothing
12
+ //
13
+ // Chat ids compare case-insensitively (the host lowercases session keys);
14
+ // the original case is recovered before any group file / SQL lookup. Member
15
+ // user ids stay case-sensitive.
16
+ import fs from "node:fs/promises";
17
+ import path from "node:path";
18
+ import { readClawChatMemoryFile } from "./clawchat-memory.js";
19
+ import { resolveOwnerIdentity } from "./owner-identity.js";
20
+ import { classifyTurn, parseClawChatSessionKey } from "./turn-boundary.js";
21
+ /** Rows scanned for recent speakers when a group note has no participant list. */
22
+ const SPEAKER_FALLBACK_ROWS = 200;
23
+ const nothing = () => ({ kind: "unknown", chatId: "", members: new Set() });
24
+ async function groupNoteIds(root) {
25
+ try {
26
+ return (await fs.readdir(path.join(root, "groups")))
27
+ .filter((name) => name.endsWith(".md"))
28
+ .map((name) => name.slice(0, -3));
29
+ }
30
+ catch (error) {
31
+ if (error.code === "ENOENT")
32
+ return [];
33
+ throw error;
34
+ }
35
+ }
36
+ function participantIds(metadata) {
37
+ return new Set((metadata.participant_ids ?? "").split(",").map((id) => id.trim()).filter(Boolean));
38
+ }
39
+ function inboundSenderId(rawJson) {
40
+ if (!rawJson)
41
+ return null;
42
+ const sender = JSON.parse(rawJson)?.sender?.id;
43
+ return typeof sender === "string" && sender.trim() ? sender.trim() : null;
44
+ }
45
+ export async function resolveMemoryScope(input) {
46
+ const { root, ctx, accountId, store, warn } = input;
47
+ try {
48
+ const parsed = parseClawChatSessionKey(ctx.sessionKey);
49
+ if (!parsed)
50
+ return nothing();
51
+ if (!store) {
52
+ warn?.("store-unavailable");
53
+ return nothing();
54
+ }
55
+ const lowered = parsed.chatId; // the parser lowercases
56
+ let chatId = lowered;
57
+ let caseKnown = false;
58
+ // 1. The trusted delivery route usually carries the original-case id.
59
+ const destination = ctx.deliveryContext?.to?.trim()
60
+ .replace(/^(?:clawchat-plugin-openclaw:)?(?:group|direct):/i, "");
61
+ if (destination && destination.toLowerCase() === lowered) {
62
+ chatId = destination;
63
+ caseKnown = true;
64
+ }
65
+ // 2. A group note filename, which wins: it is the file that will be read.
66
+ // Two notes differing only in case are ambiguous.
67
+ const fileMatches = (await groupNoteIds(root)).filter((id) => id.toLowerCase() === lowered);
68
+ if (fileMatches.length > 1) {
69
+ warn?.("ambiguous-chat-case");
70
+ return nothing();
71
+ }
72
+ const groupFileId = fileMatches[0];
73
+ if (groupFileId !== undefined) {
74
+ chatId = groupFileId;
75
+ caseKnown = true;
76
+ }
77
+ const members = groupFileId === undefined
78
+ ? new Set()
79
+ : participantIds((await readClawChatMemoryFile(root, { targetType: "group", targetId: groupFileId })).metadata);
80
+ if (parsed.kind === "direct") {
81
+ // A chat the plugin knows as a group can never be a direct chat.
82
+ if (members.size) {
83
+ warn?.("contradictory-chat-kind");
84
+ return nothing();
85
+ }
86
+ const ownerConversationId = resolveOwnerIdentity(store, accountId, input.configOwnerUserId, warn).conversationId;
87
+ if (classifyTurn({ sessionKey: ctx.sessionKey, ownerDirectConversationId: ownerConversationId }) === "owner") {
88
+ return { kind: "owner", chatId: ownerConversationId, members: new Set() };
89
+ }
90
+ const peer = typeof ctx.requesterSenderId === "string" ? ctx.requesterSenderId.trim() : "";
91
+ if (!peer)
92
+ warn?.("missing-dm-peer");
93
+ return { kind: "direct", chatId: parsed.chatId, members: new Set(peer ? [peer] : []) };
94
+ }
95
+ // 3. No cached participant list: recent inbound speakers. When neither the
96
+ // route nor a filename supplied the case, the case-insensitive query is
97
+ // the recovery path and its stored chat_id supplies the original case.
98
+ if (!members.size) {
99
+ if (!store.listRecentGroupMessages) {
100
+ warn?.("store-unavailable");
101
+ return nothing();
102
+ }
103
+ const rows = store.listRecentGroupMessages("openclaw", accountId, chatId, SPEAKER_FALLBACK_ROWS, {
104
+ ignoreCase: !caseKnown,
105
+ });
106
+ if (!caseKnown) {
107
+ const storedIds = new Set(rows.map((row) => row.chat_id)
108
+ .filter((id) => typeof id === "string" && id.toLowerCase() === lowered));
109
+ if (storedIds.size > 1) {
110
+ warn?.("ambiguous-chat-case");
111
+ return nothing();
112
+ }
113
+ if (storedIds.size === 1)
114
+ chatId = [...storedIds][0];
115
+ }
116
+ for (const row of rows) {
117
+ if (row.direction !== "inbound")
118
+ continue;
119
+ try {
120
+ const sender = inboundSenderId(row.raw_json);
121
+ if (sender)
122
+ members.add(sender);
123
+ }
124
+ catch {
125
+ warn?.("malformed-group-row");
126
+ }
127
+ }
128
+ }
129
+ return { kind: "group", chatId, members };
130
+ }
131
+ catch {
132
+ warn?.("scope-resolution");
133
+ return nothing();
134
+ }
135
+ }
136
+ export function canReadMemory(scope, target) {
137
+ if (scope.kind === "owner")
138
+ return true;
139
+ if (scope.kind === "unknown" || target.targetType === "owner")
140
+ return false;
141
+ if (target.targetType === "user")
142
+ return scope.members.has(target.targetId);
143
+ return scope.kind === "group" && target.targetType === "group" && target.targetId === scope.chatId;
144
+ }
145
+ export function memoryScopeRefusal(scope, target) {
146
+ const message = target.targetType === "owner"
147
+ ? "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."
148
+ : scope.kind === "direct"
149
+ ? "In this direct chat you can only read the note about the person you are talking to."
150
+ : scope.kind === "group"
151
+ ? "In a group you can only read this group's note and its members' notes."
152
+ : "This conversation could not be identified; no ClawChat notes are readable here.";
153
+ return { error: "not_readable_here", code: "memory_scope", message };
154
+ }
155
+ /** What a restricted search left out, for the model (never counts). */
156
+ export function memoryScopeNote(scope) {
157
+ if (scope.kind === "direct") {
158
+ return "In this direct chat only the note about the person you are talking to is searched; owner.md, other people's notes and group notes are left out.";
159
+ }
160
+ if (scope.kind === "group") {
161
+ return "In a group only this group's note and its members' notes are searched; owner.md, other groups' notes and notes about non-members are left out.";
162
+ }
163
+ if (scope.kind === "unknown")
164
+ return "This conversation could not be identified; no ClawChat notes were searched.";
165
+ return undefined;
166
+ }
@@ -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()}`;
@@ -0,0 +1,29 @@
1
+ // The one definition of "who is the owner, and which chat is the owner's DM"
2
+ // shared by dispatch, the host-tool guard and memory scope. The SQLite
3
+ // activation row decides; the config `ownerUserId` mirror is only a fallback
4
+ // once a working store has proved there is no activation row at all. An
5
+ // unavailable store, a failed read, a cleared/rejected token or an incomplete
6
+ // row never falls back to the mirror — the caller sees no owner (fail closed).
7
+ export function resolveOwnerIdentity(store, accountId, configOwnerUserId, warn, rejectedActivationToken) {
8
+ const denied = { ownerUserId: null, conversationId: null };
9
+ try {
10
+ const lookup = store?.lookupActivationIdentity?.({ platform: "openclaw", accountId });
11
+ if (!lookup || lookup.status === "unavailable") {
12
+ warn?.("owner-identity");
13
+ return denied;
14
+ }
15
+ if (lookup.status === "absent") {
16
+ return { ownerUserId: configOwnerUserId?.trim() || null, conversationId: null };
17
+ }
18
+ const { row } = lookup;
19
+ // Mirror the runtime overlay: never trust the activation rejected during this lifecycle.
20
+ if (!row.accessToken || row.accessToken === rejectedActivationToken)
21
+ return denied;
22
+ const ownerUserId = row.ownerUserId?.trim() || null;
23
+ return { ownerUserId, conversationId: ownerUserId ? row.conversationId?.trim() || null : null };
24
+ }
25
+ catch {
26
+ warn?.("owner-identity");
27
+ return denied;
28
+ }
29
+ }
@@ -268,6 +268,20 @@ function formatApprovalSummary(payload) {
268
268
  export function createOpenclawClawlingReplyDispatcher(options) {
269
269
  const { cfg, runtime, account, client, target, replyCtx, inboundMessageId, store, resolveOwnerConversationId, resolveOwnerLocale, log, } = options;
270
270
  const isGroupTarget = target.chatType === "group";
271
+ let warnedLocalReplyMedia = false;
272
+ // Non-owner turns may only send remote (http/https) media. Local sources are
273
+ // dropped here, before any load, because the host's local media roots include
274
+ // the agent workspace. Warns once per dispatcher and never names the source.
275
+ function filterReplyMedia(urls) {
276
+ if (!options.nonOwnerTurn)
277
+ return urls;
278
+ const remote = urls.filter((source) => /^https?:\/\//i.test(source.trim()));
279
+ if (remote.length !== urls.length && !warnedLocalReplyMedia) {
280
+ warnedLocalReplyMedia = true;
281
+ log?.warn?.("clawchat-plugin-openclaw: non-owner reply media dropped; only http(s) URLs are allowed");
282
+ }
283
+ return remote;
284
+ }
271
285
  const outputVisibility = effectiveOutputVisibility(account, target.chatId, target.chatType);
272
286
  const splitFullOutput = outputVisibility === "full";
273
287
  const splitNormalBlockOutput = outputVisibility === "normal";
@@ -275,12 +289,10 @@ export function createOpenclawClawlingReplyDispatcher(options) {
275
289
  // owner's usr_… id: chat_id is resolved by msghub through member-backend, and
276
290
  // a usr_… comes back "invalid conversation id" — the frame is dropped with no
277
291
  // negative ack to this client, so the owner silently never gets the approval
278
- // request. Both ids are required: ownerUserId gates whether an owner exists at
279
- // all, the conversation id is where a frame can actually be delivered.
292
+ // request. The resolver is the shared strict owner-identity lookup
293
+ // (src/owner-identity.ts): it yields a conversation only for an activation
294
+ // row with an owner, so the config `ownerUserId` mirror is not consulted.
280
295
  const ownerDirectTarget = () => {
281
- const ownerUserId = account.ownerUserId?.trim();
282
- if (!ownerUserId)
283
- return null;
284
296
  const conversationId = resolveOwnerConversationId?.()?.trim();
285
297
  return conversationId ? { chatId: conversationId, chatType: "direct" } : null;
286
298
  };
@@ -295,7 +307,8 @@ export function createOpenclawClawlingReplyDispatcher(options) {
295
307
  userId: account.userId,
296
308
  });
297
309
  };
298
- async function uploadMediaUrls(urls) {
310
+ async function uploadMediaUrls(inputUrls) {
311
+ const urls = filterReplyMedia(inputUrls);
299
312
  if (urls.length === 0)
300
313
  return [];
301
314
  const apiClient = buildApiClient();
@@ -551,10 +564,7 @@ export function createOpenclawClawlingReplyDispatcher(options) {
551
564
  const sendOwnerAttention = async (fallbackText, richFragment = null) => {
552
565
  const ownerTarget = ownerDirectTarget();
553
566
  if (!ownerTarget) {
554
- const reason = account.ownerUserId?.trim()
555
- ? "missing_owner_conversation"
556
- : "missing_owner_user_id";
557
- log?.error?.(`[${account.accountId}] clawchat-plugin-openclaw group owner attention suppressed reason=${reason} group=${target.chatId}`);
567
+ log?.error?.(`[${account.accountId}] clawchat-plugin-openclaw group owner attention suppressed reason=missing_owner_conversation group=${target.chatId}`);
558
568
  return;
559
569
  }
560
570
  await sendOpenclawClawlingText({
@@ -726,7 +736,7 @@ export function createOpenclawClawlingReplyDispatcher(options) {
726
736
  return;
727
737
  const richFragment = buildRichInteractionFragment(payload);
728
738
  const text = richFragment && account.richInteractions ? "" : resolvePayloadText(payload);
729
- const urls = resolveOutboundMediaUrls(payload).filter(Boolean);
739
+ const urls = filterReplyMedia(resolveOutboundMediaUrls(payload).filter(Boolean));
730
740
  log?.info?.(`[${account.accountId}] clawchat-plugin-openclaw deliver kind=${info?.kind ?? "unknown"} text_len=${text.length} media_urls=${urls.length} reasoning=${payload.isReasoning === true}`);
731
741
  if (isGroupTarget && richFragment) {
732
742
  if (info?.kind === "final")
@@ -820,6 +830,9 @@ export function createOpenclawClawlingReplyDispatcher(options) {
820
830
  log?.info?.(`[${account.accountId}] clawchat-plugin-openclaw final skipped: already delivered at a message boundary`);
821
831
  return;
822
832
  }
833
+ // A non-owner final whose only content was dropped local media sends nothing.
834
+ if (options.nonOwnerTurn && !finalText.trim() && !richFragment && finalUrls.length === 0)
835
+ return;
823
836
  const mediaFragments = await uploadMediaUrls(finalUrls);
824
837
  const result = await sendStatic(finalText, mediaFragments, richFragment && account.richInteractions ? [richFragment] : [], { recordMessage: true });
825
838
  if (result?.messageId)
@@ -897,7 +910,7 @@ export function createOpenclawClawlingReplyDispatcher(options) {
897
910
  if (consumeTerminalSend("reasoning"))
898
911
  return;
899
912
  const text = resolvePayloadText(payload);
900
- await emitFullRuntimeText("reasoning", text, resolveOutboundMediaUrls(payload).filter(Boolean));
913
+ await emitFullRuntimeText("reasoning", text, filterReplyMedia(resolveOutboundMediaUrls(payload).filter(Boolean)));
901
914
  const trimmed = text.trim();
902
915
  if (trimmed)
903
916
  reasoningText = reasoningText ? `${reasoningText}\n${trimmed}` : trimmed;
@@ -926,7 +939,7 @@ export function createOpenclawClawlingReplyDispatcher(options) {
926
939
  const text = resolvePayloadText(payload);
927
940
  if (isDefaultToolResultText(text))
928
941
  return;
929
- await emitFullRuntimeText("tool result", text, resolveOutboundMediaUrls(payload).filter(Boolean));
942
+ await emitFullRuntimeText("tool result", text, filterReplyMedia(resolveOutboundMediaUrls(payload).filter(Boolean)));
930
943
  }
931
944
  : undefined,
932
945
  onItemEvent: splitFullOutput