@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.
- 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/system-context.js +16 -5
- 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__/system-context.test.ts +32 -12
- 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/system-context.ts +17 -5
- package/src/working-memory.ts +5 -0
|
@@ -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`
|
|
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
|
-
|
|
388
|
-
expect(argv.
|
|
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
|
});
|
package/src/gateway/gateway.ts
CHANGED
|
@@ -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 (!
|
|
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 (!
|
|
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 && !
|
|
193
|
+
if (opts.systemContext && !extraArgs.includes("--append-system-prompt")) {
|
|
122
194
|
args.push("--append-system-prompt", opts.systemContext);
|
|
123
195
|
}
|
|
124
|
-
if (
|
|
196
|
+
if (extraArgs.length) args.push(...extraArgs);
|
|
125
197
|
return args;
|
|
126
198
|
}
|
|
127
199
|
|