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

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/src/config.ts CHANGED
@@ -125,6 +125,8 @@ 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). */
129
+ "non-owner-host-tools"?: string;
128
130
  reconnect?: OpenclawClawlingReconnectConfig;
129
131
  heartbeat?: OpenclawClawlingHeartbeatConfig;
130
132
  ack?: OpenclawClawlingAckConfig;
@@ -190,6 +192,7 @@ export const openclawClawlingAccountConfigSchema = {
190
192
  "note-cap-group": { type: "integer" },
191
193
  "note-cap-turn": { type: "integer" },
192
194
  "delta-budget-chars": { type: "integer" },
195
+ "non-owner-host-tools": { type: "string", enum: ["off", "approve"] },
193
196
  reconnect: {
194
197
  type: "object",
195
198
  additionalProperties: false,
@@ -337,6 +340,8 @@ export type ResolvedOpenclawClawlingAccount = {
337
340
  noteCapTurn: number;
338
341
  /** `delta-budget-chars`: characters of unseen group messages an @-mention adds. */
339
342
  deltaBudgetChars: number;
343
+ /** `non-owner-host-tools`: parsed mode; `approve` is reserved and currently behaves like `off`. */
344
+ nonOwnerHostTools: "off" | "approve";
340
345
  allowFrom: string[];
341
346
  reconnect: Required<OpenclawClawlingReconnectConfig>;
342
347
  heartbeat: Required<OpenclawClawlingHeartbeatConfig>;
@@ -597,6 +602,11 @@ export const SESSION_INT_KEYS = {
597
602
  "delta-budget-chars": [8000, 2000, 32000],
598
603
  } as const satisfies Record<string, readonly [number, number, number]>;
599
604
 
605
+ /** `approve` only when explicitly set; any other value (typo, `on`, boolean) means `off`. */
606
+ export function parseNonOwnerHostTools(raw: unknown): "off" | "approve" {
607
+ return typeof raw === "string" && raw.trim().toLowerCase() === "approve" ? "approve" : "off";
608
+ }
609
+
600
610
  function readSessionInt(channel: Record<string, unknown>, key: keyof typeof SESSION_INT_KEYS): number {
601
611
  const [factory, minimum, maximum] = SESSION_INT_KEYS[key];
602
612
  const raw = channel[key];
@@ -739,6 +749,7 @@ export function resolveOpenclawClawlingAccount(
739
749
  noteCapGroup: readSessionInt(channel, "note-cap-group"),
740
750
  noteCapTurn: readSessionInt(channel, "note-cap-turn"),
741
751
  deltaBudgetChars: readSessionInt(channel, "delta-budget-chars"),
752
+ nonOwnerHostTools: parseNonOwnerHostTools(channel["non-owner-host-tools"]),
742
753
  allowFrom: [],
743
754
  reconnect: readReconnect(channel.reconnect),
744
755
  heartbeat: readHeartbeat(channel.heartbeat),
@@ -0,0 +1,186 @@
1
+ // Layer 2 of the non-owner turn boundary: a fail-closed `before_tool_call`
2
+ // hook. Dispatch (layer 1) already narrows the offered tools for turns the
3
+ // owner did not start; this hook blocks anything else that still reaches
4
+ // execution. Only calls whose session key carries the ClawChat prefix are
5
+ // in scope (D8); everything else passes untouched. Tool arguments are never
6
+ // logged.
7
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
8
+ import { CHANNEL_ID, normalizeOpenclawClawlingAccountId, resolveOpenclawClawlingAccount } from "./config.ts";
9
+ import {
10
+ classifyChat,
11
+ clawChatSessionAccountId,
12
+ NON_OWNER_TOOL_ALLOWLIST,
13
+ parseClawChatSessionKey,
14
+ } from "./turn-boundary.ts";
15
+ import { clawChatDbPathForStateDir, getClawChatStore } from "./storage.ts";
16
+ import { resolveOwnerIdentity } from "./owner-identity.ts";
17
+ import { getRegisteredClawChatToolNames } from "./tools.ts";
18
+
19
+ export const BLOCK_MESSAGE =
20
+ "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.";
21
+
22
+ export type HostToolDecision = { block: true; blockReason: string } | undefined;
23
+ export type HostToolInput = {
24
+ toolName: string;
25
+ params: Record<string, unknown>;
26
+ sessionKey?: string;
27
+ ownerDirectConversationId?: string | null;
28
+ registeredTools: ReadonlySet<string>;
29
+ };
30
+
31
+ /**
32
+ * Host tool-search controls (OpenClaw 2026.9.6+). The host adds them to every
33
+ * run on its own and builds their catalog from the run's allowed tools, so in
34
+ * a non-owner turn they reach only the allowlisted tools; the host fires this
35
+ * hook again for the inner tool a `tool_call` runs, and that inner call gets
36
+ * every check below. They are not part of the dispatch allowlist.
37
+ */
38
+ export const HOST_TOOL_SEARCH_CONTROLS: readonly string[] = Object.freeze(["tool_search", "tool_describe", "tool_call"]);
39
+ const ACCOUNT_SCOPED_TOOLS: readonly string[] = ["clawchat_mention_message", "clawchat_react_message"];
40
+
41
+ const block = (): HostToolDecision => ({ block: true, blockReason: BLOCK_MESSAGE });
42
+ const CLAWCHAT_SESSION_PREFIX = new RegExp(`^agent:[^:]+:${CHANNEL_ID}:`, "i");
43
+ const PATH_FIELD = /^(?:filePath|media|iconPath|path|attachments)$/i;
44
+
45
+ /** D8: only the ClawChat session-key prefix ties a call to ClawChat; requester channel alone never does. */
46
+ export function isClawChatToolCall(input: Pick<HostToolInput, "sessionKey">): boolean {
47
+ return CLAWCHAT_SESSION_PREFIX.test(input.sessionKey?.trim() ?? "");
48
+ }
49
+
50
+ /** True when any argument looks like a local file path; only http(s) URLs are allowed. */
51
+ export function hasLocalPathArgument(value: unknown, field = ""): boolean {
52
+ if (typeof value === "string") {
53
+ const text = value.trim();
54
+ if (/^https?:\/\//i.test(text)) return false;
55
+ if (/^(?:\/|[a-z]:[\\/]|\\\\|file:|~|\.\.?[\\/])/i.test(text)) return true;
56
+ return PATH_FIELD.test(field) && text.length > 0;
57
+ }
58
+ if (Array.isArray(value)) return value.some(item => hasLocalPathArgument(item, field));
59
+ return Boolean(value && typeof value === "object" && Object.entries(value).some(
60
+ ([key, item]) => hasLocalPathArgument(item, PATH_FIELD.test(field) ? field : key),
61
+ ));
62
+ }
63
+
64
+ /** Pure decision for one tool call. `undefined` lets the call run. */
65
+ export function decideHostToolCall(input: HostToolInput): HostToolDecision {
66
+ if (!isClawChatToolCall(input)) return undefined;
67
+ const parsed = parseClawChatSessionKey(input.sessionKey);
68
+ if (!parsed) return block();
69
+ if (classifyChat({ ...parsed, ownerDirectConversationId: input.ownerDirectConversationId }) === "owner") {
70
+ return undefined;
71
+ }
72
+ // Outer pre-check only. The inner tool call is checked again and that check
73
+ // is authoritative: the host also accepts dotted/flattened argument keys
74
+ // that this outer check may not recognise.
75
+ if (hasLocalPathArgument(input.params)) return block();
76
+ if (HOST_TOOL_SEARCH_CONTROLS.includes(input.toolName)) return undefined;
77
+ if (ACCOUNT_SCOPED_TOOLS.includes(input.toolName)) {
78
+ // D9: a non-owner turn may only act on its own chat, named explicitly.
79
+ const target = input.params.chatId;
80
+ if (typeof target !== "string" || !target.trim() || target.trim().toLowerCase() !== parsed.chatId) {
81
+ return {
82
+ block: true,
83
+ blockReason: input.toolName === "clawchat_react_message"
84
+ ? "Pass this chat's chatId to react here."
85
+ : "Pass this chat's chatId to mention here.",
86
+ };
87
+ }
88
+ }
89
+ if (NON_OWNER_TOOL_ALLOWLIST.includes(input.toolName)) {
90
+ return input.toolName.startsWith("clawchat_") && !input.registeredTools.has(input.toolName)
91
+ ? block()
92
+ : undefined;
93
+ }
94
+ return block(); // `off` and the reserved `approve` both block in this release.
95
+ }
96
+
97
+ type RequesterContext = { channel?: string; accountId?: string };
98
+
99
+ export function registerHostToolsGuard(
100
+ api: OpenClawPluginApi,
101
+ options: { ownerConversation?: (accountId: string) => string | null } = {},
102
+ ): void {
103
+ const warned = new Set<string>();
104
+ const warn = (failure: string) => {
105
+ if (warned.has(failure)) return;
106
+ warned.add(failure);
107
+ try {
108
+ api.logger.warn(`clawchat-plugin-openclaw: host-tool guard ${failure}; enforcing non-owner policy`);
109
+ } catch {
110
+ // Logging cannot unlock a call.
111
+ }
112
+ };
113
+ const lookupOwnerConversation = (accountId: string): string | null => {
114
+ if (options.ownerConversation) return options.ownerConversation(accountId);
115
+ const account = resolveOpenclawClawlingAccount(api.config, accountId);
116
+ const stateDir = api.runtime.state?.resolveStateDir?.();
117
+ const store = getClawChatStore({
118
+ ...(stateDir ? { dbPath: clawChatDbPathForStateDir(stateDir) } : {}),
119
+ log: { error: message => api.logger.error?.(message) },
120
+ });
121
+ return resolveOwnerIdentity(store, accountId, account.ownerUserId).conversationId;
122
+ };
123
+
124
+ api.on("before_tool_call", (event, sdkCtx) => {
125
+ // `requester` is host 2026.8.1+ (the floor); the development SDK types predate it.
126
+ const ctx = sdkCtx as (typeof sdkCtx & { requester?: RequesterContext }) | undefined;
127
+ try {
128
+ if (!ctx) {
129
+ warn("missing-context");
130
+ return block();
131
+ }
132
+ const sessionKey = ctx.sessionKey;
133
+ if (!isClawChatToolCall({ sessionKey })) return undefined;
134
+ const requester = ctx.requester;
135
+ const parsed = parseClawChatSessionKey(sessionKey);
136
+ if (!parsed || (requester?.channel && requester.channel !== CHANNEL_ID)) {
137
+ warn("unknown-session");
138
+ return block();
139
+ }
140
+ // Direct keys carry their account; group keys do not. A group turn is
141
+ // non-owner whoever speaks, so a group call without the requester
142
+ // account (host tool-search controls and the inner calls they run get
143
+ // a run-level ctx without one) still gets the non-owner policy; only
144
+ // the tools that act as an account need it. Conflicting ids block.
145
+ const keyAccountId = clawChatSessionAccountId(sessionKey);
146
+ const requesterAccountId = requester?.accountId?.trim()
147
+ ? normalizeOpenclawClawlingAccountId(requester.accountId)
148
+ : undefined;
149
+ const accountId = requesterAccountId ?? keyAccountId;
150
+ if (keyAccountId && keyAccountId !== accountId) {
151
+ warn("account-identity");
152
+ return block();
153
+ }
154
+ if (!accountId) {
155
+ if (parsed.kind !== "group" || ACCOUNT_SCOPED_TOOLS.includes(event.toolName)) {
156
+ warn("account-identity");
157
+ return block();
158
+ }
159
+ return decideHostToolCall({
160
+ toolName: event.toolName,
161
+ params: event.params ?? {},
162
+ sessionKey,
163
+ ownerDirectConversationId: null,
164
+ registeredTools: getRegisteredClawChatToolNames(),
165
+ });
166
+ }
167
+ let ownerDirectConversationId: string | null = null;
168
+ try {
169
+ ownerDirectConversationId = lookupOwnerConversation(accountId);
170
+ } catch {
171
+ // Treated as missing below; the allowlist still applies.
172
+ }
173
+ if (!ownerDirectConversationId?.trim()) warn("missing-owner-conversation");
174
+ return decideHostToolCall({
175
+ toolName: event.toolName,
176
+ params: event.params ?? {},
177
+ sessionKey,
178
+ ownerDirectConversationId,
179
+ registeredTools: getRegisteredClawChatToolNames(),
180
+ });
181
+ } catch {
182
+ warn("handler-exception");
183
+ return block();
184
+ }
185
+ });
186
+ }
@@ -0,0 +1,35 @@
1
+ export const HOST_FLOOR = "2026.8.1";
2
+
3
+ function parse(v: string): [number, number, number] | null {
4
+ const m = /^(\d+)\.(\d+)\.(\d+)/.exec(v.trim());
5
+ return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
6
+ }
7
+
8
+ /** Prerelease suffixes count as their base version (10.1-beta.2 ≥ 8.1). Unparseable ⇒ false. */
9
+ export function hostAtLeast(version: string | undefined, min: string): boolean {
10
+ const a = version ? parse(version) : null;
11
+ const b = parse(min);
12
+ if (!a || !b) return false;
13
+ for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i] > b[i];
14
+ return true;
15
+ }
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
+
20
+ const isRecord = (v: unknown): v is Record<string, unknown> =>
21
+ typeof v === "object" && v !== null && !Array.isArray(v);
22
+
23
+ /**
24
+ * Whether the host's `tools.toolSearch` setting leaves tool search on, read the
25
+ * way the host reads it (2026.9.6+): unset or `true` ⇒ on; `false` ⇒ off; an
26
+ * object ⇒ its `enabled` flag, else on only when it sets other options.
27
+ */
28
+ export function hostToolSearchEnabled(config: unknown): boolean {
29
+ const tools = isRecord(config) ? config.tools : undefined;
30
+ const raw = isRecord(tools) ? tools.toolSearch : undefined;
31
+ if (raw === undefined || raw === true) return true;
32
+ if (!isRecord(raw)) return false;
33
+ if (typeof raw.enabled === "boolean") return raw.enabled;
34
+ return Object.keys(raw).some(key => key !== "enabled");
35
+ }
@@ -0,0 +1,191 @@
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, type ClawChatMemoryTarget } from "./clawchat-memory.ts";
19
+ import { resolveOwnerIdentity } from "./owner-identity.ts";
20
+ import type { ClawChatStore } from "./storage.ts";
21
+ import { classifyTurn, parseClawChatSessionKey } from "./turn-boundary.ts";
22
+
23
+ export type MemoryToolContext = {
24
+ sessionKey?: string;
25
+ requesterSenderId?: string | null;
26
+ deliveryContext?: { to?: string };
27
+ };
28
+
29
+ export type MemoryScope = {
30
+ kind: "owner" | "direct" | "group" | "unknown";
31
+ chatId: string;
32
+ members: ReadonlySet<string>;
33
+ };
34
+
35
+ export type MemoryScopeStore = Partial<Pick<ClawChatStore, "lookupActivationIdentity" | "listRecentGroupMessages">>;
36
+
37
+ /** Rows scanned for recent speakers when a group note has no participant list. */
38
+ const SPEAKER_FALLBACK_ROWS = 200;
39
+
40
+ const nothing = (): MemoryScope => ({ kind: "unknown", chatId: "", members: new Set() });
41
+
42
+ async function groupNoteIds(root: string): Promise<string[]> {
43
+ try {
44
+ return (await fs.readdir(path.join(root, "groups")))
45
+ .filter((name) => name.endsWith(".md"))
46
+ .map((name) => name.slice(0, -3));
47
+ } catch (error) {
48
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
49
+ throw error;
50
+ }
51
+ }
52
+
53
+ function participantIds(metadata: Record<string, string>): Set<string> {
54
+ return new Set((metadata.participant_ids ?? "").split(",").map((id) => id.trim()).filter(Boolean));
55
+ }
56
+
57
+ function inboundSenderId(rawJson: string | null): string | null {
58
+ if (!rawJson) return null;
59
+ const sender = (JSON.parse(rawJson) as { sender?: { id?: unknown } } | null)?.sender?.id;
60
+ return typeof sender === "string" && sender.trim() ? sender.trim() : null;
61
+ }
62
+
63
+ export async function resolveMemoryScope(input: {
64
+ root: string;
65
+ ctx: MemoryToolContext;
66
+ accountId: string;
67
+ configOwnerUserId?: string | null;
68
+ store: MemoryScopeStore | null;
69
+ warn?: (failureClass: string) => void;
70
+ }): Promise<MemoryScope> {
71
+ const { root, ctx, accountId, store, warn } = input;
72
+ try {
73
+ const parsed = parseClawChatSessionKey(ctx.sessionKey);
74
+ if (!parsed) return nothing();
75
+ if (!store) {
76
+ warn?.("store-unavailable");
77
+ return nothing();
78
+ }
79
+ const lowered = parsed.chatId; // the parser lowercases
80
+ let chatId = lowered;
81
+ let caseKnown = false;
82
+
83
+ // 1. The trusted delivery route usually carries the original-case id.
84
+ const destination = ctx.deliveryContext?.to?.trim()
85
+ .replace(/^(?:clawchat-plugin-openclaw:)?(?:group|direct):/i, "");
86
+ if (destination && destination.toLowerCase() === lowered) {
87
+ chatId = destination;
88
+ caseKnown = true;
89
+ }
90
+
91
+ // 2. A group note filename, which wins: it is the file that will be read.
92
+ // Two notes differing only in case are ambiguous.
93
+ const fileMatches = (await groupNoteIds(root)).filter((id) => id.toLowerCase() === lowered);
94
+ if (fileMatches.length > 1) {
95
+ warn?.("ambiguous-chat-case");
96
+ return nothing();
97
+ }
98
+ const groupFileId = fileMatches[0];
99
+ if (groupFileId !== undefined) {
100
+ chatId = groupFileId;
101
+ caseKnown = true;
102
+ }
103
+ const members = groupFileId === undefined
104
+ ? new Set<string>()
105
+ : participantIds((await readClawChatMemoryFile(root, { targetType: "group", targetId: groupFileId })).metadata);
106
+
107
+ if (parsed.kind === "direct") {
108
+ // A chat the plugin knows as a group can never be a direct chat.
109
+ if (members.size) {
110
+ warn?.("contradictory-chat-kind");
111
+ return nothing();
112
+ }
113
+ const ownerConversationId = resolveOwnerIdentity(store, accountId, input.configOwnerUserId, warn).conversationId;
114
+ if (classifyTurn({ sessionKey: ctx.sessionKey, ownerDirectConversationId: ownerConversationId }) === "owner") {
115
+ return { kind: "owner", chatId: ownerConversationId!, members: new Set() };
116
+ }
117
+ const peer = typeof ctx.requesterSenderId === "string" ? ctx.requesterSenderId.trim() : "";
118
+ if (!peer) warn?.("missing-dm-peer");
119
+ return { kind: "direct", chatId: parsed.chatId, members: new Set(peer ? [peer] : []) };
120
+ }
121
+
122
+ // 3. No cached participant list: recent inbound speakers. When neither the
123
+ // route nor a filename supplied the case, the case-insensitive query is
124
+ // the recovery path and its stored chat_id supplies the original case.
125
+ if (!members.size) {
126
+ if (!store.listRecentGroupMessages) {
127
+ warn?.("store-unavailable");
128
+ return nothing();
129
+ }
130
+ const rows = store.listRecentGroupMessages("openclaw", accountId, chatId, SPEAKER_FALLBACK_ROWS, {
131
+ ignoreCase: !caseKnown,
132
+ });
133
+ if (!caseKnown) {
134
+ const storedIds = new Set(rows.map((row) => row.chat_id)
135
+ .filter((id): id is string => typeof id === "string" && id.toLowerCase() === lowered));
136
+ if (storedIds.size > 1) {
137
+ warn?.("ambiguous-chat-case");
138
+ return nothing();
139
+ }
140
+ if (storedIds.size === 1) chatId = [...storedIds][0]!;
141
+ }
142
+ for (const row of rows) {
143
+ if (row.direction !== "inbound") continue;
144
+ try {
145
+ const sender = inboundSenderId(row.raw_json);
146
+ if (sender) members.add(sender);
147
+ } catch {
148
+ warn?.("malformed-group-row");
149
+ }
150
+ }
151
+ }
152
+ return { kind: "group", chatId, members };
153
+ } catch {
154
+ warn?.("scope-resolution");
155
+ return nothing();
156
+ }
157
+ }
158
+
159
+ export function canReadMemory(scope: MemoryScope, target: ClawChatMemoryTarget): boolean {
160
+ if (scope.kind === "owner") return true;
161
+ if (scope.kind === "unknown" || target.targetType === "owner") return false;
162
+ if (target.targetType === "user") return scope.members.has(target.targetId);
163
+ return scope.kind === "group" && target.targetType === "group" && target.targetId === scope.chatId;
164
+ }
165
+
166
+ export function memoryScopeRefusal(scope: MemoryScope, target: ClawChatMemoryTarget): {
167
+ error: "not_readable_here";
168
+ code: "memory_scope";
169
+ message: string;
170
+ } {
171
+ const message = target.targetType === "owner"
172
+ ? "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."
173
+ : scope.kind === "direct"
174
+ ? "In this direct chat you can only read the note about the person you are talking to."
175
+ : scope.kind === "group"
176
+ ? "In a group you can only read this group's note and its members' notes."
177
+ : "This conversation could not be identified; no ClawChat notes are readable here.";
178
+ return { error: "not_readable_here", code: "memory_scope", message };
179
+ }
180
+
181
+ /** What a restricted search left out, for the model (never counts). */
182
+ export function memoryScopeNote(scope: MemoryScope): string | undefined {
183
+ if (scope.kind === "direct") {
184
+ 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.";
185
+ }
186
+ if (scope.kind === "group") {
187
+ 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.";
188
+ }
189
+ if (scope.kind === "unknown") return "This conversation could not be identified; no ClawChat notes were searched.";
190
+ return undefined;
191
+ }
@@ -0,0 +1,49 @@
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
+
8
+ export type ActivationIdentityLookup =
9
+ | { status: "unavailable" }
10
+ | { status: "absent" }
11
+ | {
12
+ status: "row";
13
+ row: { ownerUserId: string | null; conversationId: string | null; accessToken: string | null };
14
+ };
15
+
16
+ /** Structural so focused fixtures can omit unrelated store methods. */
17
+ export type OwnerIdentityStore = {
18
+ lookupActivationIdentity?(input: { platform: string; accountId: string }): ActivationIdentityLookup;
19
+ };
20
+
21
+ export type OwnerIdentity = { ownerUserId: string | null; conversationId: string | null };
22
+
23
+ export function resolveOwnerIdentity(
24
+ store: OwnerIdentityStore | null | undefined,
25
+ accountId: string,
26
+ configOwnerUserId?: string | null,
27
+ warn?: (failure: string) => void,
28
+ rejectedActivationToken?: string,
29
+ ): OwnerIdentity {
30
+ const denied: OwnerIdentity = { ownerUserId: null, conversationId: null };
31
+ try {
32
+ const lookup = store?.lookupActivationIdentity?.({ platform: "openclaw", accountId });
33
+ if (!lookup || lookup.status === "unavailable") {
34
+ warn?.("owner-identity");
35
+ return denied;
36
+ }
37
+ if (lookup.status === "absent") {
38
+ return { ownerUserId: configOwnerUserId?.trim() || null, conversationId: null };
39
+ }
40
+ const { row } = lookup;
41
+ // Mirror the runtime overlay: never trust the activation rejected during this lifecycle.
42
+ if (!row.accessToken || row.accessToken === rejectedActivationToken) return denied;
43
+ const ownerUserId = row.ownerUserId?.trim() || null;
44
+ return { ownerUserId, conversationId: ownerUserId ? row.conversationId?.trim() || null : null };
45
+ } catch {
46
+ warn?.("owner-identity");
47
+ return denied;
48
+ }
49
+ }
@@ -88,7 +88,13 @@ export interface ReplyDispatcherOptions {
88
88
  * framework error final; null/absent means English.
89
89
  */
90
90
  resolveOwnerLocale?: () => string | null;
91
- log?: { info?: (m: string) => void; error?: (m: string) => void };
91
+ /**
92
+ * True when the turn was not started by the owner (any group turn, any
93
+ * non-owner direct chat). Reply media is then limited to http(s) URLs; local
94
+ * paths are never loaded. Legacy standalone callers default to owner behavior.
95
+ */
96
+ nonOwnerTurn?: boolean;
97
+ log?: { info?: (m: string) => void; warn?: (m: string) => void; error?: (m: string) => void };
92
98
  }
93
99
 
94
100
  type TypedReplyDispatcherResult = ReturnType<
@@ -435,6 +441,19 @@ export function createOpenclawClawlingReplyDispatcher(options: ReplyDispatcherOp
435
441
  log,
436
442
  } = options;
437
443
  const isGroupTarget = target.chatType === "group";
444
+ let warnedLocalReplyMedia = false;
445
+ // Non-owner turns may only send remote (http/https) media. Local sources are
446
+ // dropped here, before any load, because the host's local media roots include
447
+ // the agent workspace. Warns once per dispatcher and never names the source.
448
+ function filterReplyMedia(urls: string[]): string[] {
449
+ if (!options.nonOwnerTurn) return urls;
450
+ const remote = urls.filter((source) => /^https?:\/\//i.test(source.trim()));
451
+ if (remote.length !== urls.length && !warnedLocalReplyMedia) {
452
+ warnedLocalReplyMedia = true;
453
+ log?.warn?.("clawchat-plugin-openclaw: non-owner reply media dropped; only http(s) URLs are allowed");
454
+ }
455
+ return remote;
456
+ }
438
457
  const outputVisibility = effectiveOutputVisibility(account, target.chatId, target.chatType);
439
458
  const splitFullOutput = outputVisibility === "full";
440
459
  const splitNormalBlockOutput = outputVisibility === "normal";
@@ -442,11 +461,10 @@ export function createOpenclawClawlingReplyDispatcher(options: ReplyDispatcherOp
442
461
  // owner's usr_… id: chat_id is resolved by msghub through member-backend, and
443
462
  // a usr_… comes back "invalid conversation id" — the frame is dropped with no
444
463
  // negative ack to this client, so the owner silently never gets the approval
445
- // request. Both ids are required: ownerUserId gates whether an owner exists at
446
- // all, the conversation id is where a frame can actually be delivered.
464
+ // request. The resolver is the shared strict owner-identity lookup
465
+ // (src/owner-identity.ts): it yields a conversation only for an activation
466
+ // row with an owner, so the config `ownerUserId` mirror is not consulted.
447
467
  const ownerDirectTarget = (): { chatId: string; chatType: "direct" } | null => {
448
- const ownerUserId = account.ownerUserId?.trim();
449
- if (!ownerUserId) return null;
450
468
  const conversationId = resolveOwnerConversationId?.()?.trim();
451
469
  return conversationId ? { chatId: conversationId, chatType: "direct" as const } : null;
452
470
  };
@@ -462,7 +480,8 @@ export function createOpenclawClawlingReplyDispatcher(options: ReplyDispatcherOp
462
480
  });
463
481
  };
464
482
 
465
- async function uploadMediaUrls(urls: string[]): Promise<ClawlingMediaFragment[]> {
483
+ async function uploadMediaUrls(inputUrls: string[]): Promise<ClawlingMediaFragment[]> {
484
+ const urls = filterReplyMedia(inputUrls);
466
485
  if (urls.length === 0) return [];
467
486
  const apiClient = buildApiClient();
468
487
  if (!apiClient) {
@@ -720,11 +739,8 @@ export function createOpenclawClawlingReplyDispatcher(options: ReplyDispatcherOp
720
739
  ): Promise<void> => {
721
740
  const ownerTarget = ownerDirectTarget();
722
741
  if (!ownerTarget) {
723
- const reason = account.ownerUserId?.trim()
724
- ? "missing_owner_conversation"
725
- : "missing_owner_user_id";
726
742
  log?.error?.(
727
- `[${account.accountId}] clawchat-plugin-openclaw group owner attention suppressed reason=${reason} group=${target.chatId}`,
743
+ `[${account.accountId}] clawchat-plugin-openclaw group owner attention suppressed reason=missing_owner_conversation group=${target.chatId}`,
728
744
  );
729
745
  return;
730
746
  }
@@ -917,7 +933,7 @@ export function createOpenclawClawlingReplyDispatcher(options: ReplyDispatcherOp
917
933
  if (consumeTerminalSend(info?.kind ?? "unknown")) return;
918
934
  const richFragment = buildRichInteractionFragment(payload);
919
935
  const text = richFragment && account.richInteractions ? "" : resolvePayloadText(payload);
920
- const urls = resolveOutboundMediaUrls(payload).filter(Boolean);
936
+ const urls = filterReplyMedia(resolveOutboundMediaUrls(payload).filter(Boolean));
921
937
  log?.info?.(
922
938
  `[${account.accountId}] clawchat-plugin-openclaw deliver kind=${info?.kind ?? "unknown"} text_len=${text.length} media_urls=${urls.length} reasoning=${payload.isReasoning === true}`,
923
939
  );
@@ -1021,6 +1037,8 @@ export function createOpenclawClawlingReplyDispatcher(options: ReplyDispatcherOp
1021
1037
  log?.info?.(`[${account.accountId}] clawchat-plugin-openclaw final skipped: already delivered at a message boundary`);
1022
1038
  return;
1023
1039
  }
1040
+ // A non-owner final whose only content was dropped local media sends nothing.
1041
+ if (options.nonOwnerTurn && !finalText.trim() && !richFragment && finalUrls.length === 0) return;
1024
1042
  const mediaFragments = await uploadMediaUrls(finalUrls);
1025
1043
  const result = await sendStatic(
1026
1044
  finalText,
@@ -1097,7 +1115,7 @@ export function createOpenclawClawlingReplyDispatcher(options: ReplyDispatcherOp
1097
1115
  ? async (payload: ReplyPayload) => {
1098
1116
  if (consumeTerminalSend("reasoning")) return;
1099
1117
  const text = resolvePayloadText(payload);
1100
- await emitFullRuntimeText("reasoning", text, resolveOutboundMediaUrls(payload).filter(Boolean));
1118
+ await emitFullRuntimeText("reasoning", text, filterReplyMedia(resolveOutboundMediaUrls(payload).filter(Boolean)));
1101
1119
  const trimmed = text.trim();
1102
1120
  if (trimmed) reasoningText = reasoningText ? `${reasoningText}\n${trimmed}` : trimmed;
1103
1121
  }
@@ -1120,7 +1138,7 @@ export function createOpenclawClawlingReplyDispatcher(options: ReplyDispatcherOp
1120
1138
  if (consumeTerminalSend("tool-result")) return;
1121
1139
  const text = resolvePayloadText(payload);
1122
1140
  if (isDefaultToolResultText(text)) return;
1123
- await emitFullRuntimeText("tool result", text, resolveOutboundMediaUrls(payload).filter(Boolean));
1141
+ await emitFullRuntimeText("tool result", text, filterReplyMedia(resolveOutboundMediaUrls(payload).filter(Boolean)));
1124
1142
  }
1125
1143
  : undefined,
1126
1144
  onItemEvent: splitFullOutput