@clawling/clawchat-plugin-openclaw 2026.10.13-1 → 2026.10.19-1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/dist/src/approval-route.js +43 -9
  2. package/dist/src/channel.setup.js +1 -0
  3. package/dist/src/clawchat-memory.js +256 -30
  4. package/dist/src/clawchat-metadata.js +11 -7
  5. package/dist/src/client-ownership.js +147 -0
  6. package/dist/src/config.js +4 -0
  7. package/dist/src/host-tools-guard.js +277 -87
  8. package/dist/src/inbound.js +9 -6
  9. package/dist/src/memory-scope.js +33 -30
  10. package/dist/src/note-injection.js +10 -12
  11. package/dist/src/reply-dispatcher.js +61 -11
  12. package/dist/src/runtime.js +973 -259
  13. package/dist/src/storage.js +413 -0
  14. package/dist/src/tool-capabilities.js +525 -0
  15. package/dist/src/tool-grants.js +406 -0
  16. package/dist/src/tool-requests.js +116 -0
  17. package/dist/src/tools-schema.js +29 -0
  18. package/dist/src/tools.js +445 -21
  19. package/dist/src/turn-artifacts.js +210 -0
  20. package/dist/src/turn-boundary.js +14 -4
  21. package/dist/src/turn-grants.js +238 -0
  22. package/dist/src/ws-client.js +21 -0
  23. package/openclaw.plugin.json +12 -0
  24. package/package.json +1 -1
  25. package/prompts/platform.md +1 -1
  26. package/src/approval-route.ts +38 -10
  27. package/src/channel.setup.ts +1 -0
  28. package/src/clawchat-memory.ts +246 -35
  29. package/src/clawchat-metadata.ts +9 -6
  30. package/src/client-ownership.ts +172 -0
  31. package/src/config.ts +8 -0
  32. package/src/host-tools-guard.ts +261 -90
  33. package/src/inbound.ts +18 -14
  34. package/src/memory-scope.ts +28 -28
  35. package/src/note-injection.ts +12 -11
  36. package/src/reply-dispatcher.ts +54 -13
  37. package/src/runtime.ts +921 -274
  38. package/src/storage.ts +368 -0
  39. package/src/tool-capabilities.ts +461 -0
  40. package/src/tool-grants.ts +401 -0
  41. package/src/tool-requests.ts +124 -0
  42. package/src/tools-schema.ts +35 -0
  43. package/src/tools.ts +375 -19
  44. package/src/turn-artifacts.ts +202 -0
  45. package/src/turn-boundary.ts +16 -4
  46. package/src/turn-grants.ts +235 -0
  47. package/src/ws-client.ts +18 -0
@@ -7,7 +7,7 @@
7
7
  // would answer in the chat the turn came from — the group — so the plugin
8
8
  // manages one exact block instead: `mode: "targets"`, one target (the owner's
9
9
  // direct chat on the one ClawChat account), and a `sessionFilter` that matches
10
- // ClawChat group sessions only.
10
+ // ClawChat sessions.
11
11
  //
12
12
  // The plugin writes that block only when the host has none, and later rewrites
13
13
  // or removes only a block of exactly that shape. Any other `approvals.plugin`
@@ -16,9 +16,11 @@
16
16
  // config holds the managed block for this account and its current owner chat.
17
17
  import fs from "node:fs";
18
18
  import path from "node:path";
19
+ import { HOST_FLOOR, hostAtLeast } from "./host-version.js";
19
20
  import { CHANNEL_ID, listOpenclawClawlingAccountIds, resolveOpenclawClawlingAccount } from "./config.js";
20
- /** Substring of every ClawChat group session key (`agent:<id>:<channel>:group:<chat>`). */
21
- export const APPROVAL_SESSION_FILTER = `:${CHANNEL_ID}:group:`;
21
+ /** Substring of every ClawChat session key. */
22
+ export const APPROVAL_SESSION_FILTER = `:${CHANNEL_ID}:`;
23
+ const LEGACY_APPROVAL_SESSION_FILTER = `:${CHANNEL_ID}:group:`;
22
24
  function isRec(value) {
23
25
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
24
26
  }
@@ -79,7 +81,7 @@ export function readApprovalsPluginState(cfg) {
79
81
  if (block.enabled !== true || block.mode !== "targets")
80
82
  return { kind: "foreign" };
81
83
  const filter = block.sessionFilter;
82
- if (!Array.isArray(filter) || filter.length !== 1 || filter[0] !== APPROVAL_SESSION_FILTER) {
84
+ if (!Array.isArray(filter) || filter.length !== 1 || ![APPROVAL_SESSION_FILTER, LEGACY_APPROVAL_SESSION_FILTER].includes(filter[0])) {
83
85
  return { kind: "foreign" };
84
86
  }
85
87
  const targets = block.targets;
@@ -94,7 +96,8 @@ export function readApprovalsPluginState(cfg) {
94
96
  const accountId = typeof target.accountId === "string" ? target.accountId.trim() : "";
95
97
  if (!to || !accountId)
96
98
  return { kind: "foreign" };
97
- return { kind: "managed", target: { to, accountId } };
99
+ return { kind: "managed", target: { to, accountId },
100
+ ...(filter[0] === LEGACY_APPROVAL_SESSION_FILTER ? { needsMigration: true } : {}) };
98
101
  }
99
102
  /**
100
103
  * The target the managed block should hold now, or `null` when none should
@@ -126,7 +129,7 @@ export function planApprovalsPluginWrite(current, desired) {
126
129
  if (current.kind === "managed" && norm(current.target.accountId) !== norm(desired.accountId)) {
127
130
  return { action: "remove" };
128
131
  }
129
- if (current.kind === "managed" &&
132
+ if (current.kind === "managed" && !current.needsMigration &&
130
133
  norm(current.target.to) === norm(desired.to) &&
131
134
  norm(current.target.accountId) === norm(desired.accountId)) {
132
135
  return { action: "none" };
@@ -166,13 +169,34 @@ export function resolveApprovalRoute(input) {
166
169
  const state = readApprovalsPluginState(input.liveConfig);
167
170
  if (state.kind === "foreign")
168
171
  return { ready: false, reason: "foreign-config" };
169
- if (state.kind !== "managed" ||
172
+ if (state.kind !== "managed" || state.needsMigration ||
170
173
  norm(state.target.accountId) !== norm(input.accountId) ||
171
174
  norm(state.target.to) !== norm(owner)) {
172
175
  return { ready: false, reason: "not-applied" };
173
176
  }
174
177
  return { ready: true, target: state.target };
175
178
  }
179
+ /** Establishability, independent of whether an ask row has triggered reconciliation yet.
180
+ * Plugin approvals are separate from exec approvals (agent-tools.before-tool-call.approval.ts).
181
+ * A foreign/disabled forwarding block cannot be taken over by this plugin.
182
+ */
183
+ export function canAskEachTime(accountId, input) {
184
+ try {
185
+ if (!hostAtLeast(input.hostVersion, HOST_FLOOR))
186
+ return false;
187
+ const desired = desiredApprovalTarget({ activeAccountIds: input.activeAccountIds,
188
+ approveAccountIds: [accountId], ownerConversation: id => id === accountId ? input.ownerConversationId : null });
189
+ if (!desired.target)
190
+ return false;
191
+ const state = readApprovalsPluginState(input.liveConfig);
192
+ if (state.kind === "foreign" || (state.kind === "managed" && norm(state.target.accountId) !== norm(accountId)))
193
+ return false;
194
+ return input.canWriteConfig || resolveApprovalRoute({ ...input, accountId, approve: true }).ready;
195
+ }
196
+ catch {
197
+ return false;
198
+ }
199
+ }
176
200
  export const APPROVAL_ROUTE_WARNINGS = {
177
201
  "not-approve": "",
178
202
  "multiple-accounts": "non-owner-host-tools approve needs exactly one ClawChat account on this gateway; group host-tool calls stay blocked",
@@ -193,7 +217,17 @@ export async function reconcileApprovalsPlugin(params) {
193
217
  knownActive: params.knownActive,
194
218
  hasStoredIdentity: params.hasStoredIdentity,
195
219
  });
196
- const approveIds = active.filter((id) => resolveOpenclawClawlingAccount(cfg, id).nonOwnerHostTools === "approve");
220
+ const approveIds = active.filter((id) => {
221
+ const account = resolveOpenclawClawlingAccount(cfg, id);
222
+ if (account.nonOwnerHostTools === "approve")
223
+ return true;
224
+ try {
225
+ return account.toolGrants === "on" && params.hasActiveAskGrant?.(id) === true;
226
+ }
227
+ catch {
228
+ return false;
229
+ }
230
+ });
197
231
  const desired = desiredApprovalTarget({
198
232
  activeAccountIds: active,
199
233
  approveAccountIds: approveIds,
@@ -209,7 +243,7 @@ export async function reconcileApprovalsPlugin(params) {
209
243
  if (planned.action === "none")
210
244
  return "none";
211
245
  const guardKey = planned.action === "set"
212
- ? `set:${norm(planned.target.accountId)}:${norm(planned.target.to)}`
246
+ ? `set:${APPROVAL_SESSION_FILTER}:${norm(planned.target.accountId)}:${norm(planned.target.to)}`
213
247
  : "remove";
214
248
  try {
215
249
  if (params.writeGuard?.seen(guardKey)) {
@@ -16,6 +16,7 @@ const CLEAR_BASE_FIELDS = [
16
16
  "forwardThinking",
17
17
  "forwardToolCalls",
18
18
  "richInteractions",
19
+ "tool-grants",
19
20
  "enabled",
20
21
  ];
21
22
  /**
@@ -1,9 +1,219 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
+ import os from "node:os";
3
4
  import { randomUUID } from "node:crypto";
4
5
  import { getDefaultGroupBioPrompt, getDefaultOwnerBehaviorPrompt } from "./plugin-prompts.js";
5
6
  const metadataStart = "<!-- clawchat:metadata:start -->";
6
7
  const metadataEnd = "<!-- clawchat:metadata:end -->";
8
+ /** Exported so callers/tests can set a shorter bounded wait when needed. */
9
+ export const CLAWCHAT_MEMORY_LOCK_TIMING = {
10
+ timeoutMs: 5_000,
11
+ retryMs: 25,
12
+ staleMs: 600_000,
13
+ };
14
+ const memoryFileLockQueue = new Map();
15
+ const heldMemoryLockTokens = new Set();
16
+ async function unlinkMemoryLockArtifact(filePath) {
17
+ await fs.unlink(filePath).catch(error => {
18
+ if (error.code !== "ENOENT")
19
+ throw error;
20
+ });
21
+ }
22
+ let memoryLockTestHooks;
23
+ /** @internal Deterministic interleavings for tests; undefined in production. */
24
+ export function __setClawChatMemoryLockTestHooks(hooks) {
25
+ memoryLockTestHooks = hooks;
26
+ }
27
+ async function readMemoryLockState(filePath, staleMs) {
28
+ try {
29
+ const stat = await fs.lstat(filePath);
30
+ if (!stat.isFile() || stat.isSymbolicLink())
31
+ throw new Error(`Unsafe clawchat memory lock: ${filePath}`);
32
+ const content = await fs.readFile(filePath, "utf8");
33
+ let payload = {};
34
+ try {
35
+ const parsed = JSON.parse(content);
36
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed))
37
+ payload = parsed;
38
+ }
39
+ catch { /* Legacy or malformed payloads can only expire by age. */ }
40
+ const timestamp = typeof payload.timestamp === "number" && Number.isFinite(payload.timestamp)
41
+ ? Math.max(payload.timestamp, stat.mtimeMs) : stat.mtimeMs;
42
+ let stale = Date.now() - timestamp > staleMs;
43
+ const { pid, token, host } = payload;
44
+ if (host === os.hostname() && typeof pid === "number" && Number.isSafeInteger(pid) && pid > 0) {
45
+ if (pid === process.pid && typeof token === "string" && !heldMemoryLockTokens.has(token))
46
+ stale = true;
47
+ try {
48
+ process.kill(pid, 0);
49
+ }
50
+ catch (error) {
51
+ if (error.code === "ESRCH")
52
+ stale = true;
53
+ }
54
+ }
55
+ return { content, stale };
56
+ }
57
+ catch (error) {
58
+ if (error.code === "ENOENT")
59
+ return null;
60
+ throw error;
61
+ }
62
+ }
63
+ async function removeMemoryGuardIfMatching(guardPath, expected) {
64
+ try {
65
+ if (await fs.readFile(guardPath, "utf8") === expected)
66
+ await unlinkMemoryLockArtifact(guardPath);
67
+ }
68
+ catch (error) {
69
+ if (error.code !== "ENOENT")
70
+ throw error;
71
+ }
72
+ }
73
+ /** Only the guard itself uses a double read without another guard. A crashed
74
+ * guard holder can leave a second-order recovery race; do not recursively lock.
75
+ */
76
+ async function reclaimStaleMemoryGuard(guardPath, staleMs) {
77
+ const initial = await readMemoryLockState(guardPath, staleMs);
78
+ if (initial?.stale)
79
+ await removeMemoryGuardIfMatching(guardPath, initial.content);
80
+ }
81
+ async function withMemoryReclaimGuard(lockPath, deadline, fn) {
82
+ const guardPath = `${lockPath}.reclaim`;
83
+ const token = randomUUID();
84
+ const tempPath = `${guardPath}.${token}.tmp`;
85
+ const content = JSON.stringify({ pid: process.pid, host: os.hostname(), timestamp: Date.now(), token });
86
+ const { retryMs, staleMs } = CLAWCHAT_MEMORY_LOCK_TIMING;
87
+ let owned = false;
88
+ try {
89
+ try {
90
+ await fs.writeFile(tempPath, content, { encoding: "utf8", flag: "wx", mode: 0o600 });
91
+ for (;;) {
92
+ heldMemoryLockTokens.add(token);
93
+ try {
94
+ await fs.link(tempPath, guardPath);
95
+ owned = true;
96
+ break;
97
+ }
98
+ catch (error) {
99
+ heldMemoryLockTokens.delete(token);
100
+ if (error.code !== "EEXIST")
101
+ throw error;
102
+ await reclaimStaleMemoryGuard(guardPath, staleMs);
103
+ if (Date.now() >= deadline)
104
+ return false;
105
+ await new Promise(resolve => setTimeout(resolve, Math.min(retryMs, Math.max(0, deadline - Date.now()))));
106
+ }
107
+ }
108
+ }
109
+ finally {
110
+ await unlinkMemoryLockArtifact(tempPath);
111
+ }
112
+ await fn();
113
+ return true;
114
+ }
115
+ finally {
116
+ try {
117
+ if (owned)
118
+ await removeMemoryGuardIfMatching(guardPath, content);
119
+ }
120
+ finally {
121
+ heldMemoryLockTokens.delete(token);
122
+ }
123
+ }
124
+ }
125
+ /** Invariant: only delete lockPath inside its reclaim guard. Creation uses
126
+ * exclusive link, so a guarded re-read equal to expected can only unlink that
127
+ * same generation. Never move a live lock out of the way to inspect it.
128
+ */
129
+ async function removeMatchingMemoryLock(lockPath, expected) {
130
+ // A guard timeout must not turn a completed mutation into a reported failure.
131
+ // Leave the lock behind; dropping its held token lets later b/c recovery clean it.
132
+ await withMemoryReclaimGuard(lockPath, Date.now() + CLAWCHAT_MEMORY_LOCK_TIMING.timeoutMs, async () => {
133
+ const current = await readMemoryLockState(lockPath, CLAWCHAT_MEMORY_LOCK_TIMING.staleMs);
134
+ if (current?.content === expected)
135
+ await unlinkMemoryLockArtifact(lockPath);
136
+ });
137
+ }
138
+ /** @internal Exported for concurrent-reclaimer tests; mutations use the lock wrapper. */
139
+ export async function reclaimStaleClawChatMemoryLock(lockPath, staleMs, deadline = Date.now() + CLAWCHAT_MEMORY_LOCK_TIMING.timeoutMs) {
140
+ const initial = await readMemoryLockState(lockPath, staleMs);
141
+ if (!initial?.stale)
142
+ return;
143
+ await memoryLockTestHooks?.afterStaleRead?.();
144
+ await withMemoryReclaimGuard(lockPath, deadline, async () => {
145
+ const current = await readMemoryLockState(lockPath, staleMs);
146
+ if (current?.content === initial.content && current.stale)
147
+ await unlinkMemoryLockArtifact(lockPath);
148
+ });
149
+ }
150
+ /** All mutations of a safe target share this queue and same-directory lockfile. */
151
+ export async function withClawChatMemoryFileLock(targetPath, fn) {
152
+ const previous = memoryFileLockQueue.get(targetPath) ?? Promise.resolve();
153
+ let releaseQueue;
154
+ const queued = new Promise(resolve => { releaseQueue = resolve; });
155
+ memoryFileLockQueue.set(targetPath, queued);
156
+ await previous;
157
+ const lockPath = path.join(path.dirname(targetPath), `.${path.basename(targetPath)}.lock`);
158
+ const token = randomUUID();
159
+ const tempPath = `${lockPath}.${token}.tmp`;
160
+ const content = JSON.stringify({ pid: process.pid, host: os.hostname(), timestamp: Date.now(), token });
161
+ const { timeoutMs, retryMs, staleMs } = CLAWCHAT_MEMORY_LOCK_TIMING;
162
+ let owned = false;
163
+ try {
164
+ await fs.mkdir(path.dirname(targetPath), { recursive: true });
165
+ const deadline = Date.now() + timeoutMs;
166
+ try {
167
+ await fs.writeFile(tempPath, content, { encoding: "utf8", flag: "wx", mode: 0o600 });
168
+ for (;;) {
169
+ // Register before publishing, so a same-process reclaimer cannot mistake
170
+ // the just-published token for one left behind by a recycled pid.
171
+ heldMemoryLockTokens.add(token);
172
+ try {
173
+ await fs.link(tempPath, lockPath);
174
+ owned = true;
175
+ break;
176
+ }
177
+ catch (error) {
178
+ heldMemoryLockTokens.delete(token);
179
+ if (error.code !== "EEXIST")
180
+ throw error;
181
+ await reclaimStaleClawChatMemoryLock(lockPath, staleMs, deadline);
182
+ if (Date.now() >= deadline)
183
+ throw new Error(`Timed out acquiring clawchat memory lock: ${targetPath}`);
184
+ await new Promise(resolve => setTimeout(resolve, Math.min(retryMs, Math.max(0, deadline - Date.now()))));
185
+ }
186
+ }
187
+ }
188
+ finally {
189
+ await unlinkMemoryLockArtifact(tempPath);
190
+ }
191
+ const assertHeld = async () => {
192
+ try {
193
+ if (await fs.readFile(lockPath, "utf8") === content)
194
+ return;
195
+ }
196
+ catch (error) {
197
+ if (error.code !== "ENOENT")
198
+ throw error;
199
+ }
200
+ throw new Error(`Lost clawchat memory lock: ${targetPath}`);
201
+ };
202
+ return await fn(assertHeld);
203
+ }
204
+ finally {
205
+ try {
206
+ if (owned)
207
+ await removeMatchingMemoryLock(lockPath, content);
208
+ }
209
+ finally {
210
+ heldMemoryLockTokens.delete(token);
211
+ releaseQueue();
212
+ if (memoryFileLockQueue.get(targetPath) === queued)
213
+ memoryFileLockQueue.delete(targetPath);
214
+ }
215
+ }
216
+ }
7
217
  function assertValidTargetId(target) {
8
218
  if (target.targetType !== "owner" && target.targetType !== "user" && target.targetType !== "group") {
9
219
  throw new Error("Invalid clawchat memory targetType");
@@ -265,12 +475,13 @@ async function readExistingClawChatMemoryFile(root, target) {
265
475
  }
266
476
  return { targetPath, exists: true, parsed: parseClawChatMemoryRaw(raw), raw };
267
477
  }
268
- async function writeFileAtomically(targetPath, content) {
478
+ async function writeFileAtomically(targetPath, content, assertHeld) {
269
479
  const dir = path.dirname(targetPath);
270
480
  await fs.mkdir(dir, { recursive: true });
271
481
  const tempPath = path.join(dir, `.${path.basename(targetPath)}.${randomUUID()}.tmp`);
272
482
  try {
273
483
  await fs.writeFile(tempPath, content, "utf8");
484
+ await assertHeld();
274
485
  await fs.rename(tempPath, targetPath);
275
486
  }
276
487
  catch (error) {
@@ -359,50 +570,65 @@ export async function searchClawChatMemory(root, params, options = {}) {
359
570
  }
360
571
  export async function deleteClawChatMemoryFile(root, target) {
361
572
  const targetPath = await ensureClawChatMemoryTargetSafe(root, target);
362
- await fs.unlink(targetPath).catch((error) => {
363
- if (error.code !== "ENOENT") {
364
- throw error;
365
- }
573
+ await withClawChatMemoryFileLock(targetPath, async (assertHeld) => {
574
+ await ensureClawChatMemoryTargetSafe(root, target);
575
+ await assertHeld();
576
+ await fs.unlink(targetPath).catch((error) => {
577
+ if (error.code !== "ENOENT")
578
+ throw error;
579
+ });
366
580
  });
367
581
  }
368
582
  export async function writeClawChatMemoryBody(root, target, mode, content) {
369
583
  if (mode !== "append" && mode !== "replace") {
370
584
  throw new Error("Invalid clawchat memory write mode");
371
585
  }
372
- const file = await readExistingClawChatMemoryFile(root, target);
373
- let rawBody = file.parsed.rawBody;
374
- if (mode === "replace") {
375
- rawBody = content;
376
- }
377
- else {
378
- if (content.length === 0) {
379
- throw new Error("clawchat memory append content must be non-empty");
380
- }
381
- if (rawBody.length === 0 || rawBody.endsWith("\n") || rawBody.endsWith("\r") || content.startsWith("\n") || content.startsWith("\r")) {
382
- rawBody = `${rawBody}${content}`;
586
+ const targetPath = await ensureClawChatMemoryTargetSafe(root, target);
587
+ await withClawChatMemoryFileLock(targetPath, async (assertHeld) => {
588
+ const file = await readExistingClawChatMemoryFile(root, target);
589
+ let rawBody = file.parsed.rawBody;
590
+ if (mode === "replace") {
591
+ rawBody = content;
383
592
  }
384
593
  else {
385
- rawBody = `${rawBody}\n${content}`;
594
+ if (content.length === 0) {
595
+ throw new Error("clawchat memory append content must be non-empty");
596
+ }
597
+ if (rawBody.length === 0 || rawBody.endsWith("\n") || rawBody.endsWith("\r") || content.startsWith("\n") || content.startsWith("\r")) {
598
+ rawBody = `${rawBody}${content}`;
599
+ }
600
+ else {
601
+ rawBody = `${rawBody}\n${content}`;
602
+ }
386
603
  }
387
- }
388
- await writeFileAtomically(file.targetPath, formatRawMemoryFile(file.parsed, rawBody));
604
+ await writeFileAtomically(file.targetPath, formatRawMemoryFile(file.parsed, rawBody), assertHeld);
605
+ });
389
606
  }
390
607
  export async function editClawChatMemoryBody(root, target, oldText, newText) {
391
608
  const normalizedOldText = normalizeLineEndings(oldText);
392
609
  if (normalizedOldText.length === 0) {
393
610
  throw new Error("clawchat memory edit oldText must be non-empty");
394
611
  }
395
- const file = await readExistingClawChatMemoryFile(root, target);
396
- const { normalized: body, rawBoundaries } = normalizeWithRawBoundaryMap(file.parsed.rawBody);
397
- const firstIndex = body.indexOf(normalizedOldText);
398
- if (firstIndex === -1 || body.indexOf(normalizedOldText, firstIndex + 1) !== -1) {
399
- throw new Error("clawchat memory edit requires exactly one oldText match");
400
- }
401
- const updatedRawBody = `${file.parsed.rawBody.slice(0, rawBoundaries[firstIndex])}${normalizeLineEndings(newText)}${file.parsed.rawBody.slice(rawBoundaries[firstIndex + normalizedOldText.length])}`;
402
- await writeFileAtomically(file.targetPath, formatRawMemoryFile(file.parsed, updatedRawBody));
612
+ const targetPath = await ensureClawChatMemoryTargetSafe(root, target);
613
+ await withClawChatMemoryFileLock(targetPath, async (assertHeld) => {
614
+ const file = await readExistingClawChatMemoryFile(root, target);
615
+ const { normalized: body, rawBoundaries } = normalizeWithRawBoundaryMap(file.parsed.rawBody);
616
+ const firstIndex = body.indexOf(normalizedOldText);
617
+ if (firstIndex === -1 || body.indexOf(normalizedOldText, firstIndex + 1) !== -1) {
618
+ throw new Error("clawchat memory edit requires exactly one oldText match");
619
+ }
620
+ const updatedRawBody = `${file.parsed.rawBody.slice(0, rawBoundaries[firstIndex])}${normalizeLineEndings(newText)}${file.parsed.rawBody.slice(rawBoundaries[firstIndex + normalizedOldText.length])}`;
621
+ await writeFileAtomically(file.targetPath, formatRawMemoryFile(file.parsed, updatedRawBody), assertHeld);
622
+ });
403
623
  }
404
624
  export async function writeClawChatMetadata(root, target, metadata) {
405
- const file = await readExistingClawChatMemoryFile(root, target);
406
- const metadataToWrite = metadataWithInitialDefaults(target, file.exists, metadata);
407
- await writeFileAtomically(file.targetPath, `${formatMetadataBlock(metadataToWrite)}${rawBodySuffixForMetadataReplacement(file.parsed)}`);
625
+ const targetPath = await ensureClawChatMemoryTargetSafe(root, target);
626
+ await withClawChatMemoryFileLock(targetPath, async (assertHeld) => {
627
+ const file = await readExistingClawChatMemoryFile(root, target);
628
+ const updated = typeof metadata === "function" ? metadata(file.parsed.metadata, file.exists) : metadata;
629
+ if (updated === null)
630
+ return;
631
+ const metadataToWrite = metadataWithInitialDefaults(target, file.exists, updated);
632
+ await writeFileAtomically(file.targetPath, `${formatMetadataBlock(metadataToWrite)}${rawBodySuffixForMetadataReplacement(file.parsed)}`, assertHeld);
633
+ });
408
634
  }
@@ -211,13 +211,17 @@ export async function pullGroupMetadata(params) {
211
211
  throw new Error("ClawChat participant metadata pull requires getUserProfile");
212
212
  const profile = await getUser(mapped.userId);
213
213
  const fetched = userMetadataFromProfile(profile, mapped.userId);
214
- if (existing.exists && !fetched.profile_type)
215
- continue;
216
- const metadata = existing.exists
217
- ? { ...existing.metadata, profile_type: fetched.profile_type }
218
- : fetched;
219
- await writeClawChatMetadata(params.memoryRoot, { targetType: "user", targetId: mapped.userId }, metadata);
220
- writes.push({ targetType: "user", targetId: mapped.userId });
214
+ let written = false;
215
+ await writeClawChatMetadata(params.memoryRoot, { targetType: "user", targetId: mapped.userId }, (current, exists) => {
216
+ // The network request stays outside the lock. Recheck the latest file
217
+ // so a concurrent refresh cannot lose fields or its profile_type.
218
+ if (exists && (current.profile_type || !fetched.profile_type))
219
+ return null;
220
+ written = true;
221
+ return exists ? { ...current, profile_type: fetched.profile_type } : fetched;
222
+ });
223
+ if (written)
224
+ writes.push({ targetType: "user", targetId: mapped.userId });
221
225
  }
222
226
  catch (error) {
223
227
  failures.push({ targetType: "user", targetId: mapped.userId, error: errorMessage(error) });
@@ -0,0 +1,147 @@
1
+ const ownersKey = Symbol.for("clawchat-plugin-openclaw.wsClientOwners");
2
+ const refreshKey = Symbol.for("clawchat-plugin-openclaw.refreshCoordination");
3
+ const processState = globalThis;
4
+ // The host can load another module generation without stopping the old task.
5
+ const owners = processState[ownersKey] ??= new Map();
6
+ const refreshStates = processState[refreshKey] ??= new Map();
7
+ export const REFRESH_CHAIN_LIMIT = 1024;
8
+ export const REFRESH_LOCK_TIMEOUT_MS = 60_000;
9
+ function refreshState(accountId) {
10
+ let state = refreshStates.get(accountId);
11
+ if (!state) {
12
+ state = { children: new Map(), queue: [], locked: false, generation: 0 };
13
+ refreshStates.set(accountId, state);
14
+ }
15
+ return state;
16
+ }
17
+ export function acquireRefreshLock(accountId, warn = console.warn) {
18
+ const state = refreshState(accountId);
19
+ return new Promise((resolve) => {
20
+ const grant = () => {
21
+ state.locked = true;
22
+ const generation = ++state.generation;
23
+ let released = false;
24
+ const release = Object.assign(() => {
25
+ if (released)
26
+ return;
27
+ released = true;
28
+ clearTimeout(state.timer);
29
+ state.timer = undefined;
30
+ const next = state.queue.shift();
31
+ if (next)
32
+ next();
33
+ else
34
+ state.locked = false;
35
+ }, {
36
+ isHeld: () => !released && state.locked && state.generation === generation && refreshStates.get(accountId) === state,
37
+ });
38
+ state.timer = setTimeout(() => {
39
+ try {
40
+ warn(`[${accountId}] clawchat-plugin-openclaw refresh lock timed out; releasing queued refreshes`);
41
+ }
42
+ finally {
43
+ release();
44
+ }
45
+ }, REFRESH_LOCK_TIMEOUT_MS);
46
+ state.timer.unref?.();
47
+ resolve(release);
48
+ };
49
+ if (state.locked)
50
+ state.queue.push(grant);
51
+ else
52
+ grant();
53
+ });
54
+ }
55
+ // Only persisted results belong in the chain. Unrelated activation lineages
56
+ // have independent parents and cannot replace one another's descendants.
57
+ export function recordRotation(accountId, parentRefreshToken, tokens) {
58
+ const { children } = refreshState(accountId);
59
+ if (children.has(parentRefreshToken))
60
+ return;
61
+ children.set(parentRefreshToken, { ...tokens });
62
+ while (children.size > REFRESH_CHAIN_LIMIT) {
63
+ // Splice out the oldest parent's child instead of dropping the parent: a
64
+ // host config snapshot can keep pointing at that root for the whole run,
65
+ // and the root must still reach the newest descendant.
66
+ const root = children.keys().next().value;
67
+ const child = children.get(root);
68
+ const grandchild = children.get(child.refreshToken);
69
+ if (!grandchild || child.refreshToken === root) {
70
+ children.delete(root);
71
+ continue;
72
+ }
73
+ children.delete(child.refreshToken);
74
+ children.set(root, grandchild);
75
+ }
76
+ }
77
+ export function childOf(accountId, refreshToken) {
78
+ const tokens = refreshToken ? refreshStates.get(accountId)?.children.get(refreshToken) : undefined;
79
+ return tokens ? { ...tokens } : undefined;
80
+ }
81
+ export function tipOf(accountId, refreshToken) {
82
+ const seen = new Set();
83
+ let tip;
84
+ for (let depth = 0; refreshToken && depth < REFRESH_CHAIN_LIMIT; depth++) {
85
+ seen.add(refreshToken);
86
+ const child = childOf(accountId, refreshToken);
87
+ if (!child || seen.has(child.refreshToken))
88
+ break;
89
+ tip = child;
90
+ refreshToken = child.refreshToken;
91
+ }
92
+ return tip;
93
+ }
94
+ export function isAncestor(accountId, ancestor, descendant) {
95
+ if (!ancestor || !descendant)
96
+ return false;
97
+ const seen = new Set();
98
+ for (let depth = 0; depth < REFRESH_CHAIN_LIMIT && !seen.has(ancestor); depth++) {
99
+ seen.add(ancestor);
100
+ const child = childOf(accountId, ancestor);
101
+ if (!child)
102
+ return false;
103
+ if (child.refreshToken === descendant)
104
+ return true;
105
+ ancestor = child.refreshToken;
106
+ }
107
+ return false;
108
+ }
109
+ export function snapshotClientOwner(accountId) {
110
+ return owners.get(accountId);
111
+ }
112
+ export function ownerChangedSince(accountId, snapshot) {
113
+ return owners.get(accountId) !== snapshot;
114
+ }
115
+ export function claimClientLease(accountId, close) {
116
+ const previous = owners.get(accountId);
117
+ const replacedPrevious = Boolean(previous?.close);
118
+ const owner = { close };
119
+ // A synchronous claim from the old close callback must supersede this one.
120
+ owners.set(accountId, owner);
121
+ try {
122
+ previous?.close?.();
123
+ }
124
+ catch {
125
+ // A failed shutdown must not prevent the replacement from taking ownership.
126
+ }
127
+ return {
128
+ replacedPrevious,
129
+ isCurrent: () => owners.get(accountId) === owner,
130
+ release() {
131
+ // Retain the latest identity so a delayed re-enter can detect replacement
132
+ // even after the replacement itself has shut down.
133
+ if (owners.get(accountId) === owner) {
134
+ owner.close = undefined;
135
+ }
136
+ },
137
+ };
138
+ }
139
+ export function resetClientOwnershipForTests() {
140
+ owners.clear();
141
+ resetRefreshCoordinationForTests();
142
+ }
143
+ export function resetRefreshCoordinationForTests() {
144
+ for (const state of refreshStates.values())
145
+ clearTimeout(state.timer);
146
+ refreshStates.clear();
147
+ }
@@ -91,6 +91,8 @@ export const openclawClawlingAccountConfigSchema = {
91
91
  "note-cap-turn": { type: "integer" },
92
92
  "delta-budget-chars": { type: "integer" },
93
93
  "non-owner-host-tools": { type: "string", enum: ["off", "approve"] },
94
+ "tool-grants": { type: "string", enum: ["on", "off"] },
95
+ "public-tools": { type: "array", items: { type: "string" } },
94
96
  reconnect: {
95
97
  type: "object",
96
98
  additionalProperties: false,
@@ -519,6 +521,8 @@ export function resolveOpenclawClawlingAccount(cfg, accountId, env = process.env
519
521
  noteCapTurn: readSessionInt(channel, "note-cap-turn"),
520
522
  deltaBudgetChars: readSessionInt(channel, "delta-budget-chars"),
521
523
  nonOwnerHostTools: parseNonOwnerHostTools(channel["non-owner-host-tools"]),
524
+ toolGrants: channel["tool-grants"] === "off" ? "off" : "on",
525
+ publicTools: channel["public-tools"],
522
526
  allowFrom: [],
523
527
  reconnect: readReconnect(channel.reconnect),
524
528
  heartbeat: readHeartbeat(channel.heartbeat),