@botcord/daemon 0.2.61 → 0.2.63
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/acp-logs.d.ts +39 -0
- package/dist/acp-logs.js +333 -0
- package/dist/diagnostics.d.ts +1 -0
- package/dist/diagnostics.js +163 -1
- package/dist/gateway/dispatcher.js +32 -7
- package/dist/gateway/runtimes/acp-stream.js +114 -3
- package/dist/gateway/runtimes/openclaw-acp.js +77 -0
- package/dist/index.js +30 -24
- package/dist/openclaw-discovery.js +13 -5
- package/dist/provision.js +29 -0
- package/package.json +1 -1
- package/src/__tests__/acp-logs.test.ts +88 -0
- package/src/__tests__/diagnostics.test.ts +23 -0
- package/src/__tests__/openclaw-acp.test.ts +39 -0
- package/src/__tests__/openclaw-discovery.test.ts +1 -0
- package/src/acp-logs.ts +382 -0
- package/src/diagnostics.ts +166 -0
- package/src/gateway/__tests__/dispatcher.test.ts +26 -0
- package/src/gateway/__tests__/hermes-agent-adapter.test.ts +27 -0
- package/src/gateway/dispatcher.ts +31 -8
- package/src/gateway/runtimes/acp-stream.ts +112 -1
- package/src/gateway/runtimes/openclaw-acp.ts +76 -0
- package/src/index.ts +31 -23
- package/src/openclaw-discovery.ts +16 -5
- package/src/provision.ts +32 -1
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { LogFileEntry } from "./log.js";
|
|
2
|
+
export type AcpTraceStream = "child_start" | "child_exit" | "child_error" | "stderr" | "stdout_non_json" | "rpc_in" | "rpc_out";
|
|
3
|
+
export interface AcpTraceMeta {
|
|
4
|
+
runtime: string;
|
|
5
|
+
accountId?: string;
|
|
6
|
+
turnId?: string;
|
|
7
|
+
roomId?: string;
|
|
8
|
+
topicId?: string | null;
|
|
9
|
+
gatewayName?: string;
|
|
10
|
+
gatewayUrl?: string;
|
|
11
|
+
hermesProfile?: string;
|
|
12
|
+
sessionId?: string | null;
|
|
13
|
+
}
|
|
14
|
+
export interface AcpTraceEvent {
|
|
15
|
+
stream: AcpTraceStream;
|
|
16
|
+
direction?: "in" | "out";
|
|
17
|
+
pid?: number;
|
|
18
|
+
id?: number | string;
|
|
19
|
+
method?: string;
|
|
20
|
+
status?: "request" | "notification" | "response" | "error";
|
|
21
|
+
code?: number | null;
|
|
22
|
+
signal?: NodeJS.Signals | null;
|
|
23
|
+
error?: string;
|
|
24
|
+
chunk?: string;
|
|
25
|
+
params?: unknown;
|
|
26
|
+
result?: unknown;
|
|
27
|
+
}
|
|
28
|
+
export interface AcpTraceLogger {
|
|
29
|
+
path: string;
|
|
30
|
+
verbose: boolean;
|
|
31
|
+
write(event: AcpTraceEvent): void;
|
|
32
|
+
}
|
|
33
|
+
interface RuntimeLogFile extends LogFileEntry {
|
|
34
|
+
bundleName: string;
|
|
35
|
+
}
|
|
36
|
+
export declare function createAcpTraceLogger(meta: AcpTraceMeta): AcpTraceLogger | null;
|
|
37
|
+
export declare function listAcpTraceLogFiles(includeAll?: boolean): LogFileEntry[];
|
|
38
|
+
export declare function listRuntimeLogFiles(includeAll?: boolean): RuntimeLogFile[];
|
|
39
|
+
export {};
|
package/dist/acp-logs.js
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
import { appendFileSync, existsSync, mkdirSync, readdirSync, renameSync, statSync, unlinkSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
const ACP_LOG_DIR = path.join(homedir(), ".botcord", "logs", "acp");
|
|
5
|
+
const ACP_LOG_MAX_BYTES = 2 * 1024 * 1024;
|
|
6
|
+
const ACP_LOG_KEEP = 20;
|
|
7
|
+
const ACP_LOG_DIAGNOSTICS_DEFAULT = 10;
|
|
8
|
+
const ACP_LOG_DIAGNOSTICS_ALL = 50;
|
|
9
|
+
const RUNTIME_LOG_DEFAULT_PER_ROOT = 5;
|
|
10
|
+
const RUNTIME_LOG_ALL_PER_ROOT = 25;
|
|
11
|
+
const RUNTIME_LOG_MAX_FILE_BYTES = 2 * 1024 * 1024;
|
|
12
|
+
const SECRET_KEY_RE = /token|secret|private.?key|api.?key|authorization|password/i;
|
|
13
|
+
export function createAcpTraceLogger(meta) {
|
|
14
|
+
if (process.env.BOTCORD_ACP_LOGS === "0")
|
|
15
|
+
return null;
|
|
16
|
+
const runtime = safePathSegment(meta.runtime || "acp");
|
|
17
|
+
const key = safePathSegment([
|
|
18
|
+
meta.accountId,
|
|
19
|
+
meta.gatewayName,
|
|
20
|
+
meta.hermesProfile,
|
|
21
|
+
meta.roomId,
|
|
22
|
+
].filter(Boolean).join("_") || "default");
|
|
23
|
+
const dir = path.join(ACP_LOG_DIR, runtime);
|
|
24
|
+
const file = path.join(dir, `${key}.jsonl`);
|
|
25
|
+
const verbose = process.env.BOTCORD_ACP_TRACE === "verbose";
|
|
26
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
27
|
+
return {
|
|
28
|
+
path: file,
|
|
29
|
+
verbose,
|
|
30
|
+
write(event) {
|
|
31
|
+
writeAcpTrace(file, meta, event, verbose);
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
export function listAcpTraceLogFiles(includeAll = false) {
|
|
36
|
+
const limit = includeAll ? ACP_LOG_DIAGNOSTICS_ALL : ACP_LOG_DIAGNOSTICS_DEFAULT;
|
|
37
|
+
const out = [];
|
|
38
|
+
collectFiles(ACP_LOG_DIR, out, (name) => name.endsWith(".jsonl") || name.includes(".jsonl."));
|
|
39
|
+
return out
|
|
40
|
+
.sort((a, b) => b.mtimeMs - a.mtimeMs || b.name.localeCompare(a.name))
|
|
41
|
+
.slice(0, limit);
|
|
42
|
+
}
|
|
43
|
+
export function listRuntimeLogFiles(includeAll = false) {
|
|
44
|
+
const limit = includeAll ? RUNTIME_LOG_ALL_PER_ROOT : RUNTIME_LOG_DEFAULT_PER_ROOT;
|
|
45
|
+
const out = [];
|
|
46
|
+
for (const root of runtimeLogRoots()) {
|
|
47
|
+
const files = [];
|
|
48
|
+
collectFiles(root.dir, files, looksLikeLogFile, 4);
|
|
49
|
+
for (const entry of files
|
|
50
|
+
.sort((a, b) => b.mtimeMs - a.mtimeMs || b.name.localeCompare(a.name))
|
|
51
|
+
.slice(0, limit)) {
|
|
52
|
+
out.push({
|
|
53
|
+
...entry,
|
|
54
|
+
bundleName: path.posix.join("runtime-logs", root.label, relativeBundlePath(root.dir, entry.path)),
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
function writeAcpTrace(file, meta, event, verbose) {
|
|
61
|
+
try {
|
|
62
|
+
rotateIfNeeded(file);
|
|
63
|
+
const record = {
|
|
64
|
+
ts: new Date().toISOString(),
|
|
65
|
+
runtime: meta.runtime,
|
|
66
|
+
accountId: meta.accountId,
|
|
67
|
+
turnId: meta.turnId,
|
|
68
|
+
roomId: meta.roomId,
|
|
69
|
+
topicId: meta.topicId ?? undefined,
|
|
70
|
+
gatewayName: meta.gatewayName,
|
|
71
|
+
gatewayUrl: meta.gatewayUrl,
|
|
72
|
+
hermesProfile: meta.hermesProfile,
|
|
73
|
+
sessionId: event.params && typeof event.params === "object"
|
|
74
|
+
? pickString(event.params, "sessionId") ?? meta.sessionId ?? undefined
|
|
75
|
+
: meta.sessionId ?? undefined,
|
|
76
|
+
...summarizeEvent(event, verbose),
|
|
77
|
+
};
|
|
78
|
+
appendFileSync(file, JSON.stringify(record) + "\n", { mode: 0o600 });
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
// ACP trace logging must never affect runtime execution.
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
function summarizeEvent(event, verbose) {
|
|
85
|
+
const out = {
|
|
86
|
+
stream: event.stream,
|
|
87
|
+
};
|
|
88
|
+
if (event.direction)
|
|
89
|
+
out.direction = event.direction;
|
|
90
|
+
if (event.pid !== undefined)
|
|
91
|
+
out.pid = event.pid;
|
|
92
|
+
if (event.id !== undefined)
|
|
93
|
+
out.id = event.id;
|
|
94
|
+
if (event.method)
|
|
95
|
+
out.method = event.method;
|
|
96
|
+
if (event.status)
|
|
97
|
+
out.status = event.status;
|
|
98
|
+
if (event.code !== undefined)
|
|
99
|
+
out.code = event.code;
|
|
100
|
+
if (event.signal !== undefined)
|
|
101
|
+
out.signal = event.signal;
|
|
102
|
+
if (event.error)
|
|
103
|
+
out.error = truncate(event.error, 1000);
|
|
104
|
+
if (event.chunk)
|
|
105
|
+
out.chunk = truncate(redactSecretString(event.chunk), 2000);
|
|
106
|
+
if (event.params !== undefined)
|
|
107
|
+
out.params = summarizePayload(event.params, verbose);
|
|
108
|
+
if (event.result !== undefined)
|
|
109
|
+
out.result = summarizePayload(event.result, verbose);
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
function summarizePayload(value, verbose) {
|
|
113
|
+
const redacted = redactSecrets(value);
|
|
114
|
+
if (verbose)
|
|
115
|
+
return capPayload(redacted);
|
|
116
|
+
if (Array.isArray(redacted))
|
|
117
|
+
return { type: "array", length: redacted.length };
|
|
118
|
+
if (!redacted || typeof redacted !== "object")
|
|
119
|
+
return redacted;
|
|
120
|
+
const obj = redacted;
|
|
121
|
+
const out = {};
|
|
122
|
+
for (const [key, v] of Object.entries(obj)) {
|
|
123
|
+
if (key === "prompt") {
|
|
124
|
+
out.prompt = summarizePrompt(v);
|
|
125
|
+
}
|
|
126
|
+
else if (key === "cwd" || key === "sessionId") {
|
|
127
|
+
out[key] = v;
|
|
128
|
+
}
|
|
129
|
+
else if (key === "_meta") {
|
|
130
|
+
out[key] = summarizePayload(v, false);
|
|
131
|
+
}
|
|
132
|
+
else if (key === "update") {
|
|
133
|
+
out.update = summarizeUpdate(v);
|
|
134
|
+
}
|
|
135
|
+
else if (key === "toolCall") {
|
|
136
|
+
out.toolCall = summarizeToolCall(v);
|
|
137
|
+
}
|
|
138
|
+
else if (typeof v === "string") {
|
|
139
|
+
out[key] = stringSummary(v);
|
|
140
|
+
}
|
|
141
|
+
else if (Array.isArray(v)) {
|
|
142
|
+
out[key] = { type: "array", length: v.length };
|
|
143
|
+
}
|
|
144
|
+
else if (v && typeof v === "object") {
|
|
145
|
+
out[key] = { type: "object", keys: Object.keys(v).slice(0, 20) };
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
out[key] = v;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return out;
|
|
152
|
+
}
|
|
153
|
+
function summarizePrompt(value) {
|
|
154
|
+
if (!Array.isArray(value))
|
|
155
|
+
return summarizePayload(value, false);
|
|
156
|
+
return value.map((item) => {
|
|
157
|
+
if (!item || typeof item !== "object")
|
|
158
|
+
return summarizePayload(item, false);
|
|
159
|
+
const obj = item;
|
|
160
|
+
const text = typeof obj.text === "string" ? obj.text : "";
|
|
161
|
+
return {
|
|
162
|
+
type: obj.type,
|
|
163
|
+
textBytes: text ? Buffer.byteLength(text, "utf8") : undefined,
|
|
164
|
+
textPreview: text ? truncate(text.replace(/\s+/g, " "), 120) : undefined,
|
|
165
|
+
};
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
function summarizeUpdate(value) {
|
|
169
|
+
if (!value || typeof value !== "object")
|
|
170
|
+
return summarizePayload(value, false);
|
|
171
|
+
const obj = value;
|
|
172
|
+
const out = {};
|
|
173
|
+
if (typeof obj.sessionUpdate === "string")
|
|
174
|
+
out.sessionUpdate = obj.sessionUpdate;
|
|
175
|
+
if (typeof obj.title === "string")
|
|
176
|
+
out.title = stringSummary(obj.title);
|
|
177
|
+
if (obj.content !== undefined)
|
|
178
|
+
out.content = summarizePayload(obj.content, false);
|
|
179
|
+
return Object.keys(out).length > 0 ? out : { type: "object", keys: Object.keys(obj).slice(0, 20) };
|
|
180
|
+
}
|
|
181
|
+
function summarizeToolCall(value) {
|
|
182
|
+
if (!value || typeof value !== "object")
|
|
183
|
+
return summarizePayload(value, false);
|
|
184
|
+
const obj = value;
|
|
185
|
+
return {
|
|
186
|
+
name: typeof obj.name === "string" ? obj.name : undefined,
|
|
187
|
+
rawInput: obj.rawInput === undefined ? undefined : summarizePayload(obj.rawInput, false),
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
function redactSecrets(value) {
|
|
191
|
+
if (Array.isArray(value))
|
|
192
|
+
return value.map(redactSecrets);
|
|
193
|
+
if (!value || typeof value !== "object") {
|
|
194
|
+
return typeof value === "string" ? redactSecretString(value) : value;
|
|
195
|
+
}
|
|
196
|
+
const out = {};
|
|
197
|
+
for (const [key, v] of Object.entries(value)) {
|
|
198
|
+
out[key] = SECRET_KEY_RE.test(key) ? "[REDACTED]" : redactSecrets(v);
|
|
199
|
+
}
|
|
200
|
+
return out;
|
|
201
|
+
}
|
|
202
|
+
function redactSecretString(value) {
|
|
203
|
+
return value
|
|
204
|
+
.replace(/(Authorization:\s*Bearer\s+)[^\s"']+/gi, "$1[REDACTED]")
|
|
205
|
+
.replace(/\b(token=)[^\s"']+/gi, "$1[REDACTED]")
|
|
206
|
+
.replace(/\b(drt_|dit_|gho_)[A-Za-z0-9_-]+/g, "$1[REDACTED]");
|
|
207
|
+
}
|
|
208
|
+
function capPayload(value) {
|
|
209
|
+
if (typeof value === "string")
|
|
210
|
+
return truncate(value, 2000);
|
|
211
|
+
if (Array.isArray(value))
|
|
212
|
+
return value.slice(0, 50).map(capPayload);
|
|
213
|
+
if (!value || typeof value !== "object")
|
|
214
|
+
return value;
|
|
215
|
+
const out = {};
|
|
216
|
+
for (const [key, v] of Object.entries(value).slice(0, 50)) {
|
|
217
|
+
out[key] = capPayload(v);
|
|
218
|
+
}
|
|
219
|
+
return out;
|
|
220
|
+
}
|
|
221
|
+
function stringSummary(value) {
|
|
222
|
+
return {
|
|
223
|
+
bytes: Buffer.byteLength(value, "utf8"),
|
|
224
|
+
preview: truncate(value.replace(/\s+/g, " "), 160),
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
function truncate(value, max) {
|
|
228
|
+
return value.length > max ? `${value.slice(0, max)}…` : value;
|
|
229
|
+
}
|
|
230
|
+
function pickString(obj, key) {
|
|
231
|
+
const value = obj[key];
|
|
232
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
233
|
+
}
|
|
234
|
+
function rotateIfNeeded(file) {
|
|
235
|
+
try {
|
|
236
|
+
const st = statSync(file);
|
|
237
|
+
if (!st.isFile() || st.size <= ACP_LOG_MAX_BYTES)
|
|
238
|
+
return;
|
|
239
|
+
renameSync(file, `${file}.${new Date().toISOString().replace(/[:.]/g, "-")}.${process.pid}`);
|
|
240
|
+
const dir = path.dirname(file);
|
|
241
|
+
const base = path.basename(file);
|
|
242
|
+
const rotated = readdirSync(dir)
|
|
243
|
+
.filter((name) => name.startsWith(`${base}.`))
|
|
244
|
+
.map((name) => {
|
|
245
|
+
const p = path.join(dir, name);
|
|
246
|
+
const st = statSync(p);
|
|
247
|
+
return { p, mtimeMs: st.mtimeMs };
|
|
248
|
+
})
|
|
249
|
+
.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
250
|
+
for (const entry of rotated.slice(ACP_LOG_KEEP))
|
|
251
|
+
unlinkSync(entry.p);
|
|
252
|
+
}
|
|
253
|
+
catch {
|
|
254
|
+
// best-effort
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
function collectFiles(dir, out, accept, maxDepth = 3) {
|
|
258
|
+
if (maxDepth < 0)
|
|
259
|
+
return;
|
|
260
|
+
let names;
|
|
261
|
+
try {
|
|
262
|
+
names = readdirSync(dir);
|
|
263
|
+
}
|
|
264
|
+
catch {
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
for (const name of names) {
|
|
268
|
+
const file = path.join(dir, name);
|
|
269
|
+
try {
|
|
270
|
+
const st = statSync(file);
|
|
271
|
+
if (st.isDirectory()) {
|
|
272
|
+
collectFiles(file, out, accept, maxDepth - 1);
|
|
273
|
+
}
|
|
274
|
+
else if (st.isFile() && st.size <= RUNTIME_LOG_MAX_FILE_BYTES && accept(name, file)) {
|
|
275
|
+
out.push({
|
|
276
|
+
path: file,
|
|
277
|
+
name: path.relative(ACP_LOG_DIR, file) || name,
|
|
278
|
+
sizeBytes: st.size,
|
|
279
|
+
mtimeMs: st.mtimeMs,
|
|
280
|
+
active: true,
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
catch {
|
|
285
|
+
// ignore disappearing files
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
function runtimeLogRoots() {
|
|
290
|
+
const roots = [
|
|
291
|
+
{ label: "openclaw", dir: path.join(homedir(), ".openclaw", "logs") },
|
|
292
|
+
{ label: "qclaw", dir: path.join(homedir(), ".qclaw", "logs") },
|
|
293
|
+
{ label: "hermes", dir: path.join(homedir(), ".hermes", "logs") },
|
|
294
|
+
];
|
|
295
|
+
const hermesProfiles = path.join(homedir(), ".hermes", "profiles");
|
|
296
|
+
try {
|
|
297
|
+
for (const name of readdirSync(hermesProfiles)) {
|
|
298
|
+
roots.push({
|
|
299
|
+
label: path.posix.join("hermes-profiles", safePathSegment(name)),
|
|
300
|
+
dir: path.join(hermesProfiles, name, "logs"),
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
catch {
|
|
305
|
+
// no profiles
|
|
306
|
+
}
|
|
307
|
+
const botcordAgents = path.join(homedir(), ".botcord", "agents");
|
|
308
|
+
try {
|
|
309
|
+
for (const agent of readdirSync(botcordAgents)) {
|
|
310
|
+
roots.push({
|
|
311
|
+
label: path.posix.join("botcord-hermes", safePathSegment(agent)),
|
|
312
|
+
dir: path.join(botcordAgents, agent, "hermes-home", "logs"),
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
catch {
|
|
317
|
+
// no botcord agent homes
|
|
318
|
+
}
|
|
319
|
+
return roots.filter((root) => existsSync(root.dir));
|
|
320
|
+
}
|
|
321
|
+
function looksLikeLogFile(name) {
|
|
322
|
+
const lower = name.toLowerCase();
|
|
323
|
+
return (lower.endsWith(".log") ||
|
|
324
|
+
lower.endsWith(".jsonl") ||
|
|
325
|
+
lower.endsWith(".txt") ||
|
|
326
|
+
lower.includes("log"));
|
|
327
|
+
}
|
|
328
|
+
function relativeBundlePath(root, file) {
|
|
329
|
+
return path.relative(root, file).split(path.sep).map(safePathSegment).join("/");
|
|
330
|
+
}
|
|
331
|
+
function safePathSegment(raw) {
|
|
332
|
+
return raw.replace(/[^A-Za-z0-9._-]+/g, "_").slice(0, 120) || "unknown";
|
|
333
|
+
}
|
package/dist/diagnostics.d.ts
CHANGED
package/dist/diagnostics.js
CHANGED
|
@@ -2,15 +2,38 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
|
2
2
|
import { homedir, hostname, platform, release, arch } from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { Buffer } from "node:buffer";
|
|
5
|
+
import { createRequire } from "node:module";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
5
7
|
import { deflateRawSync } from "node:zlib";
|
|
6
8
|
import { AUTH_EXPIRED_FLAG_PATH, USER_AUTH_PATH, } from "./user-auth.js";
|
|
7
|
-
import { CONFIG_FILE_PATH, PID_PATH, SNAPSHOT_PATH, loadConfig, } from "./config.js";
|
|
9
|
+
import { CONFIG_FILE_PATH, PID_PATH, SESSIONS_PATH, SNAPSHOT_PATH, loadConfig, saveConfig, } from "./config.js";
|
|
8
10
|
import { listDaemonLogFiles, LOG_FILE_PATH } from "./log.js";
|
|
11
|
+
import { listAcpTraceLogFiles, listRuntimeLogFiles } from "./acp-logs.js";
|
|
9
12
|
import { channelsFromDaemonConfig, defaultHttpFetcher, renderDoctor, runDoctor, } from "./doctor.js";
|
|
10
13
|
import { detectRuntimes } from "./adapters/runtimes.js";
|
|
14
|
+
import { log as daemonLog } from "./log.js";
|
|
15
|
+
import { discoverLocalOpenclawGateways, mergeOpenclawGateways, openclawDiscoveryConfigEnabled, } from "./openclaw-discovery.js";
|
|
11
16
|
const DIAGNOSTICS_DIR = path.join(homedir(), ".botcord", "diagnostics");
|
|
12
17
|
const MAX_UPLOAD_BYTES = 50 * 1024 * 1024;
|
|
13
18
|
const DEFAULT_ROTATED_LOGS_IN_BUNDLE = 5;
|
|
19
|
+
const require = createRequire(import.meta.url);
|
|
20
|
+
const MODULE_PATH = fileURLToPath(import.meta.url);
|
|
21
|
+
const ENV_ALLOWLIST = new Set([
|
|
22
|
+
"NODE_ENV",
|
|
23
|
+
"PATH",
|
|
24
|
+
"BOTCORD_HUB",
|
|
25
|
+
"BOTCORD_DAEMON_HOME",
|
|
26
|
+
"BOTCORD_DAEMON_CONFIG",
|
|
27
|
+
"BOTCORD_DAEMON_LOG",
|
|
28
|
+
"BOTCORD_DAEMON_SNAPSHOT_INTERVAL_MS",
|
|
29
|
+
"BOTCORD_HERMES_AGENT_BIN",
|
|
30
|
+
"BOTCORD_CLAUDE_CODE_BIN",
|
|
31
|
+
"BOTCORD_CODEX_BIN",
|
|
32
|
+
"BOTCORD_GEMINI_BIN",
|
|
33
|
+
"BOTCORD_DEEPSEEK_TUI_BIN",
|
|
34
|
+
"BOTCORD_KIMI_CLI_BIN",
|
|
35
|
+
"OPENCLAW_ACP_URL",
|
|
36
|
+
]);
|
|
14
37
|
const SECRET_PATTERNS = [
|
|
15
38
|
[/(Authorization:\s*Bearer\s+)[^\s"']+/gi, "$1[REDACTED]"],
|
|
16
39
|
[/("?(?:accessToken|access_token|refreshToken|refresh_token|token|privateKey|private_key|secret)"?\s*:\s*")[^"]+(")/gi, "$1[REDACTED]$2"],
|
|
@@ -35,6 +58,82 @@ function safeReadText(file) {
|
|
|
35
58
|
return `read failed: ${err instanceof Error ? err.message : String(err)}\n`;
|
|
36
59
|
}
|
|
37
60
|
}
|
|
61
|
+
function readJsonFile(file) {
|
|
62
|
+
try {
|
|
63
|
+
const parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
64
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
|
65
|
+
? parsed
|
|
66
|
+
: null;
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
function findDaemonPackageJson(startFile) {
|
|
73
|
+
let dir = path.dirname(startFile);
|
|
74
|
+
for (let i = 0; i < 6; i += 1) {
|
|
75
|
+
const candidate = path.join(dir, "package.json");
|
|
76
|
+
const parsed = readJsonFile(candidate);
|
|
77
|
+
if (parsed?.name === "@botcord/daemon")
|
|
78
|
+
return parsed;
|
|
79
|
+
const next = path.dirname(dir);
|
|
80
|
+
if (next === dir)
|
|
81
|
+
break;
|
|
82
|
+
dir = next;
|
|
83
|
+
}
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
function readInstalledPackageVersion(packageJsonSpecifier) {
|
|
87
|
+
try {
|
|
88
|
+
const pkgPath = require.resolve(packageJsonSpecifier);
|
|
89
|
+
const parsed = readJsonFile(pkgPath);
|
|
90
|
+
return typeof parsed?.version === "string" ? parsed.version : null;
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
function daemonRuntimeSummary() {
|
|
97
|
+
const pkg = findDaemonPackageJson(MODULE_PATH);
|
|
98
|
+
const version = typeof pkg?.version === "string" ? pkg.version : null;
|
|
99
|
+
const startedAtMs = Date.now() - Math.round(process.uptime() * 1000);
|
|
100
|
+
return {
|
|
101
|
+
packageName: typeof pkg?.name === "string" ? pkg.name : "@botcord/daemon",
|
|
102
|
+
version,
|
|
103
|
+
modulePath: MODULE_PATH,
|
|
104
|
+
entrypoint: process.argv[1] ?? null,
|
|
105
|
+
execPath: process.execPath,
|
|
106
|
+
argv: process.argv.map((arg) => redact(arg)),
|
|
107
|
+
execArgv: process.execArgv.map((arg) => redact(arg)),
|
|
108
|
+
cwd: process.cwd(),
|
|
109
|
+
pid: process.pid,
|
|
110
|
+
ppid: process.ppid,
|
|
111
|
+
uptimeSec: Math.round(process.uptime()),
|
|
112
|
+
startedAt: new Date(startedAtMs).toISOString(),
|
|
113
|
+
versions: {
|
|
114
|
+
node: process.version,
|
|
115
|
+
v8: process.versions.v8,
|
|
116
|
+
uv: process.versions.uv,
|
|
117
|
+
openssl: process.versions.openssl,
|
|
118
|
+
},
|
|
119
|
+
packages: {
|
|
120
|
+
"@botcord/daemon": version,
|
|
121
|
+
"@botcord/cli": readInstalledPackageVersion("@botcord/cli/package.json"),
|
|
122
|
+
"@botcord/protocol-core": readInstalledPackageVersion("@botcord/protocol-core/package.json"),
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
function safeEnvironmentSummary() {
|
|
127
|
+
const out = {};
|
|
128
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
129
|
+
if (!value)
|
|
130
|
+
continue;
|
|
131
|
+
if (!ENV_ALLOWLIST.has(key) && !key.startsWith("BOTCORD_DAEMON_"))
|
|
132
|
+
continue;
|
|
133
|
+
out[key] = redact(value);
|
|
134
|
+
}
|
|
135
|
+
return out;
|
|
136
|
+
}
|
|
38
137
|
function readUserAuthSummary() {
|
|
39
138
|
const raw = safeReadText(USER_AUTH_PATH);
|
|
40
139
|
if (!raw)
|
|
@@ -76,6 +175,7 @@ async function buildDoctorEntries() {
|
|
|
76
175
|
let cfgForEndpoints = null;
|
|
77
176
|
try {
|
|
78
177
|
cfgForEndpoints = loadConfig();
|
|
178
|
+
cfgForEndpoints = await refreshDiscoveredOpenclawGateways(cfgForEndpoints);
|
|
79
179
|
channels = channelsFromDaemonConfig(cfgForEndpoints);
|
|
80
180
|
}
|
|
81
181
|
catch {
|
|
@@ -99,6 +199,33 @@ async function buildDoctorEntries() {
|
|
|
99
199
|
});
|
|
100
200
|
return { text: renderDoctor(input), json: input };
|
|
101
201
|
}
|
|
202
|
+
async function refreshDiscoveredOpenclawGateways(cfg) {
|
|
203
|
+
if (!openclawDiscoveryConfigEnabled(cfg))
|
|
204
|
+
return cfg;
|
|
205
|
+
try {
|
|
206
|
+
const found = await discoverLocalOpenclawGateways({
|
|
207
|
+
searchPaths: cfg.openclawDiscovery?.searchPaths,
|
|
208
|
+
defaultPorts: cfg.openclawDiscovery?.defaultPorts,
|
|
209
|
+
timeoutMs: 500,
|
|
210
|
+
});
|
|
211
|
+
const merged = mergeOpenclawGateways(cfg, found);
|
|
212
|
+
if (!merged.changed)
|
|
213
|
+
return cfg;
|
|
214
|
+
saveConfig(merged.cfg);
|
|
215
|
+
daemonLog.info("openclaw discovery: gateways merged", {
|
|
216
|
+
source: "diagnostics",
|
|
217
|
+
added: merged.added.map((g) => ({ name: g.name, url: g.url })),
|
|
218
|
+
});
|
|
219
|
+
return merged.cfg;
|
|
220
|
+
}
|
|
221
|
+
catch (err) {
|
|
222
|
+
daemonLog.warn("openclaw discovery failed; continuing", {
|
|
223
|
+
source: "diagnostics",
|
|
224
|
+
error: err instanceof Error ? err.message : String(err),
|
|
225
|
+
});
|
|
226
|
+
return cfg;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
102
229
|
function crc32(buf) {
|
|
103
230
|
let crc = 0xffffffff;
|
|
104
231
|
for (const b of buf) {
|
|
@@ -229,8 +356,11 @@ export async function createDiagnosticBundle(opts = {}) {
|
|
|
229
356
|
const logFile = opts.logFile ?? LOG_FILE_PATH;
|
|
230
357
|
const configFile = opts.configFile ?? CONFIG_FILE_PATH;
|
|
231
358
|
const snapshotFile = opts.snapshotFile ?? SNAPSHOT_PATH;
|
|
359
|
+
const sessionsFile = opts.sessionsFile ?? SESSIONS_PATH;
|
|
232
360
|
const includeAllLogs = opts.includeAllLogs === true;
|
|
233
361
|
const logs = bundledLogs(logFile, includeAllLogs);
|
|
362
|
+
const acpLogs = listAcpTraceLogFiles(includeAllLogs);
|
|
363
|
+
const runtimeLogs = listRuntimeLogFiles(includeAllLogs);
|
|
234
364
|
mkdirSync(diagnosticsDir, { recursive: true, mode: 0o700 });
|
|
235
365
|
const doctor = opts.doctor ?? await buildDoctorEntries();
|
|
236
366
|
const status = {
|
|
@@ -240,9 +370,12 @@ export async function createDiagnosticBundle(opts = {}) {
|
|
|
240
370
|
release: release(),
|
|
241
371
|
arch: arch(),
|
|
242
372
|
node: process.version,
|
|
373
|
+
daemon: daemonRuntimeSummary(),
|
|
374
|
+
environment: safeEnvironmentSummary(),
|
|
243
375
|
pidPath: PID_PATH,
|
|
244
376
|
pid: process.pid,
|
|
245
377
|
configPath: configFile,
|
|
378
|
+
sessionsPath: sessionsFile,
|
|
246
379
|
snapshotPath: snapshotFile,
|
|
247
380
|
logPath: logFile,
|
|
248
381
|
logsBundled: logs.map((entry) => ({
|
|
@@ -251,6 +384,16 @@ export async function createDiagnosticBundle(opts = {}) {
|
|
|
251
384
|
sizeBytes: entry.sizeBytes,
|
|
252
385
|
active: entry.active,
|
|
253
386
|
})),
|
|
387
|
+
acpLogsBundled: acpLogs.map((entry) => ({
|
|
388
|
+
name: entry.name,
|
|
389
|
+
path: entry.path,
|
|
390
|
+
sizeBytes: entry.sizeBytes,
|
|
391
|
+
})),
|
|
392
|
+
runtimeLogsBundled: runtimeLogs.map((entry) => ({
|
|
393
|
+
name: entry.bundleName,
|
|
394
|
+
path: entry.path,
|
|
395
|
+
sizeBytes: entry.sizeBytes,
|
|
396
|
+
})),
|
|
254
397
|
logsBundleMode: includeAllLogs ? "all" : `active_plus_${DEFAULT_ROTATED_LOGS_IN_BUNDLE}_rotated`,
|
|
255
398
|
diagnosticsDir,
|
|
256
399
|
userAuth: readUserAuthSummary(),
|
|
@@ -276,6 +419,20 @@ export async function createDiagnosticBundle(opts = {}) {
|
|
|
276
419
|
});
|
|
277
420
|
}
|
|
278
421
|
}
|
|
422
|
+
for (const entry of acpLogs) {
|
|
423
|
+
const log = safeReadText(entry.path);
|
|
424
|
+
entries.push({
|
|
425
|
+
name: `acp-logs/${entry.name.split(path.sep).join("/")}`,
|
|
426
|
+
data: log ?? `no ACP log file at ${entry.path}\n`,
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
for (const entry of runtimeLogs) {
|
|
430
|
+
const log = safeReadText(entry.path);
|
|
431
|
+
entries.push({
|
|
432
|
+
name: entry.bundleName,
|
|
433
|
+
data: log ?? `no runtime log file at ${entry.path}\n`,
|
|
434
|
+
});
|
|
435
|
+
}
|
|
279
436
|
const config = safeReadText(configFile);
|
|
280
437
|
entries.push({
|
|
281
438
|
name: "config.json.redacted",
|
|
@@ -286,6 +443,11 @@ export async function createDiagnosticBundle(opts = {}) {
|
|
|
286
443
|
name: "snapshot.json",
|
|
287
444
|
data: snapshot ?? `no snapshot file at ${snapshotFile}\n`,
|
|
288
445
|
});
|
|
446
|
+
const sessions = safeReadText(sessionsFile);
|
|
447
|
+
entries.push({
|
|
448
|
+
name: "sessions.json.redacted",
|
|
449
|
+
data: sessions ?? `no sessions file at ${sessionsFile}\n`,
|
|
450
|
+
});
|
|
289
451
|
const zip = createZip(entries);
|
|
290
452
|
const out = path.join(diagnosticsDir, filename);
|
|
291
453
|
writeFileSync(out, zip, { mode: 0o600 });
|
|
@@ -959,6 +959,14 @@ export class Dispatcher {
|
|
|
959
959
|
systemContext,
|
|
960
960
|
onBlock,
|
|
961
961
|
onStatus,
|
|
962
|
+
context: {
|
|
963
|
+
turnId,
|
|
964
|
+
messageId: msg.id,
|
|
965
|
+
roomId: msg.conversation.id,
|
|
966
|
+
topicId: msg.conversation.threadId ?? null,
|
|
967
|
+
channel: msg.channel,
|
|
968
|
+
conversationKind: msg.conversation.kind,
|
|
969
|
+
},
|
|
962
970
|
gateway: route.gateway,
|
|
963
971
|
...(route.hermesProfile ? { hermesProfile: route.hermesProfile } : {}),
|
|
964
972
|
});
|
|
@@ -1087,16 +1095,35 @@ export class Dispatcher {
|
|
|
1087
1095
|
}
|
|
1088
1096
|
if (!result)
|
|
1089
1097
|
return;
|
|
1098
|
+
const replyText = (result.text || "").trim();
|
|
1099
|
+
const finalTextField = truncateTextField(result.text || "");
|
|
1090
1100
|
// Persist session before reply so next turn sees the new id even if send fails.
|
|
1091
1101
|
//
|
|
1092
1102
|
// Adapter contract:
|
|
1093
|
-
// result.
|
|
1094
|
-
//
|
|
1095
|
-
// → the prior session is dead (e.g. Claude Code
|
|
1096
|
-
// "--resume <missing-uuid>"); delete the entry so
|
|
1103
|
+
// had-inbound-sessionId + result.error + no reply text
|
|
1104
|
+
// → the prior session is suspect/dead; delete it so
|
|
1097
1105
|
// we don't keep resuming a stale id every turn
|
|
1106
|
+
// even when the adapter echoes that id back
|
|
1107
|
+
// result.newSessionId truthy → upsert the entry
|
|
1098
1108
|
// otherwise → no-op (e.g. codex intentionally never persists)
|
|
1099
|
-
if (result.
|
|
1109
|
+
if (sessionId && result.error && !replyText) {
|
|
1110
|
+
try {
|
|
1111
|
+
await this.sessionStore.delete(key);
|
|
1112
|
+
this.log.info("dispatcher: dropped stale runtime session", {
|
|
1113
|
+
key,
|
|
1114
|
+
prevRuntimeSessionId: sessionId,
|
|
1115
|
+
nextRuntimeSessionId: result.newSessionId || null,
|
|
1116
|
+
error: result.error,
|
|
1117
|
+
});
|
|
1118
|
+
}
|
|
1119
|
+
catch (err) {
|
|
1120
|
+
this.log.warn("dispatcher: session-store.delete failed", {
|
|
1121
|
+
key,
|
|
1122
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1123
|
+
});
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
else if (result.newSessionId) {
|
|
1100
1127
|
const session = {
|
|
1101
1128
|
key,
|
|
1102
1129
|
runtime: route.runtime,
|
|
@@ -1141,8 +1168,6 @@ export class Dispatcher {
|
|
|
1141
1168
|
});
|
|
1142
1169
|
}
|
|
1143
1170
|
}
|
|
1144
|
-
const replyText = (result.text || "").trim();
|
|
1145
|
-
const finalTextField = truncateTextField(result.text || "");
|
|
1146
1171
|
if (!replyText) {
|
|
1147
1172
|
if (result.error) {
|
|
1148
1173
|
this.log.warn("dispatcher: runtime returned error without reply text", {
|