codeharbor 0.1.16 → 0.1.18
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/README.md +6 -0
- package/dist/cli.js +2478 -2144
- package/package.json +8 -6
package/dist/cli.js
CHANGED
|
@@ -40,1712 +40,250 @@ var import_node_http = __toESM(require("http"));
|
|
|
40
40
|
var import_node_path5 = __toESM(require("path"));
|
|
41
41
|
var import_node_util2 = require("util");
|
|
42
42
|
|
|
43
|
-
// src/
|
|
44
|
-
var
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
"/usr/bin/codex",
|
|
64
|
-
"/usr/local/bin/codex",
|
|
65
|
-
"/opt/homebrew/bin/codex"
|
|
66
|
-
];
|
|
67
|
-
const seen = /* @__PURE__ */ new Set();
|
|
68
|
-
const output = [];
|
|
69
|
-
for (const candidate of candidates) {
|
|
70
|
-
const trimmed = candidate.trim();
|
|
71
|
-
if (!trimmed || seen.has(trimmed)) {
|
|
72
|
-
continue;
|
|
73
|
-
}
|
|
74
|
-
seen.add(trimmed);
|
|
75
|
-
output.push(trimmed);
|
|
76
|
-
}
|
|
77
|
-
return output;
|
|
78
|
-
}
|
|
79
|
-
async function findWorkingCodexBin(configuredBin, options = {}) {
|
|
80
|
-
const checkBinary = options.checkBinary ?? defaultCheckBinary;
|
|
81
|
-
const candidates = buildCodexBinCandidates(configuredBin, options.env);
|
|
82
|
-
for (const candidate of candidates) {
|
|
83
|
-
try {
|
|
84
|
-
await checkBinary(candidate);
|
|
85
|
-
return candidate;
|
|
86
|
-
} catch {
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
return null;
|
|
90
|
-
}
|
|
91
|
-
async function defaultCheckBinary(bin) {
|
|
92
|
-
await execFileAsync(bin, ["--version"]);
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
// src/init.ts
|
|
96
|
-
async function runInitCommand(options = {}) {
|
|
97
|
-
const cwd = options.cwd ?? process.cwd();
|
|
98
|
-
const envPath = import_node_path2.default.resolve(cwd, ".env");
|
|
99
|
-
const templatePath = resolveInitTemplatePath(cwd);
|
|
100
|
-
const input = options.input ?? process.stdin;
|
|
101
|
-
const output = options.output ?? process.stdout;
|
|
102
|
-
const templateContent = import_node_fs.default.readFileSync(templatePath, "utf8");
|
|
103
|
-
const existingContent = import_node_fs.default.existsSync(envPath) ? import_node_fs.default.readFileSync(envPath, "utf8") : "";
|
|
104
|
-
const existingValues = existingContent ? import_dotenv.default.parse(existingContent) : {};
|
|
105
|
-
const rl = (0, import_promises.createInterface)({ input, output });
|
|
106
|
-
try {
|
|
107
|
-
if (existingContent && !options.force) {
|
|
108
|
-
const overwrite = await askYesNo(
|
|
109
|
-
rl,
|
|
110
|
-
"Detected existing .env file. Overwrite with guided setup?",
|
|
111
|
-
false
|
|
112
|
-
);
|
|
113
|
-
if (!overwrite) {
|
|
114
|
-
output.write("Init aborted. Keep existing .env unchanged.\n");
|
|
115
|
-
return;
|
|
43
|
+
// src/admin-console-html.ts
|
|
44
|
+
var ADMIN_CONSOLE_HTML = `<!doctype html>
|
|
45
|
+
<html lang="en">
|
|
46
|
+
<head>
|
|
47
|
+
<meta charset="utf-8" />
|
|
48
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
49
|
+
<title>CodeHarbor Admin Console</title>
|
|
50
|
+
<style>
|
|
51
|
+
:root {
|
|
52
|
+
--bg-start: #0f172a;
|
|
53
|
+
--bg-end: #1e293b;
|
|
54
|
+
--panel: #0b1224cc;
|
|
55
|
+
--panel-border: #334155;
|
|
56
|
+
--text: #e2e8f0;
|
|
57
|
+
--muted: #94a3b8;
|
|
58
|
+
--accent: #22d3ee;
|
|
59
|
+
--accent-strong: #06b6d4;
|
|
60
|
+
--danger: #f43f5e;
|
|
61
|
+
--ok: #10b981;
|
|
62
|
+
--warn: #f59e0b;
|
|
116
63
|
}
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
output.write(`Target file: ${envPath}
|
|
120
|
-
`);
|
|
121
|
-
const detectedCodexBin = await findWorkingCodexBin(existingValues.CODEX_BIN ?? "codex");
|
|
122
|
-
const questions = [
|
|
123
|
-
{
|
|
124
|
-
key: "MATRIX_HOMESERVER",
|
|
125
|
-
label: "Matrix homeserver URL",
|
|
126
|
-
required: true,
|
|
127
|
-
validate: (value) => {
|
|
128
|
-
try {
|
|
129
|
-
new URL(value);
|
|
130
|
-
return null;
|
|
131
|
-
} catch {
|
|
132
|
-
return "Please enter a valid URL, for example https://matrix.example.com";
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
},
|
|
136
|
-
{
|
|
137
|
-
key: "MATRIX_USER_ID",
|
|
138
|
-
label: "Matrix bot user id",
|
|
139
|
-
required: true,
|
|
140
|
-
validate: (value) => {
|
|
141
|
-
if (!/^@[^:\s]+:.+/.test(value)) {
|
|
142
|
-
return "Please enter a Matrix user id like @bot:example.com";
|
|
143
|
-
}
|
|
144
|
-
return null;
|
|
145
|
-
}
|
|
146
|
-
},
|
|
147
|
-
{
|
|
148
|
-
key: "MATRIX_ACCESS_TOKEN",
|
|
149
|
-
label: "Matrix access token",
|
|
150
|
-
required: true,
|
|
151
|
-
hiddenDefault: true
|
|
152
|
-
},
|
|
153
|
-
{
|
|
154
|
-
key: "MATRIX_COMMAND_PREFIX",
|
|
155
|
-
label: "Group command prefix",
|
|
156
|
-
fallbackValue: "!code"
|
|
157
|
-
},
|
|
158
|
-
{
|
|
159
|
-
key: "CODEX_BIN",
|
|
160
|
-
label: "Codex binary",
|
|
161
|
-
fallbackValue: detectedCodexBin ?? "codex"
|
|
162
|
-
},
|
|
163
|
-
{
|
|
164
|
-
key: "CODEX_WORKDIR",
|
|
165
|
-
label: "Codex working directory",
|
|
166
|
-
fallbackValue: cwd,
|
|
167
|
-
validate: (value) => {
|
|
168
|
-
const resolved = import_node_path2.default.resolve(cwd, value);
|
|
169
|
-
if (!import_node_fs.default.existsSync(resolved) || !import_node_fs.default.statSync(resolved).isDirectory()) {
|
|
170
|
-
return `Directory does not exist: ${resolved}`;
|
|
171
|
-
}
|
|
172
|
-
return null;
|
|
173
|
-
}
|
|
64
|
+
* {
|
|
65
|
+
box-sizing: border-box;
|
|
174
66
|
}
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
import_node_fs.default.writeFileSync(envPath, mergedContent, "utf8");
|
|
184
|
-
output.write(`Wrote ${envPath}
|
|
185
|
-
`);
|
|
186
|
-
output.write("Next steps:\n");
|
|
187
|
-
output.write("1. codex login\n");
|
|
188
|
-
output.write("2. codeharbor doctor\n");
|
|
189
|
-
output.write("3. codeharbor start\n");
|
|
190
|
-
} finally {
|
|
191
|
-
rl.close();
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
function resolveInitTemplatePath(cwd) {
|
|
195
|
-
const candidates = [
|
|
196
|
-
import_node_path2.default.resolve(cwd, ".env.example"),
|
|
197
|
-
import_node_path2.default.resolve(__dirname, "..", ".env.example")
|
|
198
|
-
];
|
|
199
|
-
for (const candidate of candidates) {
|
|
200
|
-
if (import_node_fs.default.existsSync(candidate)) {
|
|
201
|
-
return candidate;
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
throw new Error(`Cannot find template file. Tried: ${candidates.join(", ")}`);
|
|
205
|
-
}
|
|
206
|
-
function applyEnvOverrides(template, overrides) {
|
|
207
|
-
const lines = template.split(/\r?\n/);
|
|
208
|
-
const seen = /* @__PURE__ */ new Set();
|
|
209
|
-
for (let i = 0; i < lines.length; i += 1) {
|
|
210
|
-
const line = lines[i];
|
|
211
|
-
const match = /^([A-Z0-9_]+)=(.*)$/.exec(line.trim());
|
|
212
|
-
if (!match) {
|
|
213
|
-
continue;
|
|
214
|
-
}
|
|
215
|
-
const key = match[1];
|
|
216
|
-
if (!(key in overrides)) {
|
|
217
|
-
continue;
|
|
218
|
-
}
|
|
219
|
-
lines[i] = `${key}=${formatEnvValue(overrides[key] ?? "")}`;
|
|
220
|
-
seen.add(key);
|
|
221
|
-
}
|
|
222
|
-
for (const [key, value] of Object.entries(overrides)) {
|
|
223
|
-
if (seen.has(key)) {
|
|
224
|
-
continue;
|
|
225
|
-
}
|
|
226
|
-
lines.push(`${key}=${formatEnvValue(value)}`);
|
|
227
|
-
}
|
|
228
|
-
const content = lines.join("\n");
|
|
229
|
-
return content.endsWith("\n") ? content : `${content}
|
|
230
|
-
`;
|
|
231
|
-
}
|
|
232
|
-
function formatEnvValue(value) {
|
|
233
|
-
if (!value) {
|
|
234
|
-
return "";
|
|
235
|
-
}
|
|
236
|
-
if (/^[A-Za-z0-9_./:@+-]+$/.test(value)) {
|
|
237
|
-
return value;
|
|
238
|
-
}
|
|
239
|
-
return JSON.stringify(value);
|
|
240
|
-
}
|
|
241
|
-
async function askValue(rl, question, existingValue) {
|
|
242
|
-
while (true) {
|
|
243
|
-
const fallback = question.fallbackValue ?? "";
|
|
244
|
-
const displayDefault = existingValue || fallback;
|
|
245
|
-
const hint = displayDefault ? question.hiddenDefault ? "[already set]" : `[${displayDefault}]` : "";
|
|
246
|
-
const answer = (await rl.question(`${question.label} ${hint}: `)).trim();
|
|
247
|
-
const finalValue = answer || existingValue || fallback;
|
|
248
|
-
if (question.required && !finalValue) {
|
|
249
|
-
rl.write("This value is required.\n");
|
|
250
|
-
continue;
|
|
251
|
-
}
|
|
252
|
-
if (question.validate) {
|
|
253
|
-
const reason = question.validate(finalValue);
|
|
254
|
-
if (reason) {
|
|
255
|
-
rl.write(`${reason}
|
|
256
|
-
`);
|
|
257
|
-
continue;
|
|
67
|
+
body {
|
|
68
|
+
margin: 0;
|
|
69
|
+
font-family: "IBM Plex Sans", "Segoe UI", "Helvetica Neue", sans-serif;
|
|
70
|
+
color: var(--text);
|
|
71
|
+
background: radial-gradient(1200px 600px at 20% -10%, #1d4ed8 0%, transparent 55%),
|
|
72
|
+
radial-gradient(1000px 500px at 100% 0%, #0f766e 0%, transparent 55%),
|
|
73
|
+
linear-gradient(135deg, var(--bg-start), var(--bg-end));
|
|
74
|
+
min-height: 100vh;
|
|
258
75
|
}
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
const adminPath = import_node_path4.default.join(SYSTEMD_DIR, ADMIN_SERVICE_NAME);
|
|
471
|
-
const restartSudoersPath = import_node_path4.default.join(SUDOERS_DIR, RESTART_SUDOERS_FILE);
|
|
472
|
-
stopAndDisableIfPresent(MAIN_SERVICE_NAME);
|
|
473
|
-
if (import_node_fs3.default.existsSync(mainPath)) {
|
|
474
|
-
import_node_fs3.default.unlinkSync(mainPath);
|
|
475
|
-
}
|
|
476
|
-
if (options.removeAdmin) {
|
|
477
|
-
stopAndDisableIfPresent(ADMIN_SERVICE_NAME);
|
|
478
|
-
if (import_node_fs3.default.existsSync(adminPath)) {
|
|
479
|
-
import_node_fs3.default.unlinkSync(adminPath);
|
|
480
|
-
}
|
|
481
|
-
if (import_node_fs3.default.existsSync(restartSudoersPath)) {
|
|
482
|
-
import_node_fs3.default.unlinkSync(restartSudoersPath);
|
|
483
|
-
}
|
|
484
|
-
}
|
|
485
|
-
runSystemctl(["daemon-reload"]);
|
|
486
|
-
runSystemctlIgnoreFailure(["reset-failed"]);
|
|
487
|
-
output.write(`Removed systemd unit: ${mainPath}
|
|
488
|
-
`);
|
|
489
|
-
if (options.removeAdmin) {
|
|
490
|
-
output.write(`Removed systemd unit: ${adminPath}
|
|
491
|
-
`);
|
|
492
|
-
output.write(`Removed sudoers policy: ${restartSudoersPath}
|
|
493
|
-
`);
|
|
494
|
-
}
|
|
495
|
-
output.write("Done.\n");
|
|
496
|
-
}
|
|
497
|
-
function restartSystemdServices(options) {
|
|
498
|
-
assertLinuxWithSystemd();
|
|
499
|
-
const output = options.output ?? process.stdout;
|
|
500
|
-
const runWithSudoFallback = options.allowSudoFallback ?? true;
|
|
501
|
-
const systemctlRunner = hasRootPrivileges() || !runWithSudoFallback ? runSystemctl : runSystemctlWithNonInteractiveSudo;
|
|
502
|
-
systemctlRunner(["restart", MAIN_SERVICE_NAME]);
|
|
503
|
-
output.write(`Restarted service: ${MAIN_SERVICE_NAME}
|
|
504
|
-
`);
|
|
505
|
-
if (options.restartAdmin) {
|
|
506
|
-
systemctlRunner(["restart", ADMIN_SERVICE_NAME]);
|
|
507
|
-
output.write(`Restarted service: ${ADMIN_SERVICE_NAME}
|
|
508
|
-
`);
|
|
509
|
-
}
|
|
510
|
-
output.write("Done.\n");
|
|
511
|
-
}
|
|
512
|
-
function resolveUserHome(runUser) {
|
|
513
|
-
try {
|
|
514
|
-
const passwdRaw = import_node_fs3.default.readFileSync("/etc/passwd", "utf8");
|
|
515
|
-
const line = passwdRaw.split(/\r?\n/).find((item) => item.startsWith(`${runUser}:`));
|
|
516
|
-
if (!line) {
|
|
517
|
-
return null;
|
|
518
|
-
}
|
|
519
|
-
const fields = line.split(":");
|
|
520
|
-
return fields[5] ? fields[5].trim() : null;
|
|
521
|
-
} catch {
|
|
522
|
-
return null;
|
|
523
|
-
}
|
|
524
|
-
}
|
|
525
|
-
function validateUnitOptions(options) {
|
|
526
|
-
validateSimpleValue(options.runUser, "runUser");
|
|
527
|
-
validateSimpleValue(options.runtimeHome, "runtimeHome");
|
|
528
|
-
validateSimpleValue(options.nodeBinPath, "nodeBinPath");
|
|
529
|
-
validateSimpleValue(options.cliScriptPath, "cliScriptPath");
|
|
530
|
-
}
|
|
531
|
-
function validateSimpleValue(value, key) {
|
|
532
|
-
if (!value.trim()) {
|
|
533
|
-
throw new Error(`${key} cannot be empty.`);
|
|
534
|
-
}
|
|
535
|
-
if (/[\r\n]/.test(value)) {
|
|
536
|
-
throw new Error(`${key} contains invalid newline characters.`);
|
|
537
|
-
}
|
|
538
|
-
}
|
|
539
|
-
function assertLinuxWithSystemd() {
|
|
540
|
-
if (process.platform !== "linux") {
|
|
541
|
-
throw new Error("Systemd service install only supports Linux.");
|
|
542
|
-
}
|
|
543
|
-
try {
|
|
544
|
-
(0, import_node_child_process2.execFileSync)("systemctl", ["--version"], { stdio: "ignore" });
|
|
545
|
-
} catch {
|
|
546
|
-
throw new Error("systemctl is required but not found.");
|
|
547
|
-
}
|
|
548
|
-
}
|
|
549
|
-
function assertRootPrivileges() {
|
|
550
|
-
if (!hasRootPrivileges()) {
|
|
551
|
-
throw new Error("Root privileges are required. Run with sudo.");
|
|
552
|
-
}
|
|
553
|
-
}
|
|
554
|
-
function hasRootPrivileges() {
|
|
555
|
-
if (typeof process.getuid !== "function") {
|
|
556
|
-
return true;
|
|
557
|
-
}
|
|
558
|
-
return process.getuid() === 0;
|
|
559
|
-
}
|
|
560
|
-
function ensureUserExists(runUser) {
|
|
561
|
-
runCommand("id", ["-u", runUser]);
|
|
562
|
-
}
|
|
563
|
-
function resolveUserGroup(runUser) {
|
|
564
|
-
return runCommand("id", ["-gn", runUser]).trim();
|
|
565
|
-
}
|
|
566
|
-
function runSystemctl(args) {
|
|
567
|
-
runCommand("systemctl", args);
|
|
568
|
-
}
|
|
569
|
-
function runSystemctlWithNonInteractiveSudo(args) {
|
|
570
|
-
const systemctlPath = resolveSystemctlPath();
|
|
571
|
-
try {
|
|
572
|
-
runCommand("sudo", ["-n", systemctlPath, ...args]);
|
|
573
|
-
} catch (error) {
|
|
574
|
-
throw new Error(
|
|
575
|
-
"Root privileges are required. Configure passwordless sudo for the CodeHarbor service user or run the CLI command manually with sudo.",
|
|
576
|
-
{ cause: error }
|
|
577
|
-
);
|
|
578
|
-
}
|
|
579
|
-
}
|
|
580
|
-
function stopAndDisableIfPresent(unitName) {
|
|
581
|
-
runSystemctlIgnoreFailure(["disable", "--now", unitName]);
|
|
582
|
-
}
|
|
583
|
-
function runSystemctlIgnoreFailure(args) {
|
|
584
|
-
try {
|
|
585
|
-
runCommand("systemctl", args);
|
|
586
|
-
} catch {
|
|
587
|
-
}
|
|
588
|
-
}
|
|
589
|
-
function resolveSystemctlPath() {
|
|
590
|
-
const candidates = [];
|
|
591
|
-
const pathEntries = (process.env.PATH ?? "").split(import_node_path4.default.delimiter).filter(Boolean);
|
|
592
|
-
for (const entry of pathEntries) {
|
|
593
|
-
candidates.push(import_node_path4.default.join(entry, "systemctl"));
|
|
594
|
-
}
|
|
595
|
-
candidates.push("/usr/bin/systemctl", "/bin/systemctl", "/usr/local/bin/systemctl");
|
|
596
|
-
for (const candidate of candidates) {
|
|
597
|
-
if (import_node_path4.default.isAbsolute(candidate) && import_node_fs3.default.existsSync(candidate)) {
|
|
598
|
-
return candidate;
|
|
599
|
-
}
|
|
600
|
-
}
|
|
601
|
-
throw new Error("Unable to resolve absolute systemctl path.");
|
|
602
|
-
}
|
|
603
|
-
function runCommand(file, args) {
|
|
604
|
-
try {
|
|
605
|
-
return (0, import_node_child_process2.execFileSync)(file, args, {
|
|
606
|
-
encoding: "utf8",
|
|
607
|
-
stdio: ["ignore", "pipe", "pipe"]
|
|
608
|
-
});
|
|
609
|
-
} catch (error) {
|
|
610
|
-
throw new Error(formatCommandError(file, args, error), { cause: error });
|
|
611
|
-
}
|
|
612
|
-
}
|
|
613
|
-
function formatCommandError(file, args, error) {
|
|
614
|
-
const command = `${file} ${args.join(" ")}`.trim();
|
|
615
|
-
if (error && typeof error === "object") {
|
|
616
|
-
const maybeError = error;
|
|
617
|
-
const stderr = bufferToTrimmedString(maybeError.stderr);
|
|
618
|
-
const stdout = bufferToTrimmedString(maybeError.stdout);
|
|
619
|
-
const details = stderr || stdout || maybeError.message || "command failed";
|
|
620
|
-
return `Command failed: ${command}. ${details}`;
|
|
621
|
-
}
|
|
622
|
-
return `Command failed: ${command}. ${String(error)}`;
|
|
623
|
-
}
|
|
624
|
-
function bufferToTrimmedString(value) {
|
|
625
|
-
if (!value) {
|
|
626
|
-
return "";
|
|
627
|
-
}
|
|
628
|
-
const text = typeof value === "string" ? value : value.toString("utf8");
|
|
629
|
-
return text.trim();
|
|
630
|
-
}
|
|
631
|
-
|
|
632
|
-
// src/admin-server.ts
|
|
633
|
-
var execFileAsync2 = (0, import_node_util2.promisify)(import_node_child_process3.execFile);
|
|
634
|
-
var HttpError = class extends Error {
|
|
635
|
-
statusCode;
|
|
636
|
-
constructor(statusCode, message) {
|
|
637
|
-
super(message);
|
|
638
|
-
this.statusCode = statusCode;
|
|
639
|
-
}
|
|
640
|
-
};
|
|
641
|
-
var AdminServer = class {
|
|
642
|
-
config;
|
|
643
|
-
logger;
|
|
644
|
-
stateStore;
|
|
645
|
-
configService;
|
|
646
|
-
host;
|
|
647
|
-
port;
|
|
648
|
-
adminToken;
|
|
649
|
-
adminTokens;
|
|
650
|
-
adminIpAllowlist;
|
|
651
|
-
adminAllowedOrigins;
|
|
652
|
-
cwd;
|
|
653
|
-
checkCodex;
|
|
654
|
-
checkMatrix;
|
|
655
|
-
restartServices;
|
|
656
|
-
server = null;
|
|
657
|
-
address = null;
|
|
658
|
-
constructor(config, logger, stateStore, configService, options) {
|
|
659
|
-
this.config = config;
|
|
660
|
-
this.logger = logger;
|
|
661
|
-
this.stateStore = stateStore;
|
|
662
|
-
this.configService = configService;
|
|
663
|
-
this.host = options.host;
|
|
664
|
-
this.port = options.port;
|
|
665
|
-
this.adminToken = options.adminToken;
|
|
666
|
-
this.adminTokens = buildAdminTokenMap(options.adminTokens ?? []);
|
|
667
|
-
this.adminIpAllowlist = normalizeAllowlist(options.adminIpAllowlist ?? []);
|
|
668
|
-
this.adminAllowedOrigins = normalizeOriginAllowlist(options.adminAllowedOrigins ?? []);
|
|
669
|
-
this.cwd = options.cwd ?? process.cwd();
|
|
670
|
-
this.checkCodex = options.checkCodex ?? defaultCheckCodex;
|
|
671
|
-
this.checkMatrix = options.checkMatrix ?? defaultCheckMatrix;
|
|
672
|
-
this.restartServices = options.restartServices ?? defaultRestartServices;
|
|
673
|
-
}
|
|
674
|
-
getAddress() {
|
|
675
|
-
return this.address;
|
|
676
|
-
}
|
|
677
|
-
async start() {
|
|
678
|
-
if (this.server) {
|
|
679
|
-
return;
|
|
680
|
-
}
|
|
681
|
-
this.server = import_node_http.default.createServer((req, res) => {
|
|
682
|
-
void this.handleRequest(req, res);
|
|
683
|
-
});
|
|
684
|
-
await new Promise((resolve, reject) => {
|
|
685
|
-
if (!this.server) {
|
|
686
|
-
reject(new Error("admin server is not initialized"));
|
|
687
|
-
return;
|
|
688
|
-
}
|
|
689
|
-
this.server.once("error", reject);
|
|
690
|
-
this.server.listen(this.port, this.host, () => {
|
|
691
|
-
this.server?.removeListener("error", reject);
|
|
692
|
-
const address = this.server?.address();
|
|
693
|
-
if (!address || typeof address === "string") {
|
|
694
|
-
reject(new Error("failed to resolve admin server address"));
|
|
695
|
-
return;
|
|
696
|
-
}
|
|
697
|
-
this.address = {
|
|
698
|
-
host: address.address,
|
|
699
|
-
port: address.port
|
|
700
|
-
};
|
|
701
|
-
resolve();
|
|
702
|
-
});
|
|
703
|
-
});
|
|
704
|
-
}
|
|
705
|
-
async stop() {
|
|
706
|
-
if (!this.server) {
|
|
707
|
-
return;
|
|
708
|
-
}
|
|
709
|
-
const server = this.server;
|
|
710
|
-
this.server = null;
|
|
711
|
-
this.address = null;
|
|
712
|
-
await new Promise((resolve, reject) => {
|
|
713
|
-
server.close((error) => {
|
|
714
|
-
if (error) {
|
|
715
|
-
reject(error);
|
|
716
|
-
return;
|
|
717
|
-
}
|
|
718
|
-
resolve();
|
|
719
|
-
});
|
|
720
|
-
});
|
|
721
|
-
}
|
|
722
|
-
async handleRequest(req, res) {
|
|
723
|
-
try {
|
|
724
|
-
const url = new URL(req.url ?? "/", "http://localhost");
|
|
725
|
-
this.setSecurityHeaders(res);
|
|
726
|
-
const corsDecision = this.resolveCors(req);
|
|
727
|
-
this.setCorsHeaders(res, corsDecision);
|
|
728
|
-
if (!this.isClientAllowed(req)) {
|
|
729
|
-
this.sendJson(res, 403, {
|
|
730
|
-
ok: false,
|
|
731
|
-
error: "Forbidden by ADMIN_IP_ALLOWLIST."
|
|
732
|
-
});
|
|
733
|
-
return;
|
|
734
|
-
}
|
|
735
|
-
if (url.pathname.startsWith("/api/admin/") && corsDecision.origin && !corsDecision.allowed) {
|
|
736
|
-
this.sendJson(res, 403, {
|
|
737
|
-
ok: false,
|
|
738
|
-
error: "Forbidden by ADMIN_ALLOWED_ORIGINS."
|
|
739
|
-
});
|
|
740
|
-
return;
|
|
741
|
-
}
|
|
742
|
-
if (req.method === "OPTIONS") {
|
|
743
|
-
if (corsDecision.origin && !corsDecision.allowed) {
|
|
744
|
-
this.sendJson(res, 403, {
|
|
745
|
-
ok: false,
|
|
746
|
-
error: "Forbidden by ADMIN_ALLOWED_ORIGINS."
|
|
747
|
-
});
|
|
748
|
-
return;
|
|
749
|
-
}
|
|
750
|
-
res.writeHead(204);
|
|
751
|
-
res.end();
|
|
752
|
-
return;
|
|
753
|
-
}
|
|
754
|
-
if (req.method === "GET" && isUiPath(url.pathname)) {
|
|
755
|
-
this.sendHtml(res, renderAdminConsoleHtml());
|
|
756
|
-
return;
|
|
757
|
-
}
|
|
758
|
-
const requiredRole = requiredAdminRoleForRequest(req.method, url.pathname);
|
|
759
|
-
const authIdentity = requiredRole ? this.resolveAdminIdentity(req) : null;
|
|
760
|
-
if (requiredRole && !authIdentity) {
|
|
761
|
-
this.sendJson(res, 401, {
|
|
762
|
-
ok: false,
|
|
763
|
-
error: "Unauthorized. Provide Authorization: Bearer <ADMIN_TOKEN> (or token from ADMIN_TOKENS_JSON)."
|
|
764
|
-
});
|
|
765
|
-
return;
|
|
766
|
-
}
|
|
767
|
-
if (requiredRole && authIdentity && !hasRequiredAdminRole(authIdentity.role, requiredRole)) {
|
|
768
|
-
this.sendJson(res, 403, {
|
|
769
|
-
ok: false,
|
|
770
|
-
error: "Forbidden. This endpoint requires admin write permission."
|
|
771
|
-
});
|
|
772
|
-
return;
|
|
773
|
-
}
|
|
774
|
-
if (req.method === "GET" && url.pathname === "/api/admin/auth/status") {
|
|
775
|
-
this.sendJson(res, 200, {
|
|
776
|
-
ok: true,
|
|
777
|
-
data: {
|
|
778
|
-
authenticated: Boolean(authIdentity),
|
|
779
|
-
role: authIdentity?.role ?? null,
|
|
780
|
-
source: authIdentity?.source ?? "none",
|
|
781
|
-
actor: resolveIdentityActor(authIdentity),
|
|
782
|
-
canWrite: authIdentity ? hasRequiredAdminRole(authIdentity.role, "admin") : false
|
|
783
|
-
}
|
|
784
|
-
});
|
|
785
|
-
return;
|
|
786
|
-
}
|
|
787
|
-
if (req.method === "GET" && url.pathname === "/api/admin/config/global") {
|
|
788
|
-
this.sendJson(res, 200, {
|
|
789
|
-
ok: true,
|
|
790
|
-
data: buildGlobalConfigSnapshot(this.config),
|
|
791
|
-
effective: "next_start_for_env_changes"
|
|
792
|
-
});
|
|
793
|
-
return;
|
|
794
|
-
}
|
|
795
|
-
if (req.method === "PUT" && url.pathname === "/api/admin/config/global") {
|
|
796
|
-
const body = await readJsonBody(req);
|
|
797
|
-
const actor = resolveAuditActor(req, authIdentity);
|
|
798
|
-
const result = this.updateGlobalConfig(body, actor);
|
|
799
|
-
this.sendJson(res, 200, {
|
|
800
|
-
ok: true,
|
|
801
|
-
...result
|
|
802
|
-
});
|
|
803
|
-
return;
|
|
804
|
-
}
|
|
805
|
-
if (req.method === "GET" && url.pathname === "/api/admin/config/rooms") {
|
|
806
|
-
this.sendJson(res, 200, {
|
|
807
|
-
ok: true,
|
|
808
|
-
data: this.configService.listRoomSettings()
|
|
809
|
-
});
|
|
810
|
-
return;
|
|
811
|
-
}
|
|
812
|
-
const roomMatch = /^\/api\/admin\/config\/rooms\/(.+)$/.exec(url.pathname);
|
|
813
|
-
if (roomMatch) {
|
|
814
|
-
const roomId = decodeURIComponent(roomMatch[1]);
|
|
815
|
-
if (req.method === "GET") {
|
|
816
|
-
const room = this.configService.getRoomSettings(roomId);
|
|
817
|
-
if (!room) {
|
|
818
|
-
throw new HttpError(404, `room settings not found for ${roomId}`);
|
|
819
|
-
}
|
|
820
|
-
this.sendJson(res, 200, { ok: true, data: room });
|
|
821
|
-
return;
|
|
822
|
-
}
|
|
823
|
-
if (req.method === "PUT") {
|
|
824
|
-
const body = await readJsonBody(req);
|
|
825
|
-
const actor = resolveAuditActor(req, authIdentity);
|
|
826
|
-
const room = this.updateRoomConfig(roomId, body, actor);
|
|
827
|
-
this.sendJson(res, 200, { ok: true, data: room });
|
|
828
|
-
return;
|
|
829
|
-
}
|
|
830
|
-
if (req.method === "DELETE") {
|
|
831
|
-
const actor = resolveAuditActor(req, authIdentity);
|
|
832
|
-
this.configService.deleteRoomSettings(roomId, actor);
|
|
833
|
-
this.sendJson(res, 200, { ok: true, roomId });
|
|
834
|
-
return;
|
|
835
|
-
}
|
|
836
|
-
}
|
|
837
|
-
if (req.method === "GET" && url.pathname === "/api/admin/audit") {
|
|
838
|
-
const limit = normalizePositiveInt(url.searchParams.get("limit"), 20, 1, 200);
|
|
839
|
-
this.sendJson(res, 200, {
|
|
840
|
-
ok: true,
|
|
841
|
-
data: this.stateStore.listConfigRevisions(limit).map((entry) => formatAuditEntry(entry))
|
|
842
|
-
});
|
|
843
|
-
return;
|
|
844
|
-
}
|
|
845
|
-
if (req.method === "GET" && url.pathname === "/api/admin/health") {
|
|
846
|
-
const [codex, matrix] = await Promise.all([
|
|
847
|
-
this.checkCodex(this.config.codexBin),
|
|
848
|
-
this.checkMatrix(this.config.matrixHomeserver, this.config.doctorHttpTimeoutMs)
|
|
849
|
-
]);
|
|
850
|
-
this.sendJson(res, 200, {
|
|
851
|
-
ok: codex.ok && matrix.ok,
|
|
852
|
-
codex,
|
|
853
|
-
matrix,
|
|
854
|
-
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
855
|
-
});
|
|
856
|
-
return;
|
|
857
|
-
}
|
|
858
|
-
if (req.method === "POST" && url.pathname === "/api/admin/service/restart") {
|
|
859
|
-
const body = asObject(await readJsonBody(req), "service restart payload");
|
|
860
|
-
const restartAdmin = normalizeBoolean(body.withAdmin, false);
|
|
861
|
-
const actor = resolveAuditActor(req, authIdentity);
|
|
862
|
-
try {
|
|
863
|
-
const result = await this.restartServices(restartAdmin);
|
|
864
|
-
this.stateStore.appendConfigRevision(
|
|
865
|
-
actor,
|
|
866
|
-
restartAdmin ? "restart services (main + admin)" : "restart service (main)",
|
|
867
|
-
JSON.stringify({
|
|
868
|
-
type: "service_restart",
|
|
869
|
-
restartAdmin,
|
|
870
|
-
restarted: result.restarted
|
|
871
|
-
})
|
|
872
|
-
);
|
|
873
|
-
this.sendJson(res, 200, {
|
|
874
|
-
ok: true,
|
|
875
|
-
restarted: result.restarted
|
|
876
|
-
});
|
|
877
|
-
return;
|
|
878
|
-
} catch (error) {
|
|
879
|
-
throw new HttpError(
|
|
880
|
-
500,
|
|
881
|
-
`Service restart failed: ${formatError(error)}. Install services via "codeharbor service install --with-admin" to auto-configure restart permissions, or run CLI command manually with sudo.`
|
|
882
|
-
);
|
|
883
|
-
}
|
|
884
|
-
}
|
|
885
|
-
this.sendJson(res, 404, {
|
|
886
|
-
ok: false,
|
|
887
|
-
error: `Not found: ${req.method ?? "GET"} ${url.pathname}`
|
|
888
|
-
});
|
|
889
|
-
} catch (error) {
|
|
890
|
-
if (error instanceof HttpError) {
|
|
891
|
-
this.sendJson(res, error.statusCode, {
|
|
892
|
-
ok: false,
|
|
893
|
-
error: error.message
|
|
894
|
-
});
|
|
895
|
-
return;
|
|
896
|
-
}
|
|
897
|
-
this.logger.error("Admin API request failed", error);
|
|
898
|
-
this.sendJson(res, 500, {
|
|
899
|
-
ok: false,
|
|
900
|
-
error: formatError(error)
|
|
901
|
-
});
|
|
902
|
-
}
|
|
903
|
-
}
|
|
904
|
-
updateGlobalConfig(rawBody, actor) {
|
|
905
|
-
const body = asObject(rawBody, "global config payload");
|
|
906
|
-
const envUpdates = {};
|
|
907
|
-
const updatedKeys = [];
|
|
908
|
-
if ("matrixCommandPrefix" in body) {
|
|
909
|
-
const value = String(body.matrixCommandPrefix ?? "");
|
|
910
|
-
this.config.matrixCommandPrefix = value;
|
|
911
|
-
envUpdates.MATRIX_COMMAND_PREFIX = value;
|
|
912
|
-
updatedKeys.push("matrixCommandPrefix");
|
|
913
|
-
}
|
|
914
|
-
if ("codexWorkdir" in body) {
|
|
915
|
-
const workdir = import_node_path5.default.resolve(String(body.codexWorkdir ?? "").trim());
|
|
916
|
-
ensureDirectory(workdir, "codexWorkdir");
|
|
917
|
-
this.config.codexWorkdir = workdir;
|
|
918
|
-
envUpdates.CODEX_WORKDIR = workdir;
|
|
919
|
-
updatedKeys.push("codexWorkdir");
|
|
920
|
-
}
|
|
921
|
-
if ("rateLimiter" in body) {
|
|
922
|
-
const limiter = asObject(body.rateLimiter, "rateLimiter");
|
|
923
|
-
if ("windowMs" in limiter) {
|
|
924
|
-
const value = normalizePositiveInt(limiter.windowMs, this.config.rateLimiter.windowMs, 1, Number.MAX_SAFE_INTEGER);
|
|
925
|
-
this.config.rateLimiter.windowMs = value;
|
|
926
|
-
envUpdates.RATE_LIMIT_WINDOW_SECONDS = String(Math.max(1, Math.round(value / 1e3)));
|
|
927
|
-
updatedKeys.push("rateLimiter.windowMs");
|
|
928
|
-
}
|
|
929
|
-
if ("maxRequestsPerUser" in limiter) {
|
|
930
|
-
const value = normalizeNonNegativeInt(limiter.maxRequestsPerUser, this.config.rateLimiter.maxRequestsPerUser);
|
|
931
|
-
this.config.rateLimiter.maxRequestsPerUser = value;
|
|
932
|
-
envUpdates.RATE_LIMIT_MAX_REQUESTS_PER_USER = String(value);
|
|
933
|
-
updatedKeys.push("rateLimiter.maxRequestsPerUser");
|
|
934
|
-
}
|
|
935
|
-
if ("maxRequestsPerRoom" in limiter) {
|
|
936
|
-
const value = normalizeNonNegativeInt(limiter.maxRequestsPerRoom, this.config.rateLimiter.maxRequestsPerRoom);
|
|
937
|
-
this.config.rateLimiter.maxRequestsPerRoom = value;
|
|
938
|
-
envUpdates.RATE_LIMIT_MAX_REQUESTS_PER_ROOM = String(value);
|
|
939
|
-
updatedKeys.push("rateLimiter.maxRequestsPerRoom");
|
|
940
|
-
}
|
|
941
|
-
if ("maxConcurrentGlobal" in limiter) {
|
|
942
|
-
const value = normalizeNonNegativeInt(limiter.maxConcurrentGlobal, this.config.rateLimiter.maxConcurrentGlobal);
|
|
943
|
-
this.config.rateLimiter.maxConcurrentGlobal = value;
|
|
944
|
-
envUpdates.RATE_LIMIT_MAX_CONCURRENT_GLOBAL = String(value);
|
|
945
|
-
updatedKeys.push("rateLimiter.maxConcurrentGlobal");
|
|
946
|
-
}
|
|
947
|
-
if ("maxConcurrentPerUser" in limiter) {
|
|
948
|
-
const value = normalizeNonNegativeInt(limiter.maxConcurrentPerUser, this.config.rateLimiter.maxConcurrentPerUser);
|
|
949
|
-
this.config.rateLimiter.maxConcurrentPerUser = value;
|
|
950
|
-
envUpdates.RATE_LIMIT_MAX_CONCURRENT_PER_USER = String(value);
|
|
951
|
-
updatedKeys.push("rateLimiter.maxConcurrentPerUser");
|
|
952
|
-
}
|
|
953
|
-
if ("maxConcurrentPerRoom" in limiter) {
|
|
954
|
-
const value = normalizeNonNegativeInt(limiter.maxConcurrentPerRoom, this.config.rateLimiter.maxConcurrentPerRoom);
|
|
955
|
-
this.config.rateLimiter.maxConcurrentPerRoom = value;
|
|
956
|
-
envUpdates.RATE_LIMIT_MAX_CONCURRENT_PER_ROOM = String(value);
|
|
957
|
-
updatedKeys.push("rateLimiter.maxConcurrentPerRoom");
|
|
958
|
-
}
|
|
959
|
-
}
|
|
960
|
-
if ("defaultGroupTriggerPolicy" in body) {
|
|
961
|
-
const policy = asObject(body.defaultGroupTriggerPolicy, "defaultGroupTriggerPolicy");
|
|
962
|
-
if ("allowMention" in policy) {
|
|
963
|
-
const value = normalizeBoolean(policy.allowMention, this.config.defaultGroupTriggerPolicy.allowMention);
|
|
964
|
-
this.config.defaultGroupTriggerPolicy.allowMention = value;
|
|
965
|
-
envUpdates.GROUP_TRIGGER_ALLOW_MENTION = String(value);
|
|
966
|
-
updatedKeys.push("defaultGroupTriggerPolicy.allowMention");
|
|
967
|
-
}
|
|
968
|
-
if ("allowReply" in policy) {
|
|
969
|
-
const value = normalizeBoolean(policy.allowReply, this.config.defaultGroupTriggerPolicy.allowReply);
|
|
970
|
-
this.config.defaultGroupTriggerPolicy.allowReply = value;
|
|
971
|
-
envUpdates.GROUP_TRIGGER_ALLOW_REPLY = String(value);
|
|
972
|
-
updatedKeys.push("defaultGroupTriggerPolicy.allowReply");
|
|
973
|
-
}
|
|
974
|
-
if ("allowActiveWindow" in policy) {
|
|
975
|
-
const value = normalizeBoolean(policy.allowActiveWindow, this.config.defaultGroupTriggerPolicy.allowActiveWindow);
|
|
976
|
-
this.config.defaultGroupTriggerPolicy.allowActiveWindow = value;
|
|
977
|
-
envUpdates.GROUP_TRIGGER_ALLOW_ACTIVE_WINDOW = String(value);
|
|
978
|
-
updatedKeys.push("defaultGroupTriggerPolicy.allowActiveWindow");
|
|
979
|
-
}
|
|
980
|
-
if ("allowPrefix" in policy) {
|
|
981
|
-
const value = normalizeBoolean(policy.allowPrefix, this.config.defaultGroupTriggerPolicy.allowPrefix);
|
|
982
|
-
this.config.defaultGroupTriggerPolicy.allowPrefix = value;
|
|
983
|
-
envUpdates.GROUP_TRIGGER_ALLOW_PREFIX = String(value);
|
|
984
|
-
updatedKeys.push("defaultGroupTriggerPolicy.allowPrefix");
|
|
985
|
-
}
|
|
986
|
-
}
|
|
987
|
-
if ("matrixProgressUpdates" in body) {
|
|
988
|
-
const value = normalizeBoolean(body.matrixProgressUpdates, this.config.matrixProgressUpdates);
|
|
989
|
-
this.config.matrixProgressUpdates = value;
|
|
990
|
-
envUpdates.MATRIX_PROGRESS_UPDATES = String(value);
|
|
991
|
-
updatedKeys.push("matrixProgressUpdates");
|
|
992
|
-
}
|
|
993
|
-
if ("matrixProgressMinIntervalMs" in body) {
|
|
994
|
-
const value = normalizePositiveInt(
|
|
995
|
-
body.matrixProgressMinIntervalMs,
|
|
996
|
-
this.config.matrixProgressMinIntervalMs,
|
|
997
|
-
1,
|
|
998
|
-
Number.MAX_SAFE_INTEGER
|
|
999
|
-
);
|
|
1000
|
-
this.config.matrixProgressMinIntervalMs = value;
|
|
1001
|
-
envUpdates.MATRIX_PROGRESS_MIN_INTERVAL_MS = String(value);
|
|
1002
|
-
updatedKeys.push("matrixProgressMinIntervalMs");
|
|
1003
|
-
}
|
|
1004
|
-
if ("matrixTypingTimeoutMs" in body) {
|
|
1005
|
-
const value = normalizePositiveInt(
|
|
1006
|
-
body.matrixTypingTimeoutMs,
|
|
1007
|
-
this.config.matrixTypingTimeoutMs,
|
|
1008
|
-
1,
|
|
1009
|
-
Number.MAX_SAFE_INTEGER
|
|
1010
|
-
);
|
|
1011
|
-
this.config.matrixTypingTimeoutMs = value;
|
|
1012
|
-
envUpdates.MATRIX_TYPING_TIMEOUT_MS = String(value);
|
|
1013
|
-
updatedKeys.push("matrixTypingTimeoutMs");
|
|
1014
|
-
}
|
|
1015
|
-
if ("sessionActiveWindowMinutes" in body) {
|
|
1016
|
-
const value = normalizePositiveInt(
|
|
1017
|
-
body.sessionActiveWindowMinutes,
|
|
1018
|
-
this.config.sessionActiveWindowMinutes,
|
|
1019
|
-
1,
|
|
1020
|
-
Number.MAX_SAFE_INTEGER
|
|
1021
|
-
);
|
|
1022
|
-
this.config.sessionActiveWindowMinutes = value;
|
|
1023
|
-
envUpdates.SESSION_ACTIVE_WINDOW_MINUTES = String(value);
|
|
1024
|
-
updatedKeys.push("sessionActiveWindowMinutes");
|
|
1025
|
-
}
|
|
1026
|
-
if ("groupDirectModeEnabled" in body) {
|
|
1027
|
-
const value = normalizeBoolean(body.groupDirectModeEnabled, this.config.groupDirectModeEnabled);
|
|
1028
|
-
this.config.groupDirectModeEnabled = value;
|
|
1029
|
-
envUpdates.GROUP_DIRECT_MODE_ENABLED = String(value);
|
|
1030
|
-
updatedKeys.push("groupDirectModeEnabled");
|
|
1031
|
-
}
|
|
1032
|
-
if ("cliCompat" in body) {
|
|
1033
|
-
const compat = asObject(body.cliCompat, "cliCompat");
|
|
1034
|
-
if ("enabled" in compat) {
|
|
1035
|
-
const value = normalizeBoolean(compat.enabled, this.config.cliCompat.enabled);
|
|
1036
|
-
this.config.cliCompat.enabled = value;
|
|
1037
|
-
envUpdates.CLI_COMPAT_MODE = String(value);
|
|
1038
|
-
updatedKeys.push("cliCompat.enabled");
|
|
1039
|
-
}
|
|
1040
|
-
if ("passThroughEvents" in compat) {
|
|
1041
|
-
const value = normalizeBoolean(compat.passThroughEvents, this.config.cliCompat.passThroughEvents);
|
|
1042
|
-
this.config.cliCompat.passThroughEvents = value;
|
|
1043
|
-
envUpdates.CLI_COMPAT_PASSTHROUGH_EVENTS = String(value);
|
|
1044
|
-
updatedKeys.push("cliCompat.passThroughEvents");
|
|
1045
|
-
}
|
|
1046
|
-
if ("preserveWhitespace" in compat) {
|
|
1047
|
-
const value = normalizeBoolean(compat.preserveWhitespace, this.config.cliCompat.preserveWhitespace);
|
|
1048
|
-
this.config.cliCompat.preserveWhitespace = value;
|
|
1049
|
-
envUpdates.CLI_COMPAT_PRESERVE_WHITESPACE = String(value);
|
|
1050
|
-
updatedKeys.push("cliCompat.preserveWhitespace");
|
|
1051
|
-
}
|
|
1052
|
-
if ("disableReplyChunkSplit" in compat) {
|
|
1053
|
-
const value = normalizeBoolean(compat.disableReplyChunkSplit, this.config.cliCompat.disableReplyChunkSplit);
|
|
1054
|
-
this.config.cliCompat.disableReplyChunkSplit = value;
|
|
1055
|
-
envUpdates.CLI_COMPAT_DISABLE_REPLY_CHUNK_SPLIT = String(value);
|
|
1056
|
-
updatedKeys.push("cliCompat.disableReplyChunkSplit");
|
|
1057
|
-
}
|
|
1058
|
-
if ("progressThrottleMs" in compat) {
|
|
1059
|
-
const value = normalizeNonNegativeInt(compat.progressThrottleMs, this.config.cliCompat.progressThrottleMs);
|
|
1060
|
-
this.config.cliCompat.progressThrottleMs = value;
|
|
1061
|
-
envUpdates.CLI_COMPAT_PROGRESS_THROTTLE_MS = String(value);
|
|
1062
|
-
updatedKeys.push("cliCompat.progressThrottleMs");
|
|
1063
|
-
}
|
|
1064
|
-
if ("fetchMedia" in compat) {
|
|
1065
|
-
const value = normalizeBoolean(compat.fetchMedia, this.config.cliCompat.fetchMedia);
|
|
1066
|
-
this.config.cliCompat.fetchMedia = value;
|
|
1067
|
-
envUpdates.CLI_COMPAT_FETCH_MEDIA = String(value);
|
|
1068
|
-
updatedKeys.push("cliCompat.fetchMedia");
|
|
1069
|
-
}
|
|
1070
|
-
}
|
|
1071
|
-
if ("agentWorkflow" in body) {
|
|
1072
|
-
const workflow = asObject(body.agentWorkflow, "agentWorkflow");
|
|
1073
|
-
const currentAgentWorkflow = ensureAgentWorkflowConfig(this.config);
|
|
1074
|
-
if ("enabled" in workflow) {
|
|
1075
|
-
const value = normalizeBoolean(workflow.enabled, currentAgentWorkflow.enabled);
|
|
1076
|
-
currentAgentWorkflow.enabled = value;
|
|
1077
|
-
envUpdates.AGENT_WORKFLOW_ENABLED = String(value);
|
|
1078
|
-
updatedKeys.push("agentWorkflow.enabled");
|
|
1079
|
-
}
|
|
1080
|
-
if ("autoRepairMaxRounds" in workflow) {
|
|
1081
|
-
const value = normalizePositiveInt(
|
|
1082
|
-
workflow.autoRepairMaxRounds,
|
|
1083
|
-
currentAgentWorkflow.autoRepairMaxRounds,
|
|
1084
|
-
0,
|
|
1085
|
-
10
|
|
1086
|
-
);
|
|
1087
|
-
currentAgentWorkflow.autoRepairMaxRounds = value;
|
|
1088
|
-
envUpdates.AGENT_WORKFLOW_AUTO_REPAIR_MAX_ROUNDS = String(value);
|
|
1089
|
-
updatedKeys.push("agentWorkflow.autoRepairMaxRounds");
|
|
1090
|
-
}
|
|
1091
|
-
}
|
|
1092
|
-
if (updatedKeys.length === 0) {
|
|
1093
|
-
throw new HttpError(400, "No supported global config fields provided.");
|
|
1094
|
-
}
|
|
1095
|
-
this.persistEnvUpdates(envUpdates);
|
|
1096
|
-
this.stateStore.appendConfigRevision(
|
|
1097
|
-
actor,
|
|
1098
|
-
`update global config: ${updatedKeys.join(", ")}`,
|
|
1099
|
-
JSON.stringify({
|
|
1100
|
-
type: "global_config_update",
|
|
1101
|
-
updates: envUpdates
|
|
1102
|
-
})
|
|
1103
|
-
);
|
|
1104
|
-
return {
|
|
1105
|
-
data: buildGlobalConfigSnapshot(this.config),
|
|
1106
|
-
updatedKeys,
|
|
1107
|
-
restartRequired: true
|
|
1108
|
-
};
|
|
1109
|
-
}
|
|
1110
|
-
updateRoomConfig(roomId, rawBody, actor) {
|
|
1111
|
-
const body = asObject(rawBody, "room config payload");
|
|
1112
|
-
const current = this.configService.getRoomSettings(roomId);
|
|
1113
|
-
return this.configService.updateRoomSettings({
|
|
1114
|
-
roomId,
|
|
1115
|
-
enabled: normalizeBoolean(body.enabled, current?.enabled ?? true),
|
|
1116
|
-
allowMention: normalizeBoolean(body.allowMention, current?.allowMention ?? true),
|
|
1117
|
-
allowReply: normalizeBoolean(body.allowReply, current?.allowReply ?? true),
|
|
1118
|
-
allowActiveWindow: normalizeBoolean(body.allowActiveWindow, current?.allowActiveWindow ?? true),
|
|
1119
|
-
allowPrefix: normalizeBoolean(body.allowPrefix, current?.allowPrefix ?? true),
|
|
1120
|
-
workdir: normalizeString(body.workdir, current?.workdir ?? this.config.codexWorkdir, "workdir"),
|
|
1121
|
-
actor,
|
|
1122
|
-
summary: normalizeOptionalString(body.summary)
|
|
1123
|
-
});
|
|
1124
|
-
}
|
|
1125
|
-
resolveAdminIdentity(req) {
|
|
1126
|
-
if (!this.adminToken && this.adminTokens.size === 0) {
|
|
1127
|
-
return {
|
|
1128
|
-
role: "admin",
|
|
1129
|
-
actor: null,
|
|
1130
|
-
source: "open"
|
|
1131
|
-
};
|
|
1132
|
-
}
|
|
1133
|
-
const token = readAdminToken(req);
|
|
1134
|
-
if (!token) {
|
|
1135
|
-
return null;
|
|
1136
|
-
}
|
|
1137
|
-
if (this.adminToken && token === this.adminToken) {
|
|
1138
|
-
return {
|
|
1139
|
-
role: "admin",
|
|
1140
|
-
actor: null,
|
|
1141
|
-
source: "legacy"
|
|
1142
|
-
};
|
|
1143
|
-
}
|
|
1144
|
-
const mappedIdentity = this.adminTokens.get(token);
|
|
1145
|
-
if (!mappedIdentity) {
|
|
1146
|
-
return null;
|
|
1147
|
-
}
|
|
1148
|
-
return {
|
|
1149
|
-
role: mappedIdentity.role,
|
|
1150
|
-
actor: mappedIdentity.actor,
|
|
1151
|
-
source: "scoped"
|
|
1152
|
-
};
|
|
1153
|
-
}
|
|
1154
|
-
isClientAllowed(req) {
|
|
1155
|
-
if (this.adminIpAllowlist.length === 0) {
|
|
1156
|
-
return true;
|
|
1157
|
-
}
|
|
1158
|
-
const normalizedRemote = normalizeRemoteAddress(req.socket.remoteAddress);
|
|
1159
|
-
if (!normalizedRemote) {
|
|
1160
|
-
return false;
|
|
1161
|
-
}
|
|
1162
|
-
return this.adminIpAllowlist.includes(normalizedRemote);
|
|
1163
|
-
}
|
|
1164
|
-
persistEnvUpdates(updates) {
|
|
1165
|
-
const envPath = import_node_path5.default.resolve(this.cwd, ".env");
|
|
1166
|
-
const examplePath = import_node_path5.default.resolve(this.cwd, ".env.example");
|
|
1167
|
-
const template = import_node_fs4.default.existsSync(envPath) ? import_node_fs4.default.readFileSync(envPath, "utf8") : import_node_fs4.default.existsSync(examplePath) ? import_node_fs4.default.readFileSync(examplePath, "utf8") : "";
|
|
1168
|
-
const next = applyEnvOverrides(template, updates);
|
|
1169
|
-
import_node_fs4.default.writeFileSync(envPath, next, "utf8");
|
|
1170
|
-
}
|
|
1171
|
-
resolveCors(req) {
|
|
1172
|
-
const origin = normalizeOriginHeader(req.headers.origin);
|
|
1173
|
-
if (!origin) {
|
|
1174
|
-
return { origin: null, allowed: true };
|
|
1175
|
-
}
|
|
1176
|
-
if (isSameOriginRequest(req, origin)) {
|
|
1177
|
-
return { origin, allowed: true };
|
|
1178
|
-
}
|
|
1179
|
-
if (this.adminAllowedOrigins.includes("*")) {
|
|
1180
|
-
return { origin, allowed: true };
|
|
1181
|
-
}
|
|
1182
|
-
if (this.adminAllowedOrigins.length === 0) {
|
|
1183
|
-
return { origin, allowed: false };
|
|
1184
|
-
}
|
|
1185
|
-
return {
|
|
1186
|
-
origin,
|
|
1187
|
-
allowed: this.adminAllowedOrigins.includes(origin)
|
|
1188
|
-
};
|
|
1189
|
-
}
|
|
1190
|
-
setCorsHeaders(res, corsDecision) {
|
|
1191
|
-
if (!corsDecision.origin || !corsDecision.allowed) {
|
|
1192
|
-
return;
|
|
1193
|
-
}
|
|
1194
|
-
res.setHeader("Access-Control-Allow-Origin", corsDecision.origin);
|
|
1195
|
-
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Admin-Token, X-Admin-Actor");
|
|
1196
|
-
res.setHeader("Access-Control-Allow-Methods", "GET, PUT, POST, DELETE, OPTIONS");
|
|
1197
|
-
appendVaryHeader(res, "Origin");
|
|
1198
|
-
}
|
|
1199
|
-
setSecurityHeaders(res) {
|
|
1200
|
-
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
1201
|
-
res.setHeader("X-Frame-Options", "DENY");
|
|
1202
|
-
res.setHeader("Referrer-Policy", "no-referrer");
|
|
1203
|
-
res.setHeader("Permissions-Policy", "camera=(), microphone=(), geolocation=()");
|
|
1204
|
-
res.setHeader("Cross-Origin-Resource-Policy", "same-origin");
|
|
1205
|
-
res.setHeader("Cross-Origin-Opener-Policy", "same-origin");
|
|
1206
|
-
res.setHeader(
|
|
1207
|
-
"Content-Security-Policy",
|
|
1208
|
-
"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"
|
|
1209
|
-
);
|
|
1210
|
-
}
|
|
1211
|
-
sendHtml(res, html) {
|
|
1212
|
-
res.statusCode = 200;
|
|
1213
|
-
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
1214
|
-
res.end(html);
|
|
1215
|
-
}
|
|
1216
|
-
sendJson(res, statusCode, payload) {
|
|
1217
|
-
res.statusCode = statusCode;
|
|
1218
|
-
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
|
1219
|
-
res.end(JSON.stringify(payload));
|
|
1220
|
-
}
|
|
1221
|
-
};
|
|
1222
|
-
function buildGlobalConfigSnapshot(config) {
|
|
1223
|
-
return {
|
|
1224
|
-
matrixCommandPrefix: config.matrixCommandPrefix,
|
|
1225
|
-
codexWorkdir: config.codexWorkdir,
|
|
1226
|
-
rateLimiter: { ...config.rateLimiter },
|
|
1227
|
-
groupDirectModeEnabled: config.groupDirectModeEnabled,
|
|
1228
|
-
defaultGroupTriggerPolicy: { ...config.defaultGroupTriggerPolicy },
|
|
1229
|
-
matrixProgressUpdates: config.matrixProgressUpdates,
|
|
1230
|
-
matrixProgressMinIntervalMs: config.matrixProgressMinIntervalMs,
|
|
1231
|
-
matrixTypingTimeoutMs: config.matrixTypingTimeoutMs,
|
|
1232
|
-
sessionActiveWindowMinutes: config.sessionActiveWindowMinutes,
|
|
1233
|
-
cliCompat: { ...config.cliCompat },
|
|
1234
|
-
agentWorkflow: { ...ensureAgentWorkflowConfig(config) }
|
|
1235
|
-
};
|
|
1236
|
-
}
|
|
1237
|
-
function ensureAgentWorkflowConfig(config) {
|
|
1238
|
-
const mutable = config;
|
|
1239
|
-
const existing = mutable.agentWorkflow;
|
|
1240
|
-
if (existing && typeof existing.enabled === "boolean" && Number.isFinite(existing.autoRepairMaxRounds)) {
|
|
1241
|
-
return existing;
|
|
1242
|
-
}
|
|
1243
|
-
const fallback = {
|
|
1244
|
-
enabled: false,
|
|
1245
|
-
autoRepairMaxRounds: 1
|
|
1246
|
-
};
|
|
1247
|
-
mutable.agentWorkflow = fallback;
|
|
1248
|
-
return fallback;
|
|
1249
|
-
}
|
|
1250
|
-
function formatAuditEntry(entry) {
|
|
1251
|
-
return {
|
|
1252
|
-
id: entry.id,
|
|
1253
|
-
actor: entry.actor,
|
|
1254
|
-
summary: entry.summary,
|
|
1255
|
-
payloadJson: entry.payloadJson,
|
|
1256
|
-
payload: parseJsonLoose(entry.payloadJson),
|
|
1257
|
-
createdAt: entry.createdAt,
|
|
1258
|
-
createdAtIso: new Date(entry.createdAt).toISOString()
|
|
1259
|
-
};
|
|
1260
|
-
}
|
|
1261
|
-
function parseJsonLoose(raw) {
|
|
1262
|
-
try {
|
|
1263
|
-
return JSON.parse(raw);
|
|
1264
|
-
} catch {
|
|
1265
|
-
return raw;
|
|
1266
|
-
}
|
|
1267
|
-
}
|
|
1268
|
-
async function defaultRestartServices(restartAdmin) {
|
|
1269
|
-
const outputChunks = [];
|
|
1270
|
-
const output = {
|
|
1271
|
-
write: (chunk) => {
|
|
1272
|
-
outputChunks.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8"));
|
|
1273
|
-
return true;
|
|
1274
|
-
}
|
|
1275
|
-
};
|
|
1276
|
-
restartSystemdServices({
|
|
1277
|
-
restartAdmin,
|
|
1278
|
-
output
|
|
1279
|
-
});
|
|
1280
|
-
return {
|
|
1281
|
-
restarted: restartAdmin ? ["codeharbor", "codeharbor-admin"] : ["codeharbor"]
|
|
1282
|
-
};
|
|
1283
|
-
}
|
|
1284
|
-
function isUiPath(pathname) {
|
|
1285
|
-
return pathname === "/" || pathname === "/index.html" || pathname === "/settings/global" || pathname === "/settings/rooms" || pathname === "/health" || pathname === "/audit";
|
|
1286
|
-
}
|
|
1287
|
-
function normalizeAllowlist(entries) {
|
|
1288
|
-
const output = /* @__PURE__ */ new Set();
|
|
1289
|
-
for (const entry of entries) {
|
|
1290
|
-
const normalized = normalizeRemoteAddress(entry);
|
|
1291
|
-
if (normalized) {
|
|
1292
|
-
output.add(normalized);
|
|
1293
|
-
}
|
|
1294
|
-
}
|
|
1295
|
-
return [...output];
|
|
1296
|
-
}
|
|
1297
|
-
function normalizeOriginAllowlist(entries) {
|
|
1298
|
-
const output = /* @__PURE__ */ new Set();
|
|
1299
|
-
for (const entry of entries) {
|
|
1300
|
-
const normalized = normalizeOrigin(entry);
|
|
1301
|
-
if (normalized) {
|
|
1302
|
-
output.add(normalized);
|
|
1303
|
-
}
|
|
1304
|
-
}
|
|
1305
|
-
return [...output];
|
|
1306
|
-
}
|
|
1307
|
-
function normalizeRemoteAddress(value) {
|
|
1308
|
-
if (!value) {
|
|
1309
|
-
return null;
|
|
1310
|
-
}
|
|
1311
|
-
const trimmed = value.trim().toLowerCase();
|
|
1312
|
-
if (!trimmed) {
|
|
1313
|
-
return null;
|
|
1314
|
-
}
|
|
1315
|
-
const withoutZone = trimmed.includes("%") ? trimmed.slice(0, trimmed.indexOf("%")) : trimmed;
|
|
1316
|
-
if (withoutZone === "::1" || withoutZone === "0:0:0:0:0:0:0:1") {
|
|
1317
|
-
return "127.0.0.1";
|
|
1318
|
-
}
|
|
1319
|
-
if (withoutZone.startsWith("::ffff:")) {
|
|
1320
|
-
return withoutZone.slice("::ffff:".length);
|
|
1321
|
-
}
|
|
1322
|
-
return withoutZone;
|
|
1323
|
-
}
|
|
1324
|
-
function normalizeOriginHeader(value) {
|
|
1325
|
-
if (!value) {
|
|
1326
|
-
return null;
|
|
1327
|
-
}
|
|
1328
|
-
const raw = Array.isArray(value) ? value[0] ?? "" : value;
|
|
1329
|
-
return normalizeOrigin(raw);
|
|
1330
|
-
}
|
|
1331
|
-
function normalizeOrigin(value) {
|
|
1332
|
-
const trimmed = value.trim();
|
|
1333
|
-
if (!trimmed) {
|
|
1334
|
-
return null;
|
|
1335
|
-
}
|
|
1336
|
-
if (trimmed === "*") {
|
|
1337
|
-
return "*";
|
|
1338
|
-
}
|
|
1339
|
-
try {
|
|
1340
|
-
const parsed = new URL(trimmed);
|
|
1341
|
-
return `${parsed.protocol}//${parsed.host}`.toLowerCase();
|
|
1342
|
-
} catch {
|
|
1343
|
-
return null;
|
|
1344
|
-
}
|
|
1345
|
-
}
|
|
1346
|
-
function isSameOriginRequest(req, origin) {
|
|
1347
|
-
const host = normalizeHeaderValue(req.headers.host);
|
|
1348
|
-
if (!host) {
|
|
1349
|
-
return false;
|
|
1350
|
-
}
|
|
1351
|
-
const forwardedProto = normalizeHeaderValue(req.headers["x-forwarded-proto"]);
|
|
1352
|
-
const protocol = forwardedProto || "http";
|
|
1353
|
-
return origin === `${protocol}://${host}`.toLowerCase();
|
|
1354
|
-
}
|
|
1355
|
-
function appendVaryHeader(res, headerName) {
|
|
1356
|
-
const current = res.getHeader("Vary");
|
|
1357
|
-
const existing = typeof current === "string" ? current.split(",").map((v) => v.trim()).filter(Boolean) : [];
|
|
1358
|
-
if (!existing.includes(headerName)) {
|
|
1359
|
-
existing.push(headerName);
|
|
1360
|
-
}
|
|
1361
|
-
res.setHeader("Vary", existing.join(", "));
|
|
1362
|
-
}
|
|
1363
|
-
function renderAdminConsoleHtml() {
|
|
1364
|
-
return ADMIN_CONSOLE_HTML;
|
|
1365
|
-
}
|
|
1366
|
-
async function readJsonBody(req) {
|
|
1367
|
-
const chunks = [];
|
|
1368
|
-
for await (const chunk of req) {
|
|
1369
|
-
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
1370
|
-
}
|
|
1371
|
-
if (chunks.length === 0) {
|
|
1372
|
-
return {};
|
|
1373
|
-
}
|
|
1374
|
-
const raw = Buffer.concat(chunks).toString("utf8").trim();
|
|
1375
|
-
if (!raw) {
|
|
1376
|
-
return {};
|
|
1377
|
-
}
|
|
1378
|
-
try {
|
|
1379
|
-
return JSON.parse(raw);
|
|
1380
|
-
} catch {
|
|
1381
|
-
throw new HttpError(400, "Request body must be valid JSON.");
|
|
1382
|
-
}
|
|
1383
|
-
}
|
|
1384
|
-
function asObject(value, name) {
|
|
1385
|
-
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
1386
|
-
throw new HttpError(400, `${name} must be a JSON object.`);
|
|
1387
|
-
}
|
|
1388
|
-
return value;
|
|
1389
|
-
}
|
|
1390
|
-
function normalizeBoolean(value, fallback) {
|
|
1391
|
-
if (value === void 0) {
|
|
1392
|
-
return fallback;
|
|
1393
|
-
}
|
|
1394
|
-
if (typeof value !== "boolean") {
|
|
1395
|
-
throw new HttpError(400, "Expected boolean value.");
|
|
1396
|
-
}
|
|
1397
|
-
return value;
|
|
1398
|
-
}
|
|
1399
|
-
function normalizeString(value, fallback, fieldName) {
|
|
1400
|
-
if (value === void 0) {
|
|
1401
|
-
return fallback;
|
|
1402
|
-
}
|
|
1403
|
-
if (typeof value !== "string") {
|
|
1404
|
-
throw new HttpError(400, `Expected string value for ${fieldName}.`);
|
|
1405
|
-
}
|
|
1406
|
-
return value.trim();
|
|
1407
|
-
}
|
|
1408
|
-
function normalizeOptionalString(value) {
|
|
1409
|
-
if (value === void 0 || value === null) {
|
|
1410
|
-
return null;
|
|
1411
|
-
}
|
|
1412
|
-
if (typeof value !== "string") {
|
|
1413
|
-
throw new HttpError(400, "Expected string value.");
|
|
1414
|
-
}
|
|
1415
|
-
const trimmed = value.trim();
|
|
1416
|
-
return trimmed || null;
|
|
1417
|
-
}
|
|
1418
|
-
function normalizePositiveInt(value, fallback, min, max) {
|
|
1419
|
-
if (value === void 0 || value === null) {
|
|
1420
|
-
return fallback;
|
|
1421
|
-
}
|
|
1422
|
-
const parsed = Number.parseInt(String(value), 10);
|
|
1423
|
-
if (!Number.isFinite(parsed) || parsed < min || parsed > max) {
|
|
1424
|
-
throw new HttpError(400, `Expected integer in range [${min}, ${max}].`);
|
|
1425
|
-
}
|
|
1426
|
-
return parsed;
|
|
1427
|
-
}
|
|
1428
|
-
function normalizeNonNegativeInt(value, fallback) {
|
|
1429
|
-
return normalizePositiveInt(value, fallback, 0, Number.MAX_SAFE_INTEGER);
|
|
1430
|
-
}
|
|
1431
|
-
function ensureDirectory(targetPath, fieldName) {
|
|
1432
|
-
if (!import_node_fs4.default.existsSync(targetPath) || !import_node_fs4.default.statSync(targetPath).isDirectory()) {
|
|
1433
|
-
throw new HttpError(400, `${fieldName} must be an existing directory: ${targetPath}`);
|
|
1434
|
-
}
|
|
1435
|
-
}
|
|
1436
|
-
function normalizeHeaderValue(value) {
|
|
1437
|
-
if (!value) {
|
|
1438
|
-
return "";
|
|
1439
|
-
}
|
|
1440
|
-
if (Array.isArray(value)) {
|
|
1441
|
-
return value[0]?.trim() ?? "";
|
|
1442
|
-
}
|
|
1443
|
-
return value.trim();
|
|
1444
|
-
}
|
|
1445
|
-
function readAdminToken(req) {
|
|
1446
|
-
const authorization = normalizeHeaderValue(req.headers.authorization);
|
|
1447
|
-
if (authorization) {
|
|
1448
|
-
const match = /^bearer\s+(.+)$/i.exec(authorization);
|
|
1449
|
-
const token = match?.[1]?.trim() ?? "";
|
|
1450
|
-
if (token) {
|
|
1451
|
-
return token;
|
|
1452
|
-
}
|
|
1453
|
-
}
|
|
1454
|
-
const fromHeader = normalizeHeaderValue(req.headers["x-admin-token"]);
|
|
1455
|
-
return fromHeader || null;
|
|
1456
|
-
}
|
|
1457
|
-
function resolveIdentityActor(identity) {
|
|
1458
|
-
if (!identity || identity.source !== "scoped") {
|
|
1459
|
-
return null;
|
|
1460
|
-
}
|
|
1461
|
-
if (identity.actor) {
|
|
1462
|
-
return identity.actor;
|
|
1463
|
-
}
|
|
1464
|
-
return identity.role === "admin" ? "admin-token" : "viewer-token";
|
|
1465
|
-
}
|
|
1466
|
-
function resolveAuditActor(req, identity) {
|
|
1467
|
-
const scopedActor = resolveIdentityActor(identity);
|
|
1468
|
-
if (scopedActor) {
|
|
1469
|
-
return scopedActor;
|
|
1470
|
-
}
|
|
1471
|
-
const actor = normalizeHeaderValue(req.headers["x-admin-actor"]);
|
|
1472
|
-
return actor || null;
|
|
1473
|
-
}
|
|
1474
|
-
function requiredAdminRoleForRequest(method, pathname) {
|
|
1475
|
-
if (!pathname.startsWith("/api/admin/")) {
|
|
1476
|
-
return null;
|
|
1477
|
-
}
|
|
1478
|
-
const normalizedMethod = (method ?? "GET").toUpperCase();
|
|
1479
|
-
if (normalizedMethod === "GET" || normalizedMethod === "HEAD") {
|
|
1480
|
-
return "viewer";
|
|
1481
|
-
}
|
|
1482
|
-
return "admin";
|
|
1483
|
-
}
|
|
1484
|
-
function hasRequiredAdminRole(role, requiredRole) {
|
|
1485
|
-
if (requiredRole === "viewer") {
|
|
1486
|
-
return role === "viewer" || role === "admin";
|
|
1487
|
-
}
|
|
1488
|
-
return role === "admin";
|
|
1489
|
-
}
|
|
1490
|
-
function buildAdminTokenMap(tokens) {
|
|
1491
|
-
const mapped = /* @__PURE__ */ new Map();
|
|
1492
|
-
for (const token of tokens) {
|
|
1493
|
-
mapped.set(token.token, {
|
|
1494
|
-
role: token.role,
|
|
1495
|
-
actor: token.actor
|
|
1496
|
-
});
|
|
1497
|
-
}
|
|
1498
|
-
return mapped;
|
|
1499
|
-
}
|
|
1500
|
-
function formatError(error) {
|
|
1501
|
-
if (error instanceof Error) {
|
|
1502
|
-
return error.message;
|
|
1503
|
-
}
|
|
1504
|
-
return String(error);
|
|
1505
|
-
}
|
|
1506
|
-
var ADMIN_CONSOLE_HTML = `<!doctype html>
|
|
1507
|
-
<html lang="en">
|
|
1508
|
-
<head>
|
|
1509
|
-
<meta charset="utf-8" />
|
|
1510
|
-
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
1511
|
-
<title>CodeHarbor Admin Console</title>
|
|
1512
|
-
<style>
|
|
1513
|
-
:root {
|
|
1514
|
-
--bg-start: #0f172a;
|
|
1515
|
-
--bg-end: #1e293b;
|
|
1516
|
-
--panel: #0b1224cc;
|
|
1517
|
-
--panel-border: #334155;
|
|
1518
|
-
--text: #e2e8f0;
|
|
1519
|
-
--muted: #94a3b8;
|
|
1520
|
-
--accent: #22d3ee;
|
|
1521
|
-
--accent-strong: #06b6d4;
|
|
1522
|
-
--danger: #f43f5e;
|
|
1523
|
-
--ok: #10b981;
|
|
1524
|
-
--warn: #f59e0b;
|
|
1525
|
-
}
|
|
1526
|
-
* {
|
|
1527
|
-
box-sizing: border-box;
|
|
1528
|
-
}
|
|
1529
|
-
body {
|
|
1530
|
-
margin: 0;
|
|
1531
|
-
font-family: "IBM Plex Sans", "Segoe UI", "Helvetica Neue", sans-serif;
|
|
1532
|
-
color: var(--text);
|
|
1533
|
-
background: radial-gradient(1200px 600px at 20% -10%, #1d4ed8 0%, transparent 55%),
|
|
1534
|
-
radial-gradient(1000px 500px at 100% 0%, #0f766e 0%, transparent 55%),
|
|
1535
|
-
linear-gradient(135deg, var(--bg-start), var(--bg-end));
|
|
1536
|
-
min-height: 100vh;
|
|
1537
|
-
}
|
|
1538
|
-
.shell {
|
|
1539
|
-
max-width: 1100px;
|
|
1540
|
-
margin: 0 auto;
|
|
1541
|
-
padding: 20px 16px 40px;
|
|
1542
|
-
}
|
|
1543
|
-
.header {
|
|
1544
|
-
background: var(--panel);
|
|
1545
|
-
border: 1px solid var(--panel-border);
|
|
1546
|
-
border-radius: 16px;
|
|
1547
|
-
padding: 16px;
|
|
1548
|
-
backdrop-filter: blur(8px);
|
|
1549
|
-
}
|
|
1550
|
-
.title {
|
|
1551
|
-
margin: 0 0 8px;
|
|
1552
|
-
font-size: 24px;
|
|
1553
|
-
letter-spacing: 0.2px;
|
|
1554
|
-
}
|
|
1555
|
-
.subtitle {
|
|
1556
|
-
margin: 0 0 14px;
|
|
1557
|
-
color: var(--muted);
|
|
1558
|
-
font-size: 14px;
|
|
1559
|
-
}
|
|
1560
|
-
.tabs {
|
|
1561
|
-
display: flex;
|
|
1562
|
-
gap: 8px;
|
|
1563
|
-
flex-wrap: wrap;
|
|
1564
|
-
margin-bottom: 12px;
|
|
1565
|
-
}
|
|
1566
|
-
.tab {
|
|
1567
|
-
color: var(--text);
|
|
1568
|
-
text-decoration: none;
|
|
1569
|
-
border: 1px solid var(--panel-border);
|
|
1570
|
-
border-radius: 999px;
|
|
1571
|
-
padding: 6px 12px;
|
|
1572
|
-
font-size: 13px;
|
|
1573
|
-
}
|
|
1574
|
-
.tab.active {
|
|
1575
|
-
border-color: var(--accent);
|
|
1576
|
-
background: #155e7555;
|
|
1577
|
-
}
|
|
1578
|
-
.auth-row {
|
|
1579
|
-
display: grid;
|
|
1580
|
-
grid-template-columns: repeat(2, minmax(220px, 1fr)) auto auto;
|
|
1581
|
-
gap: 8px;
|
|
1582
|
-
align-items: end;
|
|
1583
|
-
}
|
|
1584
|
-
.field {
|
|
1585
|
-
display: flex;
|
|
1586
|
-
flex-direction: column;
|
|
1587
|
-
gap: 4px;
|
|
1588
|
-
}
|
|
1589
|
-
.field-label {
|
|
1590
|
-
font-size: 12px;
|
|
1591
|
-
color: var(--muted);
|
|
1592
|
-
}
|
|
1593
|
-
input,
|
|
1594
|
-
button,
|
|
1595
|
-
textarea {
|
|
1596
|
-
font: inherit;
|
|
1597
|
-
}
|
|
1598
|
-
input[type="text"],
|
|
1599
|
-
input[type="password"],
|
|
1600
|
-
input[type="number"] {
|
|
1601
|
-
border: 1px solid var(--panel-border);
|
|
1602
|
-
background: #0f172acc;
|
|
1603
|
-
color: var(--text);
|
|
1604
|
-
border-radius: 10px;
|
|
1605
|
-
padding: 8px 10px;
|
|
1606
|
-
}
|
|
1607
|
-
button {
|
|
1608
|
-
border: 1px solid var(--accent);
|
|
1609
|
-
background: #164e63;
|
|
1610
|
-
color: #ecfeff;
|
|
1611
|
-
border-radius: 10px;
|
|
1612
|
-
padding: 8px 12px;
|
|
1613
|
-
cursor: pointer;
|
|
1614
|
-
}
|
|
1615
|
-
button.secondary {
|
|
1616
|
-
border-color: var(--panel-border);
|
|
1617
|
-
background: #1e293b;
|
|
1618
|
-
color: var(--text);
|
|
1619
|
-
}
|
|
1620
|
-
button.danger {
|
|
1621
|
-
border-color: var(--danger);
|
|
1622
|
-
background: #881337;
|
|
1623
|
-
}
|
|
1624
|
-
.notice {
|
|
1625
|
-
margin: 12px 0 0;
|
|
1626
|
-
border-radius: 10px;
|
|
1627
|
-
padding: 8px 10px;
|
|
1628
|
-
font-size: 13px;
|
|
1629
|
-
border: 1px solid #334155;
|
|
1630
|
-
color: var(--muted);
|
|
1631
|
-
}
|
|
1632
|
-
.notice.ok {
|
|
1633
|
-
border-color: #065f46;
|
|
1634
|
-
color: #d1fae5;
|
|
1635
|
-
background: #064e3b88;
|
|
1636
|
-
}
|
|
1637
|
-
.notice.error {
|
|
1638
|
-
border-color: #881337;
|
|
1639
|
-
color: #ffe4e6;
|
|
1640
|
-
background: #4c051988;
|
|
1641
|
-
}
|
|
1642
|
-
.notice.warn {
|
|
1643
|
-
border-color: #92400e;
|
|
1644
|
-
color: #fef3c7;
|
|
1645
|
-
background: #78350f88;
|
|
1646
|
-
}
|
|
1647
|
-
.panel {
|
|
1648
|
-
margin-top: 14px;
|
|
1649
|
-
background: var(--panel);
|
|
1650
|
-
border: 1px solid var(--panel-border);
|
|
1651
|
-
border-radius: 16px;
|
|
1652
|
-
padding: 16px;
|
|
1653
|
-
}
|
|
1654
|
-
.panel[hidden] {
|
|
1655
|
-
display: none;
|
|
1656
|
-
}
|
|
1657
|
-
.panel-title {
|
|
1658
|
-
margin: 0 0 12px;
|
|
1659
|
-
}
|
|
1660
|
-
.grid {
|
|
1661
|
-
display: grid;
|
|
1662
|
-
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
1663
|
-
gap: 10px;
|
|
1664
|
-
}
|
|
1665
|
-
.full {
|
|
1666
|
-
grid-column: 1 / -1;
|
|
1667
|
-
}
|
|
1668
|
-
.checkbox {
|
|
1669
|
-
display: flex;
|
|
1670
|
-
gap: 8px;
|
|
1671
|
-
align-items: center;
|
|
1672
|
-
font-size: 14px;
|
|
1673
|
-
}
|
|
1674
|
-
.actions {
|
|
1675
|
-
display: flex;
|
|
1676
|
-
gap: 8px;
|
|
1677
|
-
flex-wrap: wrap;
|
|
1678
|
-
margin-top: 12px;
|
|
1679
|
-
}
|
|
1680
|
-
.table-wrap {
|
|
1681
|
-
overflow-x: auto;
|
|
1682
|
-
border: 1px solid #334155;
|
|
1683
|
-
border-radius: 12px;
|
|
1684
|
-
margin-top: 12px;
|
|
1685
|
-
}
|
|
1686
|
-
table {
|
|
1687
|
-
width: 100%;
|
|
1688
|
-
border-collapse: collapse;
|
|
1689
|
-
min-width: 720px;
|
|
1690
|
-
}
|
|
1691
|
-
th,
|
|
1692
|
-
td {
|
|
1693
|
-
border-bottom: 1px solid #334155;
|
|
1694
|
-
text-align: left;
|
|
1695
|
-
padding: 8px;
|
|
1696
|
-
font-size: 12px;
|
|
1697
|
-
vertical-align: top;
|
|
1698
|
-
}
|
|
1699
|
-
th {
|
|
1700
|
-
color: var(--muted);
|
|
1701
|
-
}
|
|
1702
|
-
pre {
|
|
1703
|
-
margin: 0;
|
|
1704
|
-
white-space: pre-wrap;
|
|
1705
|
-
word-break: break-word;
|
|
1706
|
-
font-size: 11px;
|
|
1707
|
-
color: #cbd5e1;
|
|
1708
|
-
}
|
|
1709
|
-
.muted {
|
|
1710
|
-
color: var(--muted);
|
|
1711
|
-
font-size: 12px;
|
|
1712
|
-
}
|
|
1713
|
-
@media (max-width: 900px) {
|
|
1714
|
-
.auth-row {
|
|
1715
|
-
grid-template-columns: 1fr;
|
|
1716
|
-
}
|
|
1717
|
-
.grid {
|
|
1718
|
-
grid-template-columns: 1fr;
|
|
1719
|
-
}
|
|
1720
|
-
}
|
|
1721
|
-
</style>
|
|
1722
|
-
</head>
|
|
1723
|
-
<body>
|
|
1724
|
-
<main class="shell">
|
|
1725
|
-
<section class="header">
|
|
1726
|
-
<h1 class="title">CodeHarbor Admin Console</h1>
|
|
1727
|
-
<p class="subtitle">Manage global settings, room policies, health checks, and config audit records.</p>
|
|
1728
|
-
<nav class="tabs">
|
|
1729
|
-
<a class="tab" data-page="settings-global" href="#/settings/global">Global</a>
|
|
1730
|
-
<a class="tab" data-page="settings-rooms" href="#/settings/rooms">Rooms</a>
|
|
1731
|
-
<a class="tab" data-page="health" href="#/health">Health</a>
|
|
1732
|
-
<a class="tab" data-page="audit" href="#/audit">Audit</a>
|
|
1733
|
-
</nav>
|
|
1734
|
-
<div class="auth-row">
|
|
1735
|
-
<label class="field">
|
|
1736
|
-
<span class="field-label">Admin Token (optional)</span>
|
|
1737
|
-
<input id="auth-token" type="password" placeholder="ADMIN_TOKEN" />
|
|
1738
|
-
</label>
|
|
1739
|
-
<label class="field">
|
|
1740
|
-
<span class="field-label">Actor (for audit logs)</span>
|
|
1741
|
-
<input id="auth-actor" type="text" placeholder="your-name" />
|
|
1742
|
-
</label>
|
|
1743
|
-
<button id="auth-save-btn" type="button" class="secondary">Save Auth</button>
|
|
1744
|
-
<button id="auth-clear-btn" type="button" class="secondary">Clear Auth</button>
|
|
1745
|
-
</div>
|
|
1746
|
-
<div id="notice" class="notice">Ready.</div>
|
|
1747
|
-
<p id="auth-role" class="muted">Permission: unknown</p>
|
|
1748
|
-
</section>
|
|
76
|
+
.shell {
|
|
77
|
+
max-width: 1100px;
|
|
78
|
+
margin: 0 auto;
|
|
79
|
+
padding: 20px 16px 40px;
|
|
80
|
+
}
|
|
81
|
+
.header {
|
|
82
|
+
background: var(--panel);
|
|
83
|
+
border: 1px solid var(--panel-border);
|
|
84
|
+
border-radius: 16px;
|
|
85
|
+
padding: 16px;
|
|
86
|
+
backdrop-filter: blur(8px);
|
|
87
|
+
}
|
|
88
|
+
.title {
|
|
89
|
+
margin: 0 0 8px;
|
|
90
|
+
font-size: 24px;
|
|
91
|
+
letter-spacing: 0.2px;
|
|
92
|
+
}
|
|
93
|
+
.subtitle {
|
|
94
|
+
margin: 0 0 14px;
|
|
95
|
+
color: var(--muted);
|
|
96
|
+
font-size: 14px;
|
|
97
|
+
}
|
|
98
|
+
.tabs {
|
|
99
|
+
display: flex;
|
|
100
|
+
gap: 8px;
|
|
101
|
+
flex-wrap: wrap;
|
|
102
|
+
margin-bottom: 12px;
|
|
103
|
+
}
|
|
104
|
+
.tab {
|
|
105
|
+
color: var(--text);
|
|
106
|
+
text-decoration: none;
|
|
107
|
+
border: 1px solid var(--panel-border);
|
|
108
|
+
border-radius: 999px;
|
|
109
|
+
padding: 6px 12px;
|
|
110
|
+
font-size: 13px;
|
|
111
|
+
}
|
|
112
|
+
.tab.active {
|
|
113
|
+
border-color: var(--accent);
|
|
114
|
+
background: #155e7555;
|
|
115
|
+
}
|
|
116
|
+
.auth-row {
|
|
117
|
+
display: grid;
|
|
118
|
+
grid-template-columns: repeat(2, minmax(220px, 1fr)) auto auto;
|
|
119
|
+
gap: 8px;
|
|
120
|
+
align-items: end;
|
|
121
|
+
}
|
|
122
|
+
.field {
|
|
123
|
+
display: flex;
|
|
124
|
+
flex-direction: column;
|
|
125
|
+
gap: 4px;
|
|
126
|
+
}
|
|
127
|
+
.field-label {
|
|
128
|
+
font-size: 12px;
|
|
129
|
+
color: var(--muted);
|
|
130
|
+
}
|
|
131
|
+
input,
|
|
132
|
+
button,
|
|
133
|
+
textarea {
|
|
134
|
+
font: inherit;
|
|
135
|
+
}
|
|
136
|
+
input[type="text"],
|
|
137
|
+
input[type="password"],
|
|
138
|
+
input[type="number"] {
|
|
139
|
+
border: 1px solid var(--panel-border);
|
|
140
|
+
background: #0f172acc;
|
|
141
|
+
color: var(--text);
|
|
142
|
+
border-radius: 10px;
|
|
143
|
+
padding: 8px 10px;
|
|
144
|
+
}
|
|
145
|
+
button {
|
|
146
|
+
border: 1px solid var(--accent);
|
|
147
|
+
background: #164e63;
|
|
148
|
+
color: #ecfeff;
|
|
149
|
+
border-radius: 10px;
|
|
150
|
+
padding: 8px 12px;
|
|
151
|
+
cursor: pointer;
|
|
152
|
+
}
|
|
153
|
+
button.secondary {
|
|
154
|
+
border-color: var(--panel-border);
|
|
155
|
+
background: #1e293b;
|
|
156
|
+
color: var(--text);
|
|
157
|
+
}
|
|
158
|
+
button.danger {
|
|
159
|
+
border-color: var(--danger);
|
|
160
|
+
background: #881337;
|
|
161
|
+
}
|
|
162
|
+
.notice {
|
|
163
|
+
margin: 12px 0 0;
|
|
164
|
+
border-radius: 10px;
|
|
165
|
+
padding: 8px 10px;
|
|
166
|
+
font-size: 13px;
|
|
167
|
+
border: 1px solid #334155;
|
|
168
|
+
color: var(--muted);
|
|
169
|
+
}
|
|
170
|
+
.notice.ok {
|
|
171
|
+
border-color: #065f46;
|
|
172
|
+
color: #d1fae5;
|
|
173
|
+
background: #064e3b88;
|
|
174
|
+
}
|
|
175
|
+
.notice.error {
|
|
176
|
+
border-color: #881337;
|
|
177
|
+
color: #ffe4e6;
|
|
178
|
+
background: #4c051988;
|
|
179
|
+
}
|
|
180
|
+
.notice.warn {
|
|
181
|
+
border-color: #92400e;
|
|
182
|
+
color: #fef3c7;
|
|
183
|
+
background: #78350f88;
|
|
184
|
+
}
|
|
185
|
+
.panel {
|
|
186
|
+
margin-top: 14px;
|
|
187
|
+
background: var(--panel);
|
|
188
|
+
border: 1px solid var(--panel-border);
|
|
189
|
+
border-radius: 16px;
|
|
190
|
+
padding: 16px;
|
|
191
|
+
}
|
|
192
|
+
.panel[hidden] {
|
|
193
|
+
display: none;
|
|
194
|
+
}
|
|
195
|
+
.panel-title {
|
|
196
|
+
margin: 0 0 12px;
|
|
197
|
+
}
|
|
198
|
+
.grid {
|
|
199
|
+
display: grid;
|
|
200
|
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
201
|
+
gap: 10px;
|
|
202
|
+
}
|
|
203
|
+
.full {
|
|
204
|
+
grid-column: 1 / -1;
|
|
205
|
+
}
|
|
206
|
+
.checkbox {
|
|
207
|
+
display: flex;
|
|
208
|
+
gap: 8px;
|
|
209
|
+
align-items: center;
|
|
210
|
+
font-size: 14px;
|
|
211
|
+
}
|
|
212
|
+
.actions {
|
|
213
|
+
display: flex;
|
|
214
|
+
gap: 8px;
|
|
215
|
+
flex-wrap: wrap;
|
|
216
|
+
margin-top: 12px;
|
|
217
|
+
}
|
|
218
|
+
.table-wrap {
|
|
219
|
+
overflow-x: auto;
|
|
220
|
+
border: 1px solid #334155;
|
|
221
|
+
border-radius: 12px;
|
|
222
|
+
margin-top: 12px;
|
|
223
|
+
}
|
|
224
|
+
table {
|
|
225
|
+
width: 100%;
|
|
226
|
+
border-collapse: collapse;
|
|
227
|
+
min-width: 720px;
|
|
228
|
+
}
|
|
229
|
+
th,
|
|
230
|
+
td {
|
|
231
|
+
border-bottom: 1px solid #334155;
|
|
232
|
+
text-align: left;
|
|
233
|
+
padding: 8px;
|
|
234
|
+
font-size: 12px;
|
|
235
|
+
vertical-align: top;
|
|
236
|
+
}
|
|
237
|
+
th {
|
|
238
|
+
color: var(--muted);
|
|
239
|
+
}
|
|
240
|
+
pre {
|
|
241
|
+
margin: 0;
|
|
242
|
+
white-space: pre-wrap;
|
|
243
|
+
word-break: break-word;
|
|
244
|
+
font-size: 11px;
|
|
245
|
+
color: #cbd5e1;
|
|
246
|
+
}
|
|
247
|
+
.muted {
|
|
248
|
+
color: var(--muted);
|
|
249
|
+
font-size: 12px;
|
|
250
|
+
}
|
|
251
|
+
@media (max-width: 900px) {
|
|
252
|
+
.auth-row {
|
|
253
|
+
grid-template-columns: 1fr;
|
|
254
|
+
}
|
|
255
|
+
.grid {
|
|
256
|
+
grid-template-columns: 1fr;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
</style>
|
|
260
|
+
</head>
|
|
261
|
+
<body>
|
|
262
|
+
<main class="shell">
|
|
263
|
+
<section class="header">
|
|
264
|
+
<h1 class="title">CodeHarbor Admin Console</h1>
|
|
265
|
+
<p class="subtitle">Manage global settings, room policies, health checks, and config audit records.</p>
|
|
266
|
+
<nav class="tabs">
|
|
267
|
+
<a class="tab" data-page="settings-global" href="#/settings/global">Global</a>
|
|
268
|
+
<a class="tab" data-page="settings-rooms" href="#/settings/rooms">Rooms</a>
|
|
269
|
+
<a class="tab" data-page="health" href="#/health">Health</a>
|
|
270
|
+
<a class="tab" data-page="audit" href="#/audit">Audit</a>
|
|
271
|
+
</nav>
|
|
272
|
+
<div class="auth-row">
|
|
273
|
+
<label class="field">
|
|
274
|
+
<span class="field-label">Admin Token (optional)</span>
|
|
275
|
+
<input id="auth-token" type="password" placeholder="ADMIN_TOKEN" />
|
|
276
|
+
</label>
|
|
277
|
+
<label class="field">
|
|
278
|
+
<span class="field-label">Actor (for audit logs)</span>
|
|
279
|
+
<input id="auth-actor" type="text" placeholder="your-name" />
|
|
280
|
+
</label>
|
|
281
|
+
<button id="auth-save-btn" type="button" class="secondary">Save Auth</button>
|
|
282
|
+
<button id="auth-clear-btn" type="button" class="secondary">Clear Auth</button>
|
|
283
|
+
</div>
|
|
284
|
+
<div id="notice" class="notice">Ready.</div>
|
|
285
|
+
<p id="auth-role" class="muted">Permission: unknown</p>
|
|
286
|
+
</section>
|
|
1749
287
|
|
|
1750
288
|
<section class="panel" data-view="settings-global">
|
|
1751
289
|
<h2 class="panel-title">Global Config</h2>
|
|
@@ -2257,148 +795,1626 @@ var ADMIN_CONSOLE_HTML = `<!doctype html>
|
|
|
2257
795
|
return;
|
|
2258
796
|
}
|
|
2259
797
|
try {
|
|
2260
|
-
var response = await apiRequest("/api/admin/config/rooms/" + encodeURIComponent(roomId), "GET");
|
|
2261
|
-
fillRoomForm(response.data || {});
|
|
2262
|
-
showNotice("ok", "Room config loaded for " + roomId + ".");
|
|
2263
|
-
} catch (error) {
|
|
2264
|
-
showNotice("error", "Failed to load room config: " + error.message);
|
|
798
|
+
var response = await apiRequest("/api/admin/config/rooms/" + encodeURIComponent(roomId), "GET");
|
|
799
|
+
fillRoomForm(response.data || {});
|
|
800
|
+
showNotice("ok", "Room config loaded for " + roomId + ".");
|
|
801
|
+
} catch (error) {
|
|
802
|
+
showNotice("error", "Failed to load room config: " + error.message);
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
function fillRoomForm(data) {
|
|
807
|
+
document.getElementById("room-enabled").checked = Boolean(data.enabled);
|
|
808
|
+
document.getElementById("room-mention").checked = Boolean(data.allowMention);
|
|
809
|
+
document.getElementById("room-reply").checked = Boolean(data.allowReply);
|
|
810
|
+
document.getElementById("room-window").checked = Boolean(data.allowActiveWindow);
|
|
811
|
+
document.getElementById("room-prefix").checked = Boolean(data.allowPrefix);
|
|
812
|
+
document.getElementById("room-workdir").value = data.workdir || "";
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
async function saveRoom() {
|
|
816
|
+
var roomId = asText("room-id");
|
|
817
|
+
if (!roomId) {
|
|
818
|
+
showNotice("warn", "Room ID is required.");
|
|
819
|
+
return;
|
|
820
|
+
}
|
|
821
|
+
try {
|
|
822
|
+
var body = {
|
|
823
|
+
enabled: asBool("room-enabled"),
|
|
824
|
+
allowMention: asBool("room-mention"),
|
|
825
|
+
allowReply: asBool("room-reply"),
|
|
826
|
+
allowActiveWindow: asBool("room-window"),
|
|
827
|
+
allowPrefix: asBool("room-prefix"),
|
|
828
|
+
workdir: asText("room-workdir"),
|
|
829
|
+
summary: asText("room-summary")
|
|
830
|
+
};
|
|
831
|
+
await apiRequest("/api/admin/config/rooms/" + encodeURIComponent(roomId), "PUT", body);
|
|
832
|
+
showNotice("ok", "Room config saved for " + roomId + ".");
|
|
833
|
+
await refreshRoomList();
|
|
834
|
+
await loadAudit();
|
|
835
|
+
} catch (error) {
|
|
836
|
+
showNotice("error", "Failed to save room config: " + error.message);
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
async function deleteRoom() {
|
|
841
|
+
var roomId = asText("room-id");
|
|
842
|
+
if (!roomId) {
|
|
843
|
+
showNotice("warn", "Room ID is required.");
|
|
844
|
+
return;
|
|
845
|
+
}
|
|
846
|
+
if (!window.confirm("Delete room config for " + roomId + "?")) {
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
849
|
+
try {
|
|
850
|
+
await apiRequest("/api/admin/config/rooms/" + encodeURIComponent(roomId), "DELETE");
|
|
851
|
+
showNotice("ok", "Room config deleted for " + roomId + ".");
|
|
852
|
+
await refreshRoomList();
|
|
853
|
+
await loadAudit();
|
|
854
|
+
} catch (error) {
|
|
855
|
+
showNotice("error", "Failed to delete room config: " + error.message);
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
async function loadHealth() {
|
|
860
|
+
try {
|
|
861
|
+
var response = await apiRequest("/api/admin/health", "GET");
|
|
862
|
+
healthBody.innerHTML = "";
|
|
863
|
+
|
|
864
|
+
var codex = response.codex || {};
|
|
865
|
+
var matrix = response.matrix || {};
|
|
866
|
+
|
|
867
|
+
appendHealthRow("Codex", Boolean(codex.ok), codex.ok ? (codex.version || "ok") : (codex.error || "failed"));
|
|
868
|
+
appendHealthRow(
|
|
869
|
+
"Matrix",
|
|
870
|
+
Boolean(matrix.ok),
|
|
871
|
+
matrix.ok ? "HTTP " + matrix.status + " " + JSON.stringify(matrix.versions || []) : (matrix.error || "failed")
|
|
872
|
+
);
|
|
873
|
+
appendHealthRow("Overall", Boolean(response.ok), response.timestamp || "");
|
|
874
|
+
showNotice("ok", "Health check completed.");
|
|
875
|
+
} catch (error) {
|
|
876
|
+
showNotice("error", "Health check failed: " + error.message);
|
|
877
|
+
renderEmptyRow(healthBody, 3, "Failed to run health check.");
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
function appendHealthRow(component, ok, detail) {
|
|
882
|
+
var row = document.createElement("tr");
|
|
883
|
+
appendCell(row, component);
|
|
884
|
+
appendCell(row, ok ? "OK" : "FAIL");
|
|
885
|
+
appendCell(row, detail);
|
|
886
|
+
healthBody.appendChild(row);
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
async function loadAudit() {
|
|
890
|
+
var limit = asNumber("audit-limit", 30);
|
|
891
|
+
if (limit < 1) {
|
|
892
|
+
limit = 1;
|
|
893
|
+
}
|
|
894
|
+
if (limit > 200) {
|
|
895
|
+
limit = 200;
|
|
896
|
+
}
|
|
897
|
+
try {
|
|
898
|
+
var response = await apiRequest("/api/admin/audit?limit=" + limit, "GET");
|
|
899
|
+
var items = Array.isArray(response.data) ? response.data : [];
|
|
900
|
+
auditBody.innerHTML = "";
|
|
901
|
+
if (items.length === 0) {
|
|
902
|
+
renderEmptyRow(auditBody, 5, "No audit records.");
|
|
903
|
+
return;
|
|
904
|
+
}
|
|
905
|
+
for (var i = 0; i < items.length; i += 1) {
|
|
906
|
+
var item = items[i];
|
|
907
|
+
var row = document.createElement("tr");
|
|
908
|
+
appendCell(row, String(item.id || ""));
|
|
909
|
+
appendCell(row, item.createdAtIso || "");
|
|
910
|
+
appendCell(row, item.actor || "-");
|
|
911
|
+
appendCell(row, item.summary || "");
|
|
912
|
+
var payloadCell = document.createElement("td");
|
|
913
|
+
var payloadNode = document.createElement("pre");
|
|
914
|
+
payloadNode.textContent = formatPayload(item);
|
|
915
|
+
payloadCell.appendChild(payloadNode);
|
|
916
|
+
row.appendChild(payloadCell);
|
|
917
|
+
auditBody.appendChild(row);
|
|
918
|
+
}
|
|
919
|
+
showNotice("ok", "Audit loaded: " + items.length + " record(s).");
|
|
920
|
+
} catch (error) {
|
|
921
|
+
showNotice("error", "Failed to load audit: " + error.message);
|
|
922
|
+
renderEmptyRow(auditBody, 5, "Failed to load audit records.");
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
function formatPayload(item) {
|
|
927
|
+
if (item.payload && typeof item.payload === "object") {
|
|
928
|
+
return JSON.stringify(item.payload, null, 2);
|
|
929
|
+
}
|
|
930
|
+
if (typeof item.payloadJson === "string" && item.payloadJson) {
|
|
931
|
+
return item.payloadJson;
|
|
932
|
+
}
|
|
933
|
+
return "";
|
|
934
|
+
}
|
|
935
|
+
})();
|
|
936
|
+
</script>
|
|
937
|
+
</body>
|
|
938
|
+
</html>
|
|
939
|
+
`;
|
|
940
|
+
|
|
941
|
+
// src/init.ts
|
|
942
|
+
var import_node_fs = __toESM(require("fs"));
|
|
943
|
+
var import_node_path2 = __toESM(require("path"));
|
|
944
|
+
var import_promises = require("readline/promises");
|
|
945
|
+
var import_dotenv = __toESM(require("dotenv"));
|
|
946
|
+
|
|
947
|
+
// src/codex-bin.ts
|
|
948
|
+
var import_node_child_process = require("child_process");
|
|
949
|
+
var import_node_os = __toESM(require("os"));
|
|
950
|
+
var import_node_path = __toESM(require("path"));
|
|
951
|
+
var import_node_util = require("util");
|
|
952
|
+
var execFileAsync = (0, import_node_util.promisify)(import_node_child_process.execFile);
|
|
953
|
+
function buildCodexBinCandidates(configuredBin, env = process.env) {
|
|
954
|
+
const normalized = configuredBin.trim() || "codex";
|
|
955
|
+
const home = env.HOME?.trim() || import_node_os.default.homedir();
|
|
956
|
+
const npmGlobalBin = home ? import_node_path.default.resolve(home, ".npm-global/bin/codex") : "";
|
|
957
|
+
const candidates = [
|
|
958
|
+
normalized,
|
|
959
|
+
"codex",
|
|
960
|
+
npmGlobalBin,
|
|
961
|
+
"/usr/bin/codex",
|
|
962
|
+
"/usr/local/bin/codex",
|
|
963
|
+
"/opt/homebrew/bin/codex"
|
|
964
|
+
];
|
|
965
|
+
const seen = /* @__PURE__ */ new Set();
|
|
966
|
+
const output = [];
|
|
967
|
+
for (const candidate of candidates) {
|
|
968
|
+
const trimmed = candidate.trim();
|
|
969
|
+
if (!trimmed || seen.has(trimmed)) {
|
|
970
|
+
continue;
|
|
971
|
+
}
|
|
972
|
+
seen.add(trimmed);
|
|
973
|
+
output.push(trimmed);
|
|
974
|
+
}
|
|
975
|
+
return output;
|
|
976
|
+
}
|
|
977
|
+
async function findWorkingCodexBin(configuredBin, options = {}) {
|
|
978
|
+
const checkBinary = options.checkBinary ?? defaultCheckBinary;
|
|
979
|
+
const candidates = buildCodexBinCandidates(configuredBin, options.env);
|
|
980
|
+
for (const candidate of candidates) {
|
|
981
|
+
try {
|
|
982
|
+
await checkBinary(candidate);
|
|
983
|
+
return candidate;
|
|
984
|
+
} catch {
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
return null;
|
|
988
|
+
}
|
|
989
|
+
async function defaultCheckBinary(bin) {
|
|
990
|
+
await execFileAsync(bin, ["--version"]);
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
// src/init.ts
|
|
994
|
+
async function runInitCommand(options = {}) {
|
|
995
|
+
const cwd = options.cwd ?? process.cwd();
|
|
996
|
+
const envPath = import_node_path2.default.resolve(cwd, ".env");
|
|
997
|
+
const templatePath = resolveInitTemplatePath(cwd);
|
|
998
|
+
const input = options.input ?? process.stdin;
|
|
999
|
+
const output = options.output ?? process.stdout;
|
|
1000
|
+
const templateContent = import_node_fs.default.readFileSync(templatePath, "utf8");
|
|
1001
|
+
const existingContent = import_node_fs.default.existsSync(envPath) ? import_node_fs.default.readFileSync(envPath, "utf8") : "";
|
|
1002
|
+
const existingValues = existingContent ? import_dotenv.default.parse(existingContent) : {};
|
|
1003
|
+
const rl = (0, import_promises.createInterface)({ input, output });
|
|
1004
|
+
try {
|
|
1005
|
+
if (existingContent && !options.force) {
|
|
1006
|
+
const overwrite = await askYesNo(
|
|
1007
|
+
rl,
|
|
1008
|
+
"Detected existing .env file. Overwrite with guided setup?",
|
|
1009
|
+
false
|
|
1010
|
+
);
|
|
1011
|
+
if (!overwrite) {
|
|
1012
|
+
output.write("Init aborted. Keep existing .env unchanged.\n");
|
|
1013
|
+
return;
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
output.write("CodeHarbor setup wizard\n");
|
|
1017
|
+
output.write(`Target file: ${envPath}
|
|
1018
|
+
`);
|
|
1019
|
+
const detectedCodexBin = await findWorkingCodexBin(existingValues.CODEX_BIN ?? "codex");
|
|
1020
|
+
const questions = [
|
|
1021
|
+
{
|
|
1022
|
+
key: "MATRIX_HOMESERVER",
|
|
1023
|
+
label: "Matrix homeserver URL",
|
|
1024
|
+
required: true,
|
|
1025
|
+
validate: (value) => {
|
|
1026
|
+
try {
|
|
1027
|
+
new URL(value);
|
|
1028
|
+
return null;
|
|
1029
|
+
} catch {
|
|
1030
|
+
return "Please enter a valid URL, for example https://matrix.example.com";
|
|
2265
1031
|
}
|
|
2266
1032
|
}
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
}
|
|
2276
|
-
|
|
2277
|
-
async function saveRoom() {
|
|
2278
|
-
var roomId = asText("room-id");
|
|
2279
|
-
if (!roomId) {
|
|
2280
|
-
showNotice("warn", "Room ID is required.");
|
|
2281
|
-
return;
|
|
2282
|
-
}
|
|
2283
|
-
try {
|
|
2284
|
-
var body = {
|
|
2285
|
-
enabled: asBool("room-enabled"),
|
|
2286
|
-
allowMention: asBool("room-mention"),
|
|
2287
|
-
allowReply: asBool("room-reply"),
|
|
2288
|
-
allowActiveWindow: asBool("room-window"),
|
|
2289
|
-
allowPrefix: asBool("room-prefix"),
|
|
2290
|
-
workdir: asText("room-workdir"),
|
|
2291
|
-
summary: asText("room-summary")
|
|
2292
|
-
};
|
|
2293
|
-
await apiRequest("/api/admin/config/rooms/" + encodeURIComponent(roomId), "PUT", body);
|
|
2294
|
-
showNotice("ok", "Room config saved for " + roomId + ".");
|
|
2295
|
-
await refreshRoomList();
|
|
2296
|
-
await loadAudit();
|
|
2297
|
-
} catch (error) {
|
|
2298
|
-
showNotice("error", "Failed to save room config: " + error.message);
|
|
1033
|
+
},
|
|
1034
|
+
{
|
|
1035
|
+
key: "MATRIX_USER_ID",
|
|
1036
|
+
label: "Matrix bot user id",
|
|
1037
|
+
required: true,
|
|
1038
|
+
validate: (value) => {
|
|
1039
|
+
if (!/^@[^:\s]+:.+/.test(value)) {
|
|
1040
|
+
return "Please enter a Matrix user id like @bot:example.com";
|
|
2299
1041
|
}
|
|
1042
|
+
return null;
|
|
2300
1043
|
}
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
|
|
2317
|
-
|
|
1044
|
+
},
|
|
1045
|
+
{
|
|
1046
|
+
key: "MATRIX_ACCESS_TOKEN",
|
|
1047
|
+
label: "Matrix access token",
|
|
1048
|
+
required: true,
|
|
1049
|
+
hiddenDefault: true
|
|
1050
|
+
},
|
|
1051
|
+
{
|
|
1052
|
+
key: "MATRIX_COMMAND_PREFIX",
|
|
1053
|
+
label: "Group command prefix",
|
|
1054
|
+
fallbackValue: "!code"
|
|
1055
|
+
},
|
|
1056
|
+
{
|
|
1057
|
+
key: "CODEX_BIN",
|
|
1058
|
+
label: "Codex binary",
|
|
1059
|
+
fallbackValue: detectedCodexBin ?? "codex"
|
|
1060
|
+
},
|
|
1061
|
+
{
|
|
1062
|
+
key: "CODEX_WORKDIR",
|
|
1063
|
+
label: "Codex working directory",
|
|
1064
|
+
fallbackValue: cwd,
|
|
1065
|
+
validate: (value) => {
|
|
1066
|
+
const resolved = import_node_path2.default.resolve(cwd, value);
|
|
1067
|
+
if (!import_node_fs.default.existsSync(resolved) || !import_node_fs.default.statSync(resolved).isDirectory()) {
|
|
1068
|
+
return `Directory does not exist: ${resolved}`;
|
|
2318
1069
|
}
|
|
1070
|
+
return null;
|
|
2319
1071
|
}
|
|
1072
|
+
}
|
|
1073
|
+
];
|
|
1074
|
+
const updates = {};
|
|
1075
|
+
for (const question of questions) {
|
|
1076
|
+
const existingValue = (existingValues[question.key] ?? "").trim();
|
|
1077
|
+
const value = await askValue(rl, question, existingValue);
|
|
1078
|
+
updates[question.key] = value;
|
|
1079
|
+
}
|
|
1080
|
+
const mergedContent = applyEnvOverrides(templateContent, updates);
|
|
1081
|
+
import_node_fs.default.writeFileSync(envPath, mergedContent, "utf8");
|
|
1082
|
+
output.write(`Wrote ${envPath}
|
|
1083
|
+
`);
|
|
1084
|
+
output.write("Next steps:\n");
|
|
1085
|
+
output.write("1. codex login\n");
|
|
1086
|
+
output.write("2. codeharbor doctor\n");
|
|
1087
|
+
output.write("3. codeharbor start\n");
|
|
1088
|
+
} finally {
|
|
1089
|
+
rl.close();
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
function resolveInitTemplatePath(cwd) {
|
|
1093
|
+
const candidates = [
|
|
1094
|
+
import_node_path2.default.resolve(cwd, ".env.example"),
|
|
1095
|
+
import_node_path2.default.resolve(__dirname, "..", ".env.example")
|
|
1096
|
+
];
|
|
1097
|
+
for (const candidate of candidates) {
|
|
1098
|
+
if (import_node_fs.default.existsSync(candidate)) {
|
|
1099
|
+
return candidate;
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
throw new Error(`Cannot find template file. Tried: ${candidates.join(", ")}`);
|
|
1103
|
+
}
|
|
1104
|
+
function applyEnvOverrides(template, overrides) {
|
|
1105
|
+
const lines = template.split(/\r?\n/);
|
|
1106
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1107
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
1108
|
+
const line = lines[i];
|
|
1109
|
+
const match = /^([A-Z0-9_]+)=(.*)$/.exec(line.trim());
|
|
1110
|
+
if (!match) {
|
|
1111
|
+
continue;
|
|
1112
|
+
}
|
|
1113
|
+
const key = match[1];
|
|
1114
|
+
if (!(key in overrides)) {
|
|
1115
|
+
continue;
|
|
1116
|
+
}
|
|
1117
|
+
lines[i] = `${key}=${formatEnvValue(overrides[key] ?? "")}`;
|
|
1118
|
+
seen.add(key);
|
|
1119
|
+
}
|
|
1120
|
+
for (const [key, value] of Object.entries(overrides)) {
|
|
1121
|
+
if (seen.has(key)) {
|
|
1122
|
+
continue;
|
|
1123
|
+
}
|
|
1124
|
+
lines.push(`${key}=${formatEnvValue(value)}`);
|
|
1125
|
+
}
|
|
1126
|
+
const content = lines.join("\n");
|
|
1127
|
+
return content.endsWith("\n") ? content : `${content}
|
|
1128
|
+
`;
|
|
1129
|
+
}
|
|
1130
|
+
function formatEnvValue(value) {
|
|
1131
|
+
if (!value) {
|
|
1132
|
+
return "";
|
|
1133
|
+
}
|
|
1134
|
+
if (/^[A-Za-z0-9_./:@+-]+$/.test(value)) {
|
|
1135
|
+
return value;
|
|
1136
|
+
}
|
|
1137
|
+
return JSON.stringify(value);
|
|
1138
|
+
}
|
|
1139
|
+
async function askValue(rl, question, existingValue) {
|
|
1140
|
+
while (true) {
|
|
1141
|
+
const fallback = question.fallbackValue ?? "";
|
|
1142
|
+
const displayDefault = existingValue || fallback;
|
|
1143
|
+
const hint = displayDefault ? question.hiddenDefault ? "[already set]" : `[${displayDefault}]` : "";
|
|
1144
|
+
const answer = (await rl.question(`${question.label} ${hint}: `)).trim();
|
|
1145
|
+
const finalValue = answer || existingValue || fallback;
|
|
1146
|
+
if (question.required && !finalValue) {
|
|
1147
|
+
rl.write("This value is required.\n");
|
|
1148
|
+
continue;
|
|
1149
|
+
}
|
|
1150
|
+
if (question.validate) {
|
|
1151
|
+
const reason = question.validate(finalValue);
|
|
1152
|
+
if (reason) {
|
|
1153
|
+
rl.write(`${reason}
|
|
1154
|
+
`);
|
|
1155
|
+
continue;
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
return finalValue;
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
async function askYesNo(rl, question, defaultValue) {
|
|
1162
|
+
const defaultHint = defaultValue ? "[Y/n]" : "[y/N]";
|
|
1163
|
+
const answer = (await rl.question(`${question} ${defaultHint}: `)).trim().toLowerCase();
|
|
1164
|
+
if (!answer) {
|
|
1165
|
+
return defaultValue;
|
|
1166
|
+
}
|
|
1167
|
+
return answer === "y" || answer === "yes";
|
|
1168
|
+
}
|
|
2320
1169
|
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
|
|
1170
|
+
// src/service-manager.ts
|
|
1171
|
+
var import_node_child_process2 = require("child_process");
|
|
1172
|
+
var import_node_fs3 = __toESM(require("fs"));
|
|
1173
|
+
var import_node_os3 = __toESM(require("os"));
|
|
1174
|
+
var import_node_path4 = __toESM(require("path"));
|
|
2325
1175
|
|
|
2326
|
-
|
|
2327
|
-
|
|
1176
|
+
// src/runtime-home.ts
|
|
1177
|
+
var import_node_fs2 = __toESM(require("fs"));
|
|
1178
|
+
var import_node_os2 = __toESM(require("os"));
|
|
1179
|
+
var import_node_path3 = __toESM(require("path"));
|
|
1180
|
+
var LEGACY_RUNTIME_HOME = "/opt/codeharbor";
|
|
1181
|
+
var USER_RUNTIME_HOME_DIR = ".codeharbor";
|
|
1182
|
+
var DEFAULT_RUNTIME_HOME = import_node_path3.default.resolve(import_node_os2.default.homedir(), USER_RUNTIME_HOME_DIR);
|
|
1183
|
+
var RUNTIME_HOME_ENV_KEY = "CODEHARBOR_HOME";
|
|
1184
|
+
function resolveRuntimeHome(env = process.env) {
|
|
1185
|
+
const configured = env[RUNTIME_HOME_ENV_KEY]?.trim();
|
|
1186
|
+
if (configured) {
|
|
1187
|
+
return import_node_path3.default.resolve(configured);
|
|
1188
|
+
}
|
|
1189
|
+
const legacyEnvPath = import_node_path3.default.resolve(LEGACY_RUNTIME_HOME, ".env");
|
|
1190
|
+
if (import_node_fs2.default.existsSync(legacyEnvPath)) {
|
|
1191
|
+
return LEGACY_RUNTIME_HOME;
|
|
1192
|
+
}
|
|
1193
|
+
return resolveUserRuntimeHome(env);
|
|
1194
|
+
}
|
|
1195
|
+
function resolveUserRuntimeHome(env = process.env) {
|
|
1196
|
+
const home = env.HOME?.trim() || import_node_os2.default.homedir();
|
|
1197
|
+
return import_node_path3.default.resolve(home, USER_RUNTIME_HOME_DIR);
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
// src/service-manager.ts
|
|
1201
|
+
var SYSTEMD_DIR = "/etc/systemd/system";
|
|
1202
|
+
var MAIN_SERVICE_NAME = "codeharbor.service";
|
|
1203
|
+
var ADMIN_SERVICE_NAME = "codeharbor-admin.service";
|
|
1204
|
+
var SUDOERS_DIR = "/etc/sudoers.d";
|
|
1205
|
+
var RESTART_SUDOERS_FILE = "codeharbor-restart";
|
|
1206
|
+
function resolveDefaultRunUser(env = process.env) {
|
|
1207
|
+
const sudoUser = env.SUDO_USER?.trim();
|
|
1208
|
+
if (sudoUser) {
|
|
1209
|
+
return sudoUser;
|
|
1210
|
+
}
|
|
1211
|
+
const user = env.USER?.trim();
|
|
1212
|
+
if (user) {
|
|
1213
|
+
return user;
|
|
1214
|
+
}
|
|
1215
|
+
try {
|
|
1216
|
+
return import_node_os3.default.userInfo().username;
|
|
1217
|
+
} catch {
|
|
1218
|
+
return "root";
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
function resolveRuntimeHomeForUser(runUser, env = process.env, explicitRuntimeHome) {
|
|
1222
|
+
const configuredRuntimeHome = explicitRuntimeHome?.trim() || env[RUNTIME_HOME_ENV_KEY]?.trim();
|
|
1223
|
+
if (configuredRuntimeHome) {
|
|
1224
|
+
return import_node_path4.default.resolve(configuredRuntimeHome);
|
|
1225
|
+
}
|
|
1226
|
+
const userHome = resolveUserHome(runUser);
|
|
1227
|
+
if (userHome) {
|
|
1228
|
+
return import_node_path4.default.resolve(userHome, USER_RUNTIME_HOME_DIR);
|
|
1229
|
+
}
|
|
1230
|
+
return import_node_path4.default.resolve(import_node_os3.default.homedir(), USER_RUNTIME_HOME_DIR);
|
|
1231
|
+
}
|
|
1232
|
+
function buildMainServiceUnit(options) {
|
|
1233
|
+
validateUnitOptions(options);
|
|
1234
|
+
const runtimeHome2 = import_node_path4.default.resolve(options.runtimeHome);
|
|
1235
|
+
return [
|
|
1236
|
+
"[Unit]",
|
|
1237
|
+
"Description=CodeHarbor main service",
|
|
1238
|
+
"After=network-online.target",
|
|
1239
|
+
"Wants=network-online.target",
|
|
1240
|
+
"",
|
|
1241
|
+
"[Service]",
|
|
1242
|
+
"Type=simple",
|
|
1243
|
+
`User=${options.runUser}`,
|
|
1244
|
+
`WorkingDirectory=${runtimeHome2}`,
|
|
1245
|
+
`Environment=CODEHARBOR_HOME=${runtimeHome2}`,
|
|
1246
|
+
`ExecStart=${import_node_path4.default.resolve(options.nodeBinPath)} ${import_node_path4.default.resolve(options.cliScriptPath)} start`,
|
|
1247
|
+
"Restart=always",
|
|
1248
|
+
"RestartSec=3",
|
|
1249
|
+
"NoNewPrivileges=true",
|
|
1250
|
+
"PrivateTmp=true",
|
|
1251
|
+
"ProtectSystem=full",
|
|
1252
|
+
"ProtectHome=false",
|
|
1253
|
+
`ReadWritePaths=${runtimeHome2}`,
|
|
1254
|
+
"",
|
|
1255
|
+
"[Install]",
|
|
1256
|
+
"WantedBy=multi-user.target",
|
|
1257
|
+
""
|
|
1258
|
+
].join("\n");
|
|
1259
|
+
}
|
|
1260
|
+
function buildAdminServiceUnit(options) {
|
|
1261
|
+
validateUnitOptions(options);
|
|
1262
|
+
const runtimeHome2 = import_node_path4.default.resolve(options.runtimeHome);
|
|
1263
|
+
return [
|
|
1264
|
+
"[Unit]",
|
|
1265
|
+
"Description=CodeHarbor admin service",
|
|
1266
|
+
"After=network-online.target",
|
|
1267
|
+
"Wants=network-online.target",
|
|
1268
|
+
"",
|
|
1269
|
+
"[Service]",
|
|
1270
|
+
"Type=simple",
|
|
1271
|
+
`User=${options.runUser}`,
|
|
1272
|
+
`WorkingDirectory=${runtimeHome2}`,
|
|
1273
|
+
`Environment=CODEHARBOR_HOME=${runtimeHome2}`,
|
|
1274
|
+
`ExecStart=${import_node_path4.default.resolve(options.nodeBinPath)} ${import_node_path4.default.resolve(options.cliScriptPath)} admin serve`,
|
|
1275
|
+
"Restart=always",
|
|
1276
|
+
"RestartSec=3",
|
|
1277
|
+
"NoNewPrivileges=true",
|
|
1278
|
+
"PrivateTmp=true",
|
|
1279
|
+
"ProtectSystem=full",
|
|
1280
|
+
"ProtectHome=false",
|
|
1281
|
+
`ReadWritePaths=${runtimeHome2}`,
|
|
1282
|
+
"",
|
|
1283
|
+
"[Install]",
|
|
1284
|
+
"WantedBy=multi-user.target",
|
|
1285
|
+
""
|
|
1286
|
+
].join("\n");
|
|
1287
|
+
}
|
|
1288
|
+
function buildRestartSudoersPolicy(options) {
|
|
1289
|
+
const runUser = options.runUser.trim();
|
|
1290
|
+
const systemctlPath = options.systemctlPath.trim();
|
|
1291
|
+
validateSimpleValue(runUser, "runUser");
|
|
1292
|
+
validateSimpleValue(systemctlPath, "systemctlPath");
|
|
1293
|
+
if (!import_node_path4.default.isAbsolute(systemctlPath)) {
|
|
1294
|
+
throw new Error("systemctlPath must be an absolute path.");
|
|
1295
|
+
}
|
|
1296
|
+
return [
|
|
1297
|
+
"# Managed by CodeHarbor service install; do not edit manually.",
|
|
1298
|
+
`Defaults:${runUser} !requiretty`,
|
|
1299
|
+
`${runUser} ALL=(root) NOPASSWD: ${systemctlPath} restart ${MAIN_SERVICE_NAME}, ${systemctlPath} restart ${ADMIN_SERVICE_NAME}`,
|
|
1300
|
+
""
|
|
1301
|
+
].join("\n");
|
|
1302
|
+
}
|
|
1303
|
+
function installSystemdServices(options) {
|
|
1304
|
+
assertLinuxWithSystemd();
|
|
1305
|
+
assertRootPrivileges();
|
|
1306
|
+
const output = options.output ?? process.stdout;
|
|
1307
|
+
const runUser = options.runUser.trim();
|
|
1308
|
+
const runtimeHome2 = import_node_path4.default.resolve(options.runtimeHome);
|
|
1309
|
+
validateSimpleValue(runUser, "runUser");
|
|
1310
|
+
validateSimpleValue(runtimeHome2, "runtimeHome");
|
|
1311
|
+
validateSimpleValue(options.nodeBinPath, "nodeBinPath");
|
|
1312
|
+
validateSimpleValue(options.cliScriptPath, "cliScriptPath");
|
|
1313
|
+
ensureUserExists(runUser);
|
|
1314
|
+
const runGroup = resolveUserGroup(runUser);
|
|
1315
|
+
import_node_fs3.default.mkdirSync(runtimeHome2, { recursive: true });
|
|
1316
|
+
runCommand("chown", ["-R", `${runUser}:${runGroup}`, runtimeHome2]);
|
|
1317
|
+
const mainPath = import_node_path4.default.join(SYSTEMD_DIR, MAIN_SERVICE_NAME);
|
|
1318
|
+
const adminPath = import_node_path4.default.join(SYSTEMD_DIR, ADMIN_SERVICE_NAME);
|
|
1319
|
+
const restartSudoersPath = import_node_path4.default.join(SUDOERS_DIR, RESTART_SUDOERS_FILE);
|
|
1320
|
+
const unitOptions = {
|
|
1321
|
+
runUser,
|
|
1322
|
+
runtimeHome: runtimeHome2,
|
|
1323
|
+
nodeBinPath: options.nodeBinPath,
|
|
1324
|
+
cliScriptPath: options.cliScriptPath
|
|
1325
|
+
};
|
|
1326
|
+
import_node_fs3.default.writeFileSync(mainPath, buildMainServiceUnit(unitOptions), "utf8");
|
|
1327
|
+
if (options.installAdmin) {
|
|
1328
|
+
import_node_fs3.default.writeFileSync(adminPath, buildAdminServiceUnit(unitOptions), "utf8");
|
|
1329
|
+
if (runUser !== "root") {
|
|
1330
|
+
const policy = buildRestartSudoersPolicy({
|
|
1331
|
+
runUser,
|
|
1332
|
+
systemctlPath: resolveSystemctlPath()
|
|
1333
|
+
});
|
|
1334
|
+
import_node_fs3.default.mkdirSync(SUDOERS_DIR, { recursive: true });
|
|
1335
|
+
import_node_fs3.default.writeFileSync(restartSudoersPath, policy, "utf8");
|
|
1336
|
+
import_node_fs3.default.chmodSync(restartSudoersPath, 288);
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
runSystemctl(["daemon-reload"]);
|
|
1340
|
+
if (options.startNow) {
|
|
1341
|
+
runSystemctl(["enable", "--now", MAIN_SERVICE_NAME]);
|
|
1342
|
+
if (options.installAdmin) {
|
|
1343
|
+
runSystemctl(["enable", "--now", ADMIN_SERVICE_NAME]);
|
|
1344
|
+
}
|
|
1345
|
+
} else {
|
|
1346
|
+
runSystemctl(["enable", MAIN_SERVICE_NAME]);
|
|
1347
|
+
if (options.installAdmin) {
|
|
1348
|
+
runSystemctl(["enable", ADMIN_SERVICE_NAME]);
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
output.write(`Installed systemd unit: ${mainPath}
|
|
1352
|
+
`);
|
|
1353
|
+
if (options.installAdmin) {
|
|
1354
|
+
output.write(`Installed systemd unit: ${adminPath}
|
|
1355
|
+
`);
|
|
1356
|
+
if (runUser !== "root") {
|
|
1357
|
+
output.write(`Installed sudoers policy: ${restartSudoersPath}
|
|
1358
|
+
`);
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
output.write("Done. Check status with: systemctl status codeharbor --no-pager\n");
|
|
1362
|
+
}
|
|
1363
|
+
function uninstallSystemdServices(options) {
|
|
1364
|
+
assertLinuxWithSystemd();
|
|
1365
|
+
assertRootPrivileges();
|
|
1366
|
+
const output = options.output ?? process.stdout;
|
|
1367
|
+
const mainPath = import_node_path4.default.join(SYSTEMD_DIR, MAIN_SERVICE_NAME);
|
|
1368
|
+
const adminPath = import_node_path4.default.join(SYSTEMD_DIR, ADMIN_SERVICE_NAME);
|
|
1369
|
+
const restartSudoersPath = import_node_path4.default.join(SUDOERS_DIR, RESTART_SUDOERS_FILE);
|
|
1370
|
+
stopAndDisableIfPresent(MAIN_SERVICE_NAME);
|
|
1371
|
+
if (import_node_fs3.default.existsSync(mainPath)) {
|
|
1372
|
+
import_node_fs3.default.unlinkSync(mainPath);
|
|
1373
|
+
}
|
|
1374
|
+
if (options.removeAdmin) {
|
|
1375
|
+
stopAndDisableIfPresent(ADMIN_SERVICE_NAME);
|
|
1376
|
+
if (import_node_fs3.default.existsSync(adminPath)) {
|
|
1377
|
+
import_node_fs3.default.unlinkSync(adminPath);
|
|
1378
|
+
}
|
|
1379
|
+
if (import_node_fs3.default.existsSync(restartSudoersPath)) {
|
|
1380
|
+
import_node_fs3.default.unlinkSync(restartSudoersPath);
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
runSystemctl(["daemon-reload"]);
|
|
1384
|
+
runSystemctlIgnoreFailure(["reset-failed"]);
|
|
1385
|
+
output.write(`Removed systemd unit: ${mainPath}
|
|
1386
|
+
`);
|
|
1387
|
+
if (options.removeAdmin) {
|
|
1388
|
+
output.write(`Removed systemd unit: ${adminPath}
|
|
1389
|
+
`);
|
|
1390
|
+
output.write(`Removed sudoers policy: ${restartSudoersPath}
|
|
1391
|
+
`);
|
|
1392
|
+
}
|
|
1393
|
+
output.write("Done.\n");
|
|
1394
|
+
}
|
|
1395
|
+
function restartSystemdServices(options) {
|
|
1396
|
+
assertLinuxWithSystemd();
|
|
1397
|
+
const output = options.output ?? process.stdout;
|
|
1398
|
+
const runWithSudoFallback = options.allowSudoFallback ?? true;
|
|
1399
|
+
const systemctlRunner = hasRootPrivileges() || !runWithSudoFallback ? runSystemctl : runSystemctlWithNonInteractiveSudo;
|
|
1400
|
+
systemctlRunner(["restart", MAIN_SERVICE_NAME]);
|
|
1401
|
+
output.write(`Restarted service: ${MAIN_SERVICE_NAME}
|
|
1402
|
+
`);
|
|
1403
|
+
if (options.restartAdmin) {
|
|
1404
|
+
systemctlRunner(["restart", ADMIN_SERVICE_NAME]);
|
|
1405
|
+
output.write(`Restarted service: ${ADMIN_SERVICE_NAME}
|
|
1406
|
+
`);
|
|
1407
|
+
}
|
|
1408
|
+
output.write("Done.\n");
|
|
1409
|
+
}
|
|
1410
|
+
function resolveUserHome(runUser) {
|
|
1411
|
+
try {
|
|
1412
|
+
const passwdRaw = import_node_fs3.default.readFileSync("/etc/passwd", "utf8");
|
|
1413
|
+
const line = passwdRaw.split(/\r?\n/).find((item) => item.startsWith(`${runUser}:`));
|
|
1414
|
+
if (!line) {
|
|
1415
|
+
return null;
|
|
1416
|
+
}
|
|
1417
|
+
const fields = line.split(":");
|
|
1418
|
+
return fields[5] ? fields[5].trim() : null;
|
|
1419
|
+
} catch {
|
|
1420
|
+
return null;
|
|
1421
|
+
}
|
|
1422
|
+
}
|
|
1423
|
+
function validateUnitOptions(options) {
|
|
1424
|
+
validateSimpleValue(options.runUser, "runUser");
|
|
1425
|
+
validateSimpleValue(options.runtimeHome, "runtimeHome");
|
|
1426
|
+
validateSimpleValue(options.nodeBinPath, "nodeBinPath");
|
|
1427
|
+
validateSimpleValue(options.cliScriptPath, "cliScriptPath");
|
|
1428
|
+
}
|
|
1429
|
+
function validateSimpleValue(value, key) {
|
|
1430
|
+
if (!value.trim()) {
|
|
1431
|
+
throw new Error(`${key} cannot be empty.`);
|
|
1432
|
+
}
|
|
1433
|
+
if (/[\r\n]/.test(value)) {
|
|
1434
|
+
throw new Error(`${key} contains invalid newline characters.`);
|
|
1435
|
+
}
|
|
1436
|
+
}
|
|
1437
|
+
function assertLinuxWithSystemd() {
|
|
1438
|
+
if (process.platform !== "linux") {
|
|
1439
|
+
throw new Error("Systemd service install only supports Linux.");
|
|
1440
|
+
}
|
|
1441
|
+
try {
|
|
1442
|
+
(0, import_node_child_process2.execFileSync)("systemctl", ["--version"], { stdio: "ignore" });
|
|
1443
|
+
} catch {
|
|
1444
|
+
throw new Error("systemctl is required but not found.");
|
|
1445
|
+
}
|
|
1446
|
+
}
|
|
1447
|
+
function assertRootPrivileges() {
|
|
1448
|
+
if (!hasRootPrivileges()) {
|
|
1449
|
+
throw new Error("Root privileges are required. Run with sudo.");
|
|
1450
|
+
}
|
|
1451
|
+
}
|
|
1452
|
+
function hasRootPrivileges() {
|
|
1453
|
+
if (typeof process.getuid !== "function") {
|
|
1454
|
+
return true;
|
|
1455
|
+
}
|
|
1456
|
+
return process.getuid() === 0;
|
|
1457
|
+
}
|
|
1458
|
+
function ensureUserExists(runUser) {
|
|
1459
|
+
runCommand("id", ["-u", runUser]);
|
|
1460
|
+
}
|
|
1461
|
+
function resolveUserGroup(runUser) {
|
|
1462
|
+
return runCommand("id", ["-gn", runUser]).trim();
|
|
1463
|
+
}
|
|
1464
|
+
function runSystemctl(args) {
|
|
1465
|
+
runCommand("systemctl", args);
|
|
1466
|
+
}
|
|
1467
|
+
function runSystemctlWithNonInteractiveSudo(args) {
|
|
1468
|
+
const systemctlPath = resolveSystemctlPath();
|
|
1469
|
+
try {
|
|
1470
|
+
runCommand("sudo", ["-n", systemctlPath, ...args]);
|
|
1471
|
+
} catch (error) {
|
|
1472
|
+
throw new Error(
|
|
1473
|
+
"Root privileges are required. Configure passwordless sudo for the CodeHarbor service user or run the CLI command manually with sudo.",
|
|
1474
|
+
{ cause: error }
|
|
1475
|
+
);
|
|
1476
|
+
}
|
|
1477
|
+
}
|
|
1478
|
+
function stopAndDisableIfPresent(unitName) {
|
|
1479
|
+
runSystemctlIgnoreFailure(["disable", "--now", unitName]);
|
|
1480
|
+
}
|
|
1481
|
+
function runSystemctlIgnoreFailure(args) {
|
|
1482
|
+
try {
|
|
1483
|
+
runCommand("systemctl", args);
|
|
1484
|
+
} catch {
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
function resolveSystemctlPath() {
|
|
1488
|
+
const candidates = [];
|
|
1489
|
+
const pathEntries = (process.env.PATH ?? "").split(import_node_path4.default.delimiter).filter(Boolean);
|
|
1490
|
+
for (const entry of pathEntries) {
|
|
1491
|
+
candidates.push(import_node_path4.default.join(entry, "systemctl"));
|
|
1492
|
+
}
|
|
1493
|
+
candidates.push("/usr/bin/systemctl", "/bin/systemctl", "/usr/local/bin/systemctl");
|
|
1494
|
+
for (const candidate of candidates) {
|
|
1495
|
+
if (import_node_path4.default.isAbsolute(candidate) && import_node_fs3.default.existsSync(candidate)) {
|
|
1496
|
+
return candidate;
|
|
1497
|
+
}
|
|
1498
|
+
}
|
|
1499
|
+
throw new Error("Unable to resolve absolute systemctl path.");
|
|
1500
|
+
}
|
|
1501
|
+
function runCommand(file, args) {
|
|
1502
|
+
try {
|
|
1503
|
+
return (0, import_node_child_process2.execFileSync)(file, args, {
|
|
1504
|
+
encoding: "utf8",
|
|
1505
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
1506
|
+
});
|
|
1507
|
+
} catch (error) {
|
|
1508
|
+
throw new Error(formatCommandError(file, args, error), { cause: error });
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
1511
|
+
function formatCommandError(file, args, error) {
|
|
1512
|
+
const command = `${file} ${args.join(" ")}`.trim();
|
|
1513
|
+
if (error && typeof error === "object") {
|
|
1514
|
+
const maybeError = error;
|
|
1515
|
+
const stderr = bufferToTrimmedString(maybeError.stderr);
|
|
1516
|
+
const stdout = bufferToTrimmedString(maybeError.stdout);
|
|
1517
|
+
const details = stderr || stdout || maybeError.message || "command failed";
|
|
1518
|
+
return `Command failed: ${command}. ${details}`;
|
|
1519
|
+
}
|
|
1520
|
+
return `Command failed: ${command}. ${String(error)}`;
|
|
1521
|
+
}
|
|
1522
|
+
function bufferToTrimmedString(value) {
|
|
1523
|
+
if (!value) {
|
|
1524
|
+
return "";
|
|
1525
|
+
}
|
|
1526
|
+
const text = typeof value === "string" ? value : value.toString("utf8");
|
|
1527
|
+
return text.trim();
|
|
1528
|
+
}
|
|
2328
1529
|
|
|
2329
|
-
|
|
2330
|
-
|
|
2331
|
-
|
|
2332
|
-
|
|
2333
|
-
|
|
2334
|
-
|
|
2335
|
-
|
|
2336
|
-
|
|
2337
|
-
|
|
2338
|
-
|
|
2339
|
-
|
|
2340
|
-
|
|
1530
|
+
// src/admin-server.ts
|
|
1531
|
+
var execFileAsync2 = (0, import_node_util2.promisify)(import_node_child_process3.execFile);
|
|
1532
|
+
var ADMIN_MAX_JSON_BODY_BYTES = 1048576;
|
|
1533
|
+
var HttpError = class extends Error {
|
|
1534
|
+
statusCode;
|
|
1535
|
+
constructor(statusCode, message) {
|
|
1536
|
+
super(message);
|
|
1537
|
+
this.statusCode = statusCode;
|
|
1538
|
+
}
|
|
1539
|
+
};
|
|
1540
|
+
var AdminServer = class {
|
|
1541
|
+
config;
|
|
1542
|
+
logger;
|
|
1543
|
+
stateStore;
|
|
1544
|
+
configService;
|
|
1545
|
+
host;
|
|
1546
|
+
port;
|
|
1547
|
+
adminToken;
|
|
1548
|
+
adminTokens;
|
|
1549
|
+
adminIpAllowlist;
|
|
1550
|
+
adminAllowedOrigins;
|
|
1551
|
+
cwd;
|
|
1552
|
+
checkCodex;
|
|
1553
|
+
checkMatrix;
|
|
1554
|
+
restartServices;
|
|
1555
|
+
server = null;
|
|
1556
|
+
address = null;
|
|
1557
|
+
constructor(config, logger, stateStore, configService, options) {
|
|
1558
|
+
this.config = config;
|
|
1559
|
+
this.logger = logger;
|
|
1560
|
+
this.stateStore = stateStore;
|
|
1561
|
+
this.configService = configService;
|
|
1562
|
+
this.host = options.host;
|
|
1563
|
+
this.port = options.port;
|
|
1564
|
+
this.adminToken = options.adminToken;
|
|
1565
|
+
this.adminTokens = buildAdminTokenMap(options.adminTokens ?? []);
|
|
1566
|
+
this.adminIpAllowlist = normalizeAllowlist(options.adminIpAllowlist ?? []);
|
|
1567
|
+
this.adminAllowedOrigins = normalizeOriginAllowlist(options.adminAllowedOrigins ?? []);
|
|
1568
|
+
this.cwd = options.cwd ?? process.cwd();
|
|
1569
|
+
this.checkCodex = options.checkCodex ?? defaultCheckCodex;
|
|
1570
|
+
this.checkMatrix = options.checkMatrix ?? defaultCheckMatrix;
|
|
1571
|
+
this.restartServices = options.restartServices ?? defaultRestartServices;
|
|
1572
|
+
}
|
|
1573
|
+
getAddress() {
|
|
1574
|
+
return this.address;
|
|
1575
|
+
}
|
|
1576
|
+
async start() {
|
|
1577
|
+
if (this.server) {
|
|
1578
|
+
return;
|
|
1579
|
+
}
|
|
1580
|
+
this.server = import_node_http.default.createServer((req, res) => {
|
|
1581
|
+
void this.handleRequest(req, res);
|
|
1582
|
+
});
|
|
1583
|
+
await new Promise((resolve, reject) => {
|
|
1584
|
+
if (!this.server) {
|
|
1585
|
+
reject(new Error("admin server is not initialized"));
|
|
1586
|
+
return;
|
|
1587
|
+
}
|
|
1588
|
+
this.server.once("error", reject);
|
|
1589
|
+
this.server.listen(this.port, this.host, () => {
|
|
1590
|
+
this.server?.removeListener("error", reject);
|
|
1591
|
+
const address = this.server?.address();
|
|
1592
|
+
if (!address || typeof address === "string") {
|
|
1593
|
+
reject(new Error("failed to resolve admin server address"));
|
|
1594
|
+
return;
|
|
2341
1595
|
}
|
|
2342
|
-
|
|
2343
|
-
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
|
|
2348
|
-
|
|
1596
|
+
this.address = {
|
|
1597
|
+
host: address.address,
|
|
1598
|
+
port: address.port
|
|
1599
|
+
};
|
|
1600
|
+
resolve();
|
|
1601
|
+
});
|
|
1602
|
+
});
|
|
1603
|
+
}
|
|
1604
|
+
async stop() {
|
|
1605
|
+
if (!this.server) {
|
|
1606
|
+
return;
|
|
1607
|
+
}
|
|
1608
|
+
const server = this.server;
|
|
1609
|
+
this.server = null;
|
|
1610
|
+
this.address = null;
|
|
1611
|
+
await new Promise((resolve, reject) => {
|
|
1612
|
+
server.close((error) => {
|
|
1613
|
+
if (error) {
|
|
1614
|
+
reject(error);
|
|
1615
|
+
return;
|
|
2349
1616
|
}
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
|
|
2353
|
-
|
|
2354
|
-
|
|
2355
|
-
|
|
2356
|
-
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
|
|
2361
|
-
|
|
2362
|
-
|
|
2363
|
-
|
|
2364
|
-
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
|
|
2381
|
-
showNotice("ok", "Audit loaded: " + items.length + " record(s).");
|
|
2382
|
-
} catch (error) {
|
|
2383
|
-
showNotice("error", "Failed to load audit: " + error.message);
|
|
2384
|
-
renderEmptyRow(auditBody, 5, "Failed to load audit records.");
|
|
2385
|
-
}
|
|
1617
|
+
resolve();
|
|
1618
|
+
});
|
|
1619
|
+
});
|
|
1620
|
+
}
|
|
1621
|
+
async handleRequest(req, res) {
|
|
1622
|
+
try {
|
|
1623
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
1624
|
+
this.setSecurityHeaders(res);
|
|
1625
|
+
const corsDecision = this.resolveCors(req);
|
|
1626
|
+
this.setCorsHeaders(res, corsDecision);
|
|
1627
|
+
if (!this.isClientAllowed(req)) {
|
|
1628
|
+
this.sendJson(res, 403, {
|
|
1629
|
+
ok: false,
|
|
1630
|
+
error: "Forbidden by ADMIN_IP_ALLOWLIST."
|
|
1631
|
+
});
|
|
1632
|
+
return;
|
|
1633
|
+
}
|
|
1634
|
+
if (url.pathname.startsWith("/api/admin/") && corsDecision.origin && !corsDecision.allowed) {
|
|
1635
|
+
this.sendJson(res, 403, {
|
|
1636
|
+
ok: false,
|
|
1637
|
+
error: "Forbidden by ADMIN_ALLOWED_ORIGINS."
|
|
1638
|
+
});
|
|
1639
|
+
return;
|
|
1640
|
+
}
|
|
1641
|
+
if (req.method === "OPTIONS") {
|
|
1642
|
+
if (corsDecision.origin && !corsDecision.allowed) {
|
|
1643
|
+
this.sendJson(res, 403, {
|
|
1644
|
+
ok: false,
|
|
1645
|
+
error: "Forbidden by ADMIN_ALLOWED_ORIGINS."
|
|
1646
|
+
});
|
|
1647
|
+
return;
|
|
2386
1648
|
}
|
|
2387
|
-
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
|
|
1649
|
+
res.writeHead(204);
|
|
1650
|
+
res.end();
|
|
1651
|
+
return;
|
|
1652
|
+
}
|
|
1653
|
+
if (req.method === "GET" && isUiPath(url.pathname)) {
|
|
1654
|
+
this.sendHtml(res, renderAdminConsoleHtml());
|
|
1655
|
+
return;
|
|
1656
|
+
}
|
|
1657
|
+
const requiredRole = requiredAdminRoleForRequest(req.method, url.pathname);
|
|
1658
|
+
const authIdentity = requiredRole ? this.resolveAdminIdentity(req) : null;
|
|
1659
|
+
if (requiredRole && !authIdentity) {
|
|
1660
|
+
this.sendJson(res, 401, {
|
|
1661
|
+
ok: false,
|
|
1662
|
+
error: "Unauthorized. Provide Authorization: Bearer <ADMIN_TOKEN> (or token from ADMIN_TOKENS_JSON)."
|
|
1663
|
+
});
|
|
1664
|
+
return;
|
|
1665
|
+
}
|
|
1666
|
+
if (requiredRole && authIdentity && !hasRequiredAdminRole(authIdentity.role, requiredRole)) {
|
|
1667
|
+
this.sendJson(res, 403, {
|
|
1668
|
+
ok: false,
|
|
1669
|
+
error: "Forbidden. This endpoint requires admin write permission."
|
|
1670
|
+
});
|
|
1671
|
+
return;
|
|
1672
|
+
}
|
|
1673
|
+
if (req.method === "GET" && url.pathname === "/api/admin/auth/status") {
|
|
1674
|
+
this.sendJson(res, 200, {
|
|
1675
|
+
ok: true,
|
|
1676
|
+
data: {
|
|
1677
|
+
authenticated: Boolean(authIdentity),
|
|
1678
|
+
role: authIdentity?.role ?? null,
|
|
1679
|
+
source: authIdentity?.source ?? "none",
|
|
1680
|
+
actor: resolveIdentityActor(authIdentity),
|
|
1681
|
+
canWrite: authIdentity ? hasRequiredAdminRole(authIdentity.role, "admin") : false
|
|
2391
1682
|
}
|
|
2392
|
-
|
|
2393
|
-
|
|
1683
|
+
});
|
|
1684
|
+
return;
|
|
1685
|
+
}
|
|
1686
|
+
if (req.method === "GET" && url.pathname === "/api/admin/config/global") {
|
|
1687
|
+
this.sendJson(res, 200, {
|
|
1688
|
+
ok: true,
|
|
1689
|
+
data: buildGlobalConfigSnapshot(this.config),
|
|
1690
|
+
effective: "next_start_for_env_changes"
|
|
1691
|
+
});
|
|
1692
|
+
return;
|
|
1693
|
+
}
|
|
1694
|
+
if (req.method === "PUT" && url.pathname === "/api/admin/config/global") {
|
|
1695
|
+
const body = await readJsonBody(req, ADMIN_MAX_JSON_BODY_BYTES);
|
|
1696
|
+
const actor = resolveAuditActor(req, authIdentity);
|
|
1697
|
+
const result = this.updateGlobalConfig(body, actor);
|
|
1698
|
+
this.sendJson(res, 200, {
|
|
1699
|
+
ok: true,
|
|
1700
|
+
...result
|
|
1701
|
+
});
|
|
1702
|
+
return;
|
|
1703
|
+
}
|
|
1704
|
+
if (req.method === "GET" && url.pathname === "/api/admin/config/rooms") {
|
|
1705
|
+
this.sendJson(res, 200, {
|
|
1706
|
+
ok: true,
|
|
1707
|
+
data: this.configService.listRoomSettings()
|
|
1708
|
+
});
|
|
1709
|
+
return;
|
|
1710
|
+
}
|
|
1711
|
+
const roomMatch = /^\/api\/admin\/config\/rooms\/(.+)$/.exec(url.pathname);
|
|
1712
|
+
if (roomMatch) {
|
|
1713
|
+
const roomId = decodeURIComponent(roomMatch[1]);
|
|
1714
|
+
if (req.method === "GET") {
|
|
1715
|
+
const room = this.configService.getRoomSettings(roomId);
|
|
1716
|
+
if (!room) {
|
|
1717
|
+
throw new HttpError(404, `room settings not found for ${roomId}`);
|
|
2394
1718
|
}
|
|
2395
|
-
|
|
1719
|
+
this.sendJson(res, 200, { ok: true, data: room });
|
|
1720
|
+
return;
|
|
2396
1721
|
}
|
|
2397
|
-
|
|
2398
|
-
|
|
2399
|
-
|
|
2400
|
-
|
|
2401
|
-
|
|
1722
|
+
if (req.method === "PUT") {
|
|
1723
|
+
const body = await readJsonBody(req, ADMIN_MAX_JSON_BODY_BYTES);
|
|
1724
|
+
const actor = resolveAuditActor(req, authIdentity);
|
|
1725
|
+
const room = this.updateRoomConfig(roomId, body, actor);
|
|
1726
|
+
this.sendJson(res, 200, { ok: true, data: room });
|
|
1727
|
+
return;
|
|
1728
|
+
}
|
|
1729
|
+
if (req.method === "DELETE") {
|
|
1730
|
+
const actor = resolveAuditActor(req, authIdentity);
|
|
1731
|
+
this.configService.deleteRoomSettings(roomId, actor);
|
|
1732
|
+
this.sendJson(res, 200, { ok: true, roomId });
|
|
1733
|
+
return;
|
|
1734
|
+
}
|
|
1735
|
+
}
|
|
1736
|
+
if (req.method === "GET" && url.pathname === "/api/admin/audit") {
|
|
1737
|
+
const limit = normalizePositiveInt(url.searchParams.get("limit"), 20, 1, 200);
|
|
1738
|
+
this.sendJson(res, 200, {
|
|
1739
|
+
ok: true,
|
|
1740
|
+
data: this.stateStore.listConfigRevisions(limit).map((entry) => formatAuditEntry(entry))
|
|
1741
|
+
});
|
|
1742
|
+
return;
|
|
1743
|
+
}
|
|
1744
|
+
if (req.method === "GET" && url.pathname === "/api/admin/health") {
|
|
1745
|
+
const [codex, matrix] = await Promise.all([
|
|
1746
|
+
this.checkCodex(this.config.codexBin),
|
|
1747
|
+
this.checkMatrix(this.config.matrixHomeserver, this.config.doctorHttpTimeoutMs)
|
|
1748
|
+
]);
|
|
1749
|
+
this.sendJson(res, 200, {
|
|
1750
|
+
ok: codex.ok && matrix.ok,
|
|
1751
|
+
codex,
|
|
1752
|
+
matrix,
|
|
1753
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
1754
|
+
});
|
|
1755
|
+
return;
|
|
1756
|
+
}
|
|
1757
|
+
if (req.method === "POST" && url.pathname === "/api/admin/service/restart") {
|
|
1758
|
+
const body = asObject(await readJsonBody(req, ADMIN_MAX_JSON_BODY_BYTES), "service restart payload");
|
|
1759
|
+
const restartAdmin = normalizeBoolean(body.withAdmin, false);
|
|
1760
|
+
const actor = resolveAuditActor(req, authIdentity);
|
|
1761
|
+
try {
|
|
1762
|
+
const result = await this.restartServices(restartAdmin);
|
|
1763
|
+
this.stateStore.appendConfigRevision(
|
|
1764
|
+
actor,
|
|
1765
|
+
restartAdmin ? "restart services (main + admin)" : "restart service (main)",
|
|
1766
|
+
JSON.stringify({
|
|
1767
|
+
type: "service_restart",
|
|
1768
|
+
restartAdmin,
|
|
1769
|
+
restarted: result.restarted
|
|
1770
|
+
})
|
|
1771
|
+
);
|
|
1772
|
+
this.sendJson(res, 200, {
|
|
1773
|
+
ok: true,
|
|
1774
|
+
restarted: result.restarted
|
|
1775
|
+
});
|
|
1776
|
+
return;
|
|
1777
|
+
} catch (error) {
|
|
1778
|
+
throw new HttpError(
|
|
1779
|
+
500,
|
|
1780
|
+
`Service restart failed: ${formatError(error)}. Install services via "codeharbor service install --with-admin" to auto-configure restart permissions, or run CLI command manually with sudo.`
|
|
1781
|
+
);
|
|
1782
|
+
}
|
|
1783
|
+
}
|
|
1784
|
+
this.sendJson(res, 404, {
|
|
1785
|
+
ok: false,
|
|
1786
|
+
error: `Not found: ${req.method ?? "GET"} ${url.pathname}`
|
|
1787
|
+
});
|
|
1788
|
+
} catch (error) {
|
|
1789
|
+
if (error instanceof HttpError) {
|
|
1790
|
+
this.sendJson(res, error.statusCode, {
|
|
1791
|
+
ok: false,
|
|
1792
|
+
error: error.message
|
|
1793
|
+
});
|
|
1794
|
+
return;
|
|
1795
|
+
}
|
|
1796
|
+
this.logger.error("Admin API request failed", error);
|
|
1797
|
+
this.sendJson(res, 500, {
|
|
1798
|
+
ok: false,
|
|
1799
|
+
error: formatError(error)
|
|
1800
|
+
});
|
|
1801
|
+
}
|
|
1802
|
+
}
|
|
1803
|
+
updateGlobalConfig(rawBody, actor) {
|
|
1804
|
+
const body = asObject(rawBody, "global config payload");
|
|
1805
|
+
const envUpdates = {};
|
|
1806
|
+
const updatedKeys = [];
|
|
1807
|
+
if ("matrixCommandPrefix" in body) {
|
|
1808
|
+
const value = String(body.matrixCommandPrefix ?? "");
|
|
1809
|
+
this.config.matrixCommandPrefix = value;
|
|
1810
|
+
envUpdates.MATRIX_COMMAND_PREFIX = value;
|
|
1811
|
+
updatedKeys.push("matrixCommandPrefix");
|
|
1812
|
+
}
|
|
1813
|
+
if ("codexWorkdir" in body) {
|
|
1814
|
+
const workdir = import_node_path5.default.resolve(String(body.codexWorkdir ?? "").trim());
|
|
1815
|
+
ensureDirectory(workdir, "codexWorkdir");
|
|
1816
|
+
this.config.codexWorkdir = workdir;
|
|
1817
|
+
envUpdates.CODEX_WORKDIR = workdir;
|
|
1818
|
+
updatedKeys.push("codexWorkdir");
|
|
1819
|
+
}
|
|
1820
|
+
if ("rateLimiter" in body) {
|
|
1821
|
+
const limiter = asObject(body.rateLimiter, "rateLimiter");
|
|
1822
|
+
if ("windowMs" in limiter) {
|
|
1823
|
+
const value = normalizePositiveInt(limiter.windowMs, this.config.rateLimiter.windowMs, 1, Number.MAX_SAFE_INTEGER);
|
|
1824
|
+
this.config.rateLimiter.windowMs = value;
|
|
1825
|
+
envUpdates.RATE_LIMIT_WINDOW_SECONDS = String(Math.max(1, Math.round(value / 1e3)));
|
|
1826
|
+
updatedKeys.push("rateLimiter.windowMs");
|
|
1827
|
+
}
|
|
1828
|
+
if ("maxRequestsPerUser" in limiter) {
|
|
1829
|
+
const value = normalizeNonNegativeInt(limiter.maxRequestsPerUser, this.config.rateLimiter.maxRequestsPerUser);
|
|
1830
|
+
this.config.rateLimiter.maxRequestsPerUser = value;
|
|
1831
|
+
envUpdates.RATE_LIMIT_MAX_REQUESTS_PER_USER = String(value);
|
|
1832
|
+
updatedKeys.push("rateLimiter.maxRequestsPerUser");
|
|
1833
|
+
}
|
|
1834
|
+
if ("maxRequestsPerRoom" in limiter) {
|
|
1835
|
+
const value = normalizeNonNegativeInt(limiter.maxRequestsPerRoom, this.config.rateLimiter.maxRequestsPerRoom);
|
|
1836
|
+
this.config.rateLimiter.maxRequestsPerRoom = value;
|
|
1837
|
+
envUpdates.RATE_LIMIT_MAX_REQUESTS_PER_ROOM = String(value);
|
|
1838
|
+
updatedKeys.push("rateLimiter.maxRequestsPerRoom");
|
|
1839
|
+
}
|
|
1840
|
+
if ("maxConcurrentGlobal" in limiter) {
|
|
1841
|
+
const value = normalizeNonNegativeInt(limiter.maxConcurrentGlobal, this.config.rateLimiter.maxConcurrentGlobal);
|
|
1842
|
+
this.config.rateLimiter.maxConcurrentGlobal = value;
|
|
1843
|
+
envUpdates.RATE_LIMIT_MAX_CONCURRENT_GLOBAL = String(value);
|
|
1844
|
+
updatedKeys.push("rateLimiter.maxConcurrentGlobal");
|
|
1845
|
+
}
|
|
1846
|
+
if ("maxConcurrentPerUser" in limiter) {
|
|
1847
|
+
const value = normalizeNonNegativeInt(limiter.maxConcurrentPerUser, this.config.rateLimiter.maxConcurrentPerUser);
|
|
1848
|
+
this.config.rateLimiter.maxConcurrentPerUser = value;
|
|
1849
|
+
envUpdates.RATE_LIMIT_MAX_CONCURRENT_PER_USER = String(value);
|
|
1850
|
+
updatedKeys.push("rateLimiter.maxConcurrentPerUser");
|
|
1851
|
+
}
|
|
1852
|
+
if ("maxConcurrentPerRoom" in limiter) {
|
|
1853
|
+
const value = normalizeNonNegativeInt(limiter.maxConcurrentPerRoom, this.config.rateLimiter.maxConcurrentPerRoom);
|
|
1854
|
+
this.config.rateLimiter.maxConcurrentPerRoom = value;
|
|
1855
|
+
envUpdates.RATE_LIMIT_MAX_CONCURRENT_PER_ROOM = String(value);
|
|
1856
|
+
updatedKeys.push("rateLimiter.maxConcurrentPerRoom");
|
|
1857
|
+
}
|
|
1858
|
+
}
|
|
1859
|
+
if ("defaultGroupTriggerPolicy" in body) {
|
|
1860
|
+
const policy = asObject(body.defaultGroupTriggerPolicy, "defaultGroupTriggerPolicy");
|
|
1861
|
+
if ("allowMention" in policy) {
|
|
1862
|
+
const value = normalizeBoolean(policy.allowMention, this.config.defaultGroupTriggerPolicy.allowMention);
|
|
1863
|
+
this.config.defaultGroupTriggerPolicy.allowMention = value;
|
|
1864
|
+
envUpdates.GROUP_TRIGGER_ALLOW_MENTION = String(value);
|
|
1865
|
+
updatedKeys.push("defaultGroupTriggerPolicy.allowMention");
|
|
1866
|
+
}
|
|
1867
|
+
if ("allowReply" in policy) {
|
|
1868
|
+
const value = normalizeBoolean(policy.allowReply, this.config.defaultGroupTriggerPolicy.allowReply);
|
|
1869
|
+
this.config.defaultGroupTriggerPolicy.allowReply = value;
|
|
1870
|
+
envUpdates.GROUP_TRIGGER_ALLOW_REPLY = String(value);
|
|
1871
|
+
updatedKeys.push("defaultGroupTriggerPolicy.allowReply");
|
|
1872
|
+
}
|
|
1873
|
+
if ("allowActiveWindow" in policy) {
|
|
1874
|
+
const value = normalizeBoolean(policy.allowActiveWindow, this.config.defaultGroupTriggerPolicy.allowActiveWindow);
|
|
1875
|
+
this.config.defaultGroupTriggerPolicy.allowActiveWindow = value;
|
|
1876
|
+
envUpdates.GROUP_TRIGGER_ALLOW_ACTIVE_WINDOW = String(value);
|
|
1877
|
+
updatedKeys.push("defaultGroupTriggerPolicy.allowActiveWindow");
|
|
1878
|
+
}
|
|
1879
|
+
if ("allowPrefix" in policy) {
|
|
1880
|
+
const value = normalizeBoolean(policy.allowPrefix, this.config.defaultGroupTriggerPolicy.allowPrefix);
|
|
1881
|
+
this.config.defaultGroupTriggerPolicy.allowPrefix = value;
|
|
1882
|
+
envUpdates.GROUP_TRIGGER_ALLOW_PREFIX = String(value);
|
|
1883
|
+
updatedKeys.push("defaultGroupTriggerPolicy.allowPrefix");
|
|
1884
|
+
}
|
|
1885
|
+
}
|
|
1886
|
+
if ("matrixProgressUpdates" in body) {
|
|
1887
|
+
const value = normalizeBoolean(body.matrixProgressUpdates, this.config.matrixProgressUpdates);
|
|
1888
|
+
this.config.matrixProgressUpdates = value;
|
|
1889
|
+
envUpdates.MATRIX_PROGRESS_UPDATES = String(value);
|
|
1890
|
+
updatedKeys.push("matrixProgressUpdates");
|
|
1891
|
+
}
|
|
1892
|
+
if ("matrixProgressMinIntervalMs" in body) {
|
|
1893
|
+
const value = normalizePositiveInt(
|
|
1894
|
+
body.matrixProgressMinIntervalMs,
|
|
1895
|
+
this.config.matrixProgressMinIntervalMs,
|
|
1896
|
+
1,
|
|
1897
|
+
Number.MAX_SAFE_INTEGER
|
|
1898
|
+
);
|
|
1899
|
+
this.config.matrixProgressMinIntervalMs = value;
|
|
1900
|
+
envUpdates.MATRIX_PROGRESS_MIN_INTERVAL_MS = String(value);
|
|
1901
|
+
updatedKeys.push("matrixProgressMinIntervalMs");
|
|
1902
|
+
}
|
|
1903
|
+
if ("matrixTypingTimeoutMs" in body) {
|
|
1904
|
+
const value = normalizePositiveInt(
|
|
1905
|
+
body.matrixTypingTimeoutMs,
|
|
1906
|
+
this.config.matrixTypingTimeoutMs,
|
|
1907
|
+
1,
|
|
1908
|
+
Number.MAX_SAFE_INTEGER
|
|
1909
|
+
);
|
|
1910
|
+
this.config.matrixTypingTimeoutMs = value;
|
|
1911
|
+
envUpdates.MATRIX_TYPING_TIMEOUT_MS = String(value);
|
|
1912
|
+
updatedKeys.push("matrixTypingTimeoutMs");
|
|
1913
|
+
}
|
|
1914
|
+
if ("sessionActiveWindowMinutes" in body) {
|
|
1915
|
+
const value = normalizePositiveInt(
|
|
1916
|
+
body.sessionActiveWindowMinutes,
|
|
1917
|
+
this.config.sessionActiveWindowMinutes,
|
|
1918
|
+
1,
|
|
1919
|
+
Number.MAX_SAFE_INTEGER
|
|
1920
|
+
);
|
|
1921
|
+
this.config.sessionActiveWindowMinutes = value;
|
|
1922
|
+
envUpdates.SESSION_ACTIVE_WINDOW_MINUTES = String(value);
|
|
1923
|
+
updatedKeys.push("sessionActiveWindowMinutes");
|
|
1924
|
+
}
|
|
1925
|
+
if ("groupDirectModeEnabled" in body) {
|
|
1926
|
+
const value = normalizeBoolean(body.groupDirectModeEnabled, this.config.groupDirectModeEnabled);
|
|
1927
|
+
this.config.groupDirectModeEnabled = value;
|
|
1928
|
+
envUpdates.GROUP_DIRECT_MODE_ENABLED = String(value);
|
|
1929
|
+
updatedKeys.push("groupDirectModeEnabled");
|
|
1930
|
+
}
|
|
1931
|
+
if ("cliCompat" in body) {
|
|
1932
|
+
const compat = asObject(body.cliCompat, "cliCompat");
|
|
1933
|
+
if ("enabled" in compat) {
|
|
1934
|
+
const value = normalizeBoolean(compat.enabled, this.config.cliCompat.enabled);
|
|
1935
|
+
this.config.cliCompat.enabled = value;
|
|
1936
|
+
envUpdates.CLI_COMPAT_MODE = String(value);
|
|
1937
|
+
updatedKeys.push("cliCompat.enabled");
|
|
1938
|
+
}
|
|
1939
|
+
if ("passThroughEvents" in compat) {
|
|
1940
|
+
const value = normalizeBoolean(compat.passThroughEvents, this.config.cliCompat.passThroughEvents);
|
|
1941
|
+
this.config.cliCompat.passThroughEvents = value;
|
|
1942
|
+
envUpdates.CLI_COMPAT_PASSTHROUGH_EVENTS = String(value);
|
|
1943
|
+
updatedKeys.push("cliCompat.passThroughEvents");
|
|
1944
|
+
}
|
|
1945
|
+
if ("preserveWhitespace" in compat) {
|
|
1946
|
+
const value = normalizeBoolean(compat.preserveWhitespace, this.config.cliCompat.preserveWhitespace);
|
|
1947
|
+
this.config.cliCompat.preserveWhitespace = value;
|
|
1948
|
+
envUpdates.CLI_COMPAT_PRESERVE_WHITESPACE = String(value);
|
|
1949
|
+
updatedKeys.push("cliCompat.preserveWhitespace");
|
|
1950
|
+
}
|
|
1951
|
+
if ("disableReplyChunkSplit" in compat) {
|
|
1952
|
+
const value = normalizeBoolean(compat.disableReplyChunkSplit, this.config.cliCompat.disableReplyChunkSplit);
|
|
1953
|
+
this.config.cliCompat.disableReplyChunkSplit = value;
|
|
1954
|
+
envUpdates.CLI_COMPAT_DISABLE_REPLY_CHUNK_SPLIT = String(value);
|
|
1955
|
+
updatedKeys.push("cliCompat.disableReplyChunkSplit");
|
|
1956
|
+
}
|
|
1957
|
+
if ("progressThrottleMs" in compat) {
|
|
1958
|
+
const value = normalizeNonNegativeInt(compat.progressThrottleMs, this.config.cliCompat.progressThrottleMs);
|
|
1959
|
+
this.config.cliCompat.progressThrottleMs = value;
|
|
1960
|
+
envUpdates.CLI_COMPAT_PROGRESS_THROTTLE_MS = String(value);
|
|
1961
|
+
updatedKeys.push("cliCompat.progressThrottleMs");
|
|
1962
|
+
}
|
|
1963
|
+
if ("fetchMedia" in compat) {
|
|
1964
|
+
const value = normalizeBoolean(compat.fetchMedia, this.config.cliCompat.fetchMedia);
|
|
1965
|
+
this.config.cliCompat.fetchMedia = value;
|
|
1966
|
+
envUpdates.CLI_COMPAT_FETCH_MEDIA = String(value);
|
|
1967
|
+
updatedKeys.push("cliCompat.fetchMedia");
|
|
1968
|
+
}
|
|
1969
|
+
}
|
|
1970
|
+
if ("agentWorkflow" in body) {
|
|
1971
|
+
const workflow = asObject(body.agentWorkflow, "agentWorkflow");
|
|
1972
|
+
const currentAgentWorkflow = ensureAgentWorkflowConfig(this.config);
|
|
1973
|
+
if ("enabled" in workflow) {
|
|
1974
|
+
const value = normalizeBoolean(workflow.enabled, currentAgentWorkflow.enabled);
|
|
1975
|
+
currentAgentWorkflow.enabled = value;
|
|
1976
|
+
envUpdates.AGENT_WORKFLOW_ENABLED = String(value);
|
|
1977
|
+
updatedKeys.push("agentWorkflow.enabled");
|
|
1978
|
+
}
|
|
1979
|
+
if ("autoRepairMaxRounds" in workflow) {
|
|
1980
|
+
const value = normalizePositiveInt(
|
|
1981
|
+
workflow.autoRepairMaxRounds,
|
|
1982
|
+
currentAgentWorkflow.autoRepairMaxRounds,
|
|
1983
|
+
0,
|
|
1984
|
+
10
|
|
1985
|
+
);
|
|
1986
|
+
currentAgentWorkflow.autoRepairMaxRounds = value;
|
|
1987
|
+
envUpdates.AGENT_WORKFLOW_AUTO_REPAIR_MAX_ROUNDS = String(value);
|
|
1988
|
+
updatedKeys.push("agentWorkflow.autoRepairMaxRounds");
|
|
1989
|
+
}
|
|
1990
|
+
}
|
|
1991
|
+
if (updatedKeys.length === 0) {
|
|
1992
|
+
throw new HttpError(400, "No supported global config fields provided.");
|
|
1993
|
+
}
|
|
1994
|
+
this.persistEnvUpdates(envUpdates);
|
|
1995
|
+
this.stateStore.appendConfigRevision(
|
|
1996
|
+
actor,
|
|
1997
|
+
`update global config: ${updatedKeys.join(", ")}`,
|
|
1998
|
+
JSON.stringify({
|
|
1999
|
+
type: "global_config_update",
|
|
2000
|
+
updates: envUpdates
|
|
2001
|
+
})
|
|
2002
|
+
);
|
|
2003
|
+
return {
|
|
2004
|
+
data: buildGlobalConfigSnapshot(this.config),
|
|
2005
|
+
updatedKeys,
|
|
2006
|
+
restartRequired: true
|
|
2007
|
+
};
|
|
2008
|
+
}
|
|
2009
|
+
updateRoomConfig(roomId, rawBody, actor) {
|
|
2010
|
+
const body = asObject(rawBody, "room config payload");
|
|
2011
|
+
const current = this.configService.getRoomSettings(roomId);
|
|
2012
|
+
return this.configService.updateRoomSettings({
|
|
2013
|
+
roomId,
|
|
2014
|
+
enabled: normalizeBoolean(body.enabled, current?.enabled ?? true),
|
|
2015
|
+
allowMention: normalizeBoolean(body.allowMention, current?.allowMention ?? true),
|
|
2016
|
+
allowReply: normalizeBoolean(body.allowReply, current?.allowReply ?? true),
|
|
2017
|
+
allowActiveWindow: normalizeBoolean(body.allowActiveWindow, current?.allowActiveWindow ?? true),
|
|
2018
|
+
allowPrefix: normalizeBoolean(body.allowPrefix, current?.allowPrefix ?? true),
|
|
2019
|
+
workdir: normalizeString(body.workdir, current?.workdir ?? this.config.codexWorkdir, "workdir"),
|
|
2020
|
+
actor,
|
|
2021
|
+
summary: normalizeOptionalString(body.summary)
|
|
2022
|
+
});
|
|
2023
|
+
}
|
|
2024
|
+
resolveAdminIdentity(req) {
|
|
2025
|
+
if (!this.adminToken && this.adminTokens.size === 0) {
|
|
2026
|
+
return {
|
|
2027
|
+
role: "admin",
|
|
2028
|
+
actor: null,
|
|
2029
|
+
source: "open"
|
|
2030
|
+
};
|
|
2031
|
+
}
|
|
2032
|
+
const token = readAdminToken(req);
|
|
2033
|
+
if (!token) {
|
|
2034
|
+
return null;
|
|
2035
|
+
}
|
|
2036
|
+
if (this.adminToken && token === this.adminToken) {
|
|
2037
|
+
return {
|
|
2038
|
+
role: "admin",
|
|
2039
|
+
actor: null,
|
|
2040
|
+
source: "legacy"
|
|
2041
|
+
};
|
|
2042
|
+
}
|
|
2043
|
+
const mappedIdentity = this.adminTokens.get(token);
|
|
2044
|
+
if (!mappedIdentity) {
|
|
2045
|
+
return null;
|
|
2046
|
+
}
|
|
2047
|
+
return {
|
|
2048
|
+
role: mappedIdentity.role,
|
|
2049
|
+
actor: mappedIdentity.actor,
|
|
2050
|
+
source: "scoped"
|
|
2051
|
+
};
|
|
2052
|
+
}
|
|
2053
|
+
isClientAllowed(req) {
|
|
2054
|
+
if (this.adminIpAllowlist.length === 0) {
|
|
2055
|
+
return true;
|
|
2056
|
+
}
|
|
2057
|
+
const normalizedRemote = normalizeRemoteAddress(req.socket.remoteAddress);
|
|
2058
|
+
if (!normalizedRemote) {
|
|
2059
|
+
return false;
|
|
2060
|
+
}
|
|
2061
|
+
return this.adminIpAllowlist.includes(normalizedRemote);
|
|
2062
|
+
}
|
|
2063
|
+
persistEnvUpdates(updates) {
|
|
2064
|
+
const envPath = import_node_path5.default.resolve(this.cwd, ".env");
|
|
2065
|
+
const examplePath = import_node_path5.default.resolve(this.cwd, ".env.example");
|
|
2066
|
+
const template = import_node_fs4.default.existsSync(envPath) ? import_node_fs4.default.readFileSync(envPath, "utf8") : import_node_fs4.default.existsSync(examplePath) ? import_node_fs4.default.readFileSync(examplePath, "utf8") : "";
|
|
2067
|
+
const next = applyEnvOverrides(template, updates);
|
|
2068
|
+
import_node_fs4.default.writeFileSync(envPath, next, "utf8");
|
|
2069
|
+
}
|
|
2070
|
+
resolveCors(req) {
|
|
2071
|
+
const origin = normalizeOriginHeader(req.headers.origin);
|
|
2072
|
+
if (!origin) {
|
|
2073
|
+
return { origin: null, allowed: true };
|
|
2074
|
+
}
|
|
2075
|
+
if (isSameOriginRequest(req, origin)) {
|
|
2076
|
+
return { origin, allowed: true };
|
|
2077
|
+
}
|
|
2078
|
+
if (this.adminAllowedOrigins.includes("*")) {
|
|
2079
|
+
return { origin, allowed: true };
|
|
2080
|
+
}
|
|
2081
|
+
if (this.adminAllowedOrigins.length === 0) {
|
|
2082
|
+
return { origin, allowed: false };
|
|
2083
|
+
}
|
|
2084
|
+
return {
|
|
2085
|
+
origin,
|
|
2086
|
+
allowed: this.adminAllowedOrigins.includes(origin)
|
|
2087
|
+
};
|
|
2088
|
+
}
|
|
2089
|
+
setCorsHeaders(res, corsDecision) {
|
|
2090
|
+
if (!corsDecision.origin || !corsDecision.allowed) {
|
|
2091
|
+
return;
|
|
2092
|
+
}
|
|
2093
|
+
res.setHeader("Access-Control-Allow-Origin", corsDecision.origin);
|
|
2094
|
+
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Admin-Token, X-Admin-Actor");
|
|
2095
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, PUT, POST, DELETE, OPTIONS");
|
|
2096
|
+
appendVaryHeader(res, "Origin");
|
|
2097
|
+
}
|
|
2098
|
+
setSecurityHeaders(res) {
|
|
2099
|
+
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
2100
|
+
res.setHeader("X-Frame-Options", "DENY");
|
|
2101
|
+
res.setHeader("Referrer-Policy", "no-referrer");
|
|
2102
|
+
res.setHeader("Permissions-Policy", "camera=(), microphone=(), geolocation=()");
|
|
2103
|
+
res.setHeader("Cross-Origin-Resource-Policy", "same-origin");
|
|
2104
|
+
res.setHeader("Cross-Origin-Opener-Policy", "same-origin");
|
|
2105
|
+
res.setHeader(
|
|
2106
|
+
"Content-Security-Policy",
|
|
2107
|
+
"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"
|
|
2108
|
+
);
|
|
2109
|
+
}
|
|
2110
|
+
sendHtml(res, html) {
|
|
2111
|
+
res.statusCode = 200;
|
|
2112
|
+
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
2113
|
+
res.end(html);
|
|
2114
|
+
}
|
|
2115
|
+
sendJson(res, statusCode, payload) {
|
|
2116
|
+
res.statusCode = statusCode;
|
|
2117
|
+
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
|
2118
|
+
res.end(JSON.stringify(payload));
|
|
2119
|
+
}
|
|
2120
|
+
};
|
|
2121
|
+
function buildGlobalConfigSnapshot(config) {
|
|
2122
|
+
return {
|
|
2123
|
+
matrixCommandPrefix: config.matrixCommandPrefix,
|
|
2124
|
+
codexWorkdir: config.codexWorkdir,
|
|
2125
|
+
rateLimiter: { ...config.rateLimiter },
|
|
2126
|
+
groupDirectModeEnabled: config.groupDirectModeEnabled,
|
|
2127
|
+
defaultGroupTriggerPolicy: { ...config.defaultGroupTriggerPolicy },
|
|
2128
|
+
matrixProgressUpdates: config.matrixProgressUpdates,
|
|
2129
|
+
matrixProgressMinIntervalMs: config.matrixProgressMinIntervalMs,
|
|
2130
|
+
matrixTypingTimeoutMs: config.matrixTypingTimeoutMs,
|
|
2131
|
+
sessionActiveWindowMinutes: config.sessionActiveWindowMinutes,
|
|
2132
|
+
cliCompat: { ...config.cliCompat },
|
|
2133
|
+
agentWorkflow: { ...ensureAgentWorkflowConfig(config) }
|
|
2134
|
+
};
|
|
2135
|
+
}
|
|
2136
|
+
function ensureAgentWorkflowConfig(config) {
|
|
2137
|
+
const mutable = config;
|
|
2138
|
+
const existing = mutable.agentWorkflow;
|
|
2139
|
+
if (existing && typeof existing.enabled === "boolean" && Number.isFinite(existing.autoRepairMaxRounds)) {
|
|
2140
|
+
return existing;
|
|
2141
|
+
}
|
|
2142
|
+
const fallback = {
|
|
2143
|
+
enabled: false,
|
|
2144
|
+
autoRepairMaxRounds: 1
|
|
2145
|
+
};
|
|
2146
|
+
mutable.agentWorkflow = fallback;
|
|
2147
|
+
return fallback;
|
|
2148
|
+
}
|
|
2149
|
+
function formatAuditEntry(entry) {
|
|
2150
|
+
return {
|
|
2151
|
+
id: entry.id,
|
|
2152
|
+
actor: entry.actor,
|
|
2153
|
+
summary: entry.summary,
|
|
2154
|
+
payloadJson: entry.payloadJson,
|
|
2155
|
+
payload: parseJsonLoose(entry.payloadJson),
|
|
2156
|
+
createdAt: entry.createdAt,
|
|
2157
|
+
createdAtIso: new Date(entry.createdAt).toISOString()
|
|
2158
|
+
};
|
|
2159
|
+
}
|
|
2160
|
+
function parseJsonLoose(raw) {
|
|
2161
|
+
try {
|
|
2162
|
+
return JSON.parse(raw);
|
|
2163
|
+
} catch {
|
|
2164
|
+
return raw;
|
|
2165
|
+
}
|
|
2166
|
+
}
|
|
2167
|
+
async function defaultRestartServices(restartAdmin) {
|
|
2168
|
+
const outputChunks = [];
|
|
2169
|
+
const output = {
|
|
2170
|
+
write: (chunk) => {
|
|
2171
|
+
outputChunks.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8"));
|
|
2172
|
+
return true;
|
|
2173
|
+
}
|
|
2174
|
+
};
|
|
2175
|
+
restartSystemdServices({
|
|
2176
|
+
restartAdmin,
|
|
2177
|
+
output
|
|
2178
|
+
});
|
|
2179
|
+
return {
|
|
2180
|
+
restarted: restartAdmin ? ["codeharbor", "codeharbor-admin"] : ["codeharbor"]
|
|
2181
|
+
};
|
|
2182
|
+
}
|
|
2183
|
+
function isUiPath(pathname) {
|
|
2184
|
+
return pathname === "/" || pathname === "/index.html" || pathname === "/settings/global" || pathname === "/settings/rooms" || pathname === "/health" || pathname === "/audit";
|
|
2185
|
+
}
|
|
2186
|
+
function normalizeAllowlist(entries) {
|
|
2187
|
+
const output = /* @__PURE__ */ new Set();
|
|
2188
|
+
for (const entry of entries) {
|
|
2189
|
+
const normalized = normalizeRemoteAddress(entry);
|
|
2190
|
+
if (normalized) {
|
|
2191
|
+
output.add(normalized);
|
|
2192
|
+
}
|
|
2193
|
+
}
|
|
2194
|
+
return [...output];
|
|
2195
|
+
}
|
|
2196
|
+
function normalizeOriginAllowlist(entries) {
|
|
2197
|
+
const output = /* @__PURE__ */ new Set();
|
|
2198
|
+
for (const entry of entries) {
|
|
2199
|
+
const normalized = normalizeOrigin(entry);
|
|
2200
|
+
if (normalized) {
|
|
2201
|
+
output.add(normalized);
|
|
2202
|
+
}
|
|
2203
|
+
}
|
|
2204
|
+
return [...output];
|
|
2205
|
+
}
|
|
2206
|
+
function normalizeRemoteAddress(value) {
|
|
2207
|
+
if (!value) {
|
|
2208
|
+
return null;
|
|
2209
|
+
}
|
|
2210
|
+
const trimmed = value.trim().toLowerCase();
|
|
2211
|
+
if (!trimmed) {
|
|
2212
|
+
return null;
|
|
2213
|
+
}
|
|
2214
|
+
const withoutZone = trimmed.includes("%") ? trimmed.slice(0, trimmed.indexOf("%")) : trimmed;
|
|
2215
|
+
if (withoutZone === "::1" || withoutZone === "0:0:0:0:0:0:0:1") {
|
|
2216
|
+
return "127.0.0.1";
|
|
2217
|
+
}
|
|
2218
|
+
if (withoutZone.startsWith("::ffff:")) {
|
|
2219
|
+
return withoutZone.slice("::ffff:".length);
|
|
2220
|
+
}
|
|
2221
|
+
return withoutZone;
|
|
2222
|
+
}
|
|
2223
|
+
function normalizeOriginHeader(value) {
|
|
2224
|
+
if (!value) {
|
|
2225
|
+
return null;
|
|
2226
|
+
}
|
|
2227
|
+
const raw = Array.isArray(value) ? value[0] ?? "" : value;
|
|
2228
|
+
return normalizeOrigin(raw);
|
|
2229
|
+
}
|
|
2230
|
+
function normalizeOrigin(value) {
|
|
2231
|
+
const trimmed = value.trim();
|
|
2232
|
+
if (!trimmed) {
|
|
2233
|
+
return null;
|
|
2234
|
+
}
|
|
2235
|
+
if (trimmed === "*") {
|
|
2236
|
+
return "*";
|
|
2237
|
+
}
|
|
2238
|
+
try {
|
|
2239
|
+
const parsed = new URL(trimmed);
|
|
2240
|
+
return `${parsed.protocol}//${parsed.host}`.toLowerCase();
|
|
2241
|
+
} catch {
|
|
2242
|
+
return null;
|
|
2243
|
+
}
|
|
2244
|
+
}
|
|
2245
|
+
function isSameOriginRequest(req, origin) {
|
|
2246
|
+
const host = normalizeHeaderValue(req.headers.host);
|
|
2247
|
+
if (!host) {
|
|
2248
|
+
return false;
|
|
2249
|
+
}
|
|
2250
|
+
const forwardedProto = normalizeHeaderValue(req.headers["x-forwarded-proto"]);
|
|
2251
|
+
const protocol = forwardedProto || "http";
|
|
2252
|
+
return origin === `${protocol}://${host}`.toLowerCase();
|
|
2253
|
+
}
|
|
2254
|
+
function appendVaryHeader(res, headerName) {
|
|
2255
|
+
const current = res.getHeader("Vary");
|
|
2256
|
+
const existing = typeof current === "string" ? current.split(",").map((v) => v.trim()).filter(Boolean) : [];
|
|
2257
|
+
if (!existing.includes(headerName)) {
|
|
2258
|
+
existing.push(headerName);
|
|
2259
|
+
}
|
|
2260
|
+
res.setHeader("Vary", existing.join(", "));
|
|
2261
|
+
}
|
|
2262
|
+
function renderAdminConsoleHtml() {
|
|
2263
|
+
return ADMIN_CONSOLE_HTML;
|
|
2264
|
+
}
|
|
2265
|
+
async function readJsonBody(req, maxBytes) {
|
|
2266
|
+
const contentLengthHeader = req.headers["content-length"];
|
|
2267
|
+
if (typeof contentLengthHeader === "string" && contentLengthHeader.trim()) {
|
|
2268
|
+
const declaredLength = Number.parseInt(contentLengthHeader, 10);
|
|
2269
|
+
if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
|
|
2270
|
+
throw new HttpError(413, `Request body too large. Max allowed bytes: ${maxBytes}.`);
|
|
2271
|
+
}
|
|
2272
|
+
}
|
|
2273
|
+
const chunks = [];
|
|
2274
|
+
let totalBytes = 0;
|
|
2275
|
+
for await (const chunk of req) {
|
|
2276
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
2277
|
+
totalBytes += buffer.length;
|
|
2278
|
+
if (totalBytes > maxBytes) {
|
|
2279
|
+
throw new HttpError(413, `Request body too large. Max allowed bytes: ${maxBytes}.`);
|
|
2280
|
+
}
|
|
2281
|
+
chunks.push(buffer);
|
|
2282
|
+
}
|
|
2283
|
+
if (chunks.length === 0) {
|
|
2284
|
+
return {};
|
|
2285
|
+
}
|
|
2286
|
+
const raw = Buffer.concat(chunks).toString("utf8").trim();
|
|
2287
|
+
if (!raw) {
|
|
2288
|
+
return {};
|
|
2289
|
+
}
|
|
2290
|
+
try {
|
|
2291
|
+
return JSON.parse(raw);
|
|
2292
|
+
} catch {
|
|
2293
|
+
throw new HttpError(400, "Request body must be valid JSON.");
|
|
2294
|
+
}
|
|
2295
|
+
}
|
|
2296
|
+
function asObject(value, name) {
|
|
2297
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
2298
|
+
throw new HttpError(400, `${name} must be a JSON object.`);
|
|
2299
|
+
}
|
|
2300
|
+
return value;
|
|
2301
|
+
}
|
|
2302
|
+
function normalizeBoolean(value, fallback) {
|
|
2303
|
+
if (value === void 0) {
|
|
2304
|
+
return fallback;
|
|
2305
|
+
}
|
|
2306
|
+
if (typeof value !== "boolean") {
|
|
2307
|
+
throw new HttpError(400, "Expected boolean value.");
|
|
2308
|
+
}
|
|
2309
|
+
return value;
|
|
2310
|
+
}
|
|
2311
|
+
function normalizeString(value, fallback, fieldName) {
|
|
2312
|
+
if (value === void 0) {
|
|
2313
|
+
return fallback;
|
|
2314
|
+
}
|
|
2315
|
+
if (typeof value !== "string") {
|
|
2316
|
+
throw new HttpError(400, `Expected string value for ${fieldName}.`);
|
|
2317
|
+
}
|
|
2318
|
+
return value.trim();
|
|
2319
|
+
}
|
|
2320
|
+
function normalizeOptionalString(value) {
|
|
2321
|
+
if (value === void 0 || value === null) {
|
|
2322
|
+
return null;
|
|
2323
|
+
}
|
|
2324
|
+
if (typeof value !== "string") {
|
|
2325
|
+
throw new HttpError(400, "Expected string value.");
|
|
2326
|
+
}
|
|
2327
|
+
const trimmed = value.trim();
|
|
2328
|
+
return trimmed || null;
|
|
2329
|
+
}
|
|
2330
|
+
function normalizePositiveInt(value, fallback, min, max) {
|
|
2331
|
+
if (value === void 0 || value === null) {
|
|
2332
|
+
return fallback;
|
|
2333
|
+
}
|
|
2334
|
+
const parsed = Number.parseInt(String(value), 10);
|
|
2335
|
+
if (!Number.isFinite(parsed) || parsed < min || parsed > max) {
|
|
2336
|
+
throw new HttpError(400, `Expected integer in range [${min}, ${max}].`);
|
|
2337
|
+
}
|
|
2338
|
+
return parsed;
|
|
2339
|
+
}
|
|
2340
|
+
function normalizeNonNegativeInt(value, fallback) {
|
|
2341
|
+
return normalizePositiveInt(value, fallback, 0, Number.MAX_SAFE_INTEGER);
|
|
2342
|
+
}
|
|
2343
|
+
function ensureDirectory(targetPath, fieldName) {
|
|
2344
|
+
if (!import_node_fs4.default.existsSync(targetPath) || !import_node_fs4.default.statSync(targetPath).isDirectory()) {
|
|
2345
|
+
throw new HttpError(400, `${fieldName} must be an existing directory: ${targetPath}`);
|
|
2346
|
+
}
|
|
2347
|
+
}
|
|
2348
|
+
function normalizeHeaderValue(value) {
|
|
2349
|
+
if (!value) {
|
|
2350
|
+
return "";
|
|
2351
|
+
}
|
|
2352
|
+
if (Array.isArray(value)) {
|
|
2353
|
+
return value[0]?.trim() ?? "";
|
|
2354
|
+
}
|
|
2355
|
+
return value.trim();
|
|
2356
|
+
}
|
|
2357
|
+
function readAdminToken(req) {
|
|
2358
|
+
const authorization = normalizeHeaderValue(req.headers.authorization);
|
|
2359
|
+
if (authorization) {
|
|
2360
|
+
const match = /^bearer\s+(.+)$/i.exec(authorization);
|
|
2361
|
+
const token = match?.[1]?.trim() ?? "";
|
|
2362
|
+
if (token) {
|
|
2363
|
+
return token;
|
|
2364
|
+
}
|
|
2365
|
+
}
|
|
2366
|
+
const fromHeader = normalizeHeaderValue(req.headers["x-admin-token"]);
|
|
2367
|
+
return fromHeader || null;
|
|
2368
|
+
}
|
|
2369
|
+
function resolveIdentityActor(identity) {
|
|
2370
|
+
if (!identity || identity.source !== "scoped") {
|
|
2371
|
+
return null;
|
|
2372
|
+
}
|
|
2373
|
+
if (identity.actor) {
|
|
2374
|
+
return identity.actor;
|
|
2375
|
+
}
|
|
2376
|
+
return identity.role === "admin" ? "admin-token" : "viewer-token";
|
|
2377
|
+
}
|
|
2378
|
+
function resolveAuditActor(req, identity) {
|
|
2379
|
+
const scopedActor = resolveIdentityActor(identity);
|
|
2380
|
+
if (scopedActor) {
|
|
2381
|
+
return scopedActor;
|
|
2382
|
+
}
|
|
2383
|
+
const actor = normalizeHeaderValue(req.headers["x-admin-actor"]);
|
|
2384
|
+
return actor || null;
|
|
2385
|
+
}
|
|
2386
|
+
function requiredAdminRoleForRequest(method, pathname) {
|
|
2387
|
+
if (!pathname.startsWith("/api/admin/")) {
|
|
2388
|
+
return null;
|
|
2389
|
+
}
|
|
2390
|
+
const normalizedMethod = (method ?? "GET").toUpperCase();
|
|
2391
|
+
if (normalizedMethod === "GET" || normalizedMethod === "HEAD") {
|
|
2392
|
+
return "viewer";
|
|
2393
|
+
}
|
|
2394
|
+
return "admin";
|
|
2395
|
+
}
|
|
2396
|
+
function hasRequiredAdminRole(role, requiredRole) {
|
|
2397
|
+
if (requiredRole === "viewer") {
|
|
2398
|
+
return role === "viewer" || role === "admin";
|
|
2399
|
+
}
|
|
2400
|
+
return role === "admin";
|
|
2401
|
+
}
|
|
2402
|
+
function buildAdminTokenMap(tokens) {
|
|
2403
|
+
const mapped = /* @__PURE__ */ new Map();
|
|
2404
|
+
for (const token of tokens) {
|
|
2405
|
+
mapped.set(token.token, {
|
|
2406
|
+
role: token.role,
|
|
2407
|
+
actor: token.actor
|
|
2408
|
+
});
|
|
2409
|
+
}
|
|
2410
|
+
return mapped;
|
|
2411
|
+
}
|
|
2412
|
+
function formatError(error) {
|
|
2413
|
+
if (error instanceof Error) {
|
|
2414
|
+
return error.message;
|
|
2415
|
+
}
|
|
2416
|
+
return String(error);
|
|
2417
|
+
}
|
|
2402
2418
|
async function defaultCheckCodex(bin) {
|
|
2403
2419
|
try {
|
|
2404
2420
|
const { stdout } = await execFileAsync2(bin, ["--version"]);
|
|
@@ -2594,6 +2610,10 @@ function findBreakIndex(candidate) {
|
|
|
2594
2610
|
|
|
2595
2611
|
// src/channels/matrix-channel.ts
|
|
2596
2612
|
var LOCAL_TXN_PREFIX = "codeharbor-";
|
|
2613
|
+
var MATRIX_HTTP_TIMEOUT_MS = 15e3;
|
|
2614
|
+
var MATRIX_HTTP_MAX_RETRIES = 2;
|
|
2615
|
+
var RETRYABLE_HTTP_STATUS = /* @__PURE__ */ new Set([408, 425, 429, 500, 502, 503, 504]);
|
|
2616
|
+
var ACCEPTED_MSG_TYPES = /* @__PURE__ */ new Set(["m.text", "m.image", "m.file", "m.audio", "m.video"]);
|
|
2597
2617
|
var MatrixChannel = class {
|
|
2598
2618
|
config;
|
|
2599
2619
|
logger;
|
|
@@ -2720,8 +2740,7 @@ var MatrixChannel = class {
|
|
|
2720
2740
|
return;
|
|
2721
2741
|
}
|
|
2722
2742
|
const msgtype = typeof content.msgtype === "string" ? content.msgtype : "";
|
|
2723
|
-
|
|
2724
|
-
if (!acceptedMsgtypes.has(msgtype)) {
|
|
2743
|
+
if (!ACCEPTED_MSG_TYPES.has(msgtype)) {
|
|
2725
2744
|
return;
|
|
2726
2745
|
}
|
|
2727
2746
|
const eventId = event.getId();
|
|
@@ -2822,8 +2841,9 @@ var MatrixChannel = class {
|
|
|
2822
2841
|
}
|
|
2823
2842
|
async sendRawEvent(conversationId, content) {
|
|
2824
2843
|
const txnId = `${LOCAL_TXN_PREFIX}${Date.now()}-${Math.floor(Math.random() * 1e6)}`;
|
|
2825
|
-
const
|
|
2826
|
-
|
|
2844
|
+
const url = `${this.config.matrixHomeserver}/_matrix/client/v3/rooms/${encodeURIComponent(conversationId)}/send/m.room.message/${encodeURIComponent(txnId)}`;
|
|
2845
|
+
const response = await fetchWithRetry(
|
|
2846
|
+
url,
|
|
2827
2847
|
{
|
|
2828
2848
|
method: "PUT",
|
|
2829
2849
|
headers: {
|
|
@@ -2831,10 +2851,17 @@ var MatrixChannel = class {
|
|
|
2831
2851
|
"Content-Type": "application/json"
|
|
2832
2852
|
},
|
|
2833
2853
|
body: JSON.stringify(content)
|
|
2854
|
+
},
|
|
2855
|
+
{
|
|
2856
|
+
timeoutMs: MATRIX_HTTP_TIMEOUT_MS,
|
|
2857
|
+
maxRetries: MATRIX_HTTP_MAX_RETRIES
|
|
2834
2858
|
}
|
|
2835
2859
|
);
|
|
2836
2860
|
if (!response.ok) {
|
|
2837
|
-
|
|
2861
|
+
const responseSnippet = await readResponseSnippet(response);
|
|
2862
|
+
throw new Error(
|
|
2863
|
+
`Matrix send failed (${response.status} ${response.statusText})${responseSnippet ? `: ${responseSnippet}` : ""}`
|
|
2864
|
+
);
|
|
2838
2865
|
}
|
|
2839
2866
|
const payload = await response.json();
|
|
2840
2867
|
if (!payload.event_id || typeof payload.event_id !== "string") {
|
|
@@ -2888,15 +2915,25 @@ var MatrixChannel = class {
|
|
|
2888
2915
|
Authorization: `Bearer ${this.config.matrixAccessToken}`
|
|
2889
2916
|
};
|
|
2890
2917
|
let response = null;
|
|
2918
|
+
const failedStatuses = [];
|
|
2891
2919
|
for (const url of mediaUrls) {
|
|
2892
|
-
const candidate = await
|
|
2920
|
+
const candidate = await fetchWithRetry(
|
|
2921
|
+
url,
|
|
2922
|
+
{ headers },
|
|
2923
|
+
{
|
|
2924
|
+
timeoutMs: MATRIX_HTTP_TIMEOUT_MS,
|
|
2925
|
+
maxRetries: MATRIX_HTTP_MAX_RETRIES
|
|
2926
|
+
}
|
|
2927
|
+
);
|
|
2893
2928
|
if (candidate.ok) {
|
|
2894
2929
|
response = candidate;
|
|
2895
2930
|
break;
|
|
2896
2931
|
}
|
|
2932
|
+
failedStatuses.push(candidate.status);
|
|
2897
2933
|
}
|
|
2898
2934
|
if (!response) {
|
|
2899
|
-
|
|
2935
|
+
const suffix = failedStatuses.length > 0 ? ` (statuses: ${failedStatuses.join(",")})` : "";
|
|
2936
|
+
throw new Error(`Failed to download media for ${mxcUrl}${suffix}`);
|
|
2900
2937
|
}
|
|
2901
2938
|
const bytes = Buffer.from(await response.arrayBuffer());
|
|
2902
2939
|
const extension = resolveFileExtension(fileName, mimeType);
|
|
@@ -2907,7 +2944,68 @@ var MatrixChannel = class {
|
|
|
2907
2944
|
await import_promises2.default.writeFile(targetPath, bytes);
|
|
2908
2945
|
return targetPath;
|
|
2909
2946
|
}
|
|
2910
|
-
};
|
|
2947
|
+
};
|
|
2948
|
+
async function fetchWithRetry(url, init, options) {
|
|
2949
|
+
let attempt = 0;
|
|
2950
|
+
let lastError = null;
|
|
2951
|
+
while (attempt <= options.maxRetries) {
|
|
2952
|
+
try {
|
|
2953
|
+
const response = await fetchWithTimeout(url, init, options.timeoutMs);
|
|
2954
|
+
if (response.ok || !RETRYABLE_HTTP_STATUS.has(response.status) || attempt === options.maxRetries) {
|
|
2955
|
+
return response;
|
|
2956
|
+
}
|
|
2957
|
+
} catch (error) {
|
|
2958
|
+
lastError = error;
|
|
2959
|
+
if (attempt === options.maxRetries) {
|
|
2960
|
+
throw error;
|
|
2961
|
+
}
|
|
2962
|
+
}
|
|
2963
|
+
const delayMs = computeRetryDelayMs(attempt);
|
|
2964
|
+
await sleep(delayMs);
|
|
2965
|
+
attempt += 1;
|
|
2966
|
+
}
|
|
2967
|
+
throw new Error(`HTTP request failed for ${url}: ${formatError2(lastError)}`);
|
|
2968
|
+
}
|
|
2969
|
+
async function fetchWithTimeout(url, init, timeoutMs) {
|
|
2970
|
+
const controller = new AbortController();
|
|
2971
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
2972
|
+
timer.unref?.();
|
|
2973
|
+
try {
|
|
2974
|
+
return await fetch(url, {
|
|
2975
|
+
...init,
|
|
2976
|
+
signal: controller.signal
|
|
2977
|
+
});
|
|
2978
|
+
} finally {
|
|
2979
|
+
clearTimeout(timer);
|
|
2980
|
+
}
|
|
2981
|
+
}
|
|
2982
|
+
function computeRetryDelayMs(attempt) {
|
|
2983
|
+
const base = 250;
|
|
2984
|
+
return Math.min(2e3, base * 2 ** attempt);
|
|
2985
|
+
}
|
|
2986
|
+
async function sleep(delayMs) {
|
|
2987
|
+
await new Promise((resolve) => {
|
|
2988
|
+
const timer = setTimeout(resolve, delayMs);
|
|
2989
|
+
timer.unref?.();
|
|
2990
|
+
});
|
|
2991
|
+
}
|
|
2992
|
+
async function readResponseSnippet(response) {
|
|
2993
|
+
try {
|
|
2994
|
+
const text = (await response.text()).trim();
|
|
2995
|
+
if (!text) {
|
|
2996
|
+
return "";
|
|
2997
|
+
}
|
|
2998
|
+
return text.length > 300 ? `${text.slice(0, 300)}...` : text;
|
|
2999
|
+
} catch {
|
|
3000
|
+
return "";
|
|
3001
|
+
}
|
|
3002
|
+
}
|
|
3003
|
+
function formatError2(error) {
|
|
3004
|
+
if (error instanceof Error) {
|
|
3005
|
+
return error.message;
|
|
3006
|
+
}
|
|
3007
|
+
return String(error);
|
|
3008
|
+
}
|
|
2911
3009
|
function buildRequestId(eventId) {
|
|
2912
3010
|
const suffix = Math.random().toString(36).slice(2, 8);
|
|
2913
3011
|
return `${eventId}:${suffix}`;
|
|
@@ -3030,7 +3128,7 @@ function renderMatrixHtml(body, msgtype) {
|
|
|
3030
3128
|
let match;
|
|
3031
3129
|
while ((match = codeFencePattern.exec(normalized)) !== null) {
|
|
3032
3130
|
const before = normalized.slice(cursor, match.index);
|
|
3033
|
-
const renderedBefore =
|
|
3131
|
+
const renderedBefore = renderMarkdownSection(before);
|
|
3034
3132
|
if (renderedBefore) {
|
|
3035
3133
|
sections.push(renderedBefore);
|
|
3036
3134
|
}
|
|
@@ -3043,29 +3141,142 @@ function renderMatrixHtml(body, msgtype) {
|
|
|
3043
3141
|
cursor = match.index + match[0].length;
|
|
3044
3142
|
}
|
|
3045
3143
|
const tail = normalized.slice(cursor);
|
|
3046
|
-
const renderedTail =
|
|
3144
|
+
const renderedTail = renderMarkdownSection(tail);
|
|
3047
3145
|
if (renderedTail) {
|
|
3048
3146
|
sections.push(renderedTail);
|
|
3049
3147
|
}
|
|
3050
3148
|
if (sections.length === 0) {
|
|
3051
3149
|
sections.push("<p>(\u7A7A\u6D88\u606F)</p>");
|
|
3052
3150
|
}
|
|
3053
|
-
const badge = msgtype === "m.notice" ? `<p><font color="#8a5a00"><b
|
|
3151
|
+
const badge = msgtype === "m.notice" ? `<p><font color="#8a5a00"><b>CodeHarbor \u63D0\u793A</b></font></p>` : `<p><font color="#1f7a5a"><b>CodeHarbor AI \u56DE\u590D</b></font></p>`;
|
|
3054
3152
|
return `<div>${badge}${sections.join("")}</div>`;
|
|
3055
3153
|
}
|
|
3056
|
-
function
|
|
3154
|
+
function renderMarkdownSection(raw) {
|
|
3057
3155
|
if (!raw.trim()) {
|
|
3058
3156
|
return "";
|
|
3059
3157
|
}
|
|
3060
|
-
const
|
|
3061
|
-
const
|
|
3062
|
-
|
|
3063
|
-
|
|
3064
|
-
const
|
|
3065
|
-
|
|
3066
|
-
|
|
3158
|
+
const lines = raw.replace(/\r\n/g, "\n").trim().split("\n");
|
|
3159
|
+
const blocks = [];
|
|
3160
|
+
let index = 0;
|
|
3161
|
+
while (index < lines.length) {
|
|
3162
|
+
const line = lines[index];
|
|
3163
|
+
const trimmed = line.trim();
|
|
3164
|
+
if (!trimmed) {
|
|
3165
|
+
index += 1;
|
|
3166
|
+
continue;
|
|
3167
|
+
}
|
|
3168
|
+
const headingMatch = /^(#{1,6})\s+(.+)$/.exec(trimmed);
|
|
3169
|
+
if (headingMatch) {
|
|
3170
|
+
const level = Math.min(6, headingMatch[1].length + 1);
|
|
3171
|
+
blocks.push(`<h${level}>${renderInlineMarkup(headingMatch[2])}</h${level}>`);
|
|
3172
|
+
index += 1;
|
|
3173
|
+
continue;
|
|
3174
|
+
}
|
|
3175
|
+
if (/^(?:-{3,}|\*{3,}|_{3,})$/.test(trimmed)) {
|
|
3176
|
+
blocks.push("<hr/>");
|
|
3177
|
+
index += 1;
|
|
3178
|
+
continue;
|
|
3179
|
+
}
|
|
3180
|
+
if (/^>\s?/.test(trimmed)) {
|
|
3181
|
+
const quoteLines = [];
|
|
3182
|
+
while (index < lines.length) {
|
|
3183
|
+
const current = lines[index].trim();
|
|
3184
|
+
if (!current) {
|
|
3185
|
+
break;
|
|
3186
|
+
}
|
|
3187
|
+
if (!/^>\s?/.test(current)) {
|
|
3188
|
+
break;
|
|
3189
|
+
}
|
|
3190
|
+
quoteLines.push(current.replace(/^>\s?/, ""));
|
|
3191
|
+
index += 1;
|
|
3192
|
+
}
|
|
3193
|
+
if (quoteLines.length > 0) {
|
|
3194
|
+
blocks.push(`<blockquote><p>${quoteLines.map((entry) => renderInlineMarkup(entry)).join("<br/>")}</p></blockquote>`);
|
|
3195
|
+
}
|
|
3196
|
+
continue;
|
|
3197
|
+
}
|
|
3198
|
+
if (/^\s*[-*]\s+/.test(line)) {
|
|
3199
|
+
const items = [];
|
|
3200
|
+
while (index < lines.length && /^\s*[-*]\s+/.test(lines[index])) {
|
|
3201
|
+
items.push(lines[index].replace(/^\s*[-*]\s+/, "").trim());
|
|
3202
|
+
index += 1;
|
|
3203
|
+
}
|
|
3204
|
+
blocks.push(`<ul>${items.map((item) => `<li>${renderInlineMarkup(item)}</li>`).join("")}</ul>`);
|
|
3205
|
+
continue;
|
|
3206
|
+
}
|
|
3207
|
+
if (/^\s*\d+\.\s+/.test(line)) {
|
|
3208
|
+
const items = [];
|
|
3209
|
+
while (index < lines.length && /^\s*\d+\.\s+/.test(lines[index])) {
|
|
3210
|
+
items.push(lines[index].replace(/^\s*\d+\.\s+/, "").trim());
|
|
3211
|
+
index += 1;
|
|
3212
|
+
}
|
|
3213
|
+
blocks.push(`<ol>${items.map((item) => `<li>${renderInlineMarkup(item)}</li>`).join("")}</ol>`);
|
|
3214
|
+
continue;
|
|
3215
|
+
}
|
|
3216
|
+
const paragraphLines = [];
|
|
3217
|
+
while (index < lines.length) {
|
|
3218
|
+
const current = lines[index];
|
|
3219
|
+
if (!current.trim()) {
|
|
3220
|
+
break;
|
|
3221
|
+
}
|
|
3222
|
+
if (isBlockBoundaryLine(current)) {
|
|
3223
|
+
break;
|
|
3224
|
+
}
|
|
3225
|
+
paragraphLines.push(current.trimEnd());
|
|
3226
|
+
index += 1;
|
|
3227
|
+
}
|
|
3228
|
+
if (paragraphLines.length > 0) {
|
|
3229
|
+
blocks.push(`<p>${paragraphLines.map((entry) => renderInlineMarkup(entry)).join("<br/>")}</p>`);
|
|
3230
|
+
continue;
|
|
3231
|
+
}
|
|
3232
|
+
index += 1;
|
|
3233
|
+
}
|
|
3234
|
+
return blocks.join("");
|
|
3235
|
+
}
|
|
3236
|
+
function isBlockBoundaryLine(line) {
|
|
3237
|
+
const trimmed = line.trim();
|
|
3238
|
+
if (!trimmed) {
|
|
3239
|
+
return false;
|
|
3240
|
+
}
|
|
3241
|
+
return /^(#{1,6})\s+/.test(trimmed) || /^(?:-{3,}|\*{3,}|_{3,})$/.test(trimmed) || /^>\s?/.test(trimmed) || /^\s*[-*]\s+/.test(trimmed) || /^\s*\d+\.\s+/.test(trimmed);
|
|
3242
|
+
}
|
|
3243
|
+
function renderInlineMarkup(raw) {
|
|
3244
|
+
if (!raw) {
|
|
3245
|
+
return "";
|
|
3246
|
+
}
|
|
3247
|
+
const inlineCodeSegments = [];
|
|
3248
|
+
const withPlaceholders = raw.replace(/`([^`\n]+)`/g, (_match, code) => {
|
|
3249
|
+
const token = `@@CHCODE${inlineCodeSegments.length}@@`;
|
|
3250
|
+
inlineCodeSegments.push(`<code>${escapeHtml(code)}</code>`);
|
|
3251
|
+
return token;
|
|
3252
|
+
});
|
|
3253
|
+
let rendered = escapeHtml(withPlaceholders);
|
|
3254
|
+
rendered = rendered.replace(/\[([^\]\n]+)\]\((https?:\/\/[^\s)]+)\)/g, (_match, label, url) => {
|
|
3255
|
+
const safeUrl = sanitizeLinkUrl(url);
|
|
3256
|
+
if (!safeUrl) {
|
|
3257
|
+
return escapeHtml(label);
|
|
3258
|
+
}
|
|
3259
|
+
return `<a href="${escapeHtml(safeUrl)}">${escapeHtml(label)}</a>`;
|
|
3260
|
+
});
|
|
3261
|
+
rendered = rendered.replace(/\*\*([^*\n]+)\*\*/g, "<strong>$1</strong>");
|
|
3262
|
+
rendered = rendered.replace(/(^|[^*])\*([^*\n]+)\*/g, "$1<em>$2</em>");
|
|
3263
|
+
rendered = rendered.replace(/(^|[^_])_([^_\n]+)_/g, "$1<em>$2</em>");
|
|
3264
|
+
for (let i = 0; i < inlineCodeSegments.length; i += 1) {
|
|
3265
|
+
rendered = rendered.replace(`@@CHCODE${i}@@`, inlineCodeSegments[i]);
|
|
3266
|
+
}
|
|
3067
3267
|
return rendered;
|
|
3068
3268
|
}
|
|
3269
|
+
function sanitizeLinkUrl(url) {
|
|
3270
|
+
try {
|
|
3271
|
+
const parsed = new URL(url);
|
|
3272
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
3273
|
+
return null;
|
|
3274
|
+
}
|
|
3275
|
+
return parsed.toString();
|
|
3276
|
+
} catch {
|
|
3277
|
+
return null;
|
|
3278
|
+
}
|
|
3279
|
+
}
|
|
3069
3280
|
function escapeHtml(value) {
|
|
3070
3281
|
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
3071
3282
|
}
|
|
@@ -3611,13 +3822,20 @@ var RateLimiter = class {
|
|
|
3611
3822
|
return [];
|
|
3612
3823
|
}
|
|
3613
3824
|
const threshold = now - this.options.windowMs;
|
|
3614
|
-
|
|
3615
|
-
|
|
3825
|
+
let writeIndex = 0;
|
|
3826
|
+
for (let readIndex = 0; readIndex < existing.length; readIndex += 1) {
|
|
3827
|
+
const timestamp = existing[readIndex];
|
|
3828
|
+
if (timestamp > threshold) {
|
|
3829
|
+
existing[writeIndex] = timestamp;
|
|
3830
|
+
writeIndex += 1;
|
|
3831
|
+
}
|
|
3832
|
+
}
|
|
3833
|
+
if (writeIndex === 0) {
|
|
3616
3834
|
container.delete(key);
|
|
3617
3835
|
return [];
|
|
3618
3836
|
}
|
|
3619
|
-
|
|
3620
|
-
return
|
|
3837
|
+
existing.length = writeIndex;
|
|
3838
|
+
return existing;
|
|
3621
3839
|
}
|
|
3622
3840
|
decrementCounter(container, key) {
|
|
3623
3841
|
const current = container.get(key) ?? 0;
|
|
@@ -4194,6 +4412,8 @@ function escapeRegex(value) {
|
|
|
4194
4412
|
}
|
|
4195
4413
|
|
|
4196
4414
|
// src/orchestrator.ts
|
|
4415
|
+
var RUN_SNAPSHOT_TTL_MS = 6 * 60 * 60 * 1e3;
|
|
4416
|
+
var RUN_SNAPSHOT_MAX_ENTRIES = 500;
|
|
4197
4417
|
var RequestMetrics = class {
|
|
4198
4418
|
total = 0;
|
|
4199
4419
|
success = 0;
|
|
@@ -4336,299 +4556,303 @@ var Orchestrator = class {
|
|
|
4336
4556
|
this.sessionRuntime = new CodexSessionRuntime(this.executor);
|
|
4337
4557
|
}
|
|
4338
4558
|
async handleMessage(message) {
|
|
4339
|
-
const
|
|
4340
|
-
|
|
4341
|
-
|
|
4342
|
-
|
|
4343
|
-
|
|
4344
|
-
|
|
4345
|
-
|
|
4346
|
-
this.
|
|
4347
|
-
|
|
4348
|
-
|
|
4349
|
-
|
|
4350
|
-
|
|
4351
|
-
|
|
4352
|
-
}
|
|
4353
|
-
const lock = this.getLock(sessionKey);
|
|
4354
|
-
await lock.runExclusive(async () => {
|
|
4355
|
-
const queueWaitMs = Date.now() - receivedAt;
|
|
4356
|
-
if (this.stateStore.hasProcessedEvent(sessionKey, message.eventId)) {
|
|
4357
|
-
this.metrics.record("duplicate", queueWaitMs, 0, 0);
|
|
4358
|
-
this.logger.debug("Duplicate event ignored", { requestId, eventId: message.eventId, sessionKey, queueWaitMs });
|
|
4359
|
-
return;
|
|
4360
|
-
}
|
|
4361
|
-
const roomConfig = this.resolveRoomRuntimeConfig(message.conversationId);
|
|
4362
|
-
const route = this.routeMessage(message, sessionKey, roomConfig);
|
|
4363
|
-
if (route.kind === "ignore") {
|
|
4364
|
-
this.metrics.record("ignored", queueWaitMs, 0, 0);
|
|
4365
|
-
this.logger.debug("Message ignored by routing policy", {
|
|
4366
|
-
requestId,
|
|
4367
|
-
sessionKey,
|
|
4368
|
-
isDirectMessage: message.isDirectMessage,
|
|
4369
|
-
mentionsBot: message.mentionsBot,
|
|
4370
|
-
repliesToBot: message.repliesToBot
|
|
4371
|
-
});
|
|
4372
|
-
return;
|
|
4373
|
-
}
|
|
4374
|
-
if (route.kind === "command") {
|
|
4375
|
-
await this.handleControlCommand(route.command, sessionKey, message, requestId);
|
|
4376
|
-
this.stateStore.markEventProcessed(sessionKey, message.eventId);
|
|
4377
|
-
return;
|
|
4378
|
-
}
|
|
4379
|
-
const workflowCommand = this.workflowRunner.isEnabled() ? parseWorkflowCommand(route.prompt) : null;
|
|
4380
|
-
const autoDevCommand = this.workflowRunner.isEnabled() ? parseAutoDevCommand(route.prompt) : null;
|
|
4381
|
-
if (workflowCommand?.kind === "status") {
|
|
4382
|
-
await this.handleWorkflowStatusCommand(sessionKey, message);
|
|
4383
|
-
this.stateStore.markEventProcessed(sessionKey, message.eventId);
|
|
4384
|
-
return;
|
|
4385
|
-
}
|
|
4386
|
-
if (autoDevCommand?.kind === "status") {
|
|
4387
|
-
await this.handleAutoDevStatusCommand(sessionKey, message, roomConfig.workdir);
|
|
4388
|
-
this.stateStore.markEventProcessed(sessionKey, message.eventId);
|
|
4389
|
-
return;
|
|
4390
|
-
}
|
|
4391
|
-
const rateDecision = this.rateLimiter.tryAcquire({
|
|
4392
|
-
userId: message.senderId,
|
|
4393
|
-
roomId: message.conversationId
|
|
4394
|
-
});
|
|
4395
|
-
if (!rateDecision.allowed) {
|
|
4396
|
-
this.metrics.record("rate_limited", queueWaitMs, 0, 0);
|
|
4397
|
-
await this.channel.sendNotice(message.conversationId, buildRateLimitNotice(rateDecision));
|
|
4559
|
+
const attachmentPaths = collectImagePaths(message);
|
|
4560
|
+
try {
|
|
4561
|
+
const receivedAt = Date.now();
|
|
4562
|
+
const requestId = message.requestId || message.eventId;
|
|
4563
|
+
const sessionKey = buildSessionKey(message);
|
|
4564
|
+
const directCommand = parseControlCommand(message.text.trim());
|
|
4565
|
+
if (directCommand === "stop") {
|
|
4566
|
+
if (this.stateStore.hasProcessedEvent(sessionKey, message.eventId)) {
|
|
4567
|
+
this.metrics.record("duplicate", 0, 0, 0);
|
|
4568
|
+
this.logger.debug("Duplicate stop command ignored", { requestId, eventId: message.eventId, sessionKey });
|
|
4569
|
+
return;
|
|
4570
|
+
}
|
|
4571
|
+
await this.handleStopCommand(sessionKey, message, requestId);
|
|
4398
4572
|
this.stateStore.markEventProcessed(sessionKey, message.eventId);
|
|
4399
|
-
this.logger.warn("Request rejected by rate limiter", {
|
|
4400
|
-
requestId,
|
|
4401
|
-
sessionKey,
|
|
4402
|
-
reason: rateDecision.reason,
|
|
4403
|
-
retryAfterMs: rateDecision.retryAfterMs ?? null,
|
|
4404
|
-
queueWaitMs
|
|
4405
|
-
});
|
|
4406
4573
|
return;
|
|
4407
4574
|
}
|
|
4408
|
-
|
|
4409
|
-
|
|
4410
|
-
|
|
4411
|
-
this.stateStore.
|
|
4412
|
-
|
|
4413
|
-
|
|
4414
|
-
|
|
4415
|
-
|
|
4416
|
-
|
|
4417
|
-
|
|
4418
|
-
|
|
4419
|
-
|
|
4420
|
-
|
|
4421
|
-
sendDurationMs2 += Date.now() - sendStartedAt;
|
|
4422
|
-
this.stateStore.markEventProcessed(sessionKey, message.eventId);
|
|
4423
|
-
this.metrics.record("success", queueWaitMs, Date.now() - executionStartedAt, sendDurationMs2);
|
|
4424
|
-
} catch (error) {
|
|
4425
|
-
sendDurationMs2 += await this.sendWorkflowFailure(message.conversationId, error);
|
|
4426
|
-
this.stateStore.commitExecutionHandled(sessionKey, message.eventId);
|
|
4427
|
-
const status = classifyExecutionOutcome(error);
|
|
4428
|
-
this.metrics.record(status, queueWaitMs, Date.now() - executionStartedAt, sendDurationMs2);
|
|
4429
|
-
this.logger.error("Workflow request failed", {
|
|
4575
|
+
const lock = this.getLock(sessionKey);
|
|
4576
|
+
await lock.runExclusive(async () => {
|
|
4577
|
+
const queueWaitMs = Date.now() - receivedAt;
|
|
4578
|
+
if (this.stateStore.hasProcessedEvent(sessionKey, message.eventId)) {
|
|
4579
|
+
this.metrics.record("duplicate", queueWaitMs, 0, 0);
|
|
4580
|
+
this.logger.debug("Duplicate event ignored", { requestId, eventId: message.eventId, sessionKey, queueWaitMs });
|
|
4581
|
+
return;
|
|
4582
|
+
}
|
|
4583
|
+
const roomConfig = this.resolveRoomRuntimeConfig(message.conversationId);
|
|
4584
|
+
const route = this.routeMessage(message, sessionKey, roomConfig);
|
|
4585
|
+
if (route.kind === "ignore") {
|
|
4586
|
+
this.metrics.record("ignored", queueWaitMs, 0, 0);
|
|
4587
|
+
this.logger.debug("Message ignored by routing policy", {
|
|
4430
4588
|
requestId,
|
|
4431
4589
|
sessionKey,
|
|
4432
|
-
|
|
4590
|
+
isDirectMessage: message.isDirectMessage,
|
|
4591
|
+
mentionsBot: message.mentionsBot,
|
|
4592
|
+
repliesToBot: message.repliesToBot
|
|
4433
4593
|
});
|
|
4434
|
-
|
|
4435
|
-
rateDecision.release?.();
|
|
4594
|
+
return;
|
|
4436
4595
|
}
|
|
4437
|
-
|
|
4438
|
-
|
|
4439
|
-
if (autoDevCommand?.kind === "run") {
|
|
4440
|
-
const executionStartedAt = Date.now();
|
|
4441
|
-
let sendDurationMs2 = 0;
|
|
4442
|
-
this.stateStore.activateSession(sessionKey, this.sessionActiveWindowMs);
|
|
4443
|
-
try {
|
|
4444
|
-
const sendStartedAt = Date.now();
|
|
4445
|
-
await this.handleAutoDevRunCommand(
|
|
4446
|
-
autoDevCommand.taskId,
|
|
4447
|
-
sessionKey,
|
|
4448
|
-
message,
|
|
4449
|
-
requestId,
|
|
4450
|
-
roomConfig.workdir
|
|
4451
|
-
);
|
|
4452
|
-
sendDurationMs2 += Date.now() - sendStartedAt;
|
|
4596
|
+
if (route.kind === "command") {
|
|
4597
|
+
await this.handleControlCommand(route.command, sessionKey, message, requestId);
|
|
4453
4598
|
this.stateStore.markEventProcessed(sessionKey, message.eventId);
|
|
4454
|
-
|
|
4455
|
-
}
|
|
4456
|
-
|
|
4457
|
-
|
|
4458
|
-
|
|
4459
|
-
this.
|
|
4460
|
-
this.
|
|
4599
|
+
return;
|
|
4600
|
+
}
|
|
4601
|
+
const workflowCommand = this.workflowRunner.isEnabled() ? parseWorkflowCommand(route.prompt) : null;
|
|
4602
|
+
const autoDevCommand = this.workflowRunner.isEnabled() ? parseAutoDevCommand(route.prompt) : null;
|
|
4603
|
+
if (workflowCommand?.kind === "status") {
|
|
4604
|
+
await this.handleWorkflowStatusCommand(sessionKey, message);
|
|
4605
|
+
this.stateStore.markEventProcessed(sessionKey, message.eventId);
|
|
4606
|
+
return;
|
|
4607
|
+
}
|
|
4608
|
+
if (autoDevCommand?.kind === "status") {
|
|
4609
|
+
await this.handleAutoDevStatusCommand(sessionKey, message, roomConfig.workdir);
|
|
4610
|
+
this.stateStore.markEventProcessed(sessionKey, message.eventId);
|
|
4611
|
+
return;
|
|
4612
|
+
}
|
|
4613
|
+
const rateDecision = this.rateLimiter.tryAcquire({
|
|
4614
|
+
userId: message.senderId,
|
|
4615
|
+
roomId: message.conversationId
|
|
4616
|
+
});
|
|
4617
|
+
if (!rateDecision.allowed) {
|
|
4618
|
+
this.metrics.record("rate_limited", queueWaitMs, 0, 0);
|
|
4619
|
+
await this.channel.sendNotice(message.conversationId, buildRateLimitNotice(rateDecision));
|
|
4620
|
+
this.stateStore.markEventProcessed(sessionKey, message.eventId);
|
|
4621
|
+
this.logger.warn("Request rejected by rate limiter", {
|
|
4461
4622
|
requestId,
|
|
4462
4623
|
sessionKey,
|
|
4463
|
-
|
|
4624
|
+
reason: rateDecision.reason,
|
|
4625
|
+
retryAfterMs: rateDecision.retryAfterMs ?? null,
|
|
4626
|
+
queueWaitMs
|
|
4464
4627
|
});
|
|
4465
|
-
|
|
4466
|
-
rateDecision.release?.();
|
|
4628
|
+
return;
|
|
4467
4629
|
}
|
|
4468
|
-
|
|
4469
|
-
|
|
4470
|
-
|
|
4471
|
-
|
|
4472
|
-
|
|
4473
|
-
|
|
4474
|
-
|
|
4475
|
-
|
|
4476
|
-
|
|
4477
|
-
|
|
4478
|
-
|
|
4479
|
-
|
|
4480
|
-
|
|
4481
|
-
|
|
4482
|
-
|
|
4483
|
-
|
|
4484
|
-
|
|
4485
|
-
|
|
4486
|
-
|
|
4487
|
-
|
|
4488
|
-
|
|
4630
|
+
if (workflowCommand?.kind === "run") {
|
|
4631
|
+
const executionStartedAt = Date.now();
|
|
4632
|
+
let sendDurationMs2 = 0;
|
|
4633
|
+
this.stateStore.activateSession(sessionKey, this.sessionActiveWindowMs);
|
|
4634
|
+
try {
|
|
4635
|
+
const sendStartedAt = Date.now();
|
|
4636
|
+
await this.handleWorkflowRunCommand(
|
|
4637
|
+
workflowCommand.objective,
|
|
4638
|
+
sessionKey,
|
|
4639
|
+
message,
|
|
4640
|
+
requestId,
|
|
4641
|
+
roomConfig.workdir
|
|
4642
|
+
);
|
|
4643
|
+
sendDurationMs2 += Date.now() - sendStartedAt;
|
|
4644
|
+
this.stateStore.markEventProcessed(sessionKey, message.eventId);
|
|
4645
|
+
this.metrics.record("success", queueWaitMs, Date.now() - executionStartedAt, sendDurationMs2);
|
|
4646
|
+
} catch (error) {
|
|
4647
|
+
sendDurationMs2 += await this.sendWorkflowFailure(message.conversationId, error);
|
|
4648
|
+
this.stateStore.commitExecutionHandled(sessionKey, message.eventId);
|
|
4649
|
+
const status = classifyExecutionOutcome(error);
|
|
4650
|
+
this.metrics.record(status, queueWaitMs, Date.now() - executionStartedAt, sendDurationMs2);
|
|
4651
|
+
this.logger.error("Workflow request failed", {
|
|
4652
|
+
requestId,
|
|
4653
|
+
sessionKey,
|
|
4654
|
+
error: formatError3(error)
|
|
4655
|
+
});
|
|
4656
|
+
} finally {
|
|
4657
|
+
rateDecision.release?.();
|
|
4658
|
+
}
|
|
4659
|
+
return;
|
|
4489
4660
|
}
|
|
4490
|
-
|
|
4491
|
-
|
|
4492
|
-
|
|
4493
|
-
|
|
4494
|
-
|
|
4495
|
-
|
|
4496
|
-
|
|
4497
|
-
|
|
4498
|
-
|
|
4499
|
-
|
|
4500
|
-
|
|
4501
|
-
|
|
4502
|
-
|
|
4503
|
-
|
|
4504
|
-
|
|
4505
|
-
|
|
4506
|
-
|
|
4507
|
-
|
|
4508
|
-
|
|
4509
|
-
|
|
4510
|
-
|
|
4511
|
-
|
|
4512
|
-
|
|
4513
|
-
|
|
4514
|
-
|
|
4515
|
-
sessionKey,
|
|
4516
|
-
executionPrompt,
|
|
4517
|
-
previousCodexSessionId,
|
|
4518
|
-
(progress) => {
|
|
4519
|
-
progressChain = progressChain.then(
|
|
4520
|
-
() => this.handleProgress(
|
|
4521
|
-
message.conversationId,
|
|
4522
|
-
message.isDirectMessage,
|
|
4523
|
-
progress,
|
|
4524
|
-
() => lastProgressAt,
|
|
4525
|
-
(next) => {
|
|
4526
|
-
lastProgressAt = next;
|
|
4527
|
-
},
|
|
4528
|
-
() => lastProgressText,
|
|
4529
|
-
(next) => {
|
|
4530
|
-
lastProgressText = next;
|
|
4531
|
-
},
|
|
4532
|
-
() => progressNoticeEventId,
|
|
4533
|
-
(next) => {
|
|
4534
|
-
progressNoticeEventId = next;
|
|
4535
|
-
}
|
|
4536
|
-
)
|
|
4537
|
-
).catch((progressError) => {
|
|
4538
|
-
this.logger.debug("Failed to process progress callback", { progressError });
|
|
4661
|
+
if (autoDevCommand?.kind === "run") {
|
|
4662
|
+
const executionStartedAt = Date.now();
|
|
4663
|
+
let sendDurationMs2 = 0;
|
|
4664
|
+
this.stateStore.activateSession(sessionKey, this.sessionActiveWindowMs);
|
|
4665
|
+
try {
|
|
4666
|
+
const sendStartedAt = Date.now();
|
|
4667
|
+
await this.handleAutoDevRunCommand(
|
|
4668
|
+
autoDevCommand.taskId,
|
|
4669
|
+
sessionKey,
|
|
4670
|
+
message,
|
|
4671
|
+
requestId,
|
|
4672
|
+
roomConfig.workdir
|
|
4673
|
+
);
|
|
4674
|
+
sendDurationMs2 += Date.now() - sendStartedAt;
|
|
4675
|
+
this.stateStore.markEventProcessed(sessionKey, message.eventId);
|
|
4676
|
+
this.metrics.record("success", queueWaitMs, Date.now() - executionStartedAt, sendDurationMs2);
|
|
4677
|
+
} catch (error) {
|
|
4678
|
+
sendDurationMs2 += await this.sendAutoDevFailure(message.conversationId, error);
|
|
4679
|
+
this.stateStore.commitExecutionHandled(sessionKey, message.eventId);
|
|
4680
|
+
const status = classifyExecutionOutcome(error);
|
|
4681
|
+
this.metrics.record(status, queueWaitMs, Date.now() - executionStartedAt, sendDurationMs2);
|
|
4682
|
+
this.logger.error("AutoDev request failed", {
|
|
4683
|
+
requestId,
|
|
4684
|
+
sessionKey,
|
|
4685
|
+
error: formatError3(error)
|
|
4539
4686
|
});
|
|
4540
|
-
}
|
|
4541
|
-
|
|
4542
|
-
passThroughRawEvents: this.cliCompat.enabled && this.cliCompat.passThroughEvents,
|
|
4543
|
-
imagePaths,
|
|
4544
|
-
workdir: roomConfig.workdir
|
|
4687
|
+
} finally {
|
|
4688
|
+
rateDecision.release?.();
|
|
4545
4689
|
}
|
|
4546
|
-
|
|
4547
|
-
|
|
4548
|
-
|
|
4549
|
-
|
|
4550
|
-
|
|
4690
|
+
return;
|
|
4691
|
+
}
|
|
4692
|
+
this.stateStore.activateSession(sessionKey, this.sessionActiveWindowMs);
|
|
4693
|
+
const previousCodexSessionId = this.stateStore.getCodexSessionId(sessionKey);
|
|
4694
|
+
const executionPrompt = this.buildExecutionPrompt(route.prompt, message);
|
|
4695
|
+
const imagePaths = collectImagePaths(message);
|
|
4696
|
+
let lastProgressAt = 0;
|
|
4697
|
+
let lastProgressText = "";
|
|
4698
|
+
let progressNoticeEventId = null;
|
|
4699
|
+
let progressChain = Promise.resolve();
|
|
4700
|
+
let executionHandle = null;
|
|
4701
|
+
let executionDurationMs = 0;
|
|
4702
|
+
let sendDurationMs = 0;
|
|
4703
|
+
const requestStartedAt = Date.now();
|
|
4704
|
+
let cancelRequested = false;
|
|
4705
|
+
this.runningExecutions.set(sessionKey, {
|
|
4706
|
+
requestId,
|
|
4707
|
+
startedAt: requestStartedAt,
|
|
4708
|
+
cancel: () => {
|
|
4551
4709
|
cancelRequested = true;
|
|
4552
4710
|
executionHandle?.cancel();
|
|
4553
|
-
}
|
|
4554
|
-
}
|
|
4555
|
-
|
|
4556
|
-
executionHandle.cancel();
|
|
4557
|
-
}
|
|
4558
|
-
const result = await executionHandle.result;
|
|
4559
|
-
executionDurationMs = Date.now() - executionStartedAt;
|
|
4560
|
-
await progressChain;
|
|
4561
|
-
const sendStartedAt = Date.now();
|
|
4562
|
-
await this.channel.sendMessage(message.conversationId, result.reply);
|
|
4563
|
-
await this.finishProgress(
|
|
4564
|
-
{
|
|
4565
|
-
conversationId: message.conversationId,
|
|
4566
|
-
isDirectMessage: message.isDirectMessage,
|
|
4567
|
-
getProgressNoticeEventId: () => progressNoticeEventId,
|
|
4568
|
-
setProgressNoticeEventId: (next) => {
|
|
4569
|
-
progressNoticeEventId = next;
|
|
4570
|
-
}
|
|
4571
|
-
},
|
|
4572
|
-
`\u5904\u7406\u5B8C\u6210\uFF08\u8017\u65F6 ${formatDurationMs(Date.now() - requestStartedAt)}\uFF09`
|
|
4573
|
-
);
|
|
4574
|
-
sendDurationMs = Date.now() - sendStartedAt;
|
|
4575
|
-
this.stateStore.commitExecutionSuccess(sessionKey, message.eventId, result.sessionId);
|
|
4576
|
-
this.metrics.record("success", queueWaitMs, executionDurationMs, sendDurationMs);
|
|
4577
|
-
this.logger.info("Request completed", {
|
|
4711
|
+
}
|
|
4712
|
+
});
|
|
4713
|
+
await this.recordCliCompatPrompt({
|
|
4578
4714
|
requestId,
|
|
4579
4715
|
sessionKey,
|
|
4580
|
-
|
|
4581
|
-
|
|
4582
|
-
|
|
4583
|
-
|
|
4584
|
-
totalDurationMs: Date.now() - receivedAt
|
|
4716
|
+
conversationId: message.conversationId,
|
|
4717
|
+
senderId: message.senderId,
|
|
4718
|
+
prompt: executionPrompt,
|
|
4719
|
+
imageCount: imagePaths.length
|
|
4585
4720
|
});
|
|
4586
|
-
|
|
4587
|
-
const status = classifyExecutionOutcome(error);
|
|
4588
|
-
executionDurationMs = Date.now() - requestStartedAt;
|
|
4589
|
-
await progressChain;
|
|
4590
|
-
await this.finishProgress(
|
|
4591
|
-
{
|
|
4592
|
-
conversationId: message.conversationId,
|
|
4593
|
-
isDirectMessage: message.isDirectMessage,
|
|
4594
|
-
getProgressNoticeEventId: () => progressNoticeEventId,
|
|
4595
|
-
setProgressNoticeEventId: (next) => {
|
|
4596
|
-
progressNoticeEventId = next;
|
|
4597
|
-
}
|
|
4598
|
-
},
|
|
4599
|
-
buildFailureProgressSummary(status, requestStartedAt, error)
|
|
4600
|
-
);
|
|
4601
|
-
if (status !== "cancelled") {
|
|
4602
|
-
try {
|
|
4603
|
-
await this.channel.sendMessage(
|
|
4604
|
-
message.conversationId,
|
|
4605
|
-
`[CodeHarbor] Failed to process request: ${formatError2(error)}`
|
|
4606
|
-
);
|
|
4607
|
-
} catch (sendError) {
|
|
4608
|
-
this.logger.error("Failed to send error reply to Matrix", sendError);
|
|
4609
|
-
}
|
|
4610
|
-
}
|
|
4611
|
-
this.stateStore.commitExecutionHandled(sessionKey, message.eventId);
|
|
4612
|
-
this.metrics.record(status, queueWaitMs, executionDurationMs, sendDurationMs);
|
|
4613
|
-
this.logger.error("Request failed", {
|
|
4721
|
+
this.logger.info("Processing message", {
|
|
4614
4722
|
requestId,
|
|
4615
4723
|
sessionKey,
|
|
4616
|
-
|
|
4724
|
+
hasCodexSession: Boolean(previousCodexSessionId),
|
|
4617
4725
|
queueWaitMs,
|
|
4618
|
-
|
|
4619
|
-
|
|
4620
|
-
|
|
4726
|
+
attachmentCount: message.attachments.length,
|
|
4727
|
+
workdir: roomConfig.workdir,
|
|
4728
|
+
roomConfigSource: roomConfig.source,
|
|
4729
|
+
isDirectMessage: message.isDirectMessage,
|
|
4730
|
+
mentionsBot: message.mentionsBot,
|
|
4731
|
+
repliesToBot: message.repliesToBot
|
|
4621
4732
|
});
|
|
4622
|
-
|
|
4623
|
-
|
|
4624
|
-
|
|
4625
|
-
this.
|
|
4733
|
+
const stopTyping = this.startTypingHeartbeat(message.conversationId);
|
|
4734
|
+
try {
|
|
4735
|
+
const executionStartedAt = Date.now();
|
|
4736
|
+
executionHandle = this.sessionRuntime.startExecution(
|
|
4737
|
+
sessionKey,
|
|
4738
|
+
executionPrompt,
|
|
4739
|
+
previousCodexSessionId,
|
|
4740
|
+
(progress) => {
|
|
4741
|
+
progressChain = progressChain.then(
|
|
4742
|
+
() => this.handleProgress(
|
|
4743
|
+
message.conversationId,
|
|
4744
|
+
message.isDirectMessage,
|
|
4745
|
+
progress,
|
|
4746
|
+
() => lastProgressAt,
|
|
4747
|
+
(next) => {
|
|
4748
|
+
lastProgressAt = next;
|
|
4749
|
+
},
|
|
4750
|
+
() => lastProgressText,
|
|
4751
|
+
(next) => {
|
|
4752
|
+
lastProgressText = next;
|
|
4753
|
+
},
|
|
4754
|
+
() => progressNoticeEventId,
|
|
4755
|
+
(next) => {
|
|
4756
|
+
progressNoticeEventId = next;
|
|
4757
|
+
}
|
|
4758
|
+
)
|
|
4759
|
+
).catch((progressError) => {
|
|
4760
|
+
this.logger.debug("Failed to process progress callback", { progressError });
|
|
4761
|
+
});
|
|
4762
|
+
},
|
|
4763
|
+
{
|
|
4764
|
+
passThroughRawEvents: this.cliCompat.enabled && this.cliCompat.passThroughEvents,
|
|
4765
|
+
imagePaths,
|
|
4766
|
+
workdir: roomConfig.workdir
|
|
4767
|
+
}
|
|
4768
|
+
);
|
|
4769
|
+
const running = this.runningExecutions.get(sessionKey);
|
|
4770
|
+
if (running?.requestId === requestId) {
|
|
4771
|
+
running.startedAt = executionStartedAt;
|
|
4772
|
+
running.cancel = () => {
|
|
4773
|
+
cancelRequested = true;
|
|
4774
|
+
executionHandle?.cancel();
|
|
4775
|
+
};
|
|
4776
|
+
}
|
|
4777
|
+
if (cancelRequested) {
|
|
4778
|
+
executionHandle.cancel();
|
|
4779
|
+
}
|
|
4780
|
+
const result = await executionHandle.result;
|
|
4781
|
+
executionDurationMs = Date.now() - executionStartedAt;
|
|
4782
|
+
await progressChain;
|
|
4783
|
+
const sendStartedAt = Date.now();
|
|
4784
|
+
await this.channel.sendMessage(message.conversationId, result.reply);
|
|
4785
|
+
await this.finishProgress(
|
|
4786
|
+
{
|
|
4787
|
+
conversationId: message.conversationId,
|
|
4788
|
+
isDirectMessage: message.isDirectMessage,
|
|
4789
|
+
getProgressNoticeEventId: () => progressNoticeEventId,
|
|
4790
|
+
setProgressNoticeEventId: (next) => {
|
|
4791
|
+
progressNoticeEventId = next;
|
|
4792
|
+
}
|
|
4793
|
+
},
|
|
4794
|
+
`\u5904\u7406\u5B8C\u6210\uFF08\u8017\u65F6 ${formatDurationMs(Date.now() - requestStartedAt)}\uFF09`
|
|
4795
|
+
);
|
|
4796
|
+
sendDurationMs = Date.now() - sendStartedAt;
|
|
4797
|
+
this.stateStore.commitExecutionSuccess(sessionKey, message.eventId, result.sessionId);
|
|
4798
|
+
this.metrics.record("success", queueWaitMs, executionDurationMs, sendDurationMs);
|
|
4799
|
+
this.logger.info("Request completed", {
|
|
4800
|
+
requestId,
|
|
4801
|
+
sessionKey,
|
|
4802
|
+
status: "success",
|
|
4803
|
+
queueWaitMs,
|
|
4804
|
+
executionDurationMs,
|
|
4805
|
+
sendDurationMs,
|
|
4806
|
+
totalDurationMs: Date.now() - receivedAt
|
|
4807
|
+
});
|
|
4808
|
+
} catch (error) {
|
|
4809
|
+
const status = classifyExecutionOutcome(error);
|
|
4810
|
+
executionDurationMs = Date.now() - requestStartedAt;
|
|
4811
|
+
await progressChain;
|
|
4812
|
+
await this.finishProgress(
|
|
4813
|
+
{
|
|
4814
|
+
conversationId: message.conversationId,
|
|
4815
|
+
isDirectMessage: message.isDirectMessage,
|
|
4816
|
+
getProgressNoticeEventId: () => progressNoticeEventId,
|
|
4817
|
+
setProgressNoticeEventId: (next) => {
|
|
4818
|
+
progressNoticeEventId = next;
|
|
4819
|
+
}
|
|
4820
|
+
},
|
|
4821
|
+
buildFailureProgressSummary(status, requestStartedAt, error)
|
|
4822
|
+
);
|
|
4823
|
+
if (status !== "cancelled") {
|
|
4824
|
+
try {
|
|
4825
|
+
await this.channel.sendMessage(
|
|
4826
|
+
message.conversationId,
|
|
4827
|
+
`[CodeHarbor] Failed to process request: ${formatError3(error)}`
|
|
4828
|
+
);
|
|
4829
|
+
} catch (sendError) {
|
|
4830
|
+
this.logger.error("Failed to send error reply to Matrix", sendError);
|
|
4831
|
+
}
|
|
4832
|
+
}
|
|
4833
|
+
this.stateStore.commitExecutionHandled(sessionKey, message.eventId);
|
|
4834
|
+
this.metrics.record(status, queueWaitMs, executionDurationMs, sendDurationMs);
|
|
4835
|
+
this.logger.error("Request failed", {
|
|
4836
|
+
requestId,
|
|
4837
|
+
sessionKey,
|
|
4838
|
+
status,
|
|
4839
|
+
queueWaitMs,
|
|
4840
|
+
executionDurationMs,
|
|
4841
|
+
totalDurationMs: Date.now() - receivedAt,
|
|
4842
|
+
error: formatError3(error)
|
|
4843
|
+
});
|
|
4844
|
+
} finally {
|
|
4845
|
+
const running = this.runningExecutions.get(sessionKey);
|
|
4846
|
+
if (running?.requestId === requestId) {
|
|
4847
|
+
this.runningExecutions.delete(sessionKey);
|
|
4848
|
+
}
|
|
4849
|
+
rateDecision.release?.();
|
|
4850
|
+
await stopTyping();
|
|
4626
4851
|
}
|
|
4627
|
-
|
|
4628
|
-
|
|
4629
|
-
|
|
4630
|
-
|
|
4631
|
-
});
|
|
4852
|
+
});
|
|
4853
|
+
} finally {
|
|
4854
|
+
await cleanupAttachmentFiles(attachmentPaths);
|
|
4855
|
+
}
|
|
4632
4856
|
}
|
|
4633
4857
|
routeMessage(message, sessionKey, roomConfig) {
|
|
4634
4858
|
const incomingRaw = message.text;
|
|
@@ -4674,6 +4898,8 @@ var Orchestrator = class {
|
|
|
4674
4898
|
this.stateStore.clearCodexSessionId(sessionKey);
|
|
4675
4899
|
this.stateStore.activateSession(sessionKey, this.sessionActiveWindowMs);
|
|
4676
4900
|
this.sessionRuntime.clearSession(sessionKey);
|
|
4901
|
+
this.workflowSnapshots.delete(sessionKey);
|
|
4902
|
+
this.autoDevSnapshots.delete(sessionKey);
|
|
4677
4903
|
await this.channel.sendNotice(
|
|
4678
4904
|
message.conversationId,
|
|
4679
4905
|
"[CodeHarbor] \u4E0A\u4E0B\u6587\u5DF2\u91CD\u7F6E\u3002\u4F60\u53EF\u4EE5\u7EE7\u7EED\u76F4\u63A5\u53D1\u9001\u65B0\u9700\u6C42\u3002"
|
|
@@ -4740,7 +4966,7 @@ var Orchestrator = class {
|
|
|
4740
4966
|
- runError: ${snapshot.error ?? "N/A"}`
|
|
4741
4967
|
);
|
|
4742
4968
|
} catch (error) {
|
|
4743
|
-
await this.channel.sendNotice(message.conversationId, `[CodeHarbor] AutoDev \u72B6\u6001\u8BFB\u53D6\u5931\u8D25: ${
|
|
4969
|
+
await this.channel.sendNotice(message.conversationId, `[CodeHarbor] AutoDev \u72B6\u6001\u8BFB\u53D6\u5931\u8D25: ${formatError3(error)}`);
|
|
4744
4970
|
}
|
|
4745
4971
|
}
|
|
4746
4972
|
async handleAutoDevRunCommand(taskId, sessionKey, message, requestId, workdir) {
|
|
@@ -4791,7 +5017,7 @@ var Orchestrator = class {
|
|
|
4791
5017
|
promotedToInProgress = true;
|
|
4792
5018
|
}
|
|
4793
5019
|
const startedAtIso = (/* @__PURE__ */ new Date()).toISOString();
|
|
4794
|
-
this.
|
|
5020
|
+
this.setAutoDevSnapshot(sessionKey, {
|
|
4795
5021
|
state: "running",
|
|
4796
5022
|
startedAt: startedAtIso,
|
|
4797
5023
|
endedAt: null,
|
|
@@ -4821,7 +5047,7 @@ var Orchestrator = class {
|
|
|
4821
5047
|
finalTask = await updateAutoDevTaskStatus(context.taskListPath, activeTask, "completed");
|
|
4822
5048
|
}
|
|
4823
5049
|
const endedAtIso = (/* @__PURE__ */ new Date()).toISOString();
|
|
4824
|
-
this.
|
|
5050
|
+
this.setAutoDevSnapshot(sessionKey, {
|
|
4825
5051
|
state: "succeeded",
|
|
4826
5052
|
startedAt: startedAtIso,
|
|
4827
5053
|
endedAt: endedAtIso,
|
|
@@ -4848,13 +5074,13 @@ var Orchestrator = class {
|
|
|
4848
5074
|
} catch (restoreError) {
|
|
4849
5075
|
this.logger.warn("Failed to restore AutoDev task status after failure", {
|
|
4850
5076
|
taskId: activeTask.id,
|
|
4851
|
-
error:
|
|
5077
|
+
error: formatError3(restoreError)
|
|
4852
5078
|
});
|
|
4853
5079
|
}
|
|
4854
5080
|
}
|
|
4855
5081
|
const status = classifyExecutionOutcome(error);
|
|
4856
5082
|
const endedAtIso = (/* @__PURE__ */ new Date()).toISOString();
|
|
4857
|
-
this.
|
|
5083
|
+
this.setAutoDevSnapshot(sessionKey, {
|
|
4858
5084
|
state: status === "cancelled" ? "idle" : "failed",
|
|
4859
5085
|
startedAt: startedAtIso,
|
|
4860
5086
|
endedAt: endedAtIso,
|
|
@@ -4862,7 +5088,7 @@ var Orchestrator = class {
|
|
|
4862
5088
|
taskDescription: activeTask.description,
|
|
4863
5089
|
approved: null,
|
|
4864
5090
|
repairRounds: 0,
|
|
4865
|
-
error:
|
|
5091
|
+
error: formatError3(error)
|
|
4866
5092
|
});
|
|
4867
5093
|
throw error;
|
|
4868
5094
|
}
|
|
@@ -4884,7 +5110,7 @@ var Orchestrator = class {
|
|
|
4884
5110
|
}
|
|
4885
5111
|
};
|
|
4886
5112
|
const startedAtIso = (/* @__PURE__ */ new Date()).toISOString();
|
|
4887
|
-
this.
|
|
5113
|
+
this.setWorkflowSnapshot(sessionKey, {
|
|
4888
5114
|
state: "running",
|
|
4889
5115
|
startedAt: startedAtIso,
|
|
4890
5116
|
endedAt: null,
|
|
@@ -4922,7 +5148,7 @@ var Orchestrator = class {
|
|
|
4922
5148
|
}
|
|
4923
5149
|
});
|
|
4924
5150
|
const endedAtIso = (/* @__PURE__ */ new Date()).toISOString();
|
|
4925
|
-
this.
|
|
5151
|
+
this.setWorkflowSnapshot(sessionKey, {
|
|
4926
5152
|
state: "succeeded",
|
|
4927
5153
|
startedAt: startedAtIso,
|
|
4928
5154
|
endedAt: endedAtIso,
|
|
@@ -4937,14 +5163,14 @@ var Orchestrator = class {
|
|
|
4937
5163
|
} catch (error) {
|
|
4938
5164
|
const status = classifyExecutionOutcome(error);
|
|
4939
5165
|
const endedAtIso = (/* @__PURE__ */ new Date()).toISOString();
|
|
4940
|
-
this.
|
|
5166
|
+
this.setWorkflowSnapshot(sessionKey, {
|
|
4941
5167
|
state: status === "cancelled" ? "idle" : "failed",
|
|
4942
5168
|
startedAt: startedAtIso,
|
|
4943
5169
|
endedAt: endedAtIso,
|
|
4944
5170
|
objective: normalizedObjective,
|
|
4945
5171
|
approved: null,
|
|
4946
5172
|
repairRounds: 0,
|
|
4947
|
-
error:
|
|
5173
|
+
error: formatError3(error)
|
|
4948
5174
|
});
|
|
4949
5175
|
await this.finishProgress(progressCtx, buildFailureProgressSummary(status, requestStartedAt, error));
|
|
4950
5176
|
throw error;
|
|
@@ -4963,7 +5189,7 @@ var Orchestrator = class {
|
|
|
4963
5189
|
await this.channel.sendNotice(conversationId, "[CodeHarbor] Multi-Agent workflow \u5DF2\u53D6\u6D88\u3002");
|
|
4964
5190
|
return Date.now() - startedAt;
|
|
4965
5191
|
}
|
|
4966
|
-
await this.channel.sendMessage(conversationId, `[CodeHarbor] Multi-Agent workflow \u5931\u8D25: ${
|
|
5192
|
+
await this.channel.sendMessage(conversationId, `[CodeHarbor] Multi-Agent workflow \u5931\u8D25: ${formatError3(error)}`);
|
|
4967
5193
|
return Date.now() - startedAt;
|
|
4968
5194
|
}
|
|
4969
5195
|
async sendAutoDevFailure(conversationId, error) {
|
|
@@ -4973,7 +5199,7 @@ var Orchestrator = class {
|
|
|
4973
5199
|
await this.channel.sendNotice(conversationId, "[CodeHarbor] AutoDev \u5DF2\u53D6\u6D88\u3002");
|
|
4974
5200
|
return Date.now() - startedAt;
|
|
4975
5201
|
}
|
|
4976
|
-
await this.channel.sendMessage(conversationId, `[CodeHarbor] AutoDev \u5931\u8D25: ${
|
|
5202
|
+
await this.channel.sendMessage(conversationId, `[CodeHarbor] AutoDev \u5931\u8D25: ${formatError3(error)}`);
|
|
4977
5203
|
return Date.now() - startedAt;
|
|
4978
5204
|
}
|
|
4979
5205
|
async handleStopCommand(sessionKey, message, requestId) {
|
|
@@ -5141,11 +5367,34 @@ ${attachmentSummary}
|
|
|
5141
5367
|
});
|
|
5142
5368
|
}
|
|
5143
5369
|
}
|
|
5370
|
+
setWorkflowSnapshot(sessionKey, snapshot) {
|
|
5371
|
+
this.workflowSnapshots.set(sessionKey, snapshot);
|
|
5372
|
+
this.pruneRunSnapshots(Date.now());
|
|
5373
|
+
}
|
|
5374
|
+
setAutoDevSnapshot(sessionKey, snapshot) {
|
|
5375
|
+
this.autoDevSnapshots.set(sessionKey, snapshot);
|
|
5376
|
+
this.pruneRunSnapshots(Date.now());
|
|
5377
|
+
}
|
|
5378
|
+
pruneRunSnapshots(now) {
|
|
5379
|
+
pruneSnapshotMap(
|
|
5380
|
+
this.workflowSnapshots,
|
|
5381
|
+
now,
|
|
5382
|
+
(snapshot) => snapshot.state !== "running",
|
|
5383
|
+
(snapshot) => snapshot.endedAt ?? snapshot.startedAt
|
|
5384
|
+
);
|
|
5385
|
+
pruneSnapshotMap(
|
|
5386
|
+
this.autoDevSnapshots,
|
|
5387
|
+
now,
|
|
5388
|
+
(snapshot) => snapshot.state !== "running",
|
|
5389
|
+
(snapshot) => snapshot.endedAt ?? snapshot.startedAt
|
|
5390
|
+
);
|
|
5391
|
+
}
|
|
5144
5392
|
getLock(key) {
|
|
5145
5393
|
const now = Date.now();
|
|
5146
5394
|
if (now - this.lastLockPruneAt >= this.lockPruneIntervalMs) {
|
|
5147
5395
|
this.lastLockPruneAt = now;
|
|
5148
5396
|
this.pruneSessionLocks(now);
|
|
5397
|
+
this.pruneRunSnapshots(now);
|
|
5149
5398
|
}
|
|
5150
5399
|
let entry = this.sessionLocks.get(key);
|
|
5151
5400
|
if (!entry) {
|
|
@@ -5171,6 +5420,44 @@ ${attachmentSummary}
|
|
|
5171
5420
|
}
|
|
5172
5421
|
}
|
|
5173
5422
|
};
|
|
5423
|
+
function pruneSnapshotMap(snapshots, now, isPrunable, resolveSnapshotTimeIso) {
|
|
5424
|
+
const staleKeys = [];
|
|
5425
|
+
const candidatesForOverflow = [];
|
|
5426
|
+
for (const [key, snapshot] of snapshots.entries()) {
|
|
5427
|
+
if (!isPrunable(snapshot)) {
|
|
5428
|
+
continue;
|
|
5429
|
+
}
|
|
5430
|
+
const timeIso = resolveSnapshotTimeIso(snapshot);
|
|
5431
|
+
if (!timeIso) {
|
|
5432
|
+
staleKeys.push(key);
|
|
5433
|
+
continue;
|
|
5434
|
+
}
|
|
5435
|
+
const timestamp = Date.parse(timeIso);
|
|
5436
|
+
if (!Number.isFinite(timestamp)) {
|
|
5437
|
+
staleKeys.push(key);
|
|
5438
|
+
continue;
|
|
5439
|
+
}
|
|
5440
|
+
if (now - timestamp > RUN_SNAPSHOT_TTL_MS) {
|
|
5441
|
+
staleKeys.push(key);
|
|
5442
|
+
continue;
|
|
5443
|
+
}
|
|
5444
|
+
candidatesForOverflow.push({ key, timestamp });
|
|
5445
|
+
}
|
|
5446
|
+
for (const key of staleKeys) {
|
|
5447
|
+
snapshots.delete(key);
|
|
5448
|
+
}
|
|
5449
|
+
if (snapshots.size <= RUN_SNAPSHOT_MAX_ENTRIES) {
|
|
5450
|
+
return;
|
|
5451
|
+
}
|
|
5452
|
+
const overflow = snapshots.size - RUN_SNAPSHOT_MAX_ENTRIES;
|
|
5453
|
+
if (overflow <= 0) {
|
|
5454
|
+
return;
|
|
5455
|
+
}
|
|
5456
|
+
candidatesForOverflow.sort((a, b) => a.timestamp - b.timestamp);
|
|
5457
|
+
for (let i = 0; i < overflow && i < candidatesForOverflow.length; i += 1) {
|
|
5458
|
+
snapshots.delete(candidatesForOverflow[i].key);
|
|
5459
|
+
}
|
|
5460
|
+
}
|
|
5174
5461
|
function createIdleAutoDevSnapshot() {
|
|
5175
5462
|
return {
|
|
5176
5463
|
state: "idle",
|
|
@@ -5186,7 +5473,7 @@ function createIdleAutoDevSnapshot() {
|
|
|
5186
5473
|
function buildSessionKey(message) {
|
|
5187
5474
|
return `${message.channel}:${message.conversationId}:${message.senderId}`;
|
|
5188
5475
|
}
|
|
5189
|
-
function
|
|
5476
|
+
function formatError3(error) {
|
|
5190
5477
|
if (error instanceof Error) {
|
|
5191
5478
|
return error.message;
|
|
5192
5479
|
}
|
|
@@ -5285,7 +5572,7 @@ function classifyExecutionOutcome(error) {
|
|
|
5285
5572
|
if (error instanceof CodexExecutionCancelledError) {
|
|
5286
5573
|
return "cancelled";
|
|
5287
5574
|
}
|
|
5288
|
-
const message =
|
|
5575
|
+
const message = formatError3(error).toLowerCase();
|
|
5289
5576
|
if (message.includes("timed out")) {
|
|
5290
5577
|
return "timeout";
|
|
5291
5578
|
}
|
|
@@ -5297,9 +5584,9 @@ function buildFailureProgressSummary(status, startedAt, error) {
|
|
|
5297
5584
|
return `\u5904\u7406\u5DF2\u53D6\u6D88\uFF08\u8017\u65F6 ${elapsed}\uFF09`;
|
|
5298
5585
|
}
|
|
5299
5586
|
if (status === "timeout") {
|
|
5300
|
-
return `\u5904\u7406\u8D85\u65F6\uFF08\u8017\u65F6 ${elapsed}\uFF09: ${
|
|
5587
|
+
return `\u5904\u7406\u8D85\u65F6\uFF08\u8017\u65F6 ${elapsed}\uFF09: ${formatError3(error)}`;
|
|
5301
5588
|
}
|
|
5302
|
-
return `\u5904\u7406\u5931\u8D25\uFF08\u8017\u65F6 ${elapsed}\uFF09: ${
|
|
5589
|
+
return `\u5904\u7406\u5931\u8D25\uFF08\u8017\u65F6 ${elapsed}\uFF09: ${formatError3(error)}`;
|
|
5303
5590
|
}
|
|
5304
5591
|
function buildWorkflowResultReply(result) {
|
|
5305
5592
|
return `[CodeHarbor] Multi-Agent workflow \u5B8C\u6210
|
|
@@ -6048,7 +6335,54 @@ function parseExtraArgs(raw) {
|
|
|
6048
6335
|
if (!trimmed) {
|
|
6049
6336
|
return [];
|
|
6050
6337
|
}
|
|
6051
|
-
|
|
6338
|
+
const args = [];
|
|
6339
|
+
let current = "";
|
|
6340
|
+
let quoteMode = null;
|
|
6341
|
+
let escaping = false;
|
|
6342
|
+
const pushCurrent = () => {
|
|
6343
|
+
if (!current) {
|
|
6344
|
+
return;
|
|
6345
|
+
}
|
|
6346
|
+
args.push(current);
|
|
6347
|
+
current = "";
|
|
6348
|
+
};
|
|
6349
|
+
for (const char of trimmed) {
|
|
6350
|
+
if (escaping) {
|
|
6351
|
+
current += char;
|
|
6352
|
+
escaping = false;
|
|
6353
|
+
continue;
|
|
6354
|
+
}
|
|
6355
|
+
if (quoteMode === null) {
|
|
6356
|
+
if (/\s/.test(char)) {
|
|
6357
|
+
pushCurrent();
|
|
6358
|
+
continue;
|
|
6359
|
+
}
|
|
6360
|
+
if (char === "'" || char === '"') {
|
|
6361
|
+
quoteMode = char;
|
|
6362
|
+
continue;
|
|
6363
|
+
}
|
|
6364
|
+
if (char === "\\") {
|
|
6365
|
+
escaping = true;
|
|
6366
|
+
continue;
|
|
6367
|
+
}
|
|
6368
|
+
current += char;
|
|
6369
|
+
continue;
|
|
6370
|
+
}
|
|
6371
|
+
if (char === quoteMode) {
|
|
6372
|
+
quoteMode = null;
|
|
6373
|
+
continue;
|
|
6374
|
+
}
|
|
6375
|
+
if (quoteMode === '"' && char === "\\") {
|
|
6376
|
+
escaping = true;
|
|
6377
|
+
continue;
|
|
6378
|
+
}
|
|
6379
|
+
current += char;
|
|
6380
|
+
}
|
|
6381
|
+
if (escaping || quoteMode !== null) {
|
|
6382
|
+
throw new Error("CODEX_EXTRA_ARGS contains unmatched quote or trailing escape.");
|
|
6383
|
+
}
|
|
6384
|
+
pushCurrent();
|
|
6385
|
+
return args;
|
|
6052
6386
|
}
|
|
6053
6387
|
function parseExtraEnv(raw) {
|
|
6054
6388
|
const trimmed = raw.trim();
|
|
@@ -6803,7 +7137,7 @@ configCommand.command("export").description("Export config snapshot as JSON").op
|
|
|
6803
7137
|
const home = ensureRuntimeHomeOrExit();
|
|
6804
7138
|
await runConfigExportCommand({ outputPath: options.output, cwd: home });
|
|
6805
7139
|
} catch (error) {
|
|
6806
|
-
process.stderr.write(`Config export failed: ${
|
|
7140
|
+
process.stderr.write(`Config export failed: ${formatError4(error)}
|
|
6807
7141
|
`);
|
|
6808
7142
|
process.exitCode = 1;
|
|
6809
7143
|
}
|
|
@@ -6817,7 +7151,7 @@ configCommand.command("import").description("Import config snapshot from JSON").
|
|
|
6817
7151
|
cwd: home
|
|
6818
7152
|
});
|
|
6819
7153
|
} catch (error) {
|
|
6820
|
-
process.stderr.write(`Config import failed: ${
|
|
7154
|
+
process.stderr.write(`Config import failed: ${formatError4(error)}
|
|
6821
7155
|
`);
|
|
6822
7156
|
process.exitCode = 1;
|
|
6823
7157
|
}
|
|
@@ -6836,7 +7170,7 @@ serviceCommand.command("install").description("Install and enable codeharbor sys
|
|
|
6836
7170
|
startNow: options.start ?? true
|
|
6837
7171
|
});
|
|
6838
7172
|
} catch (error) {
|
|
6839
|
-
process.stderr.write(`Service install failed: ${
|
|
7173
|
+
process.stderr.write(`Service install failed: ${formatError4(error)}
|
|
6840
7174
|
`);
|
|
6841
7175
|
process.stderr.write(
|
|
6842
7176
|
[
|
|
@@ -6857,7 +7191,7 @@ serviceCommand.command("uninstall").description("Remove codeharbor systemd servi
|
|
|
6857
7191
|
removeAdmin: options.withAdmin ?? false
|
|
6858
7192
|
});
|
|
6859
7193
|
} catch (error) {
|
|
6860
|
-
process.stderr.write(`Service uninstall failed: ${
|
|
7194
|
+
process.stderr.write(`Service uninstall failed: ${formatError4(error)}
|
|
6861
7195
|
`);
|
|
6862
7196
|
process.stderr.write(
|
|
6863
7197
|
[
|
|
@@ -6878,7 +7212,7 @@ serviceCommand.command("restart").description("Restart installed codeharbor syst
|
|
|
6878
7212
|
restartAdmin: options.withAdmin ?? false
|
|
6879
7213
|
});
|
|
6880
7214
|
} catch (error) {
|
|
6881
|
-
process.stderr.write(`Service restart failed: ${
|
|
7215
|
+
process.stderr.write(`Service restart failed: ${formatError4(error)}
|
|
6882
7216
|
`);
|
|
6883
7217
|
process.stderr.write(
|
|
6884
7218
|
[
|
|
@@ -6998,7 +7332,7 @@ function shellQuote(value) {
|
|
|
6998
7332
|
function buildExplicitSudoCommand(subcommand) {
|
|
6999
7333
|
return `sudo ${shellQuote(process.execPath)} ${shellQuote(resolveCliScriptPath())} ${subcommand}`;
|
|
7000
7334
|
}
|
|
7001
|
-
function
|
|
7335
|
+
function formatError4(error) {
|
|
7002
7336
|
if (error instanceof Error) {
|
|
7003
7337
|
return error.message;
|
|
7004
7338
|
}
|