@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 CHANGED
@@ -18,6 +18,7 @@ import { composeBotCordUserTurn } from "./turn-text.js";
18
18
  import { UserAuthManager } from "./user-auth.js";
19
19
  import { PolicyResolver } from "./gateway/policy-resolver.js";
20
20
  import { scanMention } from "./mention-scan.js";
21
+ import { createDiagnosticBundle, uploadDiagnosticBundle } from "./diagnostics.js";
21
22
  /**
22
23
  * Default hard cap for a single runtime turn. Long-running coding/research
23
24
  * tasks routinely exceed 10 minutes, so daemon-hosted agents get a larger
@@ -401,7 +402,30 @@ export async function startDaemon(opts) {
401
402
  const provisioner = createProvisioner({ gateway, policyResolver, onAgentInstalled });
402
403
  controlChannel = new ControlChannel({
403
404
  auth: userAuth,
404
- handle: provisioner,
405
+ handle: async (frame) => {
406
+ if (frame.type === "collect_diagnostics") {
407
+ logger.info("diagnostics: collect requested", { frameId: frame.id });
408
+ const bundle = await createDiagnosticBundle();
409
+ const upload = await uploadDiagnosticBundle({ auth: userAuth, bundle });
410
+ logger.info("diagnostics: uploaded", {
411
+ frameId: frame.id,
412
+ bundleId: upload.bundleId,
413
+ sizeBytes: upload.sizeBytes,
414
+ localPath: bundle.path,
415
+ });
416
+ return {
417
+ ok: true,
418
+ result: {
419
+ bundle_id: upload.bundleId,
420
+ filename: upload.filename,
421
+ size_bytes: upload.sizeBytes,
422
+ expires_at: upload.expiresAt ?? null,
423
+ local_path: bundle.path,
424
+ },
425
+ };
426
+ }
427
+ return provisioner(frame);
428
+ },
405
429
  });
406
430
  try {
407
431
  await controlChannel.start();
@@ -0,0 +1,30 @@
1
+ import { type UserAuthManager } from "./user-auth.js";
2
+ declare const DIAGNOSTICS_DIR: string;
3
+ export interface CreateDiagnosticBundleOptions {
4
+ diagnosticsDir?: string;
5
+ logFile?: string;
6
+ configFile?: string;
7
+ snapshotFile?: string;
8
+ doctor?: {
9
+ text: string;
10
+ json: unknown;
11
+ };
12
+ }
13
+ export interface DiagnosticBundleResult {
14
+ path: string;
15
+ filename: string;
16
+ sizeBytes: number;
17
+ createdAt: string;
18
+ }
19
+ export interface DiagnosticUploadResult {
20
+ bundleId: string;
21
+ filename: string;
22
+ sizeBytes: number;
23
+ expiresAt?: string;
24
+ }
25
+ export declare function createDiagnosticBundle(opts?: CreateDiagnosticBundleOptions): Promise<DiagnosticBundleResult>;
26
+ export declare function uploadDiagnosticBundle(opts: {
27
+ auth: UserAuthManager;
28
+ bundle: DiagnosticBundleResult;
29
+ }): Promise<DiagnosticUploadResult>;
30
+ export { DIAGNOSTICS_DIR };
@@ -0,0 +1,286 @@
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 { AUTH_EXPIRED_FLAG_PATH, USER_AUTH_PATH, } from "./user-auth.js";
7
+ import { CONFIG_FILE_PATH, PID_PATH, SNAPSHOT_PATH, loadConfig, } from "./config.js";
8
+ import { LOG_FILE_PATH } from "./log.js";
9
+ import { channelsFromDaemonConfig, defaultHttpFetcher, renderDoctor, runDoctor, } from "./doctor.js";
10
+ import { detectRuntimes } from "./adapters/runtimes.js";
11
+ const DIAGNOSTICS_DIR = path.join(homedir(), ".botcord", "diagnostics");
12
+ const MAX_UPLOAD_BYTES = 50 * 1024 * 1024;
13
+ const SECRET_PATTERNS = [
14
+ [/(Authorization:\s*Bearer\s+)[^\s"']+/gi, "$1[REDACTED]"],
15
+ [/("?(?:accessToken|access_token|refreshToken|refresh_token|token|privateKey|private_key|secret)"?\s*:\s*")[^"]+(")/gi, "$1[REDACTED]$2"],
16
+ [/(drt_)[A-Za-z0-9_-]+/g, "$1[REDACTED]"],
17
+ [/(dit_)[A-Za-z0-9_-]+/g, "$1[REDACTED]"],
18
+ [/([?&](?:token|access_token|refresh_token|install_token)=)[^&\s"']+/gi, "$1[REDACTED]"],
19
+ ];
20
+ function redact(input) {
21
+ let out = input;
22
+ for (const [pattern, replacement] of SECRET_PATTERNS) {
23
+ out = out.replace(pattern, replacement);
24
+ }
25
+ return out;
26
+ }
27
+ function safeReadText(file) {
28
+ if (!existsSync(file))
29
+ return null;
30
+ try {
31
+ return redact(readFileSync(file, "utf8"));
32
+ }
33
+ catch (err) {
34
+ return `read failed: ${err instanceof Error ? err.message : String(err)}\n`;
35
+ }
36
+ }
37
+ function readUserAuthSummary() {
38
+ const raw = safeReadText(USER_AUTH_PATH);
39
+ if (!raw)
40
+ return null;
41
+ try {
42
+ const parsed = JSON.parse(raw);
43
+ return {
44
+ userId: typeof parsed.userId === "string" ? parsed.userId : null,
45
+ daemonInstanceId: typeof parsed.daemonInstanceId === "string" ? parsed.daemonInstanceId : null,
46
+ hubUrl: typeof parsed.hubUrl === "string" ? parsed.hubUrl : null,
47
+ expiresAt: typeof parsed.expiresAt === "number" ? parsed.expiresAt : null,
48
+ loggedInAt: typeof parsed.loggedInAt === "string" ? parsed.loggedInAt : null,
49
+ label: typeof parsed.label === "string" ? parsed.label : null,
50
+ authExpiredFlagPresent: existsSync(AUTH_EXPIRED_FLAG_PATH),
51
+ };
52
+ }
53
+ catch (err) {
54
+ return {
55
+ error: `user-auth summary failed: ${err instanceof Error ? err.message : String(err)}`,
56
+ authExpiredFlagPresent: existsSync(AUTH_EXPIRED_FLAG_PATH),
57
+ };
58
+ }
59
+ }
60
+ const fsFileReader = {
61
+ readFile(p) {
62
+ if (!existsSync(p))
63
+ return null;
64
+ try {
65
+ return readFileSync(p, "utf8");
66
+ }
67
+ catch {
68
+ return null;
69
+ }
70
+ },
71
+ };
72
+ async function buildDoctorEntries() {
73
+ const entries = detectRuntimes();
74
+ let channels = [];
75
+ let cfgForEndpoints = null;
76
+ try {
77
+ cfgForEndpoints = loadConfig();
78
+ channels = channelsFromDaemonConfig(cfgForEndpoints);
79
+ }
80
+ catch {
81
+ channels = [];
82
+ }
83
+ if (cfgForEndpoints?.openclawGateways && cfgForEndpoints.openclawGateways.length > 0) {
84
+ const { collectRuntimeSnapshotAsync } = await import("./provision.js");
85
+ const snap = await collectRuntimeSnapshotAsync({ cfg: cfgForEndpoints });
86
+ const byId = new Map(snap.runtimes.map((r) => [r.id, r]));
87
+ for (const e of entries) {
88
+ const r = byId.get(e.id);
89
+ if (r?.endpoints)
90
+ e.endpoints = r.endpoints;
91
+ }
92
+ }
93
+ const input = await runDoctor(entries, channels, {
94
+ credentialsPath: (accountId) => path.join(homedir(), ".botcord", "credentials", `${accountId}.json`),
95
+ fileReader: fsFileReader,
96
+ fetcher: defaultHttpFetcher,
97
+ timeoutMs: 5_000,
98
+ });
99
+ return { text: renderDoctor(input), json: input };
100
+ }
101
+ function crc32(buf) {
102
+ let crc = 0xffffffff;
103
+ for (const b of buf) {
104
+ crc ^= b;
105
+ for (let i = 0; i < 8; i += 1) {
106
+ crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0);
107
+ }
108
+ }
109
+ return (crc ^ 0xffffffff) >>> 0;
110
+ }
111
+ function dosDateTime(date) {
112
+ const year = Math.max(1980, date.getFullYear());
113
+ return {
114
+ time: (date.getHours() << 11) | (date.getMinutes() << 5) | Math.floor(date.getSeconds() / 2),
115
+ date: ((year - 1980) << 9) | ((date.getMonth() + 1) << 5) | date.getDate(),
116
+ };
117
+ }
118
+ function u16(n) {
119
+ const b = Buffer.alloc(2);
120
+ b.writeUInt16LE(n & 0xffff, 0);
121
+ return b;
122
+ }
123
+ function u32(n) {
124
+ const b = Buffer.alloc(4);
125
+ b.writeUInt32LE(n >>> 0, 0);
126
+ return b;
127
+ }
128
+ function createZip(entries) {
129
+ const localParts = [];
130
+ const centralParts = [];
131
+ let offset = 0;
132
+ const now = new Date();
133
+ const dt = dosDateTime(now);
134
+ for (const entry of entries) {
135
+ const name = Buffer.from(entry.name.replace(/^\/+/, ""), "utf8");
136
+ const data = Buffer.isBuffer(entry.data)
137
+ ? entry.data
138
+ : Buffer.from(entry.data, "utf8");
139
+ const compressed = deflateRawSync(data, { level: 9 });
140
+ const crc = crc32(data);
141
+ const local = Buffer.concat([
142
+ u32(0x04034b50),
143
+ u16(20),
144
+ u16(0),
145
+ u16(8),
146
+ u16(dt.time),
147
+ u16(dt.date),
148
+ u32(crc),
149
+ u32(compressed.length),
150
+ u32(data.length),
151
+ u16(name.length),
152
+ u16(0),
153
+ name,
154
+ compressed,
155
+ ]);
156
+ localParts.push(local);
157
+ centralParts.push(Buffer.concat([
158
+ u32(0x02014b50),
159
+ u16(20),
160
+ u16(20),
161
+ u16(0),
162
+ u16(8),
163
+ u16(dt.time),
164
+ u16(dt.date),
165
+ u32(crc),
166
+ u32(compressed.length),
167
+ u32(data.length),
168
+ u16(name.length),
169
+ u16(0),
170
+ u16(0),
171
+ u16(0),
172
+ u16(0),
173
+ u32(0),
174
+ u32(offset),
175
+ name,
176
+ ]));
177
+ offset += local.length;
178
+ }
179
+ const central = Buffer.concat(centralParts);
180
+ const end = Buffer.concat([
181
+ u32(0x06054b50),
182
+ u16(0),
183
+ u16(0),
184
+ u16(entries.length),
185
+ u16(entries.length),
186
+ u32(central.length),
187
+ u32(offset),
188
+ u16(0),
189
+ ]);
190
+ return Buffer.concat([...localParts, central, end]);
191
+ }
192
+ export async function createDiagnosticBundle(opts = {}) {
193
+ const createdAt = new Date();
194
+ const stamp = createdAt.toISOString().replace(/[:.]/g, "-");
195
+ const filename = `botcord-daemon-diagnostics-${stamp}.zip`;
196
+ const diagnosticsDir = opts.diagnosticsDir ?? DIAGNOSTICS_DIR;
197
+ const logFile = opts.logFile ?? LOG_FILE_PATH;
198
+ const configFile = opts.configFile ?? CONFIG_FILE_PATH;
199
+ const snapshotFile = opts.snapshotFile ?? SNAPSHOT_PATH;
200
+ mkdirSync(diagnosticsDir, { recursive: true, mode: 0o700 });
201
+ const doctor = opts.doctor ?? await buildDoctorEntries();
202
+ const status = {
203
+ createdAt: createdAt.toISOString(),
204
+ host: hostname(),
205
+ platform: platform(),
206
+ release: release(),
207
+ arch: arch(),
208
+ node: process.version,
209
+ pidPath: PID_PATH,
210
+ pid: process.pid,
211
+ configPath: configFile,
212
+ snapshotPath: snapshotFile,
213
+ logPath: logFile,
214
+ diagnosticsDir,
215
+ userAuth: readUserAuthSummary(),
216
+ };
217
+ const entries = [
218
+ { name: "README.txt", data: "BotCord daemon diagnostics bundle. Sensitive tokens are redacted before packaging.\n" },
219
+ { name: "status.json", data: JSON.stringify(status, null, 2) + "\n" },
220
+ { name: "doctor.txt", data: doctor.text + "\n" },
221
+ { name: "doctor.json", data: JSON.stringify(doctor.json, null, 2) + "\n" },
222
+ ];
223
+ const log = safeReadText(logFile);
224
+ entries.push({
225
+ name: "daemon.log",
226
+ data: log ?? `no log file at ${logFile}\n`,
227
+ });
228
+ const config = safeReadText(configFile);
229
+ entries.push({
230
+ name: "config.json.redacted",
231
+ data: config ?? `no config file at ${configFile}\n`,
232
+ });
233
+ const snapshot = safeReadText(snapshotFile);
234
+ entries.push({
235
+ name: "snapshot.json",
236
+ data: snapshot ?? `no snapshot file at ${snapshotFile}\n`,
237
+ });
238
+ const zip = createZip(entries);
239
+ const out = path.join(diagnosticsDir, filename);
240
+ writeFileSync(out, zip, { mode: 0o600 });
241
+ return {
242
+ path: out,
243
+ filename,
244
+ sizeBytes: zip.length,
245
+ createdAt: createdAt.toISOString(),
246
+ };
247
+ }
248
+ export async function uploadDiagnosticBundle(opts) {
249
+ const record = opts.auth.current;
250
+ if (!record)
251
+ throw new Error("daemon not logged in");
252
+ const data = readFileSync(opts.bundle.path);
253
+ if (data.length > MAX_UPLOAD_BYTES) {
254
+ throw new Error(`diagnostic bundle is too large (${data.length} bytes, max ${MAX_UPLOAD_BYTES})`);
255
+ }
256
+ const token = await opts.auth.ensureAccessToken();
257
+ const url = `${record.hubUrl.replace(/\/+$/, "")}/daemon/diagnostics/upload`;
258
+ const resp = await fetch(url, {
259
+ method: "POST",
260
+ headers: {
261
+ Authorization: `Bearer ${token}`,
262
+ "Content-Type": "application/zip",
263
+ "X-BotCord-Filename": opts.bundle.filename,
264
+ },
265
+ body: data,
266
+ });
267
+ const json = await resp.json().catch(() => null);
268
+ if (!resp.ok) {
269
+ const detail = typeof json?.detail === "string"
270
+ ? json.detail
271
+ : typeof json?.error === "string"
272
+ ? json.error
273
+ : `HTTP ${resp.status}`;
274
+ throw new Error(`diagnostic upload failed: ${detail}`);
275
+ }
276
+ const bundleId = typeof json?.bundle_id === "string" ? json.bundle_id : null;
277
+ if (!bundleId)
278
+ throw new Error("diagnostic upload response missing bundle_id");
279
+ return {
280
+ bundleId,
281
+ filename: typeof json?.filename === "string" ? json.filename : opts.bundle.filename,
282
+ sizeBytes: typeof json?.size_bytes === "number" ? json.size_bytes : data.length,
283
+ ...(typeof json?.expires_at === "string" ? { expiresAt: json.expires_at } : {}),
284
+ };
285
+ }
286
+ export { DIAGNOSTICS_DIR };
@@ -117,4 +117,10 @@ export declare class Gateway {
117
117
  * No-op on unknown id.
118
118
  */
119
119
  removeChannel(id: string, reason?: string): Promise<void>;
120
+ /**
121
+ * Inject a daemon-internal inbound message into the normal dispatcher.
122
+ * Control-plane wakeups use this path so scheduled turns share the same
123
+ * routing, queueing, transcript, and runtime behavior as channel messages.
124
+ */
125
+ injectInbound(message: GatewayInboundMessage): Promise<void>;
120
126
  }
@@ -166,4 +166,12 @@ export class Gateway {
166
166
  if (idx >= 0)
167
167
  this.config.channels.splice(idx, 1);
168
168
  }
169
+ /**
170
+ * Inject a daemon-internal inbound message into the normal dispatcher.
171
+ * Control-plane wakeups use this path so scheduled turns share the same
172
+ * routing, queueing, transcript, and runtime behavior as channel messages.
173
+ */
174
+ async injectInbound(message) {
175
+ await this.dispatcher.handle({ message });
176
+ }
169
177
  }
@@ -18,6 +18,79 @@ function isValidClaudeSessionId(sessionId) {
18
18
  function invalidClaudeSessionIdError() {
19
19
  return "claude-code: invalid sessionId (expected non-control text not starting with '-')";
20
20
  }
21
+ const CLAUDE_FOREIGN_FLAGS_WITH_VALUE = new Set([
22
+ "--color",
23
+ "--config",
24
+ "--disable",
25
+ "--enable",
26
+ "--image",
27
+ "--local-provider",
28
+ "--output-last-message",
29
+ "--output-schema",
30
+ "--profile",
31
+ "--sandbox",
32
+ "-i",
33
+ "-o",
34
+ "-p",
35
+ "-s",
36
+ ]);
37
+ const CLAUDE_FOREIGN_BOOLEAN_FLAGS = new Set([
38
+ "--all",
39
+ "--dangerously-bypass-approvals-and-sandbox",
40
+ "--ephemeral",
41
+ "--full-auto",
42
+ "--ignore-rules",
43
+ "--ignore-user-config",
44
+ "--json",
45
+ "--last",
46
+ "--oss",
47
+ "--print",
48
+ "--skip-git-repo-check",
49
+ ]);
50
+ function extraFlagName(arg) {
51
+ if (!arg.startsWith("-"))
52
+ return arg;
53
+ const eq = arg.indexOf("=");
54
+ return eq === -1 ? arg : arg.slice(0, eq);
55
+ }
56
+ function nextExtraValue(args, index) {
57
+ const next = args[index + 1];
58
+ if (typeof next !== "string")
59
+ return undefined;
60
+ if (!next.startsWith("-"))
61
+ return next;
62
+ return /^-\d/.test(next) ? next : undefined;
63
+ }
64
+ function sanitizeClaudeExtraArgs(extraArgs) {
65
+ if (!extraArgs?.length)
66
+ return [];
67
+ const out = [];
68
+ for (let i = 0; i < extraArgs.length; i += 1) {
69
+ const arg = extraArgs[i];
70
+ const name = extraFlagName(arg);
71
+ if (arg === "-c") {
72
+ const value = nextExtraValue(extraArgs, i);
73
+ if (value !== undefined)
74
+ i += 1;
75
+ continue;
76
+ }
77
+ if (name === "--config" || name === "--sandbox") {
78
+ if (!arg.includes("=") && nextExtraValue(extraArgs, i) !== undefined)
79
+ i += 1;
80
+ continue;
81
+ }
82
+ if (CLAUDE_FOREIGN_FLAGS_WITH_VALUE.has(name)) {
83
+ if (!arg.includes("=") && nextExtraValue(extraArgs, i) !== undefined)
84
+ i += 1;
85
+ continue;
86
+ }
87
+ if (CLAUDE_FOREIGN_BOOLEAN_FLAGS.has(name)) {
88
+ continue;
89
+ }
90
+ out.push(arg);
91
+ }
92
+ return out;
93
+ }
21
94
  /** Resolve the Claude Code CLI path on PATH or the macOS desktop bundle fallback. */
22
95
  export function resolveClaudeCommand(deps = {}) {
23
96
  const onPath = resolveCommandOnPath("claude", deps);
@@ -75,11 +148,12 @@ export class ClaudeCodeAdapter extends NdjsonStreamAdapter {
75
148
  return this.resolvedBinary;
76
149
  }
77
150
  buildArgs(opts) {
151
+ const extraArgs = sanitizeClaudeExtraArgs(opts.extraArgs);
78
152
  const args = ["-p", opts.text, "--output-format", "stream-json", "--verbose"];
79
153
  // Headless `-p` mode does not load project `.claude/` by default, so
80
154
  // per-agent skills seeded at `<workspace>/.claude/skills/` are invisible
81
155
  // unless we opt in. `extraArgs` wins so operators can still override.
82
- if (!opts.extraArgs?.some((a) => a.startsWith("--setting-sources"))) {
156
+ if (!extraArgs.some((a) => a.startsWith("--setting-sources"))) {
83
157
  args.push("--setting-sources", "project");
84
158
  }
85
159
  if (opts.sessionId) {
@@ -93,17 +167,17 @@ export class ClaudeCodeAdapter extends NdjsonStreamAdapter {
93
167
  // MCP) because there is no prompt relay back to the user yet. Default to
94
168
  // bypassPermissions for every trust tier; operators who need a stricter
95
169
  // posture can still override with route/defaultRoute extraArgs.
96
- if (!opts.extraArgs?.some((a) => a.startsWith("--permission-mode"))) {
170
+ if (!extraArgs.some((a) => a.startsWith("--permission-mode"))) {
97
171
  args.push("--permission-mode", "bypassPermissions");
98
172
  }
99
173
  // Claude Code's `--append-system-prompt` is applied per invocation and NOT
100
174
  // persisted in the resumed session transcript — ideal for memory / digest
101
175
  // content that should re-evaluate every turn.
102
- if (opts.systemContext && !opts.extraArgs?.includes("--append-system-prompt")) {
176
+ if (opts.systemContext && !extraArgs.includes("--append-system-prompt")) {
103
177
  args.push("--append-system-prompt", opts.systemContext);
104
178
  }
105
- if (opts.extraArgs?.length)
106
- args.push(...opts.extraArgs);
179
+ if (extraArgs.length)
180
+ args.push(...extraArgs);
107
181
  return args;
108
182
  }
109
183
  handleEvent(raw, ctx) {
@@ -8,6 +8,69 @@ import { firstExistingPath, readCommandVersion, resolveCommandOnPath, } from "./
8
8
  const CODEX_DESKTOP_BUNDLE_PATH = "/Applications/Codex.app/Contents/Resources/codex";
9
9
  /** Codex UUIDv7 / v4 session ids are 36-char dashed hex; reject anything else to keep argv safe. */
10
10
  const CODEX_SESSION_ID_RE = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
11
+ const CODEX_FOREIGN_EXTRA_FLAGS_WITH_VALUE = new Set([
12
+ "--append-system-prompt",
13
+ "--permission-mode",
14
+ ]);
15
+ const CODEX_SANDBOX_MODES = new Set(["read-only", "workspace-write", "danger-full-access"]);
16
+ function extraFlagName(arg) {
17
+ if (!arg.startsWith("-"))
18
+ return arg;
19
+ const eq = arg.indexOf("=");
20
+ return eq === -1 ? arg : arg.slice(0, eq);
21
+ }
22
+ function nextExtraValue(args, index) {
23
+ const next = args[index + 1];
24
+ if (typeof next !== "string")
25
+ return undefined;
26
+ if (!next.startsWith("-"))
27
+ return next;
28
+ return /^-\d/.test(next) ? next : undefined;
29
+ }
30
+ function sanitizeCodexExtraArgs(extraArgs) {
31
+ if (!extraArgs?.length)
32
+ return [];
33
+ const out = [];
34
+ for (let i = 0; i < extraArgs.length; i += 1) {
35
+ const arg = extraArgs[i];
36
+ const name = extraFlagName(arg);
37
+ if (CODEX_FOREIGN_EXTRA_FLAGS_WITH_VALUE.has(name)) {
38
+ if (!arg.includes("=") && nextExtraValue(extraArgs, i) !== undefined)
39
+ i += 1;
40
+ continue;
41
+ }
42
+ if (name === "-s" || name === "--sandbox") {
43
+ const value = arg.includes("=") ? arg.slice(arg.indexOf("=") + 1) : nextExtraValue(extraArgs, i);
44
+ if (!arg.includes("=") && value !== undefined)
45
+ i += 1;
46
+ if (value && CODEX_SANDBOX_MODES.has(value)) {
47
+ out.push("-c", `sandbox_mode="${value}"`);
48
+ }
49
+ continue;
50
+ }
51
+ if (arg === "--full-auto") {
52
+ out.push("--dangerously-bypass-approvals-and-sandbox");
53
+ continue;
54
+ }
55
+ out.push(arg);
56
+ }
57
+ return out;
58
+ }
59
+ function hasCodexSandboxOverride(args) {
60
+ for (let i = 0; i < args.length; i += 1) {
61
+ const arg = args[i];
62
+ if (arg === "--dangerously-bypass-approvals-and-sandbox" ||
63
+ arg.startsWith("-c sandbox_mode=") ||
64
+ arg.startsWith("-csandbox_mode=") ||
65
+ arg.startsWith("--config=sandbox_mode=")) {
66
+ return true;
67
+ }
68
+ if ((arg === "-c" || arg === "--config") && args[i + 1]?.startsWith("sandbox_mode=")) {
69
+ return true;
70
+ }
71
+ }
72
+ return false;
73
+ }
11
74
  /** Resolve the Codex CLI executable via PATH or macOS desktop bundle. */
12
75
  export function resolveCodexCommand(deps = {}) {
13
76
  const onPath = resolveCommandOnPath("codex", deps);
@@ -156,6 +219,7 @@ export class CodexAdapter extends NdjsonStreamAdapter {
156
219
  */
157
220
  buildArgs(opts) {
158
221
  const tail = [];
222
+ const extraArgs = sanitizeCodexExtraArgs(opts.extraArgs);
159
223
  // Sandbox / approval policy. Expressed as `-c` overrides because
160
224
  // `codex exec resume` rejects `-s` / `--full-auto`. `-c` works on both
161
225
  // the fresh `exec` and `exec resume` paths.
@@ -165,18 +229,13 @@ export class CodexAdapter extends NdjsonStreamAdapter {
165
229
  // relay back to the user yet. Default to bypassing both approvals and the
166
230
  // sandbox for every trust tier; operators who need a stricter posture can
167
231
  // still override with route/defaultRoute extraArgs.
168
- const hasSandboxOverride = opts.extraArgs?.some((a) => a === "-s" ||
169
- a.startsWith("--sandbox") ||
170
- a === "--full-auto" ||
171
- a === "--dangerously-bypass-approvals-and-sandbox" ||
172
- a.startsWith("-c sandbox_mode=") ||
173
- a.startsWith("-csandbox_mode=")) ?? false;
232
+ const hasSandboxOverride = hasCodexSandboxOverride(extraArgs);
174
233
  if (!hasSandboxOverride) {
175
234
  tail.push("-c", 'sandbox_mode="danger-full-access"', "-c", 'approval_policy="never"');
176
235
  }
177
236
  tail.push("--skip-git-repo-check", "--json");
178
- if (opts.extraArgs?.length)
179
- tail.push(...opts.extraArgs);
237
+ if (extraArgs.length)
238
+ tail.push(...extraArgs);
180
239
  // `--` separates flags from positionals so a prompt starting with `-`
181
240
  // can never be parsed as an option. `systemContext` is NOT prepended to
182
241
  // the prompt any more — it lives in `<CODEX_HOME>/AGENTS.md` written by
package/dist/index.js CHANGED
@@ -15,6 +15,7 @@ import { renderStatus } from "./status-render.js";
15
15
  import { appendNextParam } from "./url-utils.js";
16
16
  import { channelsFromDaemonConfig, defaultHttpFetcher, renderDoctor, runDoctor, } from "./doctor.js";
17
17
  import { clearWorkingMemory, readWorkingMemory, resolveMemoryDir, updateWorkingMemory, DEFAULT_SECTION, } from "./working-memory.js";
18
+ import { createDiagnosticBundle } from "./diagnostics.js";
18
19
  import { resolveStartAuthAction } from "./start-auth.js";
19
20
  import { discoverLocalOpenclawGateways, mergeOpenclawGateways, openclawDiscoveryConfigEnabled, } from "./openclaw-discovery.js";
20
21
  const ADAPTER_LIST = listAdapterIds().join("|");
@@ -81,7 +82,9 @@ Commands:
81
82
  route list
82
83
  route remove --room <rm_xxx>|--prefix <rm_xxx>
83
84
  config Print resolved config
84
- doctor [--json] Scan local runtimes (${ADAPTER_LIST})
85
+ doctor [--json] [--bundle] Scan local runtimes (${ADAPTER_LIST});
86
+ --bundle also writes a zip under
87
+ ~/.botcord/diagnostics/
85
88
  memory get [--agent <ag_xxx>] [--json] Show current working memory
86
89
  memory set [--agent <ag_xxx>] --goal <text>
87
90
  Pin/update the agent's work goal
@@ -105,6 +108,7 @@ const BOOLEAN_FLAGS = new Set([
105
108
  "f",
106
109
  "follow",
107
110
  "json",
111
+ "bundle",
108
112
  "help",
109
113
  "h",
110
114
  "mentioned",
@@ -1211,6 +1215,17 @@ const fsFileReader = {
1211
1215
  },
1212
1216
  };
1213
1217
  async function cmdDoctor(args) {
1218
+ if (args.flags.bundle === true) {
1219
+ const bundle = await createDiagnosticBundle();
1220
+ if (args.flags.json === true) {
1221
+ console.log(JSON.stringify({ bundle }, null, 2));
1222
+ return;
1223
+ }
1224
+ console.log(`diagnostic bundle written: ${bundle.path}`);
1225
+ console.log(`size: ${bundle.sizeBytes} bytes`);
1226
+ console.log("Send this zip file to the BotCord developer/support contact.");
1227
+ return;
1228
+ }
1214
1229
  const entries = detectRuntimes();
1215
1230
  // Doctor should not hard-fail when no config exists yet; channel probes
1216
1231
  // simply produce an empty list in that case.