@botcord/daemon 0.2.61 → 0.2.62
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.js +58 -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__/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 +60 -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
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
+
import { createAcpTraceLogger } from "../../acp-logs.js";
|
|
2
3
|
import { consoleLogger } from "../log.js";
|
|
3
4
|
/**
|
|
4
5
|
* Minimal bidirectional ACP (Agent Client Protocol) client used by runtime
|
|
@@ -22,22 +23,32 @@ const ASSISTANT_TEXT_CAP = 1 * 1024 * 1024;
|
|
|
22
23
|
const KILL_GRACE_MS = 5_000;
|
|
23
24
|
/** Deadline for the initial `initialize` handshake. */
|
|
24
25
|
const INITIALIZE_TIMEOUT_MS = 30_000;
|
|
26
|
+
/** Short drain window for late `session/update` chunks after a prompt RPC error. */
|
|
27
|
+
const PROMPT_ERROR_DRAIN_MS = 750;
|
|
25
28
|
/** ACP protocol version this client targets. */
|
|
26
29
|
export const ACP_PROTOCOL_VERSION = 1;
|
|
30
|
+
function stringField(obj, key) {
|
|
31
|
+
if (!obj || typeof obj !== "object")
|
|
32
|
+
return undefined;
|
|
33
|
+
const value = obj[key];
|
|
34
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
35
|
+
}
|
|
27
36
|
/** Minimal newline-JSON-RPC framing on top of a child process's stdio. */
|
|
28
37
|
class AcpConnection {
|
|
29
38
|
child;
|
|
30
39
|
handlers;
|
|
31
40
|
logId;
|
|
41
|
+
trace;
|
|
32
42
|
nextId = 1;
|
|
33
43
|
pending = new Map();
|
|
34
44
|
stdoutBuf = "";
|
|
35
45
|
closed = false;
|
|
36
46
|
closeReason = null;
|
|
37
|
-
constructor(child, handlers, logId) {
|
|
47
|
+
constructor(child, handlers, logId, trace = null) {
|
|
38
48
|
this.child = child;
|
|
39
49
|
this.handlers = handlers;
|
|
40
50
|
this.logId = logId;
|
|
51
|
+
this.trace = trace;
|
|
41
52
|
child.stdout.setEncoding("utf8");
|
|
42
53
|
child.stdout.on("data", (chunk) => this.onStdout(chunk));
|
|
43
54
|
child.stdout.on("end", () => this.fail(new Error("stdout closed")));
|
|
@@ -61,6 +72,7 @@ class AcpConnection {
|
|
|
61
72
|
msg = JSON.parse(line);
|
|
62
73
|
}
|
|
63
74
|
catch {
|
|
75
|
+
this.trace?.write({ stream: "stdout_non_json", chunk: line });
|
|
64
76
|
log.warn(`${this.logId} non-json acp line`, { line: line.slice(0, 200) });
|
|
65
77
|
return;
|
|
66
78
|
}
|
|
@@ -73,10 +85,25 @@ class AcpConnection {
|
|
|
73
85
|
return;
|
|
74
86
|
this.pending.delete(msg.id);
|
|
75
87
|
if (msg.error) {
|
|
88
|
+
this.trace?.write({
|
|
89
|
+
stream: "rpc_in",
|
|
90
|
+
direction: "in",
|
|
91
|
+
id: msg.id,
|
|
92
|
+
status: "error",
|
|
93
|
+
code: typeof msg.error.code === "number" ? msg.error.code : undefined,
|
|
94
|
+
error: msg.error.message ?? "(no message)",
|
|
95
|
+
});
|
|
76
96
|
const err = new Error(`acp error ${msg.error.code ?? "?"}: ${msg.error.message ?? "(no message)"}`);
|
|
77
97
|
pending.reject(err);
|
|
78
98
|
}
|
|
79
99
|
else {
|
|
100
|
+
this.trace?.write({
|
|
101
|
+
stream: "rpc_in",
|
|
102
|
+
direction: "in",
|
|
103
|
+
id: msg.id,
|
|
104
|
+
status: "response",
|
|
105
|
+
result: msg.result ?? null,
|
|
106
|
+
});
|
|
80
107
|
pending.resolve(msg.result ?? null);
|
|
81
108
|
}
|
|
82
109
|
return;
|
|
@@ -84,9 +111,24 @@ class AcpConnection {
|
|
|
84
111
|
if (typeof msg.method === "string") {
|
|
85
112
|
// Server→client request (has `id`) or notification (no `id`)
|
|
86
113
|
if (msg.id !== undefined) {
|
|
114
|
+
this.trace?.write({
|
|
115
|
+
stream: "rpc_in",
|
|
116
|
+
direction: "in",
|
|
117
|
+
id: msg.id,
|
|
118
|
+
method: msg.method,
|
|
119
|
+
status: "request",
|
|
120
|
+
params: msg.params,
|
|
121
|
+
});
|
|
87
122
|
void this.handleServerRequest(msg.id, msg.method, msg.params);
|
|
88
123
|
}
|
|
89
124
|
else {
|
|
125
|
+
this.trace?.write({
|
|
126
|
+
stream: "rpc_in",
|
|
127
|
+
direction: "in",
|
|
128
|
+
method: msg.method,
|
|
129
|
+
status: "notification",
|
|
130
|
+
params: msg.params,
|
|
131
|
+
});
|
|
90
132
|
try {
|
|
91
133
|
this.handlers.onNotification(msg.method, msg.params);
|
|
92
134
|
}
|
|
@@ -114,6 +156,15 @@ class AcpConnection {
|
|
|
114
156
|
const reply = error
|
|
115
157
|
? { jsonrpc: "2.0", id, error }
|
|
116
158
|
: { jsonrpc: "2.0", id, result: result ?? null };
|
|
159
|
+
this.trace?.write({
|
|
160
|
+
stream: "rpc_out",
|
|
161
|
+
direction: "out",
|
|
162
|
+
id,
|
|
163
|
+
status: error ? "error" : "response",
|
|
164
|
+
code: error?.code,
|
|
165
|
+
error: error?.message,
|
|
166
|
+
result: error ? undefined : result ?? null,
|
|
167
|
+
});
|
|
117
168
|
this.writeMessage(reply);
|
|
118
169
|
}
|
|
119
170
|
writeMessage(obj) {
|
|
@@ -136,10 +187,25 @@ class AcpConnection {
|
|
|
136
187
|
resolve: (v) => resolve(v),
|
|
137
188
|
reject,
|
|
138
189
|
});
|
|
190
|
+
this.trace?.write({
|
|
191
|
+
stream: "rpc_out",
|
|
192
|
+
direction: "out",
|
|
193
|
+
id,
|
|
194
|
+
method,
|
|
195
|
+
status: "request",
|
|
196
|
+
params,
|
|
197
|
+
});
|
|
139
198
|
this.writeMessage({ jsonrpc: "2.0", id, method, params });
|
|
140
199
|
});
|
|
141
200
|
}
|
|
142
201
|
notify(method, params) {
|
|
202
|
+
this.trace?.write({
|
|
203
|
+
stream: "rpc_out",
|
|
204
|
+
direction: "out",
|
|
205
|
+
method,
|
|
206
|
+
status: "notification",
|
|
207
|
+
params,
|
|
208
|
+
});
|
|
143
209
|
this.writeMessage({ jsonrpc: "2.0", method, params });
|
|
144
210
|
}
|
|
145
211
|
fail(err) {
|
|
@@ -205,6 +271,20 @@ export class AcpRuntimeAdapter {
|
|
|
205
271
|
env: this.spawnEnv(opts),
|
|
206
272
|
stdio: ["pipe", "pipe", "pipe"],
|
|
207
273
|
});
|
|
274
|
+
const trace = createAcpTraceLogger({
|
|
275
|
+
runtime: this.id,
|
|
276
|
+
accountId: opts.accountId,
|
|
277
|
+
turnId: stringField(opts.context, "turnId"),
|
|
278
|
+
roomId: stringField(opts.context, "roomId"),
|
|
279
|
+
topicId: stringField(opts.context, "topicId") ?? null,
|
|
280
|
+
hermesProfile: opts.hermesProfile,
|
|
281
|
+
sessionId: opts.sessionId,
|
|
282
|
+
});
|
|
283
|
+
trace?.write({
|
|
284
|
+
stream: "child_start",
|
|
285
|
+
pid: child.pid,
|
|
286
|
+
params: { command: binary, args, cwd: opts.cwd },
|
|
287
|
+
});
|
|
208
288
|
let killTimer = null;
|
|
209
289
|
const onAbort = () => {
|
|
210
290
|
if (child.killed)
|
|
@@ -235,6 +315,7 @@ export class AcpRuntimeAdapter {
|
|
|
235
315
|
child.stderr.setEncoding("utf8");
|
|
236
316
|
child.stderr.on("data", (chunk) => {
|
|
237
317
|
stderrTail = (stderrTail + chunk).slice(-STDERR_TAIL_CAP);
|
|
318
|
+
trace?.write({ stream: "stderr", pid: child.pid, chunk });
|
|
238
319
|
});
|
|
239
320
|
const state = {
|
|
240
321
|
finalText: "",
|
|
@@ -289,11 +370,22 @@ export class AcpRuntimeAdapter {
|
|
|
289
370
|
const err = new Error(`unknown server request: ${method}`);
|
|
290
371
|
throw err;
|
|
291
372
|
},
|
|
292
|
-
}, this.id);
|
|
373
|
+
}, this.id, trace);
|
|
293
374
|
const childExit = new Promise((resolve) => {
|
|
294
|
-
child.on("close", (code) =>
|
|
375
|
+
child.on("close", (code, signal) => {
|
|
376
|
+
trace?.write({ stream: "child_exit", pid: child.pid, code, signal });
|
|
377
|
+
resolve(code ?? 0);
|
|
378
|
+
});
|
|
379
|
+
child.on("error", (err) => {
|
|
380
|
+
trace?.write({
|
|
381
|
+
stream: "child_error",
|
|
382
|
+
pid: child.pid,
|
|
383
|
+
error: err instanceof Error ? err.message : String(err),
|
|
384
|
+
});
|
|
385
|
+
});
|
|
295
386
|
});
|
|
296
387
|
let newSessionId = opts.sessionId ?? "";
|
|
388
|
+
let promptStarted = false;
|
|
297
389
|
try {
|
|
298
390
|
// 1) initialize
|
|
299
391
|
await this.withTimeout(conn.request("initialize", {
|
|
@@ -335,6 +427,7 @@ export class AcpRuntimeAdapter {
|
|
|
335
427
|
}
|
|
336
428
|
newSessionId = sessionId;
|
|
337
429
|
// 3) session/prompt
|
|
430
|
+
promptStarted = true;
|
|
338
431
|
const promptResult = (await conn.request("session/prompt", {
|
|
339
432
|
sessionId,
|
|
340
433
|
prompt: [{ type: "text", text: opts.text }],
|
|
@@ -373,6 +466,9 @@ export class AcpRuntimeAdapter {
|
|
|
373
466
|
const tail = stderrTail.slice(-STDERR_ERROR_SNIPPET).trim();
|
|
374
467
|
state.errorText =
|
|
375
468
|
state.errorText ?? (tail ? `${baseMsg}; stderr: ${tail}` : baseMsg);
|
|
469
|
+
if (promptStarted && !opts.signal.aborted) {
|
|
470
|
+
await sleepUnlessAborted(PROMPT_ERROR_DRAIN_MS, opts.signal);
|
|
471
|
+
}
|
|
376
472
|
try {
|
|
377
473
|
child.stdin.end();
|
|
378
474
|
}
|
|
@@ -417,3 +513,18 @@ export class AcpRuntimeAdapter {
|
|
|
417
513
|
});
|
|
418
514
|
}
|
|
419
515
|
}
|
|
516
|
+
function sleepUnlessAborted(ms, signal) {
|
|
517
|
+
if (signal.aborted)
|
|
518
|
+
return Promise.resolve();
|
|
519
|
+
return new Promise((resolve) => {
|
|
520
|
+
const t = setTimeout(done, ms);
|
|
521
|
+
if (typeof t.unref === "function")
|
|
522
|
+
t.unref();
|
|
523
|
+
function done() {
|
|
524
|
+
signal.removeEventListener("abort", done);
|
|
525
|
+
clearTimeout(t);
|
|
526
|
+
resolve();
|
|
527
|
+
}
|
|
528
|
+
signal.addEventListener("abort", done, { once: true });
|
|
529
|
+
});
|
|
530
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
+
import { createAcpTraceLogger } from "../../acp-logs.js";
|
|
2
3
|
import { readCommandVersion, resolveCommandOnPath, } from "./probe.js";
|
|
3
4
|
import { consoleLogger } from "../log.js";
|
|
4
5
|
const log = consoleLogger;
|
|
@@ -282,6 +283,12 @@ export class OpenclawAcpAdapter {
|
|
|
282
283
|
}
|
|
283
284
|
if (!finalText) {
|
|
284
285
|
const stopReason = pickStopReason(promptResult);
|
|
286
|
+
if (!stopReason || stopReason === "end_turn") {
|
|
287
|
+
return {
|
|
288
|
+
text: "",
|
|
289
|
+
newSessionId: acpSessionId,
|
|
290
|
+
};
|
|
291
|
+
}
|
|
285
292
|
const warningTail = handle.nonJsonStdoutTail.slice(-8).join("\n").trim();
|
|
286
293
|
const detail = warningTail ? `; stdout: ${truncateDetail(warningTail, 1000)}` : "";
|
|
287
294
|
const reason = stopReason ? `prompt stopped: ${stopReason}` : "empty assistant response";
|
|
@@ -356,10 +363,22 @@ export class OpenclawAcpAdapter {
|
|
|
356
363
|
const args = ["acp", "--url", gateway.url];
|
|
357
364
|
if (gateway.token)
|
|
358
365
|
args.push("--token", gateway.token);
|
|
366
|
+
const accountId = key.split("::", 1)[0];
|
|
367
|
+
const trace = createAcpTraceLogger({
|
|
368
|
+
runtime: acpRuntimeLogName(gateway),
|
|
369
|
+
accountId,
|
|
370
|
+
gatewayName: gateway.name,
|
|
371
|
+
gatewayUrl: gateway.url,
|
|
372
|
+
});
|
|
359
373
|
const child = this.spawnFn(command, args, {
|
|
360
374
|
stdio: ["pipe", "pipe", "pipe"],
|
|
361
375
|
env: { ...process.env },
|
|
362
376
|
});
|
|
377
|
+
trace?.write({
|
|
378
|
+
stream: "child_start",
|
|
379
|
+
pid: child.pid,
|
|
380
|
+
params: { command, args, gateway: gateway.name },
|
|
381
|
+
});
|
|
363
382
|
const handle = {
|
|
364
383
|
child,
|
|
365
384
|
pending: new Map(),
|
|
@@ -372,18 +391,26 @@ export class OpenclawAcpAdapter {
|
|
|
372
391
|
closed: false,
|
|
373
392
|
spawnedUrl: gateway.url,
|
|
374
393
|
spawnedToken: gateway.token,
|
|
394
|
+
trace,
|
|
375
395
|
};
|
|
376
396
|
child.stdout.setEncoding("utf8");
|
|
377
397
|
child.stdout.on("data", (chunk) => onStdoutChunk(handle, chunk));
|
|
378
398
|
child.stderr.setEncoding("utf8");
|
|
379
399
|
child.stderr.on("data", (chunk) => {
|
|
400
|
+
trace?.write({ stream: "stderr", pid: child.pid, chunk });
|
|
380
401
|
log.debug("openclaw-acp.stderr", { key, chunk: chunk.slice(0, 500) });
|
|
381
402
|
});
|
|
382
403
|
child.on("exit", (code, signal) => {
|
|
404
|
+
trace?.write({ stream: "child_exit", pid: child.pid, code, signal });
|
|
383
405
|
shutdownHandle(handle, `exit code=${code ?? "null"} signal=${signal ?? "null"}`);
|
|
384
406
|
ACP_POOL.delete(key);
|
|
385
407
|
});
|
|
386
408
|
child.on("error", (err) => {
|
|
409
|
+
trace?.write({
|
|
410
|
+
stream: "child_error",
|
|
411
|
+
pid: child.pid,
|
|
412
|
+
error: err instanceof Error ? err.message : String(err),
|
|
413
|
+
});
|
|
387
414
|
log.warn("openclaw-acp.child-error", {
|
|
388
415
|
key,
|
|
389
416
|
error: err instanceof Error ? err.message : String(err),
|
|
@@ -426,6 +453,18 @@ export class OpenclawAcpAdapter {
|
|
|
426
453
|
// ---------------------------------------------------------------------------
|
|
427
454
|
// JSON-RPC stdio plumbing
|
|
428
455
|
// ---------------------------------------------------------------------------
|
|
456
|
+
function acpRuntimeLogName(gateway) {
|
|
457
|
+
if (gateway.name.toLowerCase().includes("qclaw"))
|
|
458
|
+
return "qclaw-acp";
|
|
459
|
+
try {
|
|
460
|
+
if (new URL(gateway.url).port === "28789")
|
|
461
|
+
return "qclaw-acp";
|
|
462
|
+
}
|
|
463
|
+
catch {
|
|
464
|
+
// Fall back to OpenClaw.
|
|
465
|
+
}
|
|
466
|
+
return "openclaw-acp";
|
|
467
|
+
}
|
|
429
468
|
function onStdoutChunk(handle, chunk) {
|
|
430
469
|
handle.buffer += chunk;
|
|
431
470
|
let idx;
|
|
@@ -447,6 +486,7 @@ function onStdoutChunk(handle, chunk) {
|
|
|
447
486
|
error: err instanceof Error ? err.message : String(err),
|
|
448
487
|
line: line.slice(0, 200),
|
|
449
488
|
});
|
|
489
|
+
handle.trace?.write({ stream: "stdout_non_json", chunk: line });
|
|
450
490
|
continue;
|
|
451
491
|
}
|
|
452
492
|
routeMessage(handle, msg);
|
|
@@ -461,15 +501,37 @@ function routeMessage(handle, msg) {
|
|
|
461
501
|
handle.pending.delete(id);
|
|
462
502
|
if (msg.error) {
|
|
463
503
|
const message = formatRpcError(msg.error);
|
|
504
|
+
handle.trace?.write({
|
|
505
|
+
stream: "rpc_in",
|
|
506
|
+
direction: "in",
|
|
507
|
+
id,
|
|
508
|
+
status: "error",
|
|
509
|
+
code: typeof msg.error.code === "number" ? msg.error.code : undefined,
|
|
510
|
+
error: message,
|
|
511
|
+
});
|
|
464
512
|
pending.reject(new Error(message));
|
|
465
513
|
}
|
|
466
514
|
else {
|
|
515
|
+
handle.trace?.write({
|
|
516
|
+
stream: "rpc_in",
|
|
517
|
+
direction: "in",
|
|
518
|
+
id,
|
|
519
|
+
status: "response",
|
|
520
|
+
result: msg.result ?? null,
|
|
521
|
+
});
|
|
467
522
|
pending.resolve(msg.result);
|
|
468
523
|
}
|
|
469
524
|
return;
|
|
470
525
|
}
|
|
471
526
|
// Notification.
|
|
472
527
|
if (msg?.method && msg?.params) {
|
|
528
|
+
handle.trace?.write({
|
|
529
|
+
stream: "rpc_in",
|
|
530
|
+
direction: "in",
|
|
531
|
+
method: msg.method,
|
|
532
|
+
status: "notification",
|
|
533
|
+
params: msg.params,
|
|
534
|
+
});
|
|
473
535
|
const sid = msg.params?.sessionId;
|
|
474
536
|
if (typeof sid === "string") {
|
|
475
537
|
const sub = handle.subscribers.get(sid);
|
|
@@ -492,6 +554,14 @@ function sendRequest(handle, method, params) {
|
|
|
492
554
|
return new Promise((resolve, reject) => {
|
|
493
555
|
const id = handle.nextId++;
|
|
494
556
|
handle.pending.set(id, { resolve, reject, method });
|
|
557
|
+
handle.trace?.write({
|
|
558
|
+
stream: "rpc_out",
|
|
559
|
+
direction: "out",
|
|
560
|
+
id,
|
|
561
|
+
method,
|
|
562
|
+
status: "request",
|
|
563
|
+
params,
|
|
564
|
+
});
|
|
495
565
|
const frame = JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n";
|
|
496
566
|
try {
|
|
497
567
|
handle.child.stdin.write(frame);
|
|
@@ -505,6 +575,13 @@ function sendRequest(handle, method, params) {
|
|
|
505
575
|
function sendNotification(handle, method, params) {
|
|
506
576
|
if (handle.closed)
|
|
507
577
|
return;
|
|
578
|
+
handle.trace?.write({
|
|
579
|
+
stream: "rpc_out",
|
|
580
|
+
direction: "out",
|
|
581
|
+
method,
|
|
582
|
+
status: "notification",
|
|
583
|
+
params,
|
|
584
|
+
});
|
|
508
585
|
const frame = JSON.stringify({ jsonrpc: "2.0", method, params }) + "\n";
|
|
509
586
|
try {
|
|
510
587
|
handle.child.stdin.write(frame);
|
package/dist/index.js
CHANGED
|
@@ -250,6 +250,33 @@ function loadOrInitConfig(args) {
|
|
|
250
250
|
return cfg;
|
|
251
251
|
}
|
|
252
252
|
}
|
|
253
|
+
async function refreshDiscoveredOpenclawGateways(cfg, source) {
|
|
254
|
+
if (!openclawDiscoveryConfigEnabled(cfg))
|
|
255
|
+
return cfg;
|
|
256
|
+
try {
|
|
257
|
+
const found = await discoverLocalOpenclawGateways({
|
|
258
|
+
searchPaths: cfg.openclawDiscovery?.searchPaths,
|
|
259
|
+
defaultPorts: cfg.openclawDiscovery?.defaultPorts,
|
|
260
|
+
timeoutMs: 500,
|
|
261
|
+
});
|
|
262
|
+
const merged = mergeOpenclawGateways(cfg, found);
|
|
263
|
+
if (!merged.changed)
|
|
264
|
+
return cfg;
|
|
265
|
+
saveConfig(merged.cfg);
|
|
266
|
+
log.info("openclaw discovery: gateways merged", {
|
|
267
|
+
source,
|
|
268
|
+
added: merged.added.map((g) => ({ name: g.name, url: g.url })),
|
|
269
|
+
});
|
|
270
|
+
return merged.cfg;
|
|
271
|
+
}
|
|
272
|
+
catch (err) {
|
|
273
|
+
log.warn("openclaw discovery failed; continuing", {
|
|
274
|
+
source,
|
|
275
|
+
error: err instanceof Error ? err.message : String(err),
|
|
276
|
+
});
|
|
277
|
+
return cfg;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
253
280
|
/**
|
|
254
281
|
* Read the current user-auth record without throwing on parse / permission
|
|
255
282
|
* errors — those are returned as `null` so the caller treats them like a
|
|
@@ -465,28 +492,7 @@ async function ensureUserAuthForStart(args) {
|
|
|
465
492
|
}
|
|
466
493
|
async function cmdStart(args) {
|
|
467
494
|
let cfg = loadOrInitConfig(args);
|
|
468
|
-
|
|
469
|
-
try {
|
|
470
|
-
const found = await discoverLocalOpenclawGateways({
|
|
471
|
-
searchPaths: cfg.openclawDiscovery?.searchPaths,
|
|
472
|
-
defaultPorts: cfg.openclawDiscovery?.defaultPorts,
|
|
473
|
-
timeoutMs: 500,
|
|
474
|
-
});
|
|
475
|
-
const merged = mergeOpenclawGateways(cfg, found);
|
|
476
|
-
if (merged.changed) {
|
|
477
|
-
cfg = merged.cfg;
|
|
478
|
-
saveConfig(cfg);
|
|
479
|
-
log.info("openclaw discovery: gateways merged", {
|
|
480
|
-
added: merged.added.map((g) => ({ name: g.name, url: g.url })),
|
|
481
|
-
});
|
|
482
|
-
}
|
|
483
|
-
}
|
|
484
|
-
catch (err) {
|
|
485
|
-
log.warn("openclaw discovery failed; continuing", {
|
|
486
|
-
error: err instanceof Error ? err.message : String(err),
|
|
487
|
-
});
|
|
488
|
-
}
|
|
489
|
-
}
|
|
495
|
+
cfg = await refreshDiscoveredOpenclawGateways(cfg, "start");
|
|
490
496
|
// Foreground is now the default. --background (alias -d) detaches.
|
|
491
497
|
// --foreground is still accepted (no-op) for backwards compatibility and
|
|
492
498
|
// is also what the detached child re-execs itself with.
|
|
@@ -1241,8 +1247,8 @@ async function cmdDoctor(args) {
|
|
|
1241
1247
|
let cfgForEndpoints = null;
|
|
1242
1248
|
try {
|
|
1243
1249
|
const cfg = loadConfig();
|
|
1244
|
-
cfgForEndpoints = cfg;
|
|
1245
|
-
channels = channelsFromDaemonConfig(
|
|
1250
|
+
cfgForEndpoints = await refreshDiscoveredOpenclawGateways(cfg, "doctor");
|
|
1251
|
+
channels = channelsFromDaemonConfig(cfgForEndpoints);
|
|
1246
1252
|
}
|
|
1247
1253
|
catch {
|
|
1248
1254
|
channels = [];
|
|
@@ -304,6 +304,7 @@ export function mergeOpenclawGateways(cfg, found) {
|
|
|
304
304
|
}
|
|
305
305
|
function discoverFromConfigDir(root) {
|
|
306
306
|
const dir = expandHome(root);
|
|
307
|
+
const rootIsQclaw = path.basename(dir) === ".qclaw";
|
|
307
308
|
let names;
|
|
308
309
|
try {
|
|
309
310
|
names = readdirSync(dir);
|
|
@@ -324,8 +325,9 @@ function discoverFromConfigDir(root) {
|
|
|
324
325
|
const parsed = name.endsWith(".json") ? parseJsonConfig(raw) : parseTomlConfig(raw);
|
|
325
326
|
if (!parsed?.url)
|
|
326
327
|
continue;
|
|
328
|
+
const namePrefix = rootIsQclaw || name.toLowerCase() === "qclaw.json" ? "qclaw" : "openclaw";
|
|
327
329
|
const item = {
|
|
328
|
-
name: nameFromUrl(parsed.url),
|
|
330
|
+
name: nameFromUrl(parsed.url, namePrefix),
|
|
329
331
|
url: parsed.url,
|
|
330
332
|
source: "config-file",
|
|
331
333
|
};
|
|
@@ -454,24 +456,30 @@ function dedupeDiscovered(items) {
|
|
|
454
456
|
for (const item of items) {
|
|
455
457
|
const key = normalizeUrlKey(item.url);
|
|
456
458
|
const prev = byUrl.get(key);
|
|
457
|
-
if (!prev ||
|
|
459
|
+
if (!prev ||
|
|
460
|
+
priority[item.source] > priority[prev.source] ||
|
|
461
|
+
hasMoreAuth(item, prev) ||
|
|
462
|
+
prefersQclawName(item, prev)) {
|
|
458
463
|
byUrl.set(key, item);
|
|
459
464
|
}
|
|
460
465
|
}
|
|
461
466
|
return [...byUrl.values()];
|
|
462
467
|
}
|
|
468
|
+
function prefersQclawName(a, b) {
|
|
469
|
+
return a.name.startsWith("qclaw-") && !b.name.startsWith("qclaw-");
|
|
470
|
+
}
|
|
463
471
|
function hasMoreAuth(a, b) {
|
|
464
472
|
const score = (x) => (x.token ? 2 : x.tokenFile ? 1 : 0);
|
|
465
473
|
return score(a) > score(b);
|
|
466
474
|
}
|
|
467
|
-
function nameFromUrl(raw) {
|
|
475
|
+
function nameFromUrl(raw, prefix = "openclaw") {
|
|
468
476
|
try {
|
|
469
477
|
const u = new URL(raw);
|
|
470
478
|
const base = `${u.hostname}-${u.port || (u.protocol === "wss:" ? "443" : "80")}`;
|
|
471
|
-
return
|
|
479
|
+
return `${prefix}-${base.replace(/[^A-Za-z0-9_-]+/g, "-")}`;
|
|
472
480
|
}
|
|
473
481
|
catch {
|
|
474
|
-
return
|
|
482
|
+
return `${prefix}-local`;
|
|
475
483
|
}
|
|
476
484
|
}
|
|
477
485
|
function uniqueName(base, existing) {
|
package/dist/provision.js
CHANGED
|
@@ -10,6 +10,7 @@ import path from "node:path";
|
|
|
10
10
|
import { BotCordClient, CONTROL_FRAME_TYPES, defaultCredentialsFile, derivePublicKey, loadStoredCredentials, writeCredentialsFile, } from "@botcord/protocol-core";
|
|
11
11
|
import { loadConfig, resolveConfiguredAgentIds, saveConfig, } from "./config.js";
|
|
12
12
|
import { BOTCORD_CHANNEL_TYPE, buildManagedRoutes, prepareGatewayProfile, } from "./daemon-config-map.js";
|
|
13
|
+
import { discoverLocalOpenclawGateways, mergeOpenclawGateways, openclawDiscoveryConfigEnabled, } from "./openclaw-discovery.js";
|
|
13
14
|
import { agentHomeDir, agentStateDir, agentWorkspaceDir, applyAgentIdentity, ensureAttachedHermesProfileSkills, ensureAgentWorkspace, } from "./agent-workspace.js";
|
|
14
15
|
import { detectRuntimes, getAdapterModule } from "./adapters/runtimes.js";
|
|
15
16
|
import { createGatewayControl } from "./gateway-control.js";
|
|
@@ -199,6 +200,7 @@ export function createProvisioner(opts) {
|
|
|
199
200
|
let cfgForProbe;
|
|
200
201
|
try {
|
|
201
202
|
cfgForProbe = loadConfig();
|
|
203
|
+
cfgForProbe = await refreshDiscoveredOpenclawGateways(cfgForProbe);
|
|
202
204
|
}
|
|
203
205
|
catch {
|
|
204
206
|
cfgForProbe = undefined;
|
|
@@ -284,6 +286,33 @@ export function createProvisioner(opts) {
|
|
|
284
286
|
}
|
|
285
287
|
};
|
|
286
288
|
}
|
|
289
|
+
async function refreshDiscoveredOpenclawGateways(cfg) {
|
|
290
|
+
if (!openclawDiscoveryConfigEnabled(cfg))
|
|
291
|
+
return cfg;
|
|
292
|
+
try {
|
|
293
|
+
const found = await discoverLocalOpenclawGateways({
|
|
294
|
+
searchPaths: cfg.openclawDiscovery?.searchPaths,
|
|
295
|
+
defaultPorts: cfg.openclawDiscovery?.defaultPorts,
|
|
296
|
+
timeoutMs: 500,
|
|
297
|
+
});
|
|
298
|
+
const merged = mergeOpenclawGateways(cfg, found);
|
|
299
|
+
if (!merged.changed)
|
|
300
|
+
return cfg;
|
|
301
|
+
saveConfig(merged.cfg);
|
|
302
|
+
daemonLog.info("openclaw discovery: gateways merged", {
|
|
303
|
+
source: "list_runtimes",
|
|
304
|
+
added: merged.added.map((g) => ({ name: g.name, url: g.url })),
|
|
305
|
+
});
|
|
306
|
+
return merged.cfg;
|
|
307
|
+
}
|
|
308
|
+
catch (err) {
|
|
309
|
+
daemonLog.warn("openclaw discovery failed; continuing", {
|
|
310
|
+
source: "list_runtimes",
|
|
311
|
+
error: err instanceof Error ? err.message : String(err),
|
|
312
|
+
});
|
|
313
|
+
return cfg;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
287
316
|
async function handleWakeAgent(gateway, raw) {
|
|
288
317
|
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
289
318
|
return {
|
package/package.json
CHANGED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
|
|
7
|
+
const originalHome = process.env.HOME;
|
|
8
|
+
|
|
9
|
+
afterEach(() => {
|
|
10
|
+
if (originalHome === undefined) delete process.env.HOME;
|
|
11
|
+
else process.env.HOME = originalHome;
|
|
12
|
+
delete process.env.BOTCORD_ACP_LOGS;
|
|
13
|
+
delete process.env.BOTCORD_ACP_TRACE;
|
|
14
|
+
vi.resetModules();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe("ACP trace logs", () => {
|
|
18
|
+
it("writes redacted safe-mode jsonl and lists it for diagnostics", async () => {
|
|
19
|
+
const home = mkdtempSync(path.join(tmpdir(), "botcord-acp-log-home-"));
|
|
20
|
+
process.env.HOME = home;
|
|
21
|
+
vi.resetModules();
|
|
22
|
+
const { createAcpTraceLogger, listAcpTraceLogFiles } = await import("../acp-logs.js");
|
|
23
|
+
|
|
24
|
+
const logger = createAcpTraceLogger({
|
|
25
|
+
runtime: "openclaw-acp",
|
|
26
|
+
accountId: "ag_test",
|
|
27
|
+
roomId: "rm_test",
|
|
28
|
+
gatewayName: "qclaw-127-0-0-1-28789",
|
|
29
|
+
gatewayUrl: "ws://127.0.0.1:28789",
|
|
30
|
+
});
|
|
31
|
+
expect(logger).not.toBeNull();
|
|
32
|
+
logger!.write({
|
|
33
|
+
stream: "rpc_out",
|
|
34
|
+
direction: "out",
|
|
35
|
+
id: 1,
|
|
36
|
+
method: "session/prompt",
|
|
37
|
+
status: "request",
|
|
38
|
+
params: {
|
|
39
|
+
sessionId: "sess_1",
|
|
40
|
+
token: "secret-token",
|
|
41
|
+
prompt: [{ type: "text", text: "hello from a user prompt" }],
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const raw = readFileSync(logger!.path, "utf8");
|
|
46
|
+
expect(raw).toContain('"method":"session/prompt"');
|
|
47
|
+
expect(raw).toContain('"preview":"[REDACTED]"');
|
|
48
|
+
expect(raw).toContain('"textBytes"');
|
|
49
|
+
expect(raw).toContain('"textPreview"');
|
|
50
|
+
expect(raw).not.toContain("secret-token");
|
|
51
|
+
const files = listAcpTraceLogFiles();
|
|
52
|
+
expect(files).toHaveLength(1);
|
|
53
|
+
expect(files[0].path).toBe(logger!.path);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("bundles ACP and runtime logs in diagnostics", async () => {
|
|
57
|
+
const home = mkdtempSync(path.join(tmpdir(), "botcord-diag-acp-home-"));
|
|
58
|
+
process.env.HOME = home;
|
|
59
|
+
vi.resetModules();
|
|
60
|
+
const { createAcpTraceLogger } = await import("../acp-logs.js");
|
|
61
|
+
const { createDiagnosticBundle } = await import("../diagnostics.js");
|
|
62
|
+
|
|
63
|
+
const logger = createAcpTraceLogger({ runtime: "hermes-agent", accountId: "ag_hermes" });
|
|
64
|
+
logger!.write({ stream: "stderr", chunk: "hermes auth token=secret\n" });
|
|
65
|
+
const botcordDaemon = path.join(home, ".botcord", "daemon");
|
|
66
|
+
const qclawLogs = path.join(home, ".qclaw", "logs");
|
|
67
|
+
mkdirSync(botcordDaemon, { recursive: true });
|
|
68
|
+
writeFileSync(path.join(home, ".botcord", "daemon.log"), "daemon\n", { flag: "w" });
|
|
69
|
+
writeFileSync(path.join(home, ".botcord", "snapshot.json"), "{}\n", { flag: "w" });
|
|
70
|
+
writeFileSync(path.join(botcordDaemon, "config.json"), "{}\n", { flag: "w" });
|
|
71
|
+
mkdirSync(qclawLogs, { recursive: true });
|
|
72
|
+
writeFileSync(path.join(qclawLogs, "qclaw.log"), "qclaw token=secret\n");
|
|
73
|
+
|
|
74
|
+
const bundle = await createDiagnosticBundle({
|
|
75
|
+
diagnosticsDir: path.join(home, ".botcord", "diagnostics"),
|
|
76
|
+
doctor: { text: "doctor ok", json: { ok: true } },
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
expect(existsSync(bundle.path)).toBe(true);
|
|
80
|
+
const listing = execFileSync("unzip", ["-l", bundle.path], { encoding: "utf8" });
|
|
81
|
+
expect(listing).toContain("acp-logs/hermes-agent");
|
|
82
|
+
expect(listing).toContain("runtime-logs/qclaw/qclaw.log");
|
|
83
|
+
const acpLog = execFileSync("unzip", ["-p", bundle.path, "acp-logs/hermes-agent/ag_hermes.jsonl"], {
|
|
84
|
+
encoding: "utf8",
|
|
85
|
+
});
|
|
86
|
+
expect(acpLog).toContain("[REDACTED]");
|
|
87
|
+
});
|
|
88
|
+
});
|