@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.
- package/dist/src/approval-route.js +43 -9
- package/dist/src/channel.setup.js +1 -0
- package/dist/src/clawchat-memory.js +256 -30
- package/dist/src/clawchat-metadata.js +11 -7
- package/dist/src/client-ownership.js +147 -0
- package/dist/src/config.js +4 -0
- package/dist/src/host-tools-guard.js +277 -87
- package/dist/src/inbound.js +9 -6
- package/dist/src/memory-scope.js +33 -30
- package/dist/src/note-injection.js +10 -12
- package/dist/src/reply-dispatcher.js +61 -11
- package/dist/src/runtime.js +973 -259
- package/dist/src/storage.js +413 -0
- package/dist/src/tool-capabilities.js +525 -0
- package/dist/src/tool-grants.js +406 -0
- package/dist/src/tool-requests.js +116 -0
- package/dist/src/tools-schema.js +29 -0
- package/dist/src/tools.js +445 -21
- package/dist/src/turn-artifacts.js +210 -0
- package/dist/src/turn-boundary.js +14 -4
- package/dist/src/turn-grants.js +238 -0
- package/dist/src/ws-client.js +21 -0
- package/openclaw.plugin.json +12 -0
- package/package.json +1 -1
- package/prompts/platform.md +1 -1
- package/src/approval-route.ts +38 -10
- package/src/channel.setup.ts +1 -0
- package/src/clawchat-memory.ts +246 -35
- package/src/clawchat-metadata.ts +9 -6
- package/src/client-ownership.ts +172 -0
- package/src/config.ts +8 -0
- package/src/host-tools-guard.ts +261 -90
- package/src/inbound.ts +18 -14
- package/src/memory-scope.ts +28 -28
- package/src/note-injection.ts +12 -11
- package/src/reply-dispatcher.ts +54 -13
- package/src/runtime.ts +921 -274
- package/src/storage.ts +368 -0
- package/src/tool-capabilities.ts +461 -0
- package/src/tool-grants.ts +401 -0
- package/src/tool-requests.ts +124 -0
- package/src/tools-schema.ts +35 -0
- package/src/tools.ts +375 -19
- package/src/turn-artifacts.ts +202 -0
- package/src/turn-boundary.ts +16 -4
- package/src/turn-grants.ts +235 -0
- 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
|
|
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
|
|
21
|
-
export const APPROVAL_SESSION_FILTER = `:${CHANNEL_ID}
|
|
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]
|
|
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) =>
|
|
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)) {
|
|
@@ -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
|
|
363
|
-
|
|
364
|
-
|
|
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
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
rawBody =
|
|
376
|
-
|
|
377
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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
|
|
406
|
-
|
|
407
|
-
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
+
}
|
package/dist/src/config.js
CHANGED
|
@@ -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),
|