@botcord/daemon 0.2.54 → 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.
@@ -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
  });
@@ -371,7 +371,7 @@ process.stdout.write(JSON.stringify({type:"item.completed", item:{id:"i0", type:
371
371
  expect(argv).toContain('approval_policy="never"');
372
372
  });
373
373
 
374
- it("extraArgs `-s read-only` suppresses the default sandbox `-c`s", async () => {
374
+ it("extraArgs `-s read-only` is converted to resume-compatible sandbox config", async () => {
375
375
  const adapter = new CodexAdapter({ binary: echoScript() });
376
376
  const ctrl = new AbortController();
377
377
  const res = await adapter.run({
@@ -384,11 +384,87 @@ process.stdout.write(JSON.stringify({type:"item.completed", item:{id:"i0", type:
384
384
  extraArgs: ["-s", "read-only"],
385
385
  });
386
386
  const argv = JSON.parse(res.text) as string[];
387
- // Only the operator-supplied `-s` appears; our defaults are suppressed.
388
- expect(argv.filter((a) => a === "-s").length).toBe(1);
389
- expect(argv[argv.indexOf("-s") + 1]).toBe("read-only");
387
+ expect(argv).not.toContain("-s");
388
+ expect(argv).toContain('sandbox_mode="read-only"');
390
389
  expect(argv).not.toContain('sandbox_mode="workspace-write"');
391
390
  expect(argv).not.toContain('sandbox_mode="danger-full-access"');
392
391
  });
392
+
393
+ it("extraArgs `--sandbox=value` is converted on resume too", async () => {
394
+ const adapter = new CodexAdapter({ binary: echoScript() });
395
+ const ctrl = new AbortController();
396
+ const res = await adapter.run({
397
+ text: "x",
398
+ sessionId: "01234567-89ab-7def-8123-456789abcdef",
399
+ accountId: "ag_test",
400
+ cwd: tmpRoot,
401
+ signal: ctrl.signal,
402
+ trustLevel: "public",
403
+ extraArgs: ["--sandbox=workspace-write"],
404
+ });
405
+ const argv = JSON.parse(res.text) as string[];
406
+ expect(argv[0]).toBe("exec");
407
+ expect(argv[1]).toBe("resume");
408
+ expect(argv).not.toContain("--sandbox=workspace-write");
409
+ expect(argv).toContain('sandbox_mode="workspace-write"');
410
+ expect(argv).not.toContain('sandbox_mode="danger-full-access"');
411
+ });
412
+
413
+ it("maps legacy Codex --full-auto to the current bypass flag", async () => {
414
+ const adapter = new CodexAdapter({ binary: echoScript() });
415
+ const ctrl = new AbortController();
416
+ const res = await adapter.run({
417
+ text: "x",
418
+ sessionId: null,
419
+ accountId: "ag_test",
420
+ cwd: tmpRoot,
421
+ signal: ctrl.signal,
422
+ trustLevel: "public",
423
+ extraArgs: ["--full-auto"],
424
+ });
425
+ const argv = JSON.parse(res.text) as string[];
426
+ expect(argv).not.toContain("--full-auto");
427
+ expect(argv).toContain("--dangerously-bypass-approvals-and-sandbox");
428
+ expect(argv).not.toContain('sandbox_mode="danger-full-access"');
429
+ });
430
+
431
+ it("drops inherited Claude --permission-mode extraArgs and their values", async () => {
432
+ const adapter = new CodexAdapter({ binary: echoScript() });
433
+ const ctrl = new AbortController();
434
+ const res = await adapter.run({
435
+ text: "x",
436
+ sessionId: null,
437
+ accountId: "ag_test",
438
+ cwd: tmpRoot,
439
+ signal: ctrl.signal,
440
+ trustLevel: "public",
441
+ extraArgs: ["--permission-mode", "bypassPermissions", "--model", "gpt-5.2"],
442
+ });
443
+ const argv = JSON.parse(res.text) as string[];
444
+ expect(argv).not.toContain("--permission-mode");
445
+ expect(argv).not.toContain("bypassPermissions");
446
+ expect(argv).toContain("--model");
447
+ expect(argv[argv.indexOf("--model") + 1]).toBe("gpt-5.2");
448
+ expect(argv).toContain('sandbox_mode="danger-full-access"');
449
+ expect(argv).toContain('approval_policy="never"');
450
+ });
451
+
452
+ it("drops inherited Claude --permission-mode=value extraArgs", async () => {
453
+ const adapter = new CodexAdapter({ binary: echoScript() });
454
+ const ctrl = new AbortController();
455
+ const res = await adapter.run({
456
+ text: "x",
457
+ sessionId: null,
458
+ accountId: "ag_test",
459
+ cwd: tmpRoot,
460
+ signal: ctrl.signal,
461
+ trustLevel: "public",
462
+ extraArgs: ["--permission-mode=bypassPermissions"],
463
+ });
464
+ const argv = JSON.parse(res.text) as string[];
465
+ expect(argv).not.toContain("--permission-mode=bypassPermissions");
466
+ expect(argv).toContain('sandbox_mode="danger-full-access"');
467
+ expect(argv).toContain('approval_policy="never"');
468
+ });
393
469
  });
394
470
  });
@@ -261,4 +261,13 @@ export class Gateway {
261
261
  const idx = this.config.channels.findIndex((c) => c.id === id);
262
262
  if (idx >= 0) this.config.channels.splice(idx, 1);
263
263
  }
264
+
265
+ /**
266
+ * Inject a daemon-internal inbound message into the normal dispatcher.
267
+ * Control-plane wakeups use this path so scheduled turns share the same
268
+ * routing, queueing, transcript, and runtime behavior as channel messages.
269
+ */
270
+ async injectInbound(message: GatewayInboundMessage): Promise<void> {
271
+ await this.dispatcher.handle({ message });
272
+ }
264
273
  }
@@ -32,6 +32,77 @@ function invalidClaudeSessionIdError(): string {
32
32
  return "claude-code: invalid sessionId (expected non-control text not starting with '-')";
33
33
  }
34
34
 
35
+ const CLAUDE_FOREIGN_FLAGS_WITH_VALUE = new Set([
36
+ "--color",
37
+ "--config",
38
+ "--disable",
39
+ "--enable",
40
+ "--image",
41
+ "--local-provider",
42
+ "--output-last-message",
43
+ "--output-schema",
44
+ "--profile",
45
+ "--sandbox",
46
+ "-i",
47
+ "-o",
48
+ "-p",
49
+ "-s",
50
+ ]);
51
+ const CLAUDE_FOREIGN_BOOLEAN_FLAGS = new Set([
52
+ "--all",
53
+ "--dangerously-bypass-approvals-and-sandbox",
54
+ "--ephemeral",
55
+ "--full-auto",
56
+ "--ignore-rules",
57
+ "--ignore-user-config",
58
+ "--json",
59
+ "--last",
60
+ "--oss",
61
+ "--print",
62
+ "--skip-git-repo-check",
63
+ ]);
64
+
65
+ function extraFlagName(arg: string): string {
66
+ if (!arg.startsWith("-")) return arg;
67
+ const eq = arg.indexOf("=");
68
+ return eq === -1 ? arg : arg.slice(0, eq);
69
+ }
70
+
71
+ function nextExtraValue(args: string[], index: number): string | undefined {
72
+ const next = args[index + 1];
73
+ if (typeof next !== "string") return undefined;
74
+ if (!next.startsWith("-")) return next;
75
+ return /^-\d/.test(next) ? next : undefined;
76
+ }
77
+
78
+ function sanitizeClaudeExtraArgs(extraArgs: string[] | undefined): string[] {
79
+ if (!extraArgs?.length) return [];
80
+ const out: string[] = [];
81
+ for (let i = 0; i < extraArgs.length; i += 1) {
82
+ const arg = extraArgs[i];
83
+ const name = extraFlagName(arg);
84
+
85
+ if (arg === "-c") {
86
+ const value = nextExtraValue(extraArgs, i);
87
+ if (value !== undefined) i += 1;
88
+ continue;
89
+ }
90
+ if (name === "--config" || name === "--sandbox") {
91
+ if (!arg.includes("=") && nextExtraValue(extraArgs, i) !== undefined) i += 1;
92
+ continue;
93
+ }
94
+ if (CLAUDE_FOREIGN_FLAGS_WITH_VALUE.has(name)) {
95
+ if (!arg.includes("=") && nextExtraValue(extraArgs, i) !== undefined) i += 1;
96
+ continue;
97
+ }
98
+ if (CLAUDE_FOREIGN_BOOLEAN_FLAGS.has(name)) {
99
+ continue;
100
+ }
101
+ out.push(arg);
102
+ }
103
+ return out;
104
+ }
105
+
35
106
  /** Resolve the Claude Code CLI path on PATH or the macOS desktop bundle fallback. */
36
107
  export function resolveClaudeCommand(deps: ProbeDeps = {}): string | null {
37
108
  const onPath = resolveCommandOnPath("claude", deps);
@@ -95,11 +166,12 @@ export class ClaudeCodeAdapter extends NdjsonStreamAdapter {
95
166
  }
96
167
 
97
168
  protected buildArgs(opts: RuntimeRunOptions): string[] {
169
+ const extraArgs = sanitizeClaudeExtraArgs(opts.extraArgs);
98
170
  const args = ["-p", opts.text, "--output-format", "stream-json", "--verbose"];
99
171
  // Headless `-p` mode does not load project `.claude/` by default, so
100
172
  // per-agent skills seeded at `<workspace>/.claude/skills/` are invisible
101
173
  // unless we opt in. `extraArgs` wins so operators can still override.
102
- if (!opts.extraArgs?.some((a) => a.startsWith("--setting-sources"))) {
174
+ if (!extraArgs.some((a) => a.startsWith("--setting-sources"))) {
103
175
  args.push("--setting-sources", "project");
104
176
  }
105
177
  if (opts.sessionId) {
@@ -112,16 +184,16 @@ export class ClaudeCodeAdapter extends NdjsonStreamAdapter {
112
184
  // MCP) because there is no prompt relay back to the user yet. Default to
113
185
  // bypassPermissions for every trust tier; operators who need a stricter
114
186
  // posture can still override with route/defaultRoute extraArgs.
115
- if (!opts.extraArgs?.some((a) => a.startsWith("--permission-mode"))) {
187
+ if (!extraArgs.some((a) => a.startsWith("--permission-mode"))) {
116
188
  args.push("--permission-mode", "bypassPermissions");
117
189
  }
118
190
  // Claude Code's `--append-system-prompt` is applied per invocation and NOT
119
191
  // persisted in the resumed session transcript — ideal for memory / digest
120
192
  // content that should re-evaluate every turn.
121
- if (opts.systemContext && !opts.extraArgs?.includes("--append-system-prompt")) {
193
+ if (opts.systemContext && !extraArgs.includes("--append-system-prompt")) {
122
194
  args.push("--append-system-prompt", opts.systemContext);
123
195
  }
124
- if (opts.extraArgs?.length) args.push(...opts.extraArgs);
196
+ if (extraArgs.length) args.push(...extraArgs);
125
197
  return args;
126
198
  }
127
199