@botcord/daemon 0.2.55 → 0.2.56
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/daemon.js +25 -1
- package/dist/diagnostics.d.ts +30 -0
- package/dist/diagnostics.js +286 -0
- package/dist/gateway/gateway.d.ts +6 -0
- package/dist/gateway/gateway.js +8 -0
- package/dist/gateway/runtimes/claude-code.js +79 -5
- package/dist/gateway/runtimes/codex.js +67 -8
- package/dist/index.js +16 -1
- package/dist/provision.js +68 -0
- package/dist/working-memory.js +5 -0
- package/package.json +1 -1
- package/src/__tests__/diagnostics.test.ts +46 -0
- package/src/__tests__/provision.test.ts +45 -0
- package/src/__tests__/working-memory.test.ts +9 -1
- package/src/daemon.ts +25 -1
- package/src/diagnostics.ts +348 -0
- package/src/gateway/__tests__/claude-code-adapter.test.ts +35 -0
- package/src/gateway/__tests__/codex-adapter.test.ts +80 -4
- package/src/gateway/gateway.ts +9 -0
- package/src/gateway/runtimes/claude-code.ts +76 -4
- package/src/gateway/runtimes/codex.ts +66 -11
- package/src/index.ts +17 -1
- package/src/provision.ts +86 -0
- package/src/working-memory.ts +5 -0
package/dist/provision.js
CHANGED
|
@@ -263,6 +263,9 @@ export function createProvisioner(opts) {
|
|
|
263
263
|
});
|
|
264
264
|
return { ok: true, result };
|
|
265
265
|
}
|
|
266
|
+
case "wake_agent": {
|
|
267
|
+
return handleWakeAgent(gateway, frame.params);
|
|
268
|
+
}
|
|
266
269
|
default:
|
|
267
270
|
daemonLog.warn("provision.dispatch: unknown frame type", {
|
|
268
271
|
type: frame.type,
|
|
@@ -275,6 +278,71 @@ export function createProvisioner(opts) {
|
|
|
275
278
|
}
|
|
276
279
|
};
|
|
277
280
|
}
|
|
281
|
+
async function handleWakeAgent(gateway, raw) {
|
|
282
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
283
|
+
return {
|
|
284
|
+
ok: false,
|
|
285
|
+
error: { code: "bad_params", message: "wake_agent params must be an object" },
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
const params = raw;
|
|
289
|
+
const agentId = params.agent_id || params.agentId;
|
|
290
|
+
const message = params.message;
|
|
291
|
+
if (!agentId || typeof agentId !== "string") {
|
|
292
|
+
return {
|
|
293
|
+
ok: false,
|
|
294
|
+
error: { code: "bad_params", message: "wake_agent requires params.agent_id" },
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
if (!message || typeof message !== "string") {
|
|
298
|
+
return {
|
|
299
|
+
ok: false,
|
|
300
|
+
error: { code: "bad_params", message: "wake_agent requires params.message" },
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
const channels = gateway.snapshot().channels;
|
|
304
|
+
if (!channels[agentId]) {
|
|
305
|
+
return {
|
|
306
|
+
ok: false,
|
|
307
|
+
error: { code: "agent_not_loaded", message: `agent ${agentId} is not loaded in daemon gateway` },
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
const runId = params.run_id || params.runId || `wake-${Date.now()}`;
|
|
311
|
+
const scheduleId = params.schedule_id || params.scheduleId;
|
|
312
|
+
const dedupeKey = params.dedupe_key || params.dedupeKey;
|
|
313
|
+
const conversationId = `rm_schedule_${agentId}`;
|
|
314
|
+
const msg = {
|
|
315
|
+
id: runId,
|
|
316
|
+
channel: agentId,
|
|
317
|
+
accountId: agentId,
|
|
318
|
+
conversation: {
|
|
319
|
+
id: conversationId,
|
|
320
|
+
kind: "direct",
|
|
321
|
+
title: "BotCord Scheduler",
|
|
322
|
+
threadId: scheduleId ?? null,
|
|
323
|
+
},
|
|
324
|
+
sender: {
|
|
325
|
+
id: "hub",
|
|
326
|
+
name: "BotCord Scheduler",
|
|
327
|
+
kind: "system",
|
|
328
|
+
},
|
|
329
|
+
text: message,
|
|
330
|
+
raw: {
|
|
331
|
+
source_type: "botcord_schedule",
|
|
332
|
+
schedule_id: scheduleId,
|
|
333
|
+
run_id: runId,
|
|
334
|
+
dedupe_key: dedupeKey,
|
|
335
|
+
},
|
|
336
|
+
mentioned: true,
|
|
337
|
+
receivedAt: Date.now(),
|
|
338
|
+
trace: {
|
|
339
|
+
id: runId,
|
|
340
|
+
streamable: false,
|
|
341
|
+
},
|
|
342
|
+
};
|
|
343
|
+
await gateway.injectInbound(msg);
|
|
344
|
+
return { ok: true, result: { agent_id: agentId } };
|
|
345
|
+
}
|
|
278
346
|
function validateGatewayParams(raw, spec) {
|
|
279
347
|
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
280
348
|
return {
|
package/dist/working-memory.js
CHANGED
|
@@ -240,6 +240,11 @@ export function buildWorkingMemoryPrompt(opts) {
|
|
|
240
240
|
"- sections: named buckets (contacts, pending_tasks, preferences, etc.).",
|
|
241
241
|
"- Updating one section never touches others. Empty content deletes a section.",
|
|
242
242
|
"",
|
|
243
|
+
"For cross-room work, update memory before or immediately after delegating:",
|
|
244
|
+
"- If you accept a request in one room and continue it in another, record a `pending_tasks` entry with source room id/name, target room id/name, requested deliverable, current status, and where to report completion.",
|
|
245
|
+
"- When a delegated room replies or delivers an artifact, consult `pending_tasks` before deciding `NO_REPLY`; if it matches a pending handoff, acknowledge, update status, and send the promised follow-up to the source room when appropriate.",
|
|
246
|
+
"- Remove or mark the entry done once the source room has been updated.",
|
|
247
|
+
"",
|
|
243
248
|
"Only update when something meaningful changes. Keep each section tight.",
|
|
244
249
|
];
|
|
245
250
|
if (!workingMemory) {
|
package/package.json
CHANGED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { existsSync, 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 { describe, expect, it } from "vitest";
|
|
6
|
+
import { createDiagnosticBundle } from "../diagnostics.js";
|
|
7
|
+
|
|
8
|
+
describe("diagnostics bundle", () => {
|
|
9
|
+
it("writes a zip bundle under ~/.botcord/diagnostics", async () => {
|
|
10
|
+
const tmp = mkdtempSync(path.join(tmpdir(), "botcord-diag-test-"));
|
|
11
|
+
const logFile = path.join(tmp, "daemon.log");
|
|
12
|
+
const configFile = path.join(tmp, "config.json");
|
|
13
|
+
const snapshotFile = path.join(tmp, "snapshot.json");
|
|
14
|
+
const diagnosticsDir = path.join(tmp, "diagnostics");
|
|
15
|
+
writeFileSync(logFile, 'Authorization: Bearer secret-token\n{"refreshToken":"drt_secret"}\n');
|
|
16
|
+
writeFileSync(configFile, '{"token":"agent-secret","ok":true}\n');
|
|
17
|
+
writeFileSync(snapshotFile, '{"version":1}\n');
|
|
18
|
+
|
|
19
|
+
const bundle = await createDiagnosticBundle({
|
|
20
|
+
diagnosticsDir,
|
|
21
|
+
logFile,
|
|
22
|
+
configFile,
|
|
23
|
+
snapshotFile,
|
|
24
|
+
doctor: { text: "doctor ok", json: { ok: true } },
|
|
25
|
+
});
|
|
26
|
+
expect(bundle.filename).toMatch(/^botcord-daemon-diagnostics-.*\.zip$/);
|
|
27
|
+
expect(bundle.path).toContain(diagnosticsDir);
|
|
28
|
+
expect(existsSync(bundle.path)).toBe(true);
|
|
29
|
+
const bytes = readFileSync(bundle.path);
|
|
30
|
+
expect(bytes.subarray(0, 4).toString("binary")).toBe("PK\u0003\u0004");
|
|
31
|
+
|
|
32
|
+
const listing = execFileSync("unzip", ["-l", bundle.path], {
|
|
33
|
+
encoding: "utf8",
|
|
34
|
+
});
|
|
35
|
+
expect(listing).toContain("daemon.log");
|
|
36
|
+
expect(listing).toContain("doctor.json");
|
|
37
|
+
expect(listing).toContain("status.json");
|
|
38
|
+
expect(listing).toContain("config.json.redacted");
|
|
39
|
+
|
|
40
|
+
const log = execFileSync("unzip", ["-p", bundle.path, "daemon.log"], {
|
|
41
|
+
encoding: "utf8",
|
|
42
|
+
});
|
|
43
|
+
expect(log).toContain("Authorization: Bearer [REDACTED]");
|
|
44
|
+
expect(log).toContain('"refreshToken":"[REDACTED]"');
|
|
45
|
+
}, 20_000);
|
|
46
|
+
});
|
|
@@ -104,6 +104,7 @@ interface FakeGateway {
|
|
|
104
104
|
upsertManagedRoute: ReturnType<typeof vi.fn>;
|
|
105
105
|
removeManagedRoute: ReturnType<typeof vi.fn>;
|
|
106
106
|
replaceManagedRoutes: ReturnType<typeof vi.fn>;
|
|
107
|
+
injectInbound: ReturnType<typeof vi.fn>;
|
|
107
108
|
listManagedRoutes: () => GatewayRoute[];
|
|
108
109
|
snapshot: () => GatewayRuntimeSnapshot;
|
|
109
110
|
}
|
|
@@ -128,6 +129,7 @@ function makeFakeGateway(initialChannelIds: string[] = []): FakeGateway {
|
|
|
128
129
|
managed.clear();
|
|
129
130
|
for (const [id, route] of routes) managed.set(id, route);
|
|
130
131
|
}),
|
|
132
|
+
injectInbound: vi.fn(async () => {}),
|
|
131
133
|
listManagedRoutes: (): GatewayRoute[] => Array.from(managed.values()),
|
|
132
134
|
snapshot: (): GatewayRuntimeSnapshot => ({
|
|
133
135
|
channels: Object.fromEntries(
|
|
@@ -251,6 +253,49 @@ describe("list_agent_files handler", () => {
|
|
|
251
253
|
});
|
|
252
254
|
});
|
|
253
255
|
|
|
256
|
+
describe("wake_agent handler", () => {
|
|
257
|
+
it("injects a scheduled turn into the gateway dispatcher", async () => {
|
|
258
|
+
const gw = makeFakeGateway(["ag_wake"]);
|
|
259
|
+
const handler = createProvisioner({ gateway: gw as any });
|
|
260
|
+
const res = await handler({
|
|
261
|
+
id: "req_wake",
|
|
262
|
+
type: "wake_agent",
|
|
263
|
+
params: {
|
|
264
|
+
agent_id: "ag_wake",
|
|
265
|
+
message: "【BotCord 自主任务】执行本轮工作目标。",
|
|
266
|
+
run_id: "sr_test",
|
|
267
|
+
schedule_id: "sch_test",
|
|
268
|
+
dedupe_key: "sch_test:1:auto",
|
|
269
|
+
},
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
expect(res.ok).toBe(true);
|
|
273
|
+
expect(gw.injectInbound).toHaveBeenCalledTimes(1);
|
|
274
|
+
const msg = gw.injectInbound.mock.calls[0][0];
|
|
275
|
+
expect(msg.id).toBe("sr_test");
|
|
276
|
+
expect(msg.channel).toBe("ag_wake");
|
|
277
|
+
expect(msg.accountId).toBe("ag_wake");
|
|
278
|
+
expect(msg.sender.id).toBe("hub");
|
|
279
|
+
expect(msg.sender.kind).toBe("system");
|
|
280
|
+
expect(msg.text).toContain("BotCord 自主任务");
|
|
281
|
+
expect(msg.conversation.threadId).toBe("sch_test");
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it("rejects wake_agent for an unloaded agent", async () => {
|
|
285
|
+
const gw = makeFakeGateway(["ag_loaded"]);
|
|
286
|
+
const handler = createProvisioner({ gateway: gw as any });
|
|
287
|
+
const res = await handler({
|
|
288
|
+
id: "req_wake_missing",
|
|
289
|
+
type: "wake_agent",
|
|
290
|
+
params: { agent_id: "ag_missing", message: "tick" },
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
expect(res.ok).toBe(false);
|
|
294
|
+
expect(res.error?.code).toBe("agent_not_loaded");
|
|
295
|
+
expect(gw.injectInbound).not.toHaveBeenCalled();
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
|
|
254
299
|
describe("reload_config handler", () => {
|
|
255
300
|
it("adds agents listed in config but missing from gateway", async () => {
|
|
256
301
|
mockState.cfg = {
|
|
@@ -207,6 +207,15 @@ describe("buildWorkingMemoryPrompt", () => {
|
|
|
207
207
|
expect(p).toContain("currently empty");
|
|
208
208
|
});
|
|
209
209
|
|
|
210
|
+
it("instructs agents to persist cross-room handoffs", () => {
|
|
211
|
+
const p = wm.buildWorkingMemoryPrompt({ workingMemory: null });
|
|
212
|
+
expect(p).toContain("For cross-room work");
|
|
213
|
+
expect(p).toContain("pending_tasks");
|
|
214
|
+
expect(p).toContain("source room");
|
|
215
|
+
expect(p).toContain("target room");
|
|
216
|
+
expect(p).toContain("where to report completion");
|
|
217
|
+
});
|
|
218
|
+
|
|
210
219
|
it("renders goal + named sections", () => {
|
|
211
220
|
const p = wm.buildWorkingMemoryPrompt({
|
|
212
221
|
workingMemory: {
|
|
@@ -237,4 +246,3 @@ describe("buildWorkingMemoryPrompt", () => {
|
|
|
237
246
|
expect(p).toContain("‹current_memory›");
|
|
238
247
|
});
|
|
239
248
|
});
|
|
240
|
-
|
package/src/daemon.ts
CHANGED
|
@@ -46,6 +46,7 @@ import { composeBotCordUserTurn } from "./turn-text.js";
|
|
|
46
46
|
import { UserAuthManager } from "./user-auth.js";
|
|
47
47
|
import { PolicyResolver } from "./gateway/policy-resolver.js";
|
|
48
48
|
import { scanMention } from "./mention-scan.js";
|
|
49
|
+
import { createDiagnosticBundle, uploadDiagnosticBundle } from "./diagnostics.js";
|
|
49
50
|
|
|
50
51
|
/**
|
|
51
52
|
* Default hard cap for a single runtime turn. Long-running coding/research
|
|
@@ -558,7 +559,30 @@ export async function startDaemon(opts: DaemonRuntimeOptions): Promise<DaemonHan
|
|
|
558
559
|
const provisioner = createProvisioner({ gateway, policyResolver, onAgentInstalled });
|
|
559
560
|
controlChannel = new ControlChannel({
|
|
560
561
|
auth: userAuth,
|
|
561
|
-
handle:
|
|
562
|
+
handle: async (frame) => {
|
|
563
|
+
if (frame.type === "collect_diagnostics") {
|
|
564
|
+
logger.info("diagnostics: collect requested", { frameId: frame.id });
|
|
565
|
+
const bundle = await createDiagnosticBundle();
|
|
566
|
+
const upload = await uploadDiagnosticBundle({ auth: userAuth, bundle });
|
|
567
|
+
logger.info("diagnostics: uploaded", {
|
|
568
|
+
frameId: frame.id,
|
|
569
|
+
bundleId: upload.bundleId,
|
|
570
|
+
sizeBytes: upload.sizeBytes,
|
|
571
|
+
localPath: bundle.path,
|
|
572
|
+
});
|
|
573
|
+
return {
|
|
574
|
+
ok: true,
|
|
575
|
+
result: {
|
|
576
|
+
bundle_id: upload.bundleId,
|
|
577
|
+
filename: upload.filename,
|
|
578
|
+
size_bytes: upload.sizeBytes,
|
|
579
|
+
expires_at: upload.expiresAt ?? null,
|
|
580
|
+
local_path: bundle.path,
|
|
581
|
+
},
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
return provisioner(frame);
|
|
585
|
+
},
|
|
562
586
|
});
|
|
563
587
|
try {
|
|
564
588
|
await controlChannel.start();
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { homedir, hostname, platform, release, arch } from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { Buffer } from "node:buffer";
|
|
5
|
+
import { deflateRawSync } from "node:zlib";
|
|
6
|
+
import {
|
|
7
|
+
AUTH_EXPIRED_FLAG_PATH,
|
|
8
|
+
USER_AUTH_PATH,
|
|
9
|
+
type UserAuthManager,
|
|
10
|
+
type UserAuthRecord,
|
|
11
|
+
} from "./user-auth.js";
|
|
12
|
+
import {
|
|
13
|
+
CONFIG_FILE_PATH,
|
|
14
|
+
PID_PATH,
|
|
15
|
+
SNAPSHOT_PATH,
|
|
16
|
+
loadConfig,
|
|
17
|
+
type DaemonConfig,
|
|
18
|
+
} from "./config.js";
|
|
19
|
+
import { LOG_FILE_PATH } from "./log.js";
|
|
20
|
+
import {
|
|
21
|
+
channelsFromDaemonConfig,
|
|
22
|
+
defaultHttpFetcher,
|
|
23
|
+
renderDoctor,
|
|
24
|
+
runDoctor,
|
|
25
|
+
type DoctorFileReader,
|
|
26
|
+
type DoctorRuntimeEntry,
|
|
27
|
+
} from "./doctor.js";
|
|
28
|
+
import { detectRuntimes } from "./adapters/runtimes.js";
|
|
29
|
+
|
|
30
|
+
const DIAGNOSTICS_DIR = path.join(homedir(), ".botcord", "diagnostics");
|
|
31
|
+
const MAX_UPLOAD_BYTES = 50 * 1024 * 1024;
|
|
32
|
+
|
|
33
|
+
export interface CreateDiagnosticBundleOptions {
|
|
34
|
+
diagnosticsDir?: string;
|
|
35
|
+
logFile?: string;
|
|
36
|
+
configFile?: string;
|
|
37
|
+
snapshotFile?: string;
|
|
38
|
+
doctor?: { text: string; json: unknown };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface DiagnosticBundleResult {
|
|
42
|
+
path: string;
|
|
43
|
+
filename: string;
|
|
44
|
+
sizeBytes: number;
|
|
45
|
+
createdAt: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface DiagnosticUploadResult {
|
|
49
|
+
bundleId: string;
|
|
50
|
+
filename: string;
|
|
51
|
+
sizeBytes: number;
|
|
52
|
+
expiresAt?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const SECRET_PATTERNS: Array<[RegExp, string]> = [
|
|
56
|
+
[/(Authorization:\s*Bearer\s+)[^\s"']+/gi, "$1[REDACTED]"],
|
|
57
|
+
[/("?(?:accessToken|access_token|refreshToken|refresh_token|token|privateKey|private_key|secret)"?\s*:\s*")[^"]+(")/gi, "$1[REDACTED]$2"],
|
|
58
|
+
[/(drt_)[A-Za-z0-9_-]+/g, "$1[REDACTED]"],
|
|
59
|
+
[/(dit_)[A-Za-z0-9_-]+/g, "$1[REDACTED]"],
|
|
60
|
+
[/([?&](?:token|access_token|refresh_token|install_token)=)[^&\s"']+/gi, "$1[REDACTED]"],
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
function redact(input: string): string {
|
|
64
|
+
let out = input;
|
|
65
|
+
for (const [pattern, replacement] of SECRET_PATTERNS) {
|
|
66
|
+
out = out.replace(pattern, replacement);
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function safeReadText(file: string): string | null {
|
|
72
|
+
if (!existsSync(file)) return null;
|
|
73
|
+
try {
|
|
74
|
+
return redact(readFileSync(file, "utf8"));
|
|
75
|
+
} catch (err) {
|
|
76
|
+
return `read failed: ${err instanceof Error ? err.message : String(err)}\n`;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function readUserAuthSummary(): Record<string, unknown> | null {
|
|
81
|
+
const raw = safeReadText(USER_AUTH_PATH);
|
|
82
|
+
if (!raw) return null;
|
|
83
|
+
try {
|
|
84
|
+
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
|
85
|
+
return {
|
|
86
|
+
userId: typeof parsed.userId === "string" ? parsed.userId : null,
|
|
87
|
+
daemonInstanceId:
|
|
88
|
+
typeof parsed.daemonInstanceId === "string" ? parsed.daemonInstanceId : null,
|
|
89
|
+
hubUrl: typeof parsed.hubUrl === "string" ? parsed.hubUrl : null,
|
|
90
|
+
expiresAt: typeof parsed.expiresAt === "number" ? parsed.expiresAt : null,
|
|
91
|
+
loggedInAt: typeof parsed.loggedInAt === "string" ? parsed.loggedInAt : null,
|
|
92
|
+
label: typeof parsed.label === "string" ? parsed.label : null,
|
|
93
|
+
authExpiredFlagPresent: existsSync(AUTH_EXPIRED_FLAG_PATH),
|
|
94
|
+
};
|
|
95
|
+
} catch (err) {
|
|
96
|
+
return {
|
|
97
|
+
error: `user-auth summary failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
98
|
+
authExpiredFlagPresent: existsSync(AUTH_EXPIRED_FLAG_PATH),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const fsFileReader: DoctorFileReader = {
|
|
104
|
+
readFile(p: string): string | null {
|
|
105
|
+
if (!existsSync(p)) return null;
|
|
106
|
+
try {
|
|
107
|
+
return readFileSync(p, "utf8");
|
|
108
|
+
} catch {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
async function buildDoctorEntries(): Promise<{
|
|
115
|
+
text: string;
|
|
116
|
+
json: unknown;
|
|
117
|
+
}> {
|
|
118
|
+
const entries: DoctorRuntimeEntry[] = detectRuntimes();
|
|
119
|
+
let channels: ReturnType<typeof channelsFromDaemonConfig> = [];
|
|
120
|
+
let cfgForEndpoints: DaemonConfig | null = null;
|
|
121
|
+
try {
|
|
122
|
+
cfgForEndpoints = loadConfig();
|
|
123
|
+
channels = channelsFromDaemonConfig(cfgForEndpoints);
|
|
124
|
+
} catch {
|
|
125
|
+
channels = [];
|
|
126
|
+
}
|
|
127
|
+
if (cfgForEndpoints?.openclawGateways && cfgForEndpoints.openclawGateways.length > 0) {
|
|
128
|
+
const { collectRuntimeSnapshotAsync } = await import("./provision.js");
|
|
129
|
+
const snap = await collectRuntimeSnapshotAsync({ cfg: cfgForEndpoints });
|
|
130
|
+
const byId = new Map(snap.runtimes.map((r) => [r.id, r]));
|
|
131
|
+
for (const e of entries) {
|
|
132
|
+
const r = byId.get(e.id);
|
|
133
|
+
if (r?.endpoints) e.endpoints = r.endpoints;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
const input = await runDoctor(entries, channels, {
|
|
137
|
+
credentialsPath: (accountId) =>
|
|
138
|
+
path.join(homedir(), ".botcord", "credentials", `${accountId}.json`),
|
|
139
|
+
fileReader: fsFileReader,
|
|
140
|
+
fetcher: defaultHttpFetcher,
|
|
141
|
+
timeoutMs: 5_000,
|
|
142
|
+
});
|
|
143
|
+
return { text: renderDoctor(input), json: input };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function crc32(buf: Buffer): number {
|
|
147
|
+
let crc = 0xffffffff;
|
|
148
|
+
for (const b of buf) {
|
|
149
|
+
crc ^= b;
|
|
150
|
+
for (let i = 0; i < 8; i += 1) {
|
|
151
|
+
crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return (crc ^ 0xffffffff) >>> 0;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function dosDateTime(date: Date): { time: number; date: number } {
|
|
158
|
+
const year = Math.max(1980, date.getFullYear());
|
|
159
|
+
return {
|
|
160
|
+
time: (date.getHours() << 11) | (date.getMinutes() << 5) | Math.floor(date.getSeconds() / 2),
|
|
161
|
+
date: ((year - 1980) << 9) | ((date.getMonth() + 1) << 5) | date.getDate(),
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function u16(n: number): Buffer {
|
|
166
|
+
const b = Buffer.alloc(2);
|
|
167
|
+
b.writeUInt16LE(n & 0xffff, 0);
|
|
168
|
+
return b;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function u32(n: number): Buffer {
|
|
172
|
+
const b = Buffer.alloc(4);
|
|
173
|
+
b.writeUInt32LE(n >>> 0, 0);
|
|
174
|
+
return b;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function createZip(entries: Array<{ name: string; data: string | Buffer }>): Buffer {
|
|
178
|
+
const localParts: Buffer[] = [];
|
|
179
|
+
const centralParts: Buffer[] = [];
|
|
180
|
+
let offset = 0;
|
|
181
|
+
const now = new Date();
|
|
182
|
+
const dt = dosDateTime(now);
|
|
183
|
+
|
|
184
|
+
for (const entry of entries) {
|
|
185
|
+
const name = Buffer.from(entry.name.replace(/^\/+/, ""), "utf8");
|
|
186
|
+
const data = Buffer.isBuffer(entry.data)
|
|
187
|
+
? entry.data
|
|
188
|
+
: Buffer.from(entry.data, "utf8");
|
|
189
|
+
const compressed = deflateRawSync(data, { level: 9 });
|
|
190
|
+
const crc = crc32(data);
|
|
191
|
+
const local = Buffer.concat([
|
|
192
|
+
u32(0x04034b50),
|
|
193
|
+
u16(20),
|
|
194
|
+
u16(0),
|
|
195
|
+
u16(8),
|
|
196
|
+
u16(dt.time),
|
|
197
|
+
u16(dt.date),
|
|
198
|
+
u32(crc),
|
|
199
|
+
u32(compressed.length),
|
|
200
|
+
u32(data.length),
|
|
201
|
+
u16(name.length),
|
|
202
|
+
u16(0),
|
|
203
|
+
name,
|
|
204
|
+
compressed,
|
|
205
|
+
]);
|
|
206
|
+
localParts.push(local);
|
|
207
|
+
|
|
208
|
+
centralParts.push(Buffer.concat([
|
|
209
|
+
u32(0x02014b50),
|
|
210
|
+
u16(20),
|
|
211
|
+
u16(20),
|
|
212
|
+
u16(0),
|
|
213
|
+
u16(8),
|
|
214
|
+
u16(dt.time),
|
|
215
|
+
u16(dt.date),
|
|
216
|
+
u32(crc),
|
|
217
|
+
u32(compressed.length),
|
|
218
|
+
u32(data.length),
|
|
219
|
+
u16(name.length),
|
|
220
|
+
u16(0),
|
|
221
|
+
u16(0),
|
|
222
|
+
u16(0),
|
|
223
|
+
u16(0),
|
|
224
|
+
u32(0),
|
|
225
|
+
u32(offset),
|
|
226
|
+
name,
|
|
227
|
+
]));
|
|
228
|
+
offset += local.length;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const central = Buffer.concat(centralParts);
|
|
232
|
+
const end = Buffer.concat([
|
|
233
|
+
u32(0x06054b50),
|
|
234
|
+
u16(0),
|
|
235
|
+
u16(0),
|
|
236
|
+
u16(entries.length),
|
|
237
|
+
u16(entries.length),
|
|
238
|
+
u32(central.length),
|
|
239
|
+
u32(offset),
|
|
240
|
+
u16(0),
|
|
241
|
+
]);
|
|
242
|
+
return Buffer.concat([...localParts, central, end]);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export async function createDiagnosticBundle(
|
|
246
|
+
opts: CreateDiagnosticBundleOptions = {},
|
|
247
|
+
): Promise<DiagnosticBundleResult> {
|
|
248
|
+
const createdAt = new Date();
|
|
249
|
+
const stamp = createdAt.toISOString().replace(/[:.]/g, "-");
|
|
250
|
+
const filename = `botcord-daemon-diagnostics-${stamp}.zip`;
|
|
251
|
+
const diagnosticsDir = opts.diagnosticsDir ?? DIAGNOSTICS_DIR;
|
|
252
|
+
const logFile = opts.logFile ?? LOG_FILE_PATH;
|
|
253
|
+
const configFile = opts.configFile ?? CONFIG_FILE_PATH;
|
|
254
|
+
const snapshotFile = opts.snapshotFile ?? SNAPSHOT_PATH;
|
|
255
|
+
mkdirSync(diagnosticsDir, { recursive: true, mode: 0o700 });
|
|
256
|
+
|
|
257
|
+
const doctor = opts.doctor ?? await buildDoctorEntries();
|
|
258
|
+
const status = {
|
|
259
|
+
createdAt: createdAt.toISOString(),
|
|
260
|
+
host: hostname(),
|
|
261
|
+
platform: platform(),
|
|
262
|
+
release: release(),
|
|
263
|
+
arch: arch(),
|
|
264
|
+
node: process.version,
|
|
265
|
+
pidPath: PID_PATH,
|
|
266
|
+
pid: process.pid,
|
|
267
|
+
configPath: configFile,
|
|
268
|
+
snapshotPath: snapshotFile,
|
|
269
|
+
logPath: logFile,
|
|
270
|
+
diagnosticsDir,
|
|
271
|
+
userAuth: readUserAuthSummary(),
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
const entries: Array<{ name: string; data: string | Buffer }> = [
|
|
275
|
+
{ name: "README.txt", data: "BotCord daemon diagnostics bundle. Sensitive tokens are redacted before packaging.\n" },
|
|
276
|
+
{ name: "status.json", data: JSON.stringify(status, null, 2) + "\n" },
|
|
277
|
+
{ name: "doctor.txt", data: doctor.text + "\n" },
|
|
278
|
+
{ name: "doctor.json", data: JSON.stringify(doctor.json, null, 2) + "\n" },
|
|
279
|
+
];
|
|
280
|
+
const log = safeReadText(logFile);
|
|
281
|
+
entries.push({
|
|
282
|
+
name: "daemon.log",
|
|
283
|
+
data: log ?? `no log file at ${logFile}\n`,
|
|
284
|
+
});
|
|
285
|
+
const config = safeReadText(configFile);
|
|
286
|
+
entries.push({
|
|
287
|
+
name: "config.json.redacted",
|
|
288
|
+
data: config ?? `no config file at ${configFile}\n`,
|
|
289
|
+
});
|
|
290
|
+
const snapshot = safeReadText(snapshotFile);
|
|
291
|
+
entries.push({
|
|
292
|
+
name: "snapshot.json",
|
|
293
|
+
data: snapshot ?? `no snapshot file at ${snapshotFile}\n`,
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
const zip = createZip(entries);
|
|
297
|
+
const out = path.join(diagnosticsDir, filename);
|
|
298
|
+
writeFileSync(out, zip, { mode: 0o600 });
|
|
299
|
+
return {
|
|
300
|
+
path: out,
|
|
301
|
+
filename,
|
|
302
|
+
sizeBytes: zip.length,
|
|
303
|
+
createdAt: createdAt.toISOString(),
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export async function uploadDiagnosticBundle(opts: {
|
|
308
|
+
auth: UserAuthManager;
|
|
309
|
+
bundle: DiagnosticBundleResult;
|
|
310
|
+
}): Promise<DiagnosticUploadResult> {
|
|
311
|
+
const record: UserAuthRecord | null = opts.auth.current;
|
|
312
|
+
if (!record) throw new Error("daemon not logged in");
|
|
313
|
+
const data = readFileSync(opts.bundle.path);
|
|
314
|
+
if (data.length > MAX_UPLOAD_BYTES) {
|
|
315
|
+
throw new Error(`diagnostic bundle is too large (${data.length} bytes, max ${MAX_UPLOAD_BYTES})`);
|
|
316
|
+
}
|
|
317
|
+
const token = await opts.auth.ensureAccessToken();
|
|
318
|
+
const url = `${record.hubUrl.replace(/\/+$/, "")}/daemon/diagnostics/upload`;
|
|
319
|
+
const resp = await fetch(url, {
|
|
320
|
+
method: "POST",
|
|
321
|
+
headers: {
|
|
322
|
+
Authorization: `Bearer ${token}`,
|
|
323
|
+
"Content-Type": "application/zip",
|
|
324
|
+
"X-BotCord-Filename": opts.bundle.filename,
|
|
325
|
+
},
|
|
326
|
+
body: data,
|
|
327
|
+
});
|
|
328
|
+
const json = await resp.json().catch(() => null) as Record<string, unknown> | null;
|
|
329
|
+
if (!resp.ok) {
|
|
330
|
+
const detail =
|
|
331
|
+
typeof json?.detail === "string"
|
|
332
|
+
? json.detail
|
|
333
|
+
: typeof json?.error === "string"
|
|
334
|
+
? json.error
|
|
335
|
+
: `HTTP ${resp.status}`;
|
|
336
|
+
throw new Error(`diagnostic upload failed: ${detail}`);
|
|
337
|
+
}
|
|
338
|
+
const bundleId = typeof json?.bundle_id === "string" ? json.bundle_id : null;
|
|
339
|
+
if (!bundleId) throw new Error("diagnostic upload response missing bundle_id");
|
|
340
|
+
return {
|
|
341
|
+
bundleId,
|
|
342
|
+
filename: typeof json?.filename === "string" ? json.filename : opts.bundle.filename,
|
|
343
|
+
sizeBytes: typeof json?.size_bytes === "number" ? json.size_bytes : data.length,
|
|
344
|
+
...(typeof json?.expires_at === "string" ? { expiresAt: json.expires_at } : {}),
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
export { DIAGNOSTICS_DIR };
|
|
@@ -349,5 +349,40 @@ process.stdout.write(JSON.stringify({type:"result", subtype:"success", session_i
|
|
|
349
349
|
const modeIdx = argv.indexOf("--permission-mode");
|
|
350
350
|
expect(argv[modeIdx + 1]).toBe("plan");
|
|
351
351
|
});
|
|
352
|
+
|
|
353
|
+
it("drops inherited Codex-only extraArgs while preserving shared Claude flags", async () => {
|
|
354
|
+
const adapter = new ClaudeCodeAdapter({ binary: echoScript() });
|
|
355
|
+
const ctrl = new AbortController();
|
|
356
|
+
const res = await adapter.run({
|
|
357
|
+
text: "x",
|
|
358
|
+
sessionId: null,
|
|
359
|
+
accountId: "ag_test",
|
|
360
|
+
cwd: tmpRoot,
|
|
361
|
+
signal: ctrl.signal,
|
|
362
|
+
trustLevel: "public",
|
|
363
|
+
extraArgs: [
|
|
364
|
+
"-c",
|
|
365
|
+
'model="gpt-5.2"',
|
|
366
|
+
"--sandbox",
|
|
367
|
+
"read-only",
|
|
368
|
+
"--skip-git-repo-check",
|
|
369
|
+
"--json",
|
|
370
|
+
"-p",
|
|
371
|
+
"codex-profile",
|
|
372
|
+
"--model",
|
|
373
|
+
"sonnet",
|
|
374
|
+
],
|
|
375
|
+
});
|
|
376
|
+
const argv = JSON.parse(res.text) as string[];
|
|
377
|
+
expect(argv).not.toContain("-c");
|
|
378
|
+
expect(argv).not.toContain('model="gpt-5.2"');
|
|
379
|
+
expect(argv).not.toContain("--sandbox");
|
|
380
|
+
expect(argv).not.toContain("read-only");
|
|
381
|
+
expect(argv).not.toContain("--skip-git-repo-check");
|
|
382
|
+
expect(argv).not.toContain("--json");
|
|
383
|
+
expect(argv).not.toContain("codex-profile");
|
|
384
|
+
expect(argv).toContain("--model");
|
|
385
|
+
expect(argv[argv.indexOf("--model") + 1]).toBe("sonnet");
|
|
386
|
+
});
|
|
352
387
|
});
|
|
353
388
|
});
|