codeharbor 0.1.10 → 0.1.12

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.
Files changed (3) hide show
  1. package/README.md +7 -3
  2. package/dist/cli.js +1504 -1370
  3. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -30,14 +30,14 @@ var import_node_path12 = __toESM(require("path"));
30
30
  var import_commander = require("commander");
31
31
 
32
32
  // src/app.ts
33
- var import_node_child_process3 = require("child_process");
33
+ var import_node_child_process4 = require("child_process");
34
34
  var import_node_util2 = require("util");
35
35
 
36
36
  // src/admin-server.ts
37
- var import_node_child_process = require("child_process");
38
- var import_node_fs2 = __toESM(require("fs"));
37
+ var import_node_child_process2 = require("child_process");
38
+ var import_node_fs4 = __toESM(require("fs"));
39
39
  var import_node_http = __toESM(require("http"));
40
- var import_node_path2 = __toESM(require("path"));
40
+ var import_node_path4 = __toESM(require("path"));
41
41
  var import_node_util = require("util");
42
42
 
43
43
  // src/init.ts
@@ -220,152 +220,514 @@ async function askYesNo(rl, question, defaultValue) {
220
220
  return answer === "y" || answer === "yes";
221
221
  }
222
222
 
223
- // src/admin-server.ts
224
- var execFileAsync = (0, import_node_util.promisify)(import_node_child_process.execFile);
225
- var HttpError = class extends Error {
226
- statusCode;
227
- constructor(statusCode, message) {
228
- super(message);
229
- this.statusCode = statusCode;
223
+ // src/service-manager.ts
224
+ var import_node_child_process = require("child_process");
225
+ var import_node_fs3 = __toESM(require("fs"));
226
+ var import_node_os2 = __toESM(require("os"));
227
+ var import_node_path3 = __toESM(require("path"));
228
+
229
+ // src/runtime-home.ts
230
+ var import_node_fs2 = __toESM(require("fs"));
231
+ var import_node_os = __toESM(require("os"));
232
+ var import_node_path2 = __toESM(require("path"));
233
+ var LEGACY_RUNTIME_HOME = "/opt/codeharbor";
234
+ var USER_RUNTIME_HOME_DIR = ".codeharbor";
235
+ var DEFAULT_RUNTIME_HOME = import_node_path2.default.resolve(import_node_os.default.homedir(), USER_RUNTIME_HOME_DIR);
236
+ var RUNTIME_HOME_ENV_KEY = "CODEHARBOR_HOME";
237
+ function resolveRuntimeHome(env = process.env) {
238
+ const configured = env[RUNTIME_HOME_ENV_KEY]?.trim();
239
+ if (configured) {
240
+ return import_node_path2.default.resolve(configured);
230
241
  }
231
- };
232
- var AdminServer = class {
233
- config;
234
- logger;
235
- stateStore;
236
- configService;
237
- host;
238
- port;
239
- adminToken;
240
- adminIpAllowlist;
241
- adminAllowedOrigins;
242
- cwd;
243
- checkCodex;
244
- checkMatrix;
245
- server = null;
246
- address = null;
247
- constructor(config, logger, stateStore, configService, options) {
248
- this.config = config;
249
- this.logger = logger;
250
- this.stateStore = stateStore;
251
- this.configService = configService;
252
- this.host = options.host;
253
- this.port = options.port;
254
- this.adminToken = options.adminToken;
255
- this.adminIpAllowlist = normalizeAllowlist(options.adminIpAllowlist ?? []);
256
- this.adminAllowedOrigins = normalizeOriginAllowlist(options.adminAllowedOrigins ?? []);
257
- this.cwd = options.cwd ?? process.cwd();
258
- this.checkCodex = options.checkCodex ?? defaultCheckCodex;
259
- this.checkMatrix = options.checkMatrix ?? defaultCheckMatrix;
242
+ const legacyEnvPath = import_node_path2.default.resolve(LEGACY_RUNTIME_HOME, ".env");
243
+ if (import_node_fs2.default.existsSync(legacyEnvPath)) {
244
+ return LEGACY_RUNTIME_HOME;
260
245
  }
261
- getAddress() {
262
- return this.address;
246
+ return resolveUserRuntimeHome(env);
247
+ }
248
+ function resolveUserRuntimeHome(env = process.env) {
249
+ const home = env.HOME?.trim() || import_node_os.default.homedir();
250
+ return import_node_path2.default.resolve(home, USER_RUNTIME_HOME_DIR);
251
+ }
252
+
253
+ // src/service-manager.ts
254
+ var SYSTEMD_DIR = "/etc/systemd/system";
255
+ var MAIN_SERVICE_NAME = "codeharbor.service";
256
+ var ADMIN_SERVICE_NAME = "codeharbor-admin.service";
257
+ var SUDOERS_DIR = "/etc/sudoers.d";
258
+ var RESTART_SUDOERS_FILE = "codeharbor-restart";
259
+ function resolveDefaultRunUser(env = process.env) {
260
+ const sudoUser = env.SUDO_USER?.trim();
261
+ if (sudoUser) {
262
+ return sudoUser;
263
263
  }
264
- async start() {
265
- if (this.server) {
266
- return;
267
- }
268
- this.server = import_node_http.default.createServer((req, res) => {
269
- void this.handleRequest(req, res);
270
- });
271
- await new Promise((resolve, reject) => {
272
- if (!this.server) {
273
- reject(new Error("admin server is not initialized"));
274
- return;
275
- }
276
- this.server.once("error", reject);
277
- this.server.listen(this.port, this.host, () => {
278
- this.server?.removeListener("error", reject);
279
- const address = this.server?.address();
280
- if (!address || typeof address === "string") {
281
- reject(new Error("failed to resolve admin server address"));
282
- return;
283
- }
284
- this.address = {
285
- host: address.address,
286
- port: address.port
287
- };
288
- resolve();
264
+ const user = env.USER?.trim();
265
+ if (user) {
266
+ return user;
267
+ }
268
+ try {
269
+ return import_node_os2.default.userInfo().username;
270
+ } catch {
271
+ return "root";
272
+ }
273
+ }
274
+ function resolveRuntimeHomeForUser(runUser, env = process.env, explicitRuntimeHome) {
275
+ const configuredRuntimeHome = explicitRuntimeHome?.trim() || env[RUNTIME_HOME_ENV_KEY]?.trim();
276
+ if (configuredRuntimeHome) {
277
+ return import_node_path3.default.resolve(configuredRuntimeHome);
278
+ }
279
+ const userHome = resolveUserHome(runUser);
280
+ if (userHome) {
281
+ return import_node_path3.default.resolve(userHome, USER_RUNTIME_HOME_DIR);
282
+ }
283
+ return import_node_path3.default.resolve(import_node_os2.default.homedir(), USER_RUNTIME_HOME_DIR);
284
+ }
285
+ function buildMainServiceUnit(options) {
286
+ validateUnitOptions(options);
287
+ const runtimeHome2 = import_node_path3.default.resolve(options.runtimeHome);
288
+ return [
289
+ "[Unit]",
290
+ "Description=CodeHarbor main service",
291
+ "After=network-online.target",
292
+ "Wants=network-online.target",
293
+ "",
294
+ "[Service]",
295
+ "Type=simple",
296
+ `User=${options.runUser}`,
297
+ `WorkingDirectory=${runtimeHome2}`,
298
+ `Environment=CODEHARBOR_HOME=${runtimeHome2}`,
299
+ `ExecStart=${import_node_path3.default.resolve(options.nodeBinPath)} ${import_node_path3.default.resolve(options.cliScriptPath)} start`,
300
+ "Restart=always",
301
+ "RestartSec=3",
302
+ "NoNewPrivileges=true",
303
+ "PrivateTmp=true",
304
+ "ProtectSystem=full",
305
+ "ProtectHome=false",
306
+ `ReadWritePaths=${runtimeHome2}`,
307
+ "",
308
+ "[Install]",
309
+ "WantedBy=multi-user.target",
310
+ ""
311
+ ].join("\n");
312
+ }
313
+ function buildAdminServiceUnit(options) {
314
+ validateUnitOptions(options);
315
+ const runtimeHome2 = import_node_path3.default.resolve(options.runtimeHome);
316
+ return [
317
+ "[Unit]",
318
+ "Description=CodeHarbor admin service",
319
+ "After=network-online.target",
320
+ "Wants=network-online.target",
321
+ "",
322
+ "[Service]",
323
+ "Type=simple",
324
+ `User=${options.runUser}`,
325
+ `WorkingDirectory=${runtimeHome2}`,
326
+ `Environment=CODEHARBOR_HOME=${runtimeHome2}`,
327
+ `ExecStart=${import_node_path3.default.resolve(options.nodeBinPath)} ${import_node_path3.default.resolve(options.cliScriptPath)} admin serve`,
328
+ "Restart=always",
329
+ "RestartSec=3",
330
+ "NoNewPrivileges=true",
331
+ "PrivateTmp=true",
332
+ "ProtectSystem=full",
333
+ "ProtectHome=false",
334
+ `ReadWritePaths=${runtimeHome2}`,
335
+ "",
336
+ "[Install]",
337
+ "WantedBy=multi-user.target",
338
+ ""
339
+ ].join("\n");
340
+ }
341
+ function buildRestartSudoersPolicy(options) {
342
+ const runUser = options.runUser.trim();
343
+ const systemctlPath = options.systemctlPath.trim();
344
+ validateSimpleValue(runUser, "runUser");
345
+ validateSimpleValue(systemctlPath, "systemctlPath");
346
+ if (!import_node_path3.default.isAbsolute(systemctlPath)) {
347
+ throw new Error("systemctlPath must be an absolute path.");
348
+ }
349
+ return [
350
+ "# Managed by CodeHarbor service install; do not edit manually.",
351
+ `Defaults:${runUser} !requiretty`,
352
+ `${runUser} ALL=(root) NOPASSWD: ${systemctlPath} restart ${MAIN_SERVICE_NAME}, ${systemctlPath} restart ${ADMIN_SERVICE_NAME}`,
353
+ ""
354
+ ].join("\n");
355
+ }
356
+ function installSystemdServices(options) {
357
+ assertLinuxWithSystemd();
358
+ assertRootPrivileges();
359
+ const output = options.output ?? process.stdout;
360
+ const runUser = options.runUser.trim();
361
+ const runtimeHome2 = import_node_path3.default.resolve(options.runtimeHome);
362
+ validateSimpleValue(runUser, "runUser");
363
+ validateSimpleValue(runtimeHome2, "runtimeHome");
364
+ validateSimpleValue(options.nodeBinPath, "nodeBinPath");
365
+ validateSimpleValue(options.cliScriptPath, "cliScriptPath");
366
+ ensureUserExists(runUser);
367
+ const runGroup = resolveUserGroup(runUser);
368
+ import_node_fs3.default.mkdirSync(runtimeHome2, { recursive: true });
369
+ runCommand("chown", ["-R", `${runUser}:${runGroup}`, runtimeHome2]);
370
+ const mainPath = import_node_path3.default.join(SYSTEMD_DIR, MAIN_SERVICE_NAME);
371
+ const adminPath = import_node_path3.default.join(SYSTEMD_DIR, ADMIN_SERVICE_NAME);
372
+ const restartSudoersPath = import_node_path3.default.join(SUDOERS_DIR, RESTART_SUDOERS_FILE);
373
+ const unitOptions = {
374
+ runUser,
375
+ runtimeHome: runtimeHome2,
376
+ nodeBinPath: options.nodeBinPath,
377
+ cliScriptPath: options.cliScriptPath
378
+ };
379
+ import_node_fs3.default.writeFileSync(mainPath, buildMainServiceUnit(unitOptions), "utf8");
380
+ if (options.installAdmin) {
381
+ import_node_fs3.default.writeFileSync(adminPath, buildAdminServiceUnit(unitOptions), "utf8");
382
+ if (runUser !== "root") {
383
+ const policy = buildRestartSudoersPolicy({
384
+ runUser,
385
+ systemctlPath: resolveSystemctlPath()
289
386
  });
290
- });
387
+ import_node_fs3.default.mkdirSync(SUDOERS_DIR, { recursive: true });
388
+ import_node_fs3.default.writeFileSync(restartSudoersPath, policy, "utf8");
389
+ import_node_fs3.default.chmodSync(restartSudoersPath, 288);
390
+ }
291
391
  }
292
- async stop() {
293
- if (!this.server) {
294
- return;
392
+ runSystemctl(["daemon-reload"]);
393
+ if (options.startNow) {
394
+ runSystemctl(["enable", "--now", MAIN_SERVICE_NAME]);
395
+ if (options.installAdmin) {
396
+ runSystemctl(["enable", "--now", ADMIN_SERVICE_NAME]);
397
+ }
398
+ } else {
399
+ runSystemctl(["enable", MAIN_SERVICE_NAME]);
400
+ if (options.installAdmin) {
401
+ runSystemctl(["enable", ADMIN_SERVICE_NAME]);
295
402
  }
296
- const server = this.server;
297
- this.server = null;
298
- this.address = null;
299
- await new Promise((resolve, reject) => {
300
- server.close((error) => {
301
- if (error) {
302
- reject(error);
303
- return;
304
- }
305
- resolve();
306
- });
307
- });
308
403
  }
309
- async handleRequest(req, res) {
310
- try {
311
- const url = new URL(req.url ?? "/", "http://localhost");
312
- this.setSecurityHeaders(res);
313
- const corsDecision = this.resolveCors(req);
314
- this.setCorsHeaders(res, corsDecision);
315
- if (!this.isClientAllowed(req)) {
316
- this.sendJson(res, 403, {
317
- ok: false,
318
- error: "Forbidden by ADMIN_IP_ALLOWLIST."
319
- });
320
- return;
321
- }
322
- if (url.pathname.startsWith("/api/admin/") && corsDecision.origin && !corsDecision.allowed) {
323
- this.sendJson(res, 403, {
324
- ok: false,
325
- error: "Forbidden by ADMIN_ALLOWED_ORIGINS."
326
- });
327
- return;
328
- }
329
- if (req.method === "OPTIONS") {
330
- if (corsDecision.origin && !corsDecision.allowed) {
331
- this.sendJson(res, 403, {
332
- ok: false,
333
- error: "Forbidden by ADMIN_ALLOWED_ORIGINS."
334
- });
335
- return;
336
- }
337
- res.writeHead(204);
338
- res.end();
339
- return;
340
- }
341
- if (req.method === "GET" && isUiPath(url.pathname)) {
342
- this.sendHtml(res, renderAdminConsoleHtml());
404
+ output.write(`Installed systemd unit: ${mainPath}
405
+ `);
406
+ if (options.installAdmin) {
407
+ output.write(`Installed systemd unit: ${adminPath}
408
+ `);
409
+ if (runUser !== "root") {
410
+ output.write(`Installed sudoers policy: ${restartSudoersPath}
411
+ `);
412
+ }
413
+ }
414
+ output.write("Done. Check status with: systemctl status codeharbor --no-pager\n");
415
+ }
416
+ function uninstallSystemdServices(options) {
417
+ assertLinuxWithSystemd();
418
+ assertRootPrivileges();
419
+ const output = options.output ?? process.stdout;
420
+ const mainPath = import_node_path3.default.join(SYSTEMD_DIR, MAIN_SERVICE_NAME);
421
+ const adminPath = import_node_path3.default.join(SYSTEMD_DIR, ADMIN_SERVICE_NAME);
422
+ const restartSudoersPath = import_node_path3.default.join(SUDOERS_DIR, RESTART_SUDOERS_FILE);
423
+ stopAndDisableIfPresent(MAIN_SERVICE_NAME);
424
+ if (import_node_fs3.default.existsSync(mainPath)) {
425
+ import_node_fs3.default.unlinkSync(mainPath);
426
+ }
427
+ if (options.removeAdmin) {
428
+ stopAndDisableIfPresent(ADMIN_SERVICE_NAME);
429
+ if (import_node_fs3.default.existsSync(adminPath)) {
430
+ import_node_fs3.default.unlinkSync(adminPath);
431
+ }
432
+ if (import_node_fs3.default.existsSync(restartSudoersPath)) {
433
+ import_node_fs3.default.unlinkSync(restartSudoersPath);
434
+ }
435
+ }
436
+ runSystemctl(["daemon-reload"]);
437
+ runSystemctlIgnoreFailure(["reset-failed"]);
438
+ output.write(`Removed systemd unit: ${mainPath}
439
+ `);
440
+ if (options.removeAdmin) {
441
+ output.write(`Removed systemd unit: ${adminPath}
442
+ `);
443
+ output.write(`Removed sudoers policy: ${restartSudoersPath}
444
+ `);
445
+ }
446
+ output.write("Done.\n");
447
+ }
448
+ function restartSystemdServices(options) {
449
+ assertLinuxWithSystemd();
450
+ const output = options.output ?? process.stdout;
451
+ const runWithSudoFallback = options.allowSudoFallback ?? true;
452
+ const systemctlRunner = hasRootPrivileges() || !runWithSudoFallback ? runSystemctl : runSystemctlWithNonInteractiveSudo;
453
+ systemctlRunner(["restart", MAIN_SERVICE_NAME]);
454
+ output.write(`Restarted service: ${MAIN_SERVICE_NAME}
455
+ `);
456
+ if (options.restartAdmin) {
457
+ systemctlRunner(["restart", ADMIN_SERVICE_NAME]);
458
+ output.write(`Restarted service: ${ADMIN_SERVICE_NAME}
459
+ `);
460
+ }
461
+ output.write("Done.\n");
462
+ }
463
+ function resolveUserHome(runUser) {
464
+ try {
465
+ const passwdRaw = import_node_fs3.default.readFileSync("/etc/passwd", "utf8");
466
+ const line = passwdRaw.split(/\r?\n/).find((item) => item.startsWith(`${runUser}:`));
467
+ if (!line) {
468
+ return null;
469
+ }
470
+ const fields = line.split(":");
471
+ return fields[5] ? fields[5].trim() : null;
472
+ } catch {
473
+ return null;
474
+ }
475
+ }
476
+ function validateUnitOptions(options) {
477
+ validateSimpleValue(options.runUser, "runUser");
478
+ validateSimpleValue(options.runtimeHome, "runtimeHome");
479
+ validateSimpleValue(options.nodeBinPath, "nodeBinPath");
480
+ validateSimpleValue(options.cliScriptPath, "cliScriptPath");
481
+ }
482
+ function validateSimpleValue(value, key) {
483
+ if (!value.trim()) {
484
+ throw new Error(`${key} cannot be empty.`);
485
+ }
486
+ if (/[\r\n]/.test(value)) {
487
+ throw new Error(`${key} contains invalid newline characters.`);
488
+ }
489
+ }
490
+ function assertLinuxWithSystemd() {
491
+ if (process.platform !== "linux") {
492
+ throw new Error("Systemd service install only supports Linux.");
493
+ }
494
+ try {
495
+ (0, import_node_child_process.execFileSync)("systemctl", ["--version"], { stdio: "ignore" });
496
+ } catch {
497
+ throw new Error("systemctl is required but not found.");
498
+ }
499
+ }
500
+ function assertRootPrivileges() {
501
+ if (!hasRootPrivileges()) {
502
+ throw new Error("Root privileges are required. Run with sudo.");
503
+ }
504
+ }
505
+ function hasRootPrivileges() {
506
+ if (typeof process.getuid !== "function") {
507
+ return true;
508
+ }
509
+ return process.getuid() === 0;
510
+ }
511
+ function ensureUserExists(runUser) {
512
+ runCommand("id", ["-u", runUser]);
513
+ }
514
+ function resolveUserGroup(runUser) {
515
+ return runCommand("id", ["-gn", runUser]).trim();
516
+ }
517
+ function runSystemctl(args) {
518
+ runCommand("systemctl", args);
519
+ }
520
+ function runSystemctlWithNonInteractiveSudo(args) {
521
+ const systemctlPath = resolveSystemctlPath();
522
+ try {
523
+ runCommand("sudo", ["-n", systemctlPath, ...args]);
524
+ } catch (error) {
525
+ throw new Error(
526
+ "Root privileges are required. Configure passwordless sudo for the CodeHarbor service user or run the CLI command manually with sudo.",
527
+ { cause: error }
528
+ );
529
+ }
530
+ }
531
+ function stopAndDisableIfPresent(unitName) {
532
+ runSystemctlIgnoreFailure(["disable", "--now", unitName]);
533
+ }
534
+ function runSystemctlIgnoreFailure(args) {
535
+ try {
536
+ runCommand("systemctl", args);
537
+ } catch {
538
+ }
539
+ }
540
+ function resolveSystemctlPath() {
541
+ const candidates = [];
542
+ const pathEntries = (process.env.PATH ?? "").split(import_node_path3.default.delimiter).filter(Boolean);
543
+ for (const entry of pathEntries) {
544
+ candidates.push(import_node_path3.default.join(entry, "systemctl"));
545
+ }
546
+ candidates.push("/usr/bin/systemctl", "/bin/systemctl", "/usr/local/bin/systemctl");
547
+ for (const candidate of candidates) {
548
+ if (import_node_path3.default.isAbsolute(candidate) && import_node_fs3.default.existsSync(candidate)) {
549
+ return candidate;
550
+ }
551
+ }
552
+ throw new Error("Unable to resolve absolute systemctl path.");
553
+ }
554
+ function runCommand(file, args) {
555
+ try {
556
+ return (0, import_node_child_process.execFileSync)(file, args, {
557
+ encoding: "utf8",
558
+ stdio: ["ignore", "pipe", "pipe"]
559
+ });
560
+ } catch (error) {
561
+ throw new Error(formatCommandError(file, args, error), { cause: error });
562
+ }
563
+ }
564
+ function formatCommandError(file, args, error) {
565
+ const command = `${file} ${args.join(" ")}`.trim();
566
+ if (error && typeof error === "object") {
567
+ const maybeError = error;
568
+ const stderr = bufferToTrimmedString(maybeError.stderr);
569
+ const stdout = bufferToTrimmedString(maybeError.stdout);
570
+ const details = stderr || stdout || maybeError.message || "command failed";
571
+ return `Command failed: ${command}. ${details}`;
572
+ }
573
+ return `Command failed: ${command}. ${String(error)}`;
574
+ }
575
+ function bufferToTrimmedString(value) {
576
+ if (!value) {
577
+ return "";
578
+ }
579
+ const text = typeof value === "string" ? value : value.toString("utf8");
580
+ return text.trim();
581
+ }
582
+
583
+ // src/admin-server.ts
584
+ var execFileAsync = (0, import_node_util.promisify)(import_node_child_process2.execFile);
585
+ var HttpError = class extends Error {
586
+ statusCode;
587
+ constructor(statusCode, message) {
588
+ super(message);
589
+ this.statusCode = statusCode;
590
+ }
591
+ };
592
+ var AdminServer = class {
593
+ config;
594
+ logger;
595
+ stateStore;
596
+ configService;
597
+ host;
598
+ port;
599
+ adminToken;
600
+ adminIpAllowlist;
601
+ adminAllowedOrigins;
602
+ cwd;
603
+ checkCodex;
604
+ checkMatrix;
605
+ restartServices;
606
+ server = null;
607
+ address = null;
608
+ constructor(config, logger, stateStore, configService, options) {
609
+ this.config = config;
610
+ this.logger = logger;
611
+ this.stateStore = stateStore;
612
+ this.configService = configService;
613
+ this.host = options.host;
614
+ this.port = options.port;
615
+ this.adminToken = options.adminToken;
616
+ this.adminIpAllowlist = normalizeAllowlist(options.adminIpAllowlist ?? []);
617
+ this.adminAllowedOrigins = normalizeOriginAllowlist(options.adminAllowedOrigins ?? []);
618
+ this.cwd = options.cwd ?? process.cwd();
619
+ this.checkCodex = options.checkCodex ?? defaultCheckCodex;
620
+ this.checkMatrix = options.checkMatrix ?? defaultCheckMatrix;
621
+ this.restartServices = options.restartServices ?? defaultRestartServices;
622
+ }
623
+ getAddress() {
624
+ return this.address;
625
+ }
626
+ async start() {
627
+ if (this.server) {
628
+ return;
629
+ }
630
+ this.server = import_node_http.default.createServer((req, res) => {
631
+ void this.handleRequest(req, res);
632
+ });
633
+ await new Promise((resolve, reject) => {
634
+ if (!this.server) {
635
+ reject(new Error("admin server is not initialized"));
343
636
  return;
344
637
  }
345
- if (url.pathname.startsWith("/api/admin/") && !this.isAuthorized(req)) {
346
- this.sendJson(res, 401, {
638
+ this.server.once("error", reject);
639
+ this.server.listen(this.port, this.host, () => {
640
+ this.server?.removeListener("error", reject);
641
+ const address = this.server?.address();
642
+ if (!address || typeof address === "string") {
643
+ reject(new Error("failed to resolve admin server address"));
644
+ return;
645
+ }
646
+ this.address = {
647
+ host: address.address,
648
+ port: address.port
649
+ };
650
+ resolve();
651
+ });
652
+ });
653
+ }
654
+ async stop() {
655
+ if (!this.server) {
656
+ return;
657
+ }
658
+ const server = this.server;
659
+ this.server = null;
660
+ this.address = null;
661
+ await new Promise((resolve, reject) => {
662
+ server.close((error) => {
663
+ if (error) {
664
+ reject(error);
665
+ return;
666
+ }
667
+ resolve();
668
+ });
669
+ });
670
+ }
671
+ async handleRequest(req, res) {
672
+ try {
673
+ const url = new URL(req.url ?? "/", "http://localhost");
674
+ this.setSecurityHeaders(res);
675
+ const corsDecision = this.resolveCors(req);
676
+ this.setCorsHeaders(res, corsDecision);
677
+ if (!this.isClientAllowed(req)) {
678
+ this.sendJson(res, 403, {
347
679
  ok: false,
348
- error: "Unauthorized. Provide Authorization: Bearer <ADMIN_TOKEN>."
680
+ error: "Forbidden by ADMIN_IP_ALLOWLIST."
349
681
  });
350
682
  return;
351
683
  }
352
- if (req.method === "GET" && url.pathname === "/api/admin/config/global") {
353
- this.sendJson(res, 200, {
354
- ok: true,
355
- data: buildGlobalConfigSnapshot(this.config),
356
- effective: "next_start_for_env_changes"
684
+ if (url.pathname.startsWith("/api/admin/") && corsDecision.origin && !corsDecision.allowed) {
685
+ this.sendJson(res, 403, {
686
+ ok: false,
687
+ error: "Forbidden by ADMIN_ALLOWED_ORIGINS."
357
688
  });
358
689
  return;
359
690
  }
360
- if (req.method === "PUT" && url.pathname === "/api/admin/config/global") {
361
- const body = await readJsonBody(req);
362
- const actor = readActor(req);
363
- const result = this.updateGlobalConfig(body, actor);
364
- this.sendJson(res, 200, {
365
- ok: true,
366
- ...result
367
- });
368
- return;
691
+ if (req.method === "OPTIONS") {
692
+ if (corsDecision.origin && !corsDecision.allowed) {
693
+ this.sendJson(res, 403, {
694
+ ok: false,
695
+ error: "Forbidden by ADMIN_ALLOWED_ORIGINS."
696
+ });
697
+ return;
698
+ }
699
+ res.writeHead(204);
700
+ res.end();
701
+ return;
702
+ }
703
+ if (req.method === "GET" && isUiPath(url.pathname)) {
704
+ this.sendHtml(res, renderAdminConsoleHtml());
705
+ return;
706
+ }
707
+ if (url.pathname.startsWith("/api/admin/") && !this.isAuthorized(req)) {
708
+ this.sendJson(res, 401, {
709
+ ok: false,
710
+ error: "Unauthorized. Provide Authorization: Bearer <ADMIN_TOKEN>."
711
+ });
712
+ return;
713
+ }
714
+ if (req.method === "GET" && url.pathname === "/api/admin/config/global") {
715
+ this.sendJson(res, 200, {
716
+ ok: true,
717
+ data: buildGlobalConfigSnapshot(this.config),
718
+ effective: "next_start_for_env_changes"
719
+ });
720
+ return;
721
+ }
722
+ if (req.method === "PUT" && url.pathname === "/api/admin/config/global") {
723
+ const body = await readJsonBody(req);
724
+ const actor = readActor(req);
725
+ const result = this.updateGlobalConfig(body, actor);
726
+ this.sendJson(res, 200, {
727
+ ok: true,
728
+ ...result
729
+ });
730
+ return;
369
731
  }
370
732
  if (req.method === "GET" && url.pathname === "/api/admin/config/rooms") {
371
733
  this.sendJson(res, 200, {
@@ -420,6 +782,33 @@ var AdminServer = class {
420
782
  });
421
783
  return;
422
784
  }
785
+ if (req.method === "POST" && url.pathname === "/api/admin/service/restart") {
786
+ const body = asObject(await readJsonBody(req), "service restart payload");
787
+ const restartAdmin = normalizeBoolean(body.withAdmin, false);
788
+ const actor = readActor(req);
789
+ try {
790
+ const result = await this.restartServices(restartAdmin);
791
+ this.stateStore.appendConfigRevision(
792
+ actor,
793
+ restartAdmin ? "restart services (main + admin)" : "restart service (main)",
794
+ JSON.stringify({
795
+ type: "service_restart",
796
+ restartAdmin,
797
+ restarted: result.restarted
798
+ })
799
+ );
800
+ this.sendJson(res, 200, {
801
+ ok: true,
802
+ restarted: result.restarted
803
+ });
804
+ return;
805
+ } catch (error) {
806
+ throw new HttpError(
807
+ 500,
808
+ `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.`
809
+ );
810
+ }
811
+ }
423
812
  this.sendJson(res, 404, {
424
813
  ok: false,
425
814
  error: `Not found: ${req.method ?? "GET"} ${url.pathname}`
@@ -450,7 +839,7 @@ var AdminServer = class {
450
839
  updatedKeys.push("matrixCommandPrefix");
451
840
  }
452
841
  if ("codexWorkdir" in body) {
453
- const workdir = import_node_path2.default.resolve(String(body.codexWorkdir ?? "").trim());
842
+ const workdir = import_node_path4.default.resolve(String(body.codexWorkdir ?? "").trim());
454
843
  ensureDirectory(workdir, "codexWorkdir");
455
844
  this.config.codexWorkdir = workdir;
456
845
  envUpdates.CODEX_WORKDIR = workdir;
@@ -674,11 +1063,11 @@ var AdminServer = class {
674
1063
  return this.adminIpAllowlist.includes(normalizedRemote);
675
1064
  }
676
1065
  persistEnvUpdates(updates) {
677
- const envPath = import_node_path2.default.resolve(this.cwd, ".env");
678
- const examplePath = import_node_path2.default.resolve(this.cwd, ".env.example");
679
- const template = import_node_fs2.default.existsSync(envPath) ? import_node_fs2.default.readFileSync(envPath, "utf8") : import_node_fs2.default.existsSync(examplePath) ? import_node_fs2.default.readFileSync(examplePath, "utf8") : "";
1066
+ const envPath = import_node_path4.default.resolve(this.cwd, ".env");
1067
+ const examplePath = import_node_path4.default.resolve(this.cwd, ".env.example");
1068
+ 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") : "";
680
1069
  const next = applyEnvOverrides(template, updates);
681
- import_node_fs2.default.writeFileSync(envPath, next, "utf8");
1070
+ import_node_fs4.default.writeFileSync(envPath, next, "utf8");
682
1071
  }
683
1072
  resolveCors(req) {
684
1073
  const origin = normalizeOriginHeader(req.headers.origin);
@@ -705,7 +1094,7 @@ var AdminServer = class {
705
1094
  }
706
1095
  res.setHeader("Access-Control-Allow-Origin", corsDecision.origin);
707
1096
  res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Admin-Token, X-Admin-Actor");
708
- res.setHeader("Access-Control-Allow-Methods", "GET, PUT, DELETE, OPTIONS");
1097
+ res.setHeader("Access-Control-Allow-Methods", "GET, PUT, POST, DELETE, OPTIONS");
709
1098
  appendVaryHeader(res, "Origin");
710
1099
  }
711
1100
  setSecurityHeaders(res) {
@@ -776,6 +1165,22 @@ function parseJsonLoose(raw) {
776
1165
  return raw;
777
1166
  }
778
1167
  }
1168
+ async function defaultRestartServices(restartAdmin) {
1169
+ const outputChunks = [];
1170
+ const output = {
1171
+ write: (chunk) => {
1172
+ outputChunks.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8"));
1173
+ return true;
1174
+ }
1175
+ };
1176
+ restartSystemdServices({
1177
+ restartAdmin,
1178
+ output
1179
+ });
1180
+ return {
1181
+ restarted: restartAdmin ? ["codeharbor", "codeharbor-admin"] : ["codeharbor"]
1182
+ };
1183
+ }
779
1184
  function isUiPath(pathname) {
780
1185
  return pathname === "/" || pathname === "/index.html" || pathname === "/settings/global" || pathname === "/settings/rooms" || pathname === "/health" || pathname === "/audit";
781
1186
  }
@@ -924,7 +1329,7 @@ function normalizeNonNegativeInt(value, fallback) {
924
1329
  return normalizePositiveInt(value, fallback, 0, Number.MAX_SAFE_INTEGER);
925
1330
  }
926
1331
  function ensureDirectory(targetPath, fieldName) {
927
- if (!import_node_fs2.default.existsSync(targetPath) || !import_node_fs2.default.statSync(targetPath).isDirectory()) {
1332
+ if (!import_node_fs4.default.existsSync(targetPath) || !import_node_fs4.default.statSync(targetPath).isDirectory()) {
928
1333
  throw new HttpError(400, `${fieldName} must be an existing directory: ${targetPath}`);
929
1334
  }
930
1335
  }
@@ -1266,6 +1671,8 @@ var ADMIN_CONSOLE_HTML = `<!doctype html>
1266
1671
  <div class="actions">
1267
1672
  <button id="global-save-btn" type="button">Save Global Config</button>
1268
1673
  <button id="global-reload-btn" type="button" class="secondary">Reload</button>
1674
+ <button id="global-restart-main-btn" type="button" class="secondary">Restart Main Service</button>
1675
+ <button id="global-restart-all-btn" type="button" class="secondary">Restart Main + Admin</button>
1269
1676
  </div>
1270
1677
  <p class="muted">Saving global config updates .env and requires restart to fully take effect.</p>
1271
1678
  </section>
@@ -1408,6 +1815,12 @@ var ADMIN_CONSOLE_HTML = `<!doctype html>
1408
1815
 
1409
1816
  document.getElementById("global-save-btn").addEventListener("click", saveGlobal);
1410
1817
  document.getElementById("global-reload-btn").addEventListener("click", loadGlobal);
1818
+ document.getElementById("global-restart-main-btn").addEventListener("click", function () {
1819
+ restartManagedServices(false);
1820
+ });
1821
+ document.getElementById("global-restart-all-btn").addEventListener("click", function () {
1822
+ restartManagedServices(true);
1823
+ });
1411
1824
  document.getElementById("room-load-btn").addEventListener("click", loadRoom);
1412
1825
  document.getElementById("room-save-btn").addEventListener("click", saveRoom);
1413
1826
  document.getElementById("room-delete-btn").addEventListener("click", deleteRoom);
@@ -1611,6 +2024,19 @@ var ADMIN_CONSOLE_HTML = `<!doctype html>
1611
2024
  }
1612
2025
  }
1613
2026
 
2027
+ async function restartManagedServices(withAdmin) {
2028
+ try {
2029
+ var response = await apiRequest("/api/admin/service/restart", "POST", {
2030
+ withAdmin: Boolean(withAdmin)
2031
+ });
2032
+ var restarted = Array.isArray(response.restarted) ? response.restarted.join(", ") : "codeharbor";
2033
+ var suffix = withAdmin ? " Admin page may reconnect during restart." : "";
2034
+ showNotice("warn", "Restart requested: " + restarted + "." + suffix);
2035
+ } catch (error) {
2036
+ showNotice("error", "Failed to restart service(s): " + error.message);
2037
+ }
2038
+ }
2039
+
1614
2040
  async function refreshRoomList() {
1615
2041
  try {
1616
2042
  var response = await apiRequest("/api/admin/config/rooms", "GET");
@@ -1836,8 +2262,8 @@ async function defaultCheckMatrix(homeserver, timeoutMs) {
1836
2262
 
1837
2263
  // src/channels/matrix-channel.ts
1838
2264
  var import_promises2 = __toESM(require("fs/promises"));
1839
- var import_node_os = __toESM(require("os"));
1840
- var import_node_path3 = __toESM(require("path"));
2265
+ var import_node_os3 = __toESM(require("os"));
2266
+ var import_node_path5 = __toESM(require("path"));
1841
2267
  var import_matrix_js_sdk = require("matrix-js-sdk");
1842
2268
 
1843
2269
  // src/utils/message.ts
@@ -2260,10 +2686,10 @@ var MatrixChannel = class {
2260
2686
  }
2261
2687
  const bytes = Buffer.from(await response.arrayBuffer());
2262
2688
  const extension = resolveFileExtension(fileName, mimeType);
2263
- const directory = import_node_path3.default.join(import_node_os.default.tmpdir(), "codeharbor-media");
2689
+ const directory = import_node_path5.default.join(import_node_os3.default.tmpdir(), "codeharbor-media");
2264
2690
  await import_promises2.default.mkdir(directory, { recursive: true });
2265
2691
  const safeEventId = sanitizeFilename(eventId);
2266
- const targetPath = import_node_path3.default.join(directory, `${safeEventId}-${index}${extension}`);
2692
+ const targetPath = import_node_path5.default.join(directory, `${safeEventId}-${index}${extension}`);
2267
2693
  await import_promises2.default.writeFile(targetPath, bytes);
2268
2694
  return targetPath;
2269
2695
  }
@@ -2348,7 +2774,7 @@ function sanitizeFilename(value) {
2348
2774
  return value.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 80);
2349
2775
  }
2350
2776
  function resolveFileExtension(fileName, mimeType) {
2351
- const ext = import_node_path3.default.extname(fileName).trim();
2777
+ const ext = import_node_path5.default.extname(fileName).trim();
2352
2778
  if (ext) {
2353
2779
  return ext;
2354
2780
  }
@@ -2365,14 +2791,14 @@ function resolveFileExtension(fileName, mimeType) {
2365
2791
  }
2366
2792
 
2367
2793
  // src/config-service.ts
2368
- var import_node_fs3 = __toESM(require("fs"));
2369
- var import_node_path4 = __toESM(require("path"));
2794
+ var import_node_fs5 = __toESM(require("fs"));
2795
+ var import_node_path6 = __toESM(require("path"));
2370
2796
  var ConfigService = class {
2371
2797
  stateStore;
2372
2798
  defaultWorkdir;
2373
2799
  constructor(stateStore, defaultWorkdir) {
2374
2800
  this.stateStore = stateStore;
2375
- this.defaultWorkdir = import_node_path4.default.resolve(defaultWorkdir);
2801
+ this.defaultWorkdir = import_node_path6.default.resolve(defaultWorkdir);
2376
2802
  }
2377
2803
  resolveRoomConfig(roomId, fallbackPolicy) {
2378
2804
  const room = this.stateStore.getRoomSettings(roomId);
@@ -2443,8 +2869,8 @@ function normalizeRoomSettingsInput(input) {
2443
2869
  if (!roomId) {
2444
2870
  throw new Error("roomId is required.");
2445
2871
  }
2446
- const workdir = import_node_path4.default.resolve(input.workdir);
2447
- if (!import_node_fs3.default.existsSync(workdir) || !import_node_fs3.default.statSync(workdir).isDirectory()) {
2872
+ const workdir = import_node_path6.default.resolve(input.workdir);
2873
+ if (!import_node_fs5.default.existsSync(workdir) || !import_node_fs5.default.statSync(workdir).isDirectory()) {
2448
2874
  throw new Error(`workdir does not exist or is not a directory: ${workdir}`);
2449
2875
  }
2450
2876
  return {
@@ -2459,7 +2885,7 @@ function normalizeRoomSettingsInput(input) {
2459
2885
  }
2460
2886
 
2461
2887
  // src/executor/codex-executor.ts
2462
- var import_node_child_process2 = require("child_process");
2888
+ var import_node_child_process3 = require("child_process");
2463
2889
  var import_node_readline = __toESM(require("readline"));
2464
2890
  var CodexExecutionCancelledError = class extends Error {
2465
2891
  constructor(message = "codex execution cancelled") {
@@ -2477,7 +2903,7 @@ var CodexExecutor = class {
2477
2903
  }
2478
2904
  startExecution(prompt, sessionId, onProgress, startOptions) {
2479
2905
  const args = buildCodexArgs(prompt, sessionId, this.options, startOptions);
2480
- const child = (0, import_node_child_process2.spawn)(this.options.bin, args, {
2906
+ const child = (0, import_node_child_process3.spawn)(this.options.bin, args, {
2481
2907
  cwd: startOptions?.workdir ?? this.options.workdir,
2482
2908
  env: {
2483
2909
  ...process.env,
@@ -2713,20 +3139,20 @@ var import_async_mutex = require("async-mutex");
2713
3139
  var import_promises3 = __toESM(require("fs/promises"));
2714
3140
 
2715
3141
  // src/compat/cli-compat-recorder.ts
2716
- var import_node_fs4 = __toESM(require("fs"));
2717
- var import_node_path5 = __toESM(require("path"));
3142
+ var import_node_fs6 = __toESM(require("fs"));
3143
+ var import_node_path7 = __toESM(require("path"));
2718
3144
  var CliCompatRecorder = class {
2719
3145
  filePath;
2720
3146
  chain = Promise.resolve();
2721
3147
  constructor(filePath) {
2722
- this.filePath = import_node_path5.default.resolve(filePath);
2723
- import_node_fs4.default.mkdirSync(import_node_path5.default.dirname(this.filePath), { recursive: true });
3148
+ this.filePath = import_node_path7.default.resolve(filePath);
3149
+ import_node_fs6.default.mkdirSync(import_node_path7.default.dirname(this.filePath), { recursive: true });
2724
3150
  }
2725
3151
  append(entry) {
2726
3152
  const payload = `${JSON.stringify(entry)}
2727
3153
  `;
2728
3154
  this.chain = this.chain.then(async () => {
2729
- await import_node_fs4.default.promises.appendFile(this.filePath, payload, "utf8");
3155
+ await import_node_fs6.default.promises.appendFile(this.filePath, payload, "utf8");
2730
3156
  });
2731
3157
  return this.chain;
2732
3158
  }
@@ -4114,8 +4540,8 @@ ${result.review}
4114
4540
  }
4115
4541
 
4116
4542
  // src/store/state-store.ts
4117
- var import_node_fs5 = __toESM(require("fs"));
4118
- var import_node_path6 = __toESM(require("path"));
4543
+ var import_node_fs7 = __toESM(require("fs"));
4544
+ var import_node_path8 = __toESM(require("path"));
4119
4545
  var ONE_DAY_MS = 24 * 60 * 60 * 1e3;
4120
4546
  var PRUNE_INTERVAL_MS = 5 * 60 * 1e3;
4121
4547
  var SQLITE_MODULE_ID = `node:${"sqlite"}`;
@@ -4141,7 +4567,7 @@ var StateStore = class {
4141
4567
  this.maxProcessedEventsPerSession = maxProcessedEventsPerSession;
4142
4568
  this.maxSessionAgeMs = maxSessionAgeDays * ONE_DAY_MS;
4143
4569
  this.maxSessions = maxSessions;
4144
- import_node_fs5.default.mkdirSync(import_node_path6.default.dirname(this.dbPath), { recursive: true });
4570
+ import_node_fs7.default.mkdirSync(import_node_path8.default.dirname(this.dbPath), { recursive: true });
4145
4571
  this.db = new DatabaseSync(this.dbPath);
4146
4572
  this.initializeSchema();
4147
4573
  this.importLegacyStateIfNeeded();
@@ -4386,7 +4812,7 @@ var StateStore = class {
4386
4812
  `);
4387
4813
  }
4388
4814
  importLegacyStateIfNeeded() {
4389
- if (!this.legacyJsonPath || !import_node_fs5.default.existsSync(this.legacyJsonPath)) {
4815
+ if (!this.legacyJsonPath || !import_node_fs7.default.existsSync(this.legacyJsonPath)) {
4390
4816
  return;
4391
4817
  }
4392
4818
  const countRow = this.db.prepare("SELECT COUNT(*) AS count FROM sessions").get();
@@ -4416,1296 +4842,1004 @@ var StateStore = class {
4416
4842
  }
4417
4843
  }
4418
4844
  this.db.exec("COMMIT");
4419
- } catch (error) {
4420
- this.db.exec("ROLLBACK");
4421
- throw error;
4422
- }
4423
- }
4424
- maybePruneExpiredSessions() {
4425
- const now = Date.now();
4426
- if (now - this.lastPruneAt < PRUNE_INTERVAL_MS) {
4427
- return;
4428
- }
4429
- this.lastPruneAt = now;
4430
- if (this.pruneSessions(now)) {
4431
- this.touchDatabase();
4432
- }
4433
- }
4434
- pruneSessions(now = Date.now()) {
4435
- let changed = false;
4436
- if (this.pruneExpiredSessions(now)) {
4437
- changed = true;
4438
- }
4439
- if (this.pruneExcessSessions()) {
4440
- changed = true;
4441
- }
4442
- return changed;
4443
- }
4444
- pruneExpiredSessions(now) {
4445
- if (this.maxSessionAgeMs <= 0) {
4446
- return false;
4447
- }
4448
- const result = this.db.prepare("DELETE FROM sessions WHERE updated_at < ?1").run(now - this.maxSessionAgeMs);
4449
- return (result.changes ?? 0) > 0;
4450
- }
4451
- pruneExcessSessions() {
4452
- if (this.maxSessions <= 0) {
4453
- return false;
4454
- }
4455
- const row = this.db.prepare("SELECT COUNT(*) AS count FROM sessions").get();
4456
- const count = row?.count ?? 0;
4457
- if (count <= this.maxSessions) {
4458
- return false;
4459
- }
4460
- const removeCount = count - this.maxSessions;
4461
- const result = this.db.prepare(
4462
- "DELETE FROM sessions WHERE session_key IN (SELECT session_key FROM sessions ORDER BY updated_at ASC LIMIT ?1)"
4463
- ).run(removeCount);
4464
- return (result.changes ?? 0) > 0;
4465
- }
4466
- touchDatabase() {
4467
- this.db.exec("PRAGMA wal_checkpoint(PASSIVE)");
4468
- }
4469
- };
4470
- function loadLegacyState(filePath) {
4471
- try {
4472
- const raw = import_node_fs5.default.readFileSync(filePath, "utf8");
4473
- const parsed = JSON.parse(raw);
4474
- if (!parsed.sessions || typeof parsed.sessions !== "object") {
4475
- return null;
4476
- }
4477
- normalizeLegacyState(parsed);
4478
- return parsed;
4479
- } catch {
4480
- return null;
4481
- }
4482
- }
4483
- function parseUpdatedAt(updatedAt) {
4484
- const timestamp = Date.parse(updatedAt);
4485
- return Number.isFinite(timestamp) ? timestamp : Date.now();
4486
- }
4487
- function parseOptionalTimestamp(value) {
4488
- if (!value) {
4489
- return null;
4490
- }
4491
- const ts = Date.parse(value);
4492
- return Number.isFinite(ts) ? ts : null;
4493
- }
4494
- function normalizeLegacyState(state) {
4495
- for (const session of Object.values(state.sessions)) {
4496
- if (!Array.isArray(session.processedEventIds)) {
4497
- session.processedEventIds = [];
4498
- }
4499
- if (typeof session.codexSessionId !== "string" && session.codexSessionId !== null) {
4500
- session.codexSessionId = null;
4501
- }
4502
- if (typeof session.updatedAt !== "string") {
4503
- session.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
4504
- }
4505
- if (typeof session.activeUntil !== "string" && session.activeUntil !== null) {
4506
- session.activeUntil = null;
4507
- }
4508
- }
4509
- }
4510
- function boolToInt(value) {
4511
- return value ? 1 : 0;
4512
- }
4513
-
4514
- // src/app.ts
4515
- var execFileAsync2 = (0, import_node_util2.promisify)(import_node_child_process3.execFile);
4516
- var CodeHarborApp = class {
4517
- config;
4518
- logger;
4519
- stateStore;
4520
- channel;
4521
- orchestrator;
4522
- configService;
4523
- constructor(config) {
4524
- this.config = config;
4525
- this.logger = new Logger(config.logLevel);
4526
- this.stateStore = new StateStore(
4527
- config.stateDbPath,
4528
- config.legacyStateJsonPath,
4529
- config.maxProcessedEventsPerSession,
4530
- config.maxSessionAgeDays,
4531
- config.maxSessions
4532
- );
4533
- this.configService = new ConfigService(this.stateStore, config.codexWorkdir);
4534
- const executor = new CodexExecutor({
4535
- bin: config.codexBin,
4536
- model: config.codexModel,
4537
- workdir: config.codexWorkdir,
4538
- dangerousBypass: config.codexDangerousBypass,
4539
- timeoutMs: config.codexExecTimeoutMs,
4540
- sandboxMode: config.codexSandboxMode,
4541
- approvalPolicy: config.codexApprovalPolicy,
4542
- extraArgs: config.codexExtraArgs,
4543
- extraEnv: config.codexExtraEnv
4544
- });
4545
- this.channel = new MatrixChannel(config, this.logger);
4546
- this.orchestrator = new Orchestrator(this.channel, executor, this.stateStore, this.logger, {
4547
- progressUpdatesEnabled: config.matrixProgressUpdates,
4548
- progressMinIntervalMs: config.matrixProgressMinIntervalMs,
4549
- typingTimeoutMs: config.matrixTypingTimeoutMs,
4550
- commandPrefix: config.matrixCommandPrefix,
4551
- matrixUserId: config.matrixUserId,
4552
- sessionActiveWindowMinutes: config.sessionActiveWindowMinutes,
4553
- defaultGroupTriggerPolicy: config.defaultGroupTriggerPolicy,
4554
- roomTriggerPolicies: config.roomTriggerPolicies,
4555
- rateLimiterOptions: config.rateLimiter,
4556
- cliCompat: config.cliCompat,
4557
- multiAgentWorkflow: config.agentWorkflow,
4558
- configService: this.configService,
4559
- defaultCodexWorkdir: config.codexWorkdir
4560
- });
4561
- }
4562
- async start() {
4563
- this.logger.info("CodeHarbor starting", {
4564
- matrixHomeserver: this.config.matrixHomeserver,
4565
- workdir: this.config.codexWorkdir,
4566
- prefix: this.config.matrixCommandPrefix || "<none>"
4567
- });
4568
- await this.channel.start(this.orchestrator.handleMessage.bind(this.orchestrator));
4569
- this.logger.info("CodeHarbor is running.");
4570
- }
4571
- async stop() {
4572
- this.logger.info("CodeHarbor stopping.");
4573
- try {
4574
- await this.channel.stop();
4575
- } finally {
4576
- await this.stateStore.flush();
4577
- }
4578
- }
4579
- };
4580
- var CodeHarborAdminApp = class {
4581
- config;
4582
- logger;
4583
- stateStore;
4584
- configService;
4585
- adminServer;
4586
- constructor(config, options) {
4587
- this.config = config;
4588
- this.logger = new Logger(config.logLevel);
4589
- this.stateStore = new StateStore(
4590
- config.stateDbPath,
4591
- config.legacyStateJsonPath,
4592
- config.maxProcessedEventsPerSession,
4593
- config.maxSessionAgeDays,
4594
- config.maxSessions
4595
- );
4596
- this.configService = new ConfigService(this.stateStore, config.codexWorkdir);
4597
- this.adminServer = new AdminServer(config, this.logger, this.stateStore, this.configService, {
4598
- host: options?.host ?? config.adminBindHost,
4599
- port: options?.port ?? config.adminPort,
4600
- adminToken: config.adminToken,
4601
- adminIpAllowlist: config.adminIpAllowlist,
4602
- adminAllowedOrigins: config.adminAllowedOrigins
4603
- });
4604
- }
4605
- async start() {
4606
- await this.adminServer.start();
4607
- const address = this.adminServer.getAddress();
4608
- this.logger.info("CodeHarbor admin server started", {
4609
- host: address?.host ?? this.config.adminBindHost,
4610
- port: address?.port ?? this.config.adminPort,
4611
- tokenProtected: Boolean(this.config.adminToken)
4612
- });
4613
- }
4614
- async stop() {
4615
- this.logger.info("CodeHarbor admin server stopping.");
4616
- try {
4617
- await this.adminServer.stop();
4618
- } finally {
4619
- await this.stateStore.flush();
4620
- }
4621
- }
4622
- };
4623
- async function runDoctor(config) {
4624
- const logger = new Logger(config.logLevel);
4625
- logger.info("Doctor check started");
4626
- try {
4627
- const { stdout } = await execFileAsync2(config.codexBin, ["--version"]);
4628
- logger.info("codex available", { version: stdout.trim() });
4629
- } catch (error) {
4630
- logger.error("codex unavailable", error);
4631
- throw error;
4632
- }
4633
- try {
4634
- const controller = new AbortController();
4635
- const timer = setTimeout(() => controller.abort(), config.doctorHttpTimeoutMs);
4636
- timer.unref?.();
4637
- const response = await fetch(`${config.matrixHomeserver}/_matrix/client/versions`, {
4638
- signal: controller.signal
4639
- }).finally(() => {
4640
- clearTimeout(timer);
4641
- });
4642
- if (!response.ok) {
4643
- throw new Error(`HTTP ${response.status}`);
4644
- }
4645
- const body = await response.json();
4646
- logger.info("matrix reachable", { versions: body.versions ?? [] });
4647
- } catch (error) {
4648
- logger.error("matrix unreachable", error);
4649
- throw error;
4650
- }
4651
- logger.info("Doctor check passed");
4652
- }
4653
-
4654
- // src/utils/admin-host.ts
4655
- function isNonLoopbackHost(host) {
4656
- const normalized = host.trim().toLowerCase();
4657
- if (!normalized) {
4658
- return false;
4659
- }
4660
- return !(normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1" || normalized === "[::1]");
4661
- }
4662
-
4663
- // src/config.ts
4664
- var import_node_fs6 = __toESM(require("fs"));
4665
- var import_node_path7 = __toESM(require("path"));
4666
- var import_dotenv2 = __toESM(require("dotenv"));
4667
- var import_zod = require("zod");
4668
- var configSchema = import_zod.z.object({
4669
- MATRIX_HOMESERVER: import_zod.z.string().url(),
4670
- MATRIX_USER_ID: import_zod.z.string().min(1),
4671
- MATRIX_ACCESS_TOKEN: import_zod.z.string().min(1),
4672
- MATRIX_COMMAND_PREFIX: import_zod.z.string().default("!code"),
4673
- CODEX_BIN: import_zod.z.string().default("codex"),
4674
- CODEX_MODEL: import_zod.z.string().optional(),
4675
- CODEX_WORKDIR: import_zod.z.string().default("."),
4676
- CODEX_DANGEROUS_BYPASS: import_zod.z.string().default("false").transform((v) => v.toLowerCase() === "true"),
4677
- CODEX_EXEC_TIMEOUT_MS: import_zod.z.string().default("600000").transform((v) => Number.parseInt(v, 10)).pipe(import_zod.z.number().int().positive()),
4678
- CODEX_SANDBOX_MODE: import_zod.z.string().optional(),
4679
- CODEX_APPROVAL_POLICY: import_zod.z.string().optional(),
4680
- CODEX_EXTRA_ARGS: import_zod.z.string().default(""),
4681
- CODEX_EXTRA_ENV_JSON: import_zod.z.string().default(""),
4682
- AGENT_WORKFLOW_ENABLED: import_zod.z.string().default("false").transform((v) => v.toLowerCase() === "true"),
4683
- AGENT_WORKFLOW_AUTO_REPAIR_MAX_ROUNDS: import_zod.z.string().default("1").transform((v) => Number.parseInt(v, 10)).pipe(import_zod.z.number().int().min(0).max(10)),
4684
- STATE_DB_PATH: import_zod.z.string().default("data/state.db"),
4685
- STATE_PATH: import_zod.z.string().default("data/state.json"),
4686
- MAX_PROCESSED_EVENTS_PER_SESSION: import_zod.z.string().default("200").transform((v) => Number.parseInt(v, 10)).pipe(import_zod.z.number().int().positive()),
4687
- MAX_SESSION_AGE_DAYS: import_zod.z.string().default("30").transform((v) => Number.parseInt(v, 10)).pipe(import_zod.z.number().int().positive()),
4688
- MAX_SESSIONS: import_zod.z.string().default("5000").transform((v) => Number.parseInt(v, 10)).pipe(import_zod.z.number().int().positive()),
4689
- REPLY_CHUNK_SIZE: import_zod.z.string().default("3500").transform((v) => Number.parseInt(v, 10)).pipe(import_zod.z.number().int().positive()),
4690
- MATRIX_PROGRESS_UPDATES: import_zod.z.string().default("true").transform((v) => v.toLowerCase() === "true"),
4691
- MATRIX_PROGRESS_MIN_INTERVAL_MS: import_zod.z.string().default("2500").transform((v) => Number.parseInt(v, 10)).pipe(import_zod.z.number().int().positive()),
4692
- MATRIX_TYPING_TIMEOUT_MS: import_zod.z.string().default("10000").transform((v) => Number.parseInt(v, 10)).pipe(import_zod.z.number().int().positive()),
4693
- SESSION_ACTIVE_WINDOW_MINUTES: import_zod.z.string().default("20").transform((v) => Number.parseInt(v, 10)).pipe(import_zod.z.number().int().positive()),
4694
- GROUP_TRIGGER_ALLOW_MENTION: import_zod.z.string().default("true").transform((v) => v.toLowerCase() === "true"),
4695
- GROUP_TRIGGER_ALLOW_REPLY: import_zod.z.string().default("true").transform((v) => v.toLowerCase() === "true"),
4696
- GROUP_TRIGGER_ALLOW_ACTIVE_WINDOW: import_zod.z.string().default("true").transform((v) => v.toLowerCase() === "true"),
4697
- GROUP_TRIGGER_ALLOW_PREFIX: import_zod.z.string().default("true").transform((v) => v.toLowerCase() === "true"),
4698
- ROOM_TRIGGER_POLICY_JSON: import_zod.z.string().default(""),
4699
- RATE_LIMIT_WINDOW_SECONDS: import_zod.z.string().default("60").transform((v) => Number.parseInt(v, 10)).pipe(import_zod.z.number().int().positive()),
4700
- RATE_LIMIT_MAX_REQUESTS_PER_USER: import_zod.z.string().default("20").transform((v) => Number.parseInt(v, 10)).pipe(import_zod.z.number().int().nonnegative()),
4701
- RATE_LIMIT_MAX_REQUESTS_PER_ROOM: import_zod.z.string().default("120").transform((v) => Number.parseInt(v, 10)).pipe(import_zod.z.number().int().nonnegative()),
4702
- RATE_LIMIT_MAX_CONCURRENT_GLOBAL: import_zod.z.string().default("8").transform((v) => Number.parseInt(v, 10)).pipe(import_zod.z.number().int().nonnegative()),
4703
- RATE_LIMIT_MAX_CONCURRENT_PER_USER: import_zod.z.string().default("1").transform((v) => Number.parseInt(v, 10)).pipe(import_zod.z.number().int().nonnegative()),
4704
- RATE_LIMIT_MAX_CONCURRENT_PER_ROOM: import_zod.z.string().default("4").transform((v) => Number.parseInt(v, 10)).pipe(import_zod.z.number().int().nonnegative()),
4705
- CLI_COMPAT_MODE: import_zod.z.string().default("false").transform((v) => v.toLowerCase() === "true"),
4706
- CLI_COMPAT_PASSTHROUGH_EVENTS: import_zod.z.string().default("true").transform((v) => v.toLowerCase() === "true"),
4707
- CLI_COMPAT_PRESERVE_WHITESPACE: import_zod.z.string().default("true").transform((v) => v.toLowerCase() === "true"),
4708
- CLI_COMPAT_DISABLE_REPLY_CHUNK_SPLIT: import_zod.z.string().default("false").transform((v) => v.toLowerCase() === "true"),
4709
- CLI_COMPAT_PROGRESS_THROTTLE_MS: import_zod.z.string().default("300").transform((v) => Number.parseInt(v, 10)).pipe(import_zod.z.number().int().nonnegative()),
4710
- CLI_COMPAT_FETCH_MEDIA: import_zod.z.string().default("true").transform((v) => v.toLowerCase() === "true"),
4711
- CLI_COMPAT_RECORD_PATH: import_zod.z.string().default(""),
4712
- DOCTOR_HTTP_TIMEOUT_MS: import_zod.z.string().default("10000").transform((v) => Number.parseInt(v, 10)).pipe(import_zod.z.number().int().positive()),
4713
- ADMIN_BIND_HOST: import_zod.z.string().default("127.0.0.1"),
4714
- ADMIN_PORT: import_zod.z.string().default("8787").transform((v) => Number.parseInt(v, 10)).pipe(import_zod.z.number().int().min(1).max(65535)),
4715
- ADMIN_TOKEN: import_zod.z.string().default(""),
4716
- ADMIN_IP_ALLOWLIST: import_zod.z.string().default(""),
4717
- ADMIN_ALLOWED_ORIGINS: import_zod.z.string().default(""),
4718
- LOG_LEVEL: import_zod.z.enum(["debug", "info", "warn", "error"]).default("info")
4719
- }).transform((v) => ({
4720
- matrixHomeserver: v.MATRIX_HOMESERVER,
4721
- matrixUserId: v.MATRIX_USER_ID,
4722
- matrixAccessToken: v.MATRIX_ACCESS_TOKEN,
4723
- matrixCommandPrefix: v.MATRIX_COMMAND_PREFIX,
4724
- codexBin: v.CODEX_BIN,
4725
- codexModel: v.CODEX_MODEL?.trim() || null,
4726
- codexWorkdir: import_node_path7.default.resolve(v.CODEX_WORKDIR),
4727
- codexDangerousBypass: v.CODEX_DANGEROUS_BYPASS,
4728
- codexExecTimeoutMs: v.CODEX_EXEC_TIMEOUT_MS,
4729
- codexSandboxMode: v.CODEX_SANDBOX_MODE?.trim() || null,
4730
- codexApprovalPolicy: v.CODEX_APPROVAL_POLICY?.trim() || null,
4731
- codexExtraArgs: parseExtraArgs(v.CODEX_EXTRA_ARGS),
4732
- codexExtraEnv: parseExtraEnv(v.CODEX_EXTRA_ENV_JSON),
4733
- agentWorkflow: {
4734
- enabled: v.AGENT_WORKFLOW_ENABLED,
4735
- autoRepairMaxRounds: v.AGENT_WORKFLOW_AUTO_REPAIR_MAX_ROUNDS
4736
- },
4737
- stateDbPath: import_node_path7.default.resolve(v.STATE_DB_PATH),
4738
- legacyStateJsonPath: v.STATE_PATH.trim() ? import_node_path7.default.resolve(v.STATE_PATH) : null,
4739
- maxProcessedEventsPerSession: v.MAX_PROCESSED_EVENTS_PER_SESSION,
4740
- maxSessionAgeDays: v.MAX_SESSION_AGE_DAYS,
4741
- maxSessions: v.MAX_SESSIONS,
4742
- replyChunkSize: v.REPLY_CHUNK_SIZE,
4743
- matrixProgressUpdates: v.MATRIX_PROGRESS_UPDATES,
4744
- matrixProgressMinIntervalMs: v.MATRIX_PROGRESS_MIN_INTERVAL_MS,
4745
- matrixTypingTimeoutMs: v.MATRIX_TYPING_TIMEOUT_MS,
4746
- sessionActiveWindowMinutes: v.SESSION_ACTIVE_WINDOW_MINUTES,
4747
- defaultGroupTriggerPolicy: {
4748
- allowMention: v.GROUP_TRIGGER_ALLOW_MENTION,
4749
- allowReply: v.GROUP_TRIGGER_ALLOW_REPLY,
4750
- allowActiveWindow: v.GROUP_TRIGGER_ALLOW_ACTIVE_WINDOW,
4751
- allowPrefix: v.GROUP_TRIGGER_ALLOW_PREFIX
4752
- },
4753
- roomTriggerPolicies: parseRoomTriggerPolicyOverrides(v.ROOM_TRIGGER_POLICY_JSON),
4754
- rateLimiter: {
4755
- windowMs: v.RATE_LIMIT_WINDOW_SECONDS * 1e3,
4756
- maxRequestsPerUser: v.RATE_LIMIT_MAX_REQUESTS_PER_USER,
4757
- maxRequestsPerRoom: v.RATE_LIMIT_MAX_REQUESTS_PER_ROOM,
4758
- maxConcurrentGlobal: v.RATE_LIMIT_MAX_CONCURRENT_GLOBAL,
4759
- maxConcurrentPerUser: v.RATE_LIMIT_MAX_CONCURRENT_PER_USER,
4760
- maxConcurrentPerRoom: v.RATE_LIMIT_MAX_CONCURRENT_PER_ROOM
4761
- },
4762
- cliCompat: {
4763
- enabled: v.CLI_COMPAT_MODE,
4764
- passThroughEvents: v.CLI_COMPAT_PASSTHROUGH_EVENTS,
4765
- preserveWhitespace: v.CLI_COMPAT_PRESERVE_WHITESPACE,
4766
- disableReplyChunkSplit: v.CLI_COMPAT_DISABLE_REPLY_CHUNK_SPLIT,
4767
- progressThrottleMs: v.CLI_COMPAT_PROGRESS_THROTTLE_MS,
4768
- fetchMedia: v.CLI_COMPAT_FETCH_MEDIA,
4769
- recordPath: v.CLI_COMPAT_RECORD_PATH.trim() ? import_node_path7.default.resolve(v.CLI_COMPAT_RECORD_PATH) : null
4770
- },
4771
- doctorHttpTimeoutMs: v.DOCTOR_HTTP_TIMEOUT_MS,
4772
- adminBindHost: v.ADMIN_BIND_HOST.trim() || "127.0.0.1",
4773
- adminPort: v.ADMIN_PORT,
4774
- adminToken: v.ADMIN_TOKEN.trim() || null,
4775
- adminIpAllowlist: parseCsvList(v.ADMIN_IP_ALLOWLIST),
4776
- adminAllowedOrigins: parseCsvList(v.ADMIN_ALLOWED_ORIGINS),
4777
- logLevel: v.LOG_LEVEL
4778
- }));
4779
- function loadEnvFromFile(filePath = import_node_path7.default.resolve(process.cwd(), ".env"), env = process.env) {
4780
- import_dotenv2.default.config({
4781
- path: filePath,
4782
- processEnv: env,
4783
- quiet: true
4784
- });
4785
- }
4786
- function loadConfig(env = process.env) {
4787
- const parsed = configSchema.safeParse(env);
4788
- if (!parsed.success) {
4789
- const message = parsed.error.issues.map((issue) => `${issue.path.join(".") || "config"}: ${issue.message}`).join("; ");
4790
- throw new Error(`Invalid configuration: ${message}`);
4791
- }
4792
- import_node_fs6.default.mkdirSync(import_node_path7.default.dirname(parsed.data.stateDbPath), { recursive: true });
4793
- if (parsed.data.legacyStateJsonPath) {
4794
- import_node_fs6.default.mkdirSync(import_node_path7.default.dirname(parsed.data.legacyStateJsonPath), { recursive: true });
4795
- }
4796
- return parsed.data;
4797
- }
4798
- function parseRoomTriggerPolicyOverrides(raw) {
4799
- const trimmed = raw.trim();
4800
- if (!trimmed) {
4801
- return {};
4802
- }
4803
- let parsed;
4804
- try {
4805
- parsed = JSON.parse(trimmed);
4806
- } catch {
4807
- throw new Error("ROOM_TRIGGER_POLICY_JSON must be valid JSON.");
4808
- }
4809
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
4810
- throw new Error("ROOM_TRIGGER_POLICY_JSON must be an object keyed by room id.");
4811
- }
4812
- const output = {};
4813
- for (const [roomId, value] of Object.entries(parsed)) {
4814
- if (!value || typeof value !== "object" || Array.isArray(value)) {
4815
- throw new Error(`ROOM_TRIGGER_POLICY_JSON[${roomId}] must be an object.`);
4816
- }
4817
- const item = value;
4818
- const override = {};
4819
- for (const key of ["allowMention", "allowReply", "allowActiveWindow", "allowPrefix"]) {
4820
- if (item[key] === void 0) {
4821
- continue;
4822
- }
4823
- if (typeof item[key] !== "boolean") {
4824
- throw new Error(`ROOM_TRIGGER_POLICY_JSON[${roomId}].${key} must be boolean.`);
4825
- }
4826
- override[key] = item[key];
4827
- }
4828
- output[roomId] = override;
4829
- }
4830
- return output;
4831
- }
4832
- function parseExtraArgs(raw) {
4833
- const trimmed = raw.trim();
4834
- if (!trimmed) {
4835
- return [];
4836
- }
4837
- return trimmed.split(/\s+/).map((entry) => entry.trim()).filter((entry) => entry.length > 0);
4838
- }
4839
- function parseExtraEnv(raw) {
4840
- const trimmed = raw.trim();
4841
- if (!trimmed) {
4842
- return {};
4843
- }
4844
- let parsed;
4845
- try {
4846
- parsed = JSON.parse(trimmed);
4847
- } catch {
4848
- throw new Error("CODEX_EXTRA_ENV_JSON must be valid JSON object.");
4849
- }
4850
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
4851
- throw new Error("CODEX_EXTRA_ENV_JSON must be a key/value object.");
4852
- }
4853
- const output = {};
4854
- for (const [key, value] of Object.entries(parsed)) {
4855
- if (typeof value !== "string") {
4856
- throw new Error(`CODEX_EXTRA_ENV_JSON[${key}] must be string.`);
4857
- }
4858
- output[key] = value;
4859
- }
4860
- return output;
4861
- }
4862
- function parseCsvList(raw) {
4863
- return raw.split(",").map((entry) => entry.trim()).filter((entry) => entry.length > 0);
4864
- }
4865
-
4866
- // src/config-snapshot.ts
4867
- var import_node_fs7 = __toESM(require("fs"));
4868
- var import_node_path8 = __toESM(require("path"));
4869
- var import_zod2 = require("zod");
4870
- var CONFIG_SNAPSHOT_SCHEMA_VERSION = 1;
4871
- var CONFIG_SNAPSHOT_ENV_KEYS = [
4872
- "MATRIX_HOMESERVER",
4873
- "MATRIX_USER_ID",
4874
- "MATRIX_ACCESS_TOKEN",
4875
- "MATRIX_COMMAND_PREFIX",
4876
- "CODEX_BIN",
4877
- "CODEX_MODEL",
4878
- "CODEX_WORKDIR",
4879
- "CODEX_DANGEROUS_BYPASS",
4880
- "CODEX_EXEC_TIMEOUT_MS",
4881
- "CODEX_SANDBOX_MODE",
4882
- "CODEX_APPROVAL_POLICY",
4883
- "CODEX_EXTRA_ARGS",
4884
- "CODEX_EXTRA_ENV_JSON",
4885
- "AGENT_WORKFLOW_ENABLED",
4886
- "AGENT_WORKFLOW_AUTO_REPAIR_MAX_ROUNDS",
4887
- "STATE_DB_PATH",
4888
- "STATE_PATH",
4889
- "MAX_PROCESSED_EVENTS_PER_SESSION",
4890
- "MAX_SESSION_AGE_DAYS",
4891
- "MAX_SESSIONS",
4892
- "REPLY_CHUNK_SIZE",
4893
- "MATRIX_PROGRESS_UPDATES",
4894
- "MATRIX_PROGRESS_MIN_INTERVAL_MS",
4895
- "MATRIX_TYPING_TIMEOUT_MS",
4896
- "SESSION_ACTIVE_WINDOW_MINUTES",
4897
- "GROUP_TRIGGER_ALLOW_MENTION",
4898
- "GROUP_TRIGGER_ALLOW_REPLY",
4899
- "GROUP_TRIGGER_ALLOW_ACTIVE_WINDOW",
4900
- "GROUP_TRIGGER_ALLOW_PREFIX",
4901
- "ROOM_TRIGGER_POLICY_JSON",
4902
- "RATE_LIMIT_WINDOW_SECONDS",
4903
- "RATE_LIMIT_MAX_REQUESTS_PER_USER",
4904
- "RATE_LIMIT_MAX_REQUESTS_PER_ROOM",
4905
- "RATE_LIMIT_MAX_CONCURRENT_GLOBAL",
4906
- "RATE_LIMIT_MAX_CONCURRENT_PER_USER",
4907
- "RATE_LIMIT_MAX_CONCURRENT_PER_ROOM",
4908
- "CLI_COMPAT_MODE",
4909
- "CLI_COMPAT_PASSTHROUGH_EVENTS",
4910
- "CLI_COMPAT_PRESERVE_WHITESPACE",
4911
- "CLI_COMPAT_DISABLE_REPLY_CHUNK_SPLIT",
4912
- "CLI_COMPAT_PROGRESS_THROTTLE_MS",
4913
- "CLI_COMPAT_FETCH_MEDIA",
4914
- "CLI_COMPAT_RECORD_PATH",
4915
- "DOCTOR_HTTP_TIMEOUT_MS",
4916
- "ADMIN_BIND_HOST",
4917
- "ADMIN_PORT",
4918
- "ADMIN_TOKEN",
4919
- "ADMIN_IP_ALLOWLIST",
4920
- "ADMIN_ALLOWED_ORIGINS",
4921
- "LOG_LEVEL"
4922
- ];
4923
- var BOOLEAN_STRING = /^(true|false)$/i;
4924
- var INTEGER_STRING = /^-?\d+$/;
4925
- var LOG_LEVELS = ["debug", "info", "warn", "error"];
4926
- var roomSnapshotSchema = import_zod2.z.object({
4927
- roomId: import_zod2.z.string().min(1),
4928
- enabled: import_zod2.z.boolean(),
4929
- allowMention: import_zod2.z.boolean(),
4930
- allowReply: import_zod2.z.boolean(),
4931
- allowActiveWindow: import_zod2.z.boolean(),
4932
- allowPrefix: import_zod2.z.boolean(),
4933
- workdir: import_zod2.z.string().min(1)
4934
- }).strict();
4935
- var envSnapshotSchema = import_zod2.z.object({
4936
- MATRIX_HOMESERVER: import_zod2.z.string().url(),
4937
- MATRIX_USER_ID: import_zod2.z.string().min(1),
4938
- MATRIX_ACCESS_TOKEN: import_zod2.z.string().min(1),
4939
- MATRIX_COMMAND_PREFIX: import_zod2.z.string(),
4940
- CODEX_BIN: import_zod2.z.string().min(1),
4941
- CODEX_MODEL: import_zod2.z.string(),
4942
- CODEX_WORKDIR: import_zod2.z.string().min(1),
4943
- CODEX_DANGEROUS_BYPASS: booleanStringSchema("CODEX_DANGEROUS_BYPASS"),
4944
- CODEX_EXEC_TIMEOUT_MS: integerStringSchema("CODEX_EXEC_TIMEOUT_MS", 1),
4945
- CODEX_SANDBOX_MODE: import_zod2.z.string(),
4946
- CODEX_APPROVAL_POLICY: import_zod2.z.string(),
4947
- CODEX_EXTRA_ARGS: import_zod2.z.string(),
4948
- CODEX_EXTRA_ENV_JSON: jsonObjectStringSchema("CODEX_EXTRA_ENV_JSON", true),
4949
- AGENT_WORKFLOW_ENABLED: booleanStringSchema("AGENT_WORKFLOW_ENABLED"),
4950
- AGENT_WORKFLOW_AUTO_REPAIR_MAX_ROUNDS: integerStringSchema("AGENT_WORKFLOW_AUTO_REPAIR_MAX_ROUNDS", 0, 10),
4951
- STATE_DB_PATH: import_zod2.z.string().min(1),
4952
- STATE_PATH: import_zod2.z.string(),
4953
- MAX_PROCESSED_EVENTS_PER_SESSION: integerStringSchema("MAX_PROCESSED_EVENTS_PER_SESSION", 1),
4954
- MAX_SESSION_AGE_DAYS: integerStringSchema("MAX_SESSION_AGE_DAYS", 1),
4955
- MAX_SESSIONS: integerStringSchema("MAX_SESSIONS", 1),
4956
- REPLY_CHUNK_SIZE: integerStringSchema("REPLY_CHUNK_SIZE", 1),
4957
- MATRIX_PROGRESS_UPDATES: booleanStringSchema("MATRIX_PROGRESS_UPDATES"),
4958
- MATRIX_PROGRESS_MIN_INTERVAL_MS: integerStringSchema("MATRIX_PROGRESS_MIN_INTERVAL_MS", 1),
4959
- MATRIX_TYPING_TIMEOUT_MS: integerStringSchema("MATRIX_TYPING_TIMEOUT_MS", 1),
4960
- SESSION_ACTIVE_WINDOW_MINUTES: integerStringSchema("SESSION_ACTIVE_WINDOW_MINUTES", 1),
4961
- GROUP_TRIGGER_ALLOW_MENTION: booleanStringSchema("GROUP_TRIGGER_ALLOW_MENTION"),
4962
- GROUP_TRIGGER_ALLOW_REPLY: booleanStringSchema("GROUP_TRIGGER_ALLOW_REPLY"),
4963
- GROUP_TRIGGER_ALLOW_ACTIVE_WINDOW: booleanStringSchema("GROUP_TRIGGER_ALLOW_ACTIVE_WINDOW"),
4964
- GROUP_TRIGGER_ALLOW_PREFIX: booleanStringSchema("GROUP_TRIGGER_ALLOW_PREFIX"),
4965
- ROOM_TRIGGER_POLICY_JSON: jsonObjectStringSchema("ROOM_TRIGGER_POLICY_JSON", true),
4966
- RATE_LIMIT_WINDOW_SECONDS: integerStringSchema("RATE_LIMIT_WINDOW_SECONDS", 1),
4967
- RATE_LIMIT_MAX_REQUESTS_PER_USER: integerStringSchema("RATE_LIMIT_MAX_REQUESTS_PER_USER", 0),
4968
- RATE_LIMIT_MAX_REQUESTS_PER_ROOM: integerStringSchema("RATE_LIMIT_MAX_REQUESTS_PER_ROOM", 0),
4969
- RATE_LIMIT_MAX_CONCURRENT_GLOBAL: integerStringSchema("RATE_LIMIT_MAX_CONCURRENT_GLOBAL", 0),
4970
- RATE_LIMIT_MAX_CONCURRENT_PER_USER: integerStringSchema("RATE_LIMIT_MAX_CONCURRENT_PER_USER", 0),
4971
- RATE_LIMIT_MAX_CONCURRENT_PER_ROOM: integerStringSchema("RATE_LIMIT_MAX_CONCURRENT_PER_ROOM", 0),
4972
- CLI_COMPAT_MODE: booleanStringSchema("CLI_COMPAT_MODE"),
4973
- CLI_COMPAT_PASSTHROUGH_EVENTS: booleanStringSchema("CLI_COMPAT_PASSTHROUGH_EVENTS"),
4974
- CLI_COMPAT_PRESERVE_WHITESPACE: booleanStringSchema("CLI_COMPAT_PRESERVE_WHITESPACE"),
4975
- CLI_COMPAT_DISABLE_REPLY_CHUNK_SPLIT: booleanStringSchema("CLI_COMPAT_DISABLE_REPLY_CHUNK_SPLIT"),
4976
- CLI_COMPAT_PROGRESS_THROTTLE_MS: integerStringSchema("CLI_COMPAT_PROGRESS_THROTTLE_MS", 0),
4977
- CLI_COMPAT_FETCH_MEDIA: booleanStringSchema("CLI_COMPAT_FETCH_MEDIA"),
4978
- CLI_COMPAT_RECORD_PATH: import_zod2.z.string(),
4979
- DOCTOR_HTTP_TIMEOUT_MS: integerStringSchema("DOCTOR_HTTP_TIMEOUT_MS", 1),
4980
- ADMIN_BIND_HOST: import_zod2.z.string(),
4981
- ADMIN_PORT: integerStringSchema("ADMIN_PORT", 1, 65535),
4982
- ADMIN_TOKEN: import_zod2.z.string(),
4983
- ADMIN_IP_ALLOWLIST: import_zod2.z.string(),
4984
- ADMIN_ALLOWED_ORIGINS: import_zod2.z.string().default(""),
4985
- LOG_LEVEL: import_zod2.z.enum(LOG_LEVELS)
4986
- }).strict();
4987
- var configSnapshotSchema = import_zod2.z.object({
4988
- schemaVersion: import_zod2.z.literal(CONFIG_SNAPSHOT_SCHEMA_VERSION),
4989
- exportedAt: import_zod2.z.string().datetime({ offset: true }),
4990
- env: envSnapshotSchema,
4991
- rooms: import_zod2.z.array(roomSnapshotSchema)
4992
- }).strict().superRefine((value, ctx) => {
4993
- const seen = /* @__PURE__ */ new Set();
4994
- for (const room of value.rooms) {
4995
- const roomId = room.roomId.trim();
4996
- if (!roomId) {
4997
- ctx.addIssue({
4998
- code: import_zod2.z.ZodIssueCode.custom,
4999
- message: "rooms[].roomId cannot be empty."
5000
- });
5001
- continue;
5002
- }
5003
- if (seen.has(roomId)) {
5004
- ctx.addIssue({
5005
- code: import_zod2.z.ZodIssueCode.custom,
5006
- message: `Duplicate room id in snapshot: ${roomId}`
5007
- });
5008
- }
5009
- seen.add(roomId);
5010
- }
5011
- });
5012
- function buildConfigSnapshot(config, roomSettings, now = /* @__PURE__ */ new Date()) {
5013
- return {
5014
- schemaVersion: CONFIG_SNAPSHOT_SCHEMA_VERSION,
5015
- exportedAt: now.toISOString(),
5016
- env: buildSnapshotEnv(config),
5017
- rooms: roomSettings.map((room) => ({
5018
- roomId: room.roomId,
5019
- enabled: room.enabled,
5020
- allowMention: room.allowMention,
5021
- allowReply: room.allowReply,
5022
- allowActiveWindow: room.allowActiveWindow,
5023
- allowPrefix: room.allowPrefix,
5024
- workdir: room.workdir
5025
- }))
5026
- };
5027
- }
5028
- function parseConfigSnapshot(raw) {
5029
- const parsed = configSnapshotSchema.safeParse(raw);
5030
- if (!parsed.success) {
5031
- const message = parsed.error.issues.map((issue) => `${issue.path.join(".") || "snapshot"}: ${issue.message}`).join("; ");
5032
- throw new Error(`Invalid config snapshot: ${message}`);
5033
- }
5034
- return parsed.data;
5035
- }
5036
- function serializeConfigSnapshot(snapshot) {
5037
- return `${JSON.stringify(snapshot, null, 2)}
5038
- `;
5039
- }
5040
- async function runConfigExportCommand(options = {}) {
5041
- const cwd = options.cwd ?? process.cwd();
5042
- const output = options.output ?? process.stdout;
5043
- const config = loadConfig(options.env ?? process.env);
5044
- const stateStore = new StateStore(
5045
- config.stateDbPath,
5046
- config.legacyStateJsonPath,
5047
- config.maxProcessedEventsPerSession,
5048
- config.maxSessionAgeDays,
5049
- config.maxSessions
5050
- );
5051
- try {
5052
- const snapshot = buildConfigSnapshot(config, stateStore.listRoomSettings(), options.now ?? /* @__PURE__ */ new Date());
5053
- const serialized = serializeConfigSnapshot(snapshot);
5054
- if (options.outputPath) {
5055
- const targetPath = import_node_path8.default.resolve(cwd, options.outputPath);
5056
- import_node_fs7.default.mkdirSync(import_node_path8.default.dirname(targetPath), { recursive: true });
5057
- import_node_fs7.default.writeFileSync(targetPath, serialized, "utf8");
5058
- output.write(`Exported config snapshot to ${targetPath}
5059
- `);
5060
- return;
5061
- }
5062
- output.write(serialized);
5063
- } finally {
5064
- await stateStore.flush();
5065
- }
5066
- }
5067
- async function runConfigImportCommand(options) {
5068
- const cwd = options.cwd ?? process.cwd();
5069
- const output = options.output ?? process.stdout;
5070
- const actor = options.actor?.trim() || "cli:config-import";
5071
- const sourcePath = import_node_path8.default.resolve(cwd, options.filePath);
5072
- if (!import_node_fs7.default.existsSync(sourcePath)) {
5073
- throw new Error(`Config snapshot file not found: ${sourcePath}`);
5074
- }
5075
- const snapshot = parseConfigSnapshot(parseJsonFile(sourcePath));
5076
- const normalizedEnv = normalizeSnapshotEnv(snapshot.env, cwd);
5077
- ensureDirectory2(normalizedEnv.CODEX_WORKDIR, "CODEX_WORKDIR");
5078
- const normalizedRooms = normalizeSnapshotRooms(snapshot.rooms, cwd);
5079
- if (options.dryRun) {
5080
- output.write(
5081
- [
5082
- `Config snapshot is valid: ${sourcePath}`,
5083
- `- schemaVersion: ${snapshot.schemaVersion}`,
5084
- `- rooms: ${normalizedRooms.length}`,
5085
- "- dry-run: no changes were written"
5086
- ].join("\n") + "\n"
5087
- );
5088
- return;
5089
- }
5090
- persistEnvSnapshot(cwd, normalizedEnv);
5091
- const stateStore = new StateStore(
5092
- normalizedEnv.STATE_DB_PATH,
5093
- normalizedEnv.STATE_PATH ? normalizedEnv.STATE_PATH : null,
5094
- parseIntStrict(normalizedEnv.MAX_PROCESSED_EVENTS_PER_SESSION),
5095
- parseIntStrict(normalizedEnv.MAX_SESSION_AGE_DAYS),
5096
- parseIntStrict(normalizedEnv.MAX_SESSIONS)
5097
- );
5098
- try {
5099
- synchronizeRoomSettings(stateStore, normalizedRooms);
5100
- stateStore.appendConfigRevision(
5101
- actor,
5102
- `import config snapshot from ${import_node_path8.default.basename(sourcePath)}`,
5103
- JSON.stringify({
5104
- type: "config_snapshot_import",
5105
- sourcePath,
5106
- roomCount: normalizedRooms.length,
5107
- envKeyCount: CONFIG_SNAPSHOT_ENV_KEYS.length
5108
- })
5109
- );
5110
- } finally {
5111
- await stateStore.flush();
5112
- }
5113
- output.write(
5114
- [
5115
- `Imported config snapshot from ${sourcePath}`,
5116
- `- updated .env in ${import_node_path8.default.resolve(cwd, ".env")}`,
5117
- `- synchronized room settings: ${normalizedRooms.length}`,
5118
- "- restart required: yes (global env settings are restart-scoped)"
5119
- ].join("\n") + "\n"
5120
- );
5121
- }
5122
- function buildSnapshotEnv(config) {
5123
- return {
5124
- MATRIX_HOMESERVER: config.matrixHomeserver,
5125
- MATRIX_USER_ID: config.matrixUserId,
5126
- MATRIX_ACCESS_TOKEN: config.matrixAccessToken,
5127
- MATRIX_COMMAND_PREFIX: config.matrixCommandPrefix,
5128
- CODEX_BIN: config.codexBin,
5129
- CODEX_MODEL: config.codexModel ?? "",
5130
- CODEX_WORKDIR: config.codexWorkdir,
5131
- CODEX_DANGEROUS_BYPASS: String(config.codexDangerousBypass),
5132
- CODEX_EXEC_TIMEOUT_MS: String(config.codexExecTimeoutMs),
5133
- CODEX_SANDBOX_MODE: config.codexSandboxMode ?? "",
5134
- CODEX_APPROVAL_POLICY: config.codexApprovalPolicy ?? "",
5135
- CODEX_EXTRA_ARGS: config.codexExtraArgs.join(" "),
5136
- CODEX_EXTRA_ENV_JSON: serializeJsonObject(config.codexExtraEnv),
5137
- AGENT_WORKFLOW_ENABLED: String(config.agentWorkflow.enabled),
5138
- AGENT_WORKFLOW_AUTO_REPAIR_MAX_ROUNDS: String(config.agentWorkflow.autoRepairMaxRounds),
5139
- STATE_DB_PATH: config.stateDbPath,
5140
- STATE_PATH: config.legacyStateJsonPath ?? "",
5141
- MAX_PROCESSED_EVENTS_PER_SESSION: String(config.maxProcessedEventsPerSession),
5142
- MAX_SESSION_AGE_DAYS: String(config.maxSessionAgeDays),
5143
- MAX_SESSIONS: String(config.maxSessions),
5144
- REPLY_CHUNK_SIZE: String(config.replyChunkSize),
5145
- MATRIX_PROGRESS_UPDATES: String(config.matrixProgressUpdates),
5146
- MATRIX_PROGRESS_MIN_INTERVAL_MS: String(config.matrixProgressMinIntervalMs),
5147
- MATRIX_TYPING_TIMEOUT_MS: String(config.matrixTypingTimeoutMs),
5148
- SESSION_ACTIVE_WINDOW_MINUTES: String(config.sessionActiveWindowMinutes),
5149
- GROUP_TRIGGER_ALLOW_MENTION: String(config.defaultGroupTriggerPolicy.allowMention),
5150
- GROUP_TRIGGER_ALLOW_REPLY: String(config.defaultGroupTriggerPolicy.allowReply),
5151
- GROUP_TRIGGER_ALLOW_ACTIVE_WINDOW: String(config.defaultGroupTriggerPolicy.allowActiveWindow),
5152
- GROUP_TRIGGER_ALLOW_PREFIX: String(config.defaultGroupTriggerPolicy.allowPrefix),
5153
- ROOM_TRIGGER_POLICY_JSON: serializeJsonObject(config.roomTriggerPolicies),
5154
- RATE_LIMIT_WINDOW_SECONDS: String(Math.max(1, Math.round(config.rateLimiter.windowMs / 1e3))),
5155
- RATE_LIMIT_MAX_REQUESTS_PER_USER: String(config.rateLimiter.maxRequestsPerUser),
5156
- RATE_LIMIT_MAX_REQUESTS_PER_ROOM: String(config.rateLimiter.maxRequestsPerRoom),
5157
- RATE_LIMIT_MAX_CONCURRENT_GLOBAL: String(config.rateLimiter.maxConcurrentGlobal),
5158
- RATE_LIMIT_MAX_CONCURRENT_PER_USER: String(config.rateLimiter.maxConcurrentPerUser),
5159
- RATE_LIMIT_MAX_CONCURRENT_PER_ROOM: String(config.rateLimiter.maxConcurrentPerRoom),
5160
- CLI_COMPAT_MODE: String(config.cliCompat.enabled),
5161
- CLI_COMPAT_PASSTHROUGH_EVENTS: String(config.cliCompat.passThroughEvents),
5162
- CLI_COMPAT_PRESERVE_WHITESPACE: String(config.cliCompat.preserveWhitespace),
5163
- CLI_COMPAT_DISABLE_REPLY_CHUNK_SPLIT: String(config.cliCompat.disableReplyChunkSplit),
5164
- CLI_COMPAT_PROGRESS_THROTTLE_MS: String(config.cliCompat.progressThrottleMs),
5165
- CLI_COMPAT_FETCH_MEDIA: String(config.cliCompat.fetchMedia),
5166
- CLI_COMPAT_RECORD_PATH: config.cliCompat.recordPath ?? "",
5167
- DOCTOR_HTTP_TIMEOUT_MS: String(config.doctorHttpTimeoutMs),
5168
- ADMIN_BIND_HOST: config.adminBindHost,
5169
- ADMIN_PORT: String(config.adminPort),
5170
- ADMIN_TOKEN: config.adminToken ?? "",
5171
- ADMIN_IP_ALLOWLIST: config.adminIpAllowlist.join(","),
5172
- ADMIN_ALLOWED_ORIGINS: config.adminAllowedOrigins.join(","),
5173
- LOG_LEVEL: config.logLevel
5174
- };
5175
- }
5176
- function parseJsonFile(filePath) {
5177
- try {
5178
- const raw = import_node_fs7.default.readFileSync(filePath, "utf8");
5179
- return JSON.parse(raw);
5180
- } catch (error) {
5181
- const message = error instanceof Error ? error.message : String(error);
5182
- throw new Error(`Failed to parse snapshot JSON: ${message}`, {
5183
- cause: error
5184
- });
4845
+ } catch (error) {
4846
+ this.db.exec("ROLLBACK");
4847
+ throw error;
4848
+ }
5185
4849
  }
5186
- }
5187
- function normalizeSnapshotEnv(env, cwd) {
5188
- return {
5189
- ...env,
5190
- CODEX_WORKDIR: import_node_path8.default.resolve(cwd, env.CODEX_WORKDIR),
5191
- STATE_DB_PATH: import_node_path8.default.resolve(cwd, env.STATE_DB_PATH),
5192
- STATE_PATH: env.STATE_PATH.trim() ? import_node_path8.default.resolve(cwd, env.STATE_PATH) : "",
5193
- CLI_COMPAT_RECORD_PATH: env.CLI_COMPAT_RECORD_PATH.trim() ? import_node_path8.default.resolve(cwd, env.CLI_COMPAT_RECORD_PATH) : ""
5194
- };
5195
- }
5196
- function normalizeSnapshotRooms(rooms, cwd) {
5197
- const normalized = [];
5198
- const seen = /* @__PURE__ */ new Set();
5199
- for (const room of rooms) {
5200
- const roomId = room.roomId.trim();
5201
- if (!roomId) {
5202
- throw new Error("roomId is required for every room in snapshot.");
4850
+ maybePruneExpiredSessions() {
4851
+ const now = Date.now();
4852
+ if (now - this.lastPruneAt < PRUNE_INTERVAL_MS) {
4853
+ return;
5203
4854
  }
5204
- if (seen.has(roomId)) {
5205
- throw new Error(`Duplicate roomId in snapshot: ${roomId}`);
4855
+ this.lastPruneAt = now;
4856
+ if (this.pruneSessions(now)) {
4857
+ this.touchDatabase();
5206
4858
  }
5207
- seen.add(roomId);
5208
- const workdir = import_node_path8.default.resolve(cwd, room.workdir);
5209
- ensureDirectory2(workdir, `room workdir (${roomId})`);
5210
- normalized.push({
5211
- roomId,
5212
- enabled: room.enabled,
5213
- allowMention: room.allowMention,
5214
- allowReply: room.allowReply,
5215
- allowActiveWindow: room.allowActiveWindow,
5216
- allowPrefix: room.allowPrefix,
5217
- workdir
5218
- });
5219
4859
  }
5220
- return normalized;
5221
- }
5222
- function synchronizeRoomSettings(stateStore, rooms) {
5223
- const incoming = new Map(rooms.map((room) => [room.roomId, room]));
5224
- const existing = stateStore.listRoomSettings();
5225
- for (const room of existing) {
5226
- if (!incoming.has(room.roomId)) {
5227
- stateStore.deleteRoomSettings(room.roomId);
4860
+ pruneSessions(now = Date.now()) {
4861
+ let changed = false;
4862
+ if (this.pruneExpiredSessions(now)) {
4863
+ changed = true;
4864
+ }
4865
+ if (this.pruneExcessSessions()) {
4866
+ changed = true;
5228
4867
  }
4868
+ return changed;
5229
4869
  }
5230
- for (const room of rooms) {
5231
- stateStore.upsertRoomSettings(room);
4870
+ pruneExpiredSessions(now) {
4871
+ if (this.maxSessionAgeMs <= 0) {
4872
+ return false;
4873
+ }
4874
+ const result = this.db.prepare("DELETE FROM sessions WHERE updated_at < ?1").run(now - this.maxSessionAgeMs);
4875
+ return (result.changes ?? 0) > 0;
5232
4876
  }
5233
- }
5234
- function persistEnvSnapshot(cwd, env) {
5235
- const envPath = import_node_path8.default.resolve(cwd, ".env");
5236
- const examplePath = import_node_path8.default.resolve(cwd, ".env.example");
5237
- const template = import_node_fs7.default.existsSync(envPath) ? import_node_fs7.default.readFileSync(envPath, "utf8") : import_node_fs7.default.existsSync(examplePath) ? import_node_fs7.default.readFileSync(examplePath, "utf8") : "";
5238
- const overrides = {};
5239
- for (const key of CONFIG_SNAPSHOT_ENV_KEYS) {
5240
- overrides[key] = env[key];
4877
+ pruneExcessSessions() {
4878
+ if (this.maxSessions <= 0) {
4879
+ return false;
4880
+ }
4881
+ const row = this.db.prepare("SELECT COUNT(*) AS count FROM sessions").get();
4882
+ const count = row?.count ?? 0;
4883
+ if (count <= this.maxSessions) {
4884
+ return false;
4885
+ }
4886
+ const removeCount = count - this.maxSessions;
4887
+ const result = this.db.prepare(
4888
+ "DELETE FROM sessions WHERE session_key IN (SELECT session_key FROM sessions ORDER BY updated_at ASC LIMIT ?1)"
4889
+ ).run(removeCount);
4890
+ return (result.changes ?? 0) > 0;
5241
4891
  }
5242
- const next = applyEnvOverrides(template, overrides);
5243
- import_node_fs7.default.writeFileSync(envPath, next, "utf8");
5244
- }
5245
- function ensureDirectory2(dirPath, label) {
5246
- if (!import_node_fs7.default.existsSync(dirPath) || !import_node_fs7.default.statSync(dirPath).isDirectory()) {
5247
- throw new Error(`${label} does not exist or is not a directory: ${dirPath}`);
4892
+ touchDatabase() {
4893
+ this.db.exec("PRAGMA wal_checkpoint(PASSIVE)");
5248
4894
  }
5249
- }
5250
- function parseIntStrict(raw) {
5251
- const value = Number.parseInt(raw, 10);
5252
- if (!Number.isFinite(value)) {
5253
- throw new Error(`Invalid integer value: ${raw}`);
4895
+ };
4896
+ function loadLegacyState(filePath) {
4897
+ try {
4898
+ const raw = import_node_fs7.default.readFileSync(filePath, "utf8");
4899
+ const parsed = JSON.parse(raw);
4900
+ if (!parsed.sessions || typeof parsed.sessions !== "object") {
4901
+ return null;
4902
+ }
4903
+ normalizeLegacyState(parsed);
4904
+ return parsed;
4905
+ } catch {
4906
+ return null;
5254
4907
  }
5255
- return value;
5256
4908
  }
5257
- function serializeJsonObject(value) {
5258
- return Object.keys(value).length > 0 ? JSON.stringify(value) : "";
4909
+ function parseUpdatedAt(updatedAt) {
4910
+ const timestamp = Date.parse(updatedAt);
4911
+ return Number.isFinite(timestamp) ? timestamp : Date.now();
5259
4912
  }
5260
- function booleanStringSchema(key) {
5261
- return import_zod2.z.string().refine((value) => BOOLEAN_STRING.test(value), {
5262
- message: `${key} must be a boolean string (true/false).`
5263
- });
4913
+ function parseOptionalTimestamp(value) {
4914
+ if (!value) {
4915
+ return null;
4916
+ }
4917
+ const ts = Date.parse(value);
4918
+ return Number.isFinite(ts) ? ts : null;
5264
4919
  }
5265
- function integerStringSchema(key, min, max = Number.MAX_SAFE_INTEGER) {
5266
- return import_zod2.z.string().refine((value) => {
5267
- const trimmed = value.trim();
5268
- if (!INTEGER_STRING.test(trimmed)) {
5269
- return false;
4920
+ function normalizeLegacyState(state) {
4921
+ for (const session of Object.values(state.sessions)) {
4922
+ if (!Array.isArray(session.processedEventIds)) {
4923
+ session.processedEventIds = [];
5270
4924
  }
5271
- const parsed = Number.parseInt(trimmed, 10);
5272
- if (!Number.isFinite(parsed)) {
5273
- return false;
4925
+ if (typeof session.codexSessionId !== "string" && session.codexSessionId !== null) {
4926
+ session.codexSessionId = null;
5274
4927
  }
5275
- return parsed >= min && parsed <= max;
5276
- }, {
5277
- message: `${key} must be an integer string in range [${min}, ${max}].`
5278
- });
5279
- }
5280
- function jsonObjectStringSchema(key, allowEmpty) {
5281
- return import_zod2.z.string().refine((value) => {
5282
- const trimmed = value.trim();
5283
- if (!trimmed) {
5284
- return allowEmpty;
4928
+ if (typeof session.updatedAt !== "string") {
4929
+ session.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
5285
4930
  }
5286
- let parsed;
5287
- try {
5288
- parsed = JSON.parse(trimmed);
5289
- } catch {
5290
- return false;
4931
+ if (typeof session.activeUntil !== "string" && session.activeUntil !== null) {
4932
+ session.activeUntil = null;
5291
4933
  }
5292
- return Boolean(parsed) && typeof parsed === "object" && !Array.isArray(parsed);
5293
- }, {
5294
- message: `${key} must be an empty string or a JSON object string.`
5295
- });
4934
+ }
4935
+ }
4936
+ function boolToInt(value) {
4937
+ return value ? 1 : 0;
5296
4938
  }
5297
4939
 
5298
- // src/preflight.ts
5299
- var import_node_child_process4 = require("child_process");
5300
- var import_node_fs8 = __toESM(require("fs"));
5301
- var import_node_path9 = __toESM(require("path"));
5302
- var import_node_util3 = require("util");
5303
- var execFileAsync3 = (0, import_node_util3.promisify)(import_node_child_process4.execFile);
5304
- var REQUIRED_ENV_KEYS = ["MATRIX_HOMESERVER", "MATRIX_USER_ID", "MATRIX_ACCESS_TOKEN"];
5305
- async function runStartupPreflight(options = {}) {
5306
- const env = options.env ?? process.env;
5307
- const cwd = options.cwd ?? process.cwd();
5308
- const checkCodexBinary = options.checkCodexBinary ?? defaultCheckCodexBinary;
5309
- const fileExists = options.fileExists ?? import_node_fs8.default.existsSync;
5310
- const isDirectory = options.isDirectory ?? defaultIsDirectory;
5311
- const issues = [];
5312
- const envPath = import_node_path9.default.resolve(cwd, ".env");
5313
- if (!fileExists(envPath)) {
5314
- issues.push({
5315
- level: "warn",
5316
- code: "missing_dotenv",
5317
- check: ".env",
5318
- message: `No .env file found at ${envPath}.`,
5319
- fix: 'Run "codeharbor init" to create baseline config.'
4940
+ // src/app.ts
4941
+ var execFileAsync2 = (0, import_node_util2.promisify)(import_node_child_process4.execFile);
4942
+ var CodeHarborApp = class {
4943
+ config;
4944
+ logger;
4945
+ stateStore;
4946
+ channel;
4947
+ orchestrator;
4948
+ configService;
4949
+ constructor(config) {
4950
+ this.config = config;
4951
+ this.logger = new Logger(config.logLevel);
4952
+ this.stateStore = new StateStore(
4953
+ config.stateDbPath,
4954
+ config.legacyStateJsonPath,
4955
+ config.maxProcessedEventsPerSession,
4956
+ config.maxSessionAgeDays,
4957
+ config.maxSessions
4958
+ );
4959
+ this.configService = new ConfigService(this.stateStore, config.codexWorkdir);
4960
+ const executor = new CodexExecutor({
4961
+ bin: config.codexBin,
4962
+ model: config.codexModel,
4963
+ workdir: config.codexWorkdir,
4964
+ dangerousBypass: config.codexDangerousBypass,
4965
+ timeoutMs: config.codexExecTimeoutMs,
4966
+ sandboxMode: config.codexSandboxMode,
4967
+ approvalPolicy: config.codexApprovalPolicy,
4968
+ extraArgs: config.codexExtraArgs,
4969
+ extraEnv: config.codexExtraEnv
4970
+ });
4971
+ this.channel = new MatrixChannel(config, this.logger);
4972
+ this.orchestrator = new Orchestrator(this.channel, executor, this.stateStore, this.logger, {
4973
+ progressUpdatesEnabled: config.matrixProgressUpdates,
4974
+ progressMinIntervalMs: config.matrixProgressMinIntervalMs,
4975
+ typingTimeoutMs: config.matrixTypingTimeoutMs,
4976
+ commandPrefix: config.matrixCommandPrefix,
4977
+ matrixUserId: config.matrixUserId,
4978
+ sessionActiveWindowMinutes: config.sessionActiveWindowMinutes,
4979
+ defaultGroupTriggerPolicy: config.defaultGroupTriggerPolicy,
4980
+ roomTriggerPolicies: config.roomTriggerPolicies,
4981
+ rateLimiterOptions: config.rateLimiter,
4982
+ cliCompat: config.cliCompat,
4983
+ multiAgentWorkflow: config.agentWorkflow,
4984
+ configService: this.configService,
4985
+ defaultCodexWorkdir: config.codexWorkdir
4986
+ });
4987
+ }
4988
+ async start() {
4989
+ this.logger.info("CodeHarbor starting", {
4990
+ matrixHomeserver: this.config.matrixHomeserver,
4991
+ workdir: this.config.codexWorkdir,
4992
+ prefix: this.config.matrixCommandPrefix || "<none>"
4993
+ });
4994
+ await this.channel.start(this.orchestrator.handleMessage.bind(this.orchestrator));
4995
+ this.logger.info("CodeHarbor is running.");
4996
+ }
4997
+ async stop() {
4998
+ this.logger.info("CodeHarbor stopping.");
4999
+ try {
5000
+ await this.channel.stop();
5001
+ } finally {
5002
+ await this.stateStore.flush();
5003
+ }
5004
+ }
5005
+ };
5006
+ var CodeHarborAdminApp = class {
5007
+ config;
5008
+ logger;
5009
+ stateStore;
5010
+ configService;
5011
+ adminServer;
5012
+ constructor(config, options) {
5013
+ this.config = config;
5014
+ this.logger = new Logger(config.logLevel);
5015
+ this.stateStore = new StateStore(
5016
+ config.stateDbPath,
5017
+ config.legacyStateJsonPath,
5018
+ config.maxProcessedEventsPerSession,
5019
+ config.maxSessionAgeDays,
5020
+ config.maxSessions
5021
+ );
5022
+ this.configService = new ConfigService(this.stateStore, config.codexWorkdir);
5023
+ this.adminServer = new AdminServer(config, this.logger, this.stateStore, this.configService, {
5024
+ host: options?.host ?? config.adminBindHost,
5025
+ port: options?.port ?? config.adminPort,
5026
+ adminToken: config.adminToken,
5027
+ adminIpAllowlist: config.adminIpAllowlist,
5028
+ adminAllowedOrigins: config.adminAllowedOrigins
5320
5029
  });
5321
5030
  }
5322
- for (const key of REQUIRED_ENV_KEYS) {
5323
- if (!readEnv(env, key)) {
5324
- issues.push({
5325
- level: "error",
5326
- code: "missing_env",
5327
- check: key,
5328
- message: `${key} is required.`,
5329
- fix: `Run "codeharbor init" or set ${key} in .env.`
5330
- });
5331
- }
5031
+ async start() {
5032
+ await this.adminServer.start();
5033
+ const address = this.adminServer.getAddress();
5034
+ this.logger.info("CodeHarbor admin server started", {
5035
+ host: address?.host ?? this.config.adminBindHost,
5036
+ port: address?.port ?? this.config.adminPort,
5037
+ tokenProtected: Boolean(this.config.adminToken)
5038
+ });
5332
5039
  }
5333
- const matrixHomeserver = readEnv(env, "MATRIX_HOMESERVER");
5334
- if (matrixHomeserver) {
5040
+ async stop() {
5041
+ this.logger.info("CodeHarbor admin server stopping.");
5335
5042
  try {
5336
- new URL(matrixHomeserver);
5337
- } catch {
5338
- issues.push({
5339
- level: "error",
5340
- code: "invalid_matrix_homeserver",
5341
- check: "MATRIX_HOMESERVER",
5342
- message: `Invalid URL: "${matrixHomeserver}".`,
5343
- fix: "Set MATRIX_HOMESERVER to a full URL, for example https://matrix.example.com."
5344
- });
5043
+ await this.adminServer.stop();
5044
+ } finally {
5045
+ await this.stateStore.flush();
5345
5046
  }
5346
5047
  }
5347
- const matrixUserId = readEnv(env, "MATRIX_USER_ID");
5348
- if (matrixUserId && !/^@[^:\s]+:.+/.test(matrixUserId)) {
5349
- issues.push({
5350
- level: "error",
5351
- code: "invalid_matrix_user_id",
5352
- check: "MATRIX_USER_ID",
5353
- message: `Unexpected Matrix user id format: "${matrixUserId}".`,
5354
- fix: "Set MATRIX_USER_ID like @bot:example.com."
5355
- });
5356
- }
5357
- const codexBin = readEnv(env, "CODEX_BIN") || "codex";
5048
+ };
5049
+ async function runDoctor(config) {
5050
+ const logger = new Logger(config.logLevel);
5051
+ logger.info("Doctor check started");
5358
5052
  try {
5359
- await checkCodexBinary(codexBin);
5053
+ const { stdout } = await execFileAsync2(config.codexBin, ["--version"]);
5054
+ logger.info("codex available", { version: stdout.trim() });
5360
5055
  } catch (error) {
5361
- const reason = error instanceof Error && error.message ? ` (${error.message})` : "";
5362
- issues.push({
5363
- level: "error",
5364
- code: "missing_codex_bin",
5365
- check: "CODEX_BIN",
5366
- message: `Unable to execute "${codexBin}"${reason}.`,
5367
- fix: `Install Codex CLI and ensure "${codexBin}" is in PATH, or set CODEX_BIN=/absolute/path/to/codex.`
5368
- });
5056
+ logger.error("codex unavailable", error);
5057
+ throw error;
5369
5058
  }
5370
- const configuredWorkdir = readEnv(env, "CODEX_WORKDIR");
5371
- const workdir = import_node_path9.default.resolve(cwd, configuredWorkdir || cwd);
5372
- if (!fileExists(workdir) || !isDirectory(workdir)) {
5373
- issues.push({
5374
- level: "error",
5375
- code: "invalid_codex_workdir",
5376
- check: "CODEX_WORKDIR",
5377
- message: `Working directory does not exist or is not a directory: ${workdir}.`,
5378
- fix: `Set CODEX_WORKDIR to an existing directory, for example CODEX_WORKDIR=${cwd}.`
5059
+ try {
5060
+ const controller = new AbortController();
5061
+ const timer = setTimeout(() => controller.abort(), config.doctorHttpTimeoutMs);
5062
+ timer.unref?.();
5063
+ const response = await fetch(`${config.matrixHomeserver}/_matrix/client/versions`, {
5064
+ signal: controller.signal
5065
+ }).finally(() => {
5066
+ clearTimeout(timer);
5379
5067
  });
5068
+ if (!response.ok) {
5069
+ throw new Error(`HTTP ${response.status}`);
5070
+ }
5071
+ const body = await response.json();
5072
+ logger.info("matrix reachable", { versions: body.versions ?? [] });
5073
+ } catch (error) {
5074
+ logger.error("matrix unreachable", error);
5075
+ throw error;
5380
5076
  }
5381
- return {
5382
- ok: issues.every((issue) => issue.level !== "error"),
5383
- issues
5384
- };
5077
+ logger.info("Doctor check passed");
5385
5078
  }
5386
- function formatPreflightReport(result, commandName) {
5387
- const lines = [];
5388
- const errors = result.issues.filter((issue) => issue.level === "error").length;
5389
- const warnings = result.issues.filter((issue) => issue.level === "warn").length;
5390
- if (result.ok) {
5391
- lines.push(`Preflight check passed for "codeharbor ${commandName}" with ${warnings} warning(s).`);
5392
- } else {
5393
- lines.push(
5394
- `Preflight check failed for "codeharbor ${commandName}" with ${errors} error(s) and ${warnings} warning(s).`
5395
- );
5079
+
5080
+ // src/utils/admin-host.ts
5081
+ function isNonLoopbackHost(host) {
5082
+ const normalized = host.trim().toLowerCase();
5083
+ if (!normalized) {
5084
+ return false;
5085
+ }
5086
+ return !(normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1" || normalized === "[::1]");
5087
+ }
5088
+
5089
+ // src/config.ts
5090
+ var import_node_fs8 = __toESM(require("fs"));
5091
+ var import_node_path9 = __toESM(require("path"));
5092
+ var import_dotenv2 = __toESM(require("dotenv"));
5093
+ var import_zod = require("zod");
5094
+ var configSchema = import_zod.z.object({
5095
+ MATRIX_HOMESERVER: import_zod.z.string().url(),
5096
+ MATRIX_USER_ID: import_zod.z.string().min(1),
5097
+ MATRIX_ACCESS_TOKEN: import_zod.z.string().min(1),
5098
+ MATRIX_COMMAND_PREFIX: import_zod.z.string().default("!code"),
5099
+ CODEX_BIN: import_zod.z.string().default("codex"),
5100
+ CODEX_MODEL: import_zod.z.string().optional(),
5101
+ CODEX_WORKDIR: import_zod.z.string().default("."),
5102
+ CODEX_DANGEROUS_BYPASS: import_zod.z.string().default("false").transform((v) => v.toLowerCase() === "true"),
5103
+ CODEX_EXEC_TIMEOUT_MS: import_zod.z.string().default("600000").transform((v) => Number.parseInt(v, 10)).pipe(import_zod.z.number().int().positive()),
5104
+ CODEX_SANDBOX_MODE: import_zod.z.string().optional(),
5105
+ CODEX_APPROVAL_POLICY: import_zod.z.string().optional(),
5106
+ CODEX_EXTRA_ARGS: import_zod.z.string().default(""),
5107
+ CODEX_EXTRA_ENV_JSON: import_zod.z.string().default(""),
5108
+ AGENT_WORKFLOW_ENABLED: import_zod.z.string().default("false").transform((v) => v.toLowerCase() === "true"),
5109
+ AGENT_WORKFLOW_AUTO_REPAIR_MAX_ROUNDS: import_zod.z.string().default("1").transform((v) => Number.parseInt(v, 10)).pipe(import_zod.z.number().int().min(0).max(10)),
5110
+ STATE_DB_PATH: import_zod.z.string().default("data/state.db"),
5111
+ STATE_PATH: import_zod.z.string().default("data/state.json"),
5112
+ MAX_PROCESSED_EVENTS_PER_SESSION: import_zod.z.string().default("200").transform((v) => Number.parseInt(v, 10)).pipe(import_zod.z.number().int().positive()),
5113
+ MAX_SESSION_AGE_DAYS: import_zod.z.string().default("30").transform((v) => Number.parseInt(v, 10)).pipe(import_zod.z.number().int().positive()),
5114
+ MAX_SESSIONS: import_zod.z.string().default("5000").transform((v) => Number.parseInt(v, 10)).pipe(import_zod.z.number().int().positive()),
5115
+ REPLY_CHUNK_SIZE: import_zod.z.string().default("3500").transform((v) => Number.parseInt(v, 10)).pipe(import_zod.z.number().int().positive()),
5116
+ MATRIX_PROGRESS_UPDATES: import_zod.z.string().default("true").transform((v) => v.toLowerCase() === "true"),
5117
+ MATRIX_PROGRESS_MIN_INTERVAL_MS: import_zod.z.string().default("2500").transform((v) => Number.parseInt(v, 10)).pipe(import_zod.z.number().int().positive()),
5118
+ MATRIX_TYPING_TIMEOUT_MS: import_zod.z.string().default("10000").transform((v) => Number.parseInt(v, 10)).pipe(import_zod.z.number().int().positive()),
5119
+ SESSION_ACTIVE_WINDOW_MINUTES: import_zod.z.string().default("20").transform((v) => Number.parseInt(v, 10)).pipe(import_zod.z.number().int().positive()),
5120
+ GROUP_TRIGGER_ALLOW_MENTION: import_zod.z.string().default("true").transform((v) => v.toLowerCase() === "true"),
5121
+ GROUP_TRIGGER_ALLOW_REPLY: import_zod.z.string().default("true").transform((v) => v.toLowerCase() === "true"),
5122
+ GROUP_TRIGGER_ALLOW_ACTIVE_WINDOW: import_zod.z.string().default("true").transform((v) => v.toLowerCase() === "true"),
5123
+ GROUP_TRIGGER_ALLOW_PREFIX: import_zod.z.string().default("true").transform((v) => v.toLowerCase() === "true"),
5124
+ ROOM_TRIGGER_POLICY_JSON: import_zod.z.string().default(""),
5125
+ RATE_LIMIT_WINDOW_SECONDS: import_zod.z.string().default("60").transform((v) => Number.parseInt(v, 10)).pipe(import_zod.z.number().int().positive()),
5126
+ RATE_LIMIT_MAX_REQUESTS_PER_USER: import_zod.z.string().default("20").transform((v) => Number.parseInt(v, 10)).pipe(import_zod.z.number().int().nonnegative()),
5127
+ RATE_LIMIT_MAX_REQUESTS_PER_ROOM: import_zod.z.string().default("120").transform((v) => Number.parseInt(v, 10)).pipe(import_zod.z.number().int().nonnegative()),
5128
+ RATE_LIMIT_MAX_CONCURRENT_GLOBAL: import_zod.z.string().default("8").transform((v) => Number.parseInt(v, 10)).pipe(import_zod.z.number().int().nonnegative()),
5129
+ RATE_LIMIT_MAX_CONCURRENT_PER_USER: import_zod.z.string().default("1").transform((v) => Number.parseInt(v, 10)).pipe(import_zod.z.number().int().nonnegative()),
5130
+ RATE_LIMIT_MAX_CONCURRENT_PER_ROOM: import_zod.z.string().default("4").transform((v) => Number.parseInt(v, 10)).pipe(import_zod.z.number().int().nonnegative()),
5131
+ CLI_COMPAT_MODE: import_zod.z.string().default("false").transform((v) => v.toLowerCase() === "true"),
5132
+ CLI_COMPAT_PASSTHROUGH_EVENTS: import_zod.z.string().default("true").transform((v) => v.toLowerCase() === "true"),
5133
+ CLI_COMPAT_PRESERVE_WHITESPACE: import_zod.z.string().default("true").transform((v) => v.toLowerCase() === "true"),
5134
+ CLI_COMPAT_DISABLE_REPLY_CHUNK_SPLIT: import_zod.z.string().default("false").transform((v) => v.toLowerCase() === "true"),
5135
+ CLI_COMPAT_PROGRESS_THROTTLE_MS: import_zod.z.string().default("300").transform((v) => Number.parseInt(v, 10)).pipe(import_zod.z.number().int().nonnegative()),
5136
+ CLI_COMPAT_FETCH_MEDIA: import_zod.z.string().default("true").transform((v) => v.toLowerCase() === "true"),
5137
+ CLI_COMPAT_RECORD_PATH: import_zod.z.string().default(""),
5138
+ DOCTOR_HTTP_TIMEOUT_MS: import_zod.z.string().default("10000").transform((v) => Number.parseInt(v, 10)).pipe(import_zod.z.number().int().positive()),
5139
+ ADMIN_BIND_HOST: import_zod.z.string().default("127.0.0.1"),
5140
+ ADMIN_PORT: import_zod.z.string().default("8787").transform((v) => Number.parseInt(v, 10)).pipe(import_zod.z.number().int().min(1).max(65535)),
5141
+ ADMIN_TOKEN: import_zod.z.string().default(""),
5142
+ ADMIN_IP_ALLOWLIST: import_zod.z.string().default(""),
5143
+ ADMIN_ALLOWED_ORIGINS: import_zod.z.string().default(""),
5144
+ LOG_LEVEL: import_zod.z.enum(["debug", "info", "warn", "error"]).default("info")
5145
+ }).transform((v) => ({
5146
+ matrixHomeserver: v.MATRIX_HOMESERVER,
5147
+ matrixUserId: v.MATRIX_USER_ID,
5148
+ matrixAccessToken: v.MATRIX_ACCESS_TOKEN,
5149
+ matrixCommandPrefix: v.MATRIX_COMMAND_PREFIX,
5150
+ codexBin: v.CODEX_BIN,
5151
+ codexModel: v.CODEX_MODEL?.trim() || null,
5152
+ codexWorkdir: import_node_path9.default.resolve(v.CODEX_WORKDIR),
5153
+ codexDangerousBypass: v.CODEX_DANGEROUS_BYPASS,
5154
+ codexExecTimeoutMs: v.CODEX_EXEC_TIMEOUT_MS,
5155
+ codexSandboxMode: v.CODEX_SANDBOX_MODE?.trim() || null,
5156
+ codexApprovalPolicy: v.CODEX_APPROVAL_POLICY?.trim() || null,
5157
+ codexExtraArgs: parseExtraArgs(v.CODEX_EXTRA_ARGS),
5158
+ codexExtraEnv: parseExtraEnv(v.CODEX_EXTRA_ENV_JSON),
5159
+ agentWorkflow: {
5160
+ enabled: v.AGENT_WORKFLOW_ENABLED,
5161
+ autoRepairMaxRounds: v.AGENT_WORKFLOW_AUTO_REPAIR_MAX_ROUNDS
5162
+ },
5163
+ stateDbPath: import_node_path9.default.resolve(v.STATE_DB_PATH),
5164
+ legacyStateJsonPath: v.STATE_PATH.trim() ? import_node_path9.default.resolve(v.STATE_PATH) : null,
5165
+ maxProcessedEventsPerSession: v.MAX_PROCESSED_EVENTS_PER_SESSION,
5166
+ maxSessionAgeDays: v.MAX_SESSION_AGE_DAYS,
5167
+ maxSessions: v.MAX_SESSIONS,
5168
+ replyChunkSize: v.REPLY_CHUNK_SIZE,
5169
+ matrixProgressUpdates: v.MATRIX_PROGRESS_UPDATES,
5170
+ matrixProgressMinIntervalMs: v.MATRIX_PROGRESS_MIN_INTERVAL_MS,
5171
+ matrixTypingTimeoutMs: v.MATRIX_TYPING_TIMEOUT_MS,
5172
+ sessionActiveWindowMinutes: v.SESSION_ACTIVE_WINDOW_MINUTES,
5173
+ defaultGroupTriggerPolicy: {
5174
+ allowMention: v.GROUP_TRIGGER_ALLOW_MENTION,
5175
+ allowReply: v.GROUP_TRIGGER_ALLOW_REPLY,
5176
+ allowActiveWindow: v.GROUP_TRIGGER_ALLOW_ACTIVE_WINDOW,
5177
+ allowPrefix: v.GROUP_TRIGGER_ALLOW_PREFIX
5178
+ },
5179
+ roomTriggerPolicies: parseRoomTriggerPolicyOverrides(v.ROOM_TRIGGER_POLICY_JSON),
5180
+ rateLimiter: {
5181
+ windowMs: v.RATE_LIMIT_WINDOW_SECONDS * 1e3,
5182
+ maxRequestsPerUser: v.RATE_LIMIT_MAX_REQUESTS_PER_USER,
5183
+ maxRequestsPerRoom: v.RATE_LIMIT_MAX_REQUESTS_PER_ROOM,
5184
+ maxConcurrentGlobal: v.RATE_LIMIT_MAX_CONCURRENT_GLOBAL,
5185
+ maxConcurrentPerUser: v.RATE_LIMIT_MAX_CONCURRENT_PER_USER,
5186
+ maxConcurrentPerRoom: v.RATE_LIMIT_MAX_CONCURRENT_PER_ROOM
5187
+ },
5188
+ cliCompat: {
5189
+ enabled: v.CLI_COMPAT_MODE,
5190
+ passThroughEvents: v.CLI_COMPAT_PASSTHROUGH_EVENTS,
5191
+ preserveWhitespace: v.CLI_COMPAT_PRESERVE_WHITESPACE,
5192
+ disableReplyChunkSplit: v.CLI_COMPAT_DISABLE_REPLY_CHUNK_SPLIT,
5193
+ progressThrottleMs: v.CLI_COMPAT_PROGRESS_THROTTLE_MS,
5194
+ fetchMedia: v.CLI_COMPAT_FETCH_MEDIA,
5195
+ recordPath: v.CLI_COMPAT_RECORD_PATH.trim() ? import_node_path9.default.resolve(v.CLI_COMPAT_RECORD_PATH) : null
5196
+ },
5197
+ doctorHttpTimeoutMs: v.DOCTOR_HTTP_TIMEOUT_MS,
5198
+ adminBindHost: v.ADMIN_BIND_HOST.trim() || "127.0.0.1",
5199
+ adminPort: v.ADMIN_PORT,
5200
+ adminToken: v.ADMIN_TOKEN.trim() || null,
5201
+ adminIpAllowlist: parseCsvList(v.ADMIN_IP_ALLOWLIST),
5202
+ adminAllowedOrigins: parseCsvList(v.ADMIN_ALLOWED_ORIGINS),
5203
+ logLevel: v.LOG_LEVEL
5204
+ }));
5205
+ function loadEnvFromFile(filePath = import_node_path9.default.resolve(process.cwd(), ".env"), env = process.env) {
5206
+ import_dotenv2.default.config({
5207
+ path: filePath,
5208
+ processEnv: env,
5209
+ quiet: true
5210
+ });
5211
+ }
5212
+ function loadConfig(env = process.env) {
5213
+ const parsed = configSchema.safeParse(env);
5214
+ if (!parsed.success) {
5215
+ const message = parsed.error.issues.map((issue) => `${issue.path.join(".") || "config"}: ${issue.message}`).join("; ");
5216
+ throw new Error(`Invalid configuration: ${message}`);
5217
+ }
5218
+ import_node_fs8.default.mkdirSync(import_node_path9.default.dirname(parsed.data.stateDbPath), { recursive: true });
5219
+ if (parsed.data.legacyStateJsonPath) {
5220
+ import_node_fs8.default.mkdirSync(import_node_path9.default.dirname(parsed.data.legacyStateJsonPath), { recursive: true });
5221
+ }
5222
+ return parsed.data;
5223
+ }
5224
+ function parseRoomTriggerPolicyOverrides(raw) {
5225
+ const trimmed = raw.trim();
5226
+ if (!trimmed) {
5227
+ return {};
5228
+ }
5229
+ let parsed;
5230
+ try {
5231
+ parsed = JSON.parse(trimmed);
5232
+ } catch {
5233
+ throw new Error("ROOM_TRIGGER_POLICY_JSON must be valid JSON.");
5396
5234
  }
5397
- for (const issue of result.issues) {
5398
- const level = issue.level.toUpperCase();
5399
- lines.push(`- [${level}] ${issue.check}: ${issue.message}`);
5400
- lines.push(` fix: ${issue.fix}`);
5235
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
5236
+ throw new Error("ROOM_TRIGGER_POLICY_JSON must be an object keyed by room id.");
5401
5237
  }
5402
- return `${lines.join("\n")}
5403
- `;
5238
+ const output = {};
5239
+ for (const [roomId, value] of Object.entries(parsed)) {
5240
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
5241
+ throw new Error(`ROOM_TRIGGER_POLICY_JSON[${roomId}] must be an object.`);
5242
+ }
5243
+ const item = value;
5244
+ const override = {};
5245
+ for (const key of ["allowMention", "allowReply", "allowActiveWindow", "allowPrefix"]) {
5246
+ if (item[key] === void 0) {
5247
+ continue;
5248
+ }
5249
+ if (typeof item[key] !== "boolean") {
5250
+ throw new Error(`ROOM_TRIGGER_POLICY_JSON[${roomId}].${key} must be boolean.`);
5251
+ }
5252
+ override[key] = item[key];
5253
+ }
5254
+ output[roomId] = override;
5255
+ }
5256
+ return output;
5404
5257
  }
5405
- async function defaultCheckCodexBinary(bin) {
5406
- await execFileAsync3(bin, ["--version"]);
5258
+ function parseExtraArgs(raw) {
5259
+ const trimmed = raw.trim();
5260
+ if (!trimmed) {
5261
+ return [];
5262
+ }
5263
+ return trimmed.split(/\s+/).map((entry) => entry.trim()).filter((entry) => entry.length > 0);
5407
5264
  }
5408
- function defaultIsDirectory(targetPath) {
5265
+ function parseExtraEnv(raw) {
5266
+ const trimmed = raw.trim();
5267
+ if (!trimmed) {
5268
+ return {};
5269
+ }
5270
+ let parsed;
5409
5271
  try {
5410
- return import_node_fs8.default.statSync(targetPath).isDirectory();
5272
+ parsed = JSON.parse(trimmed);
5411
5273
  } catch {
5412
- return false;
5274
+ throw new Error("CODEX_EXTRA_ENV_JSON must be valid JSON object.");
5275
+ }
5276
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
5277
+ throw new Error("CODEX_EXTRA_ENV_JSON must be a key/value object.");
5278
+ }
5279
+ const output = {};
5280
+ for (const [key, value] of Object.entries(parsed)) {
5281
+ if (typeof value !== "string") {
5282
+ throw new Error(`CODEX_EXTRA_ENV_JSON[${key}] must be string.`);
5283
+ }
5284
+ output[key] = value;
5413
5285
  }
5286
+ return output;
5414
5287
  }
5415
- function readEnv(env, key) {
5416
- return env[key]?.trim() ?? "";
5288
+ function parseCsvList(raw) {
5289
+ return raw.split(",").map((entry) => entry.trim()).filter((entry) => entry.length > 0);
5417
5290
  }
5418
5291
 
5419
- // src/runtime-home.ts
5292
+ // src/config-snapshot.ts
5420
5293
  var import_node_fs9 = __toESM(require("fs"));
5421
- var import_node_os2 = __toESM(require("os"));
5422
5294
  var import_node_path10 = __toESM(require("path"));
5423
- var LEGACY_RUNTIME_HOME = "/opt/codeharbor";
5424
- var USER_RUNTIME_HOME_DIR = ".codeharbor";
5425
- var DEFAULT_RUNTIME_HOME = import_node_path10.default.resolve(import_node_os2.default.homedir(), USER_RUNTIME_HOME_DIR);
5426
- var RUNTIME_HOME_ENV_KEY = "CODEHARBOR_HOME";
5427
- function resolveRuntimeHome(env = process.env) {
5428
- const configured = env[RUNTIME_HOME_ENV_KEY]?.trim();
5429
- if (configured) {
5430
- return import_node_path10.default.resolve(configured);
5431
- }
5432
- const legacyEnvPath = import_node_path10.default.resolve(LEGACY_RUNTIME_HOME, ".env");
5433
- if (import_node_fs9.default.existsSync(legacyEnvPath)) {
5434
- return LEGACY_RUNTIME_HOME;
5295
+ var import_zod2 = require("zod");
5296
+ var CONFIG_SNAPSHOT_SCHEMA_VERSION = 1;
5297
+ var CONFIG_SNAPSHOT_ENV_KEYS = [
5298
+ "MATRIX_HOMESERVER",
5299
+ "MATRIX_USER_ID",
5300
+ "MATRIX_ACCESS_TOKEN",
5301
+ "MATRIX_COMMAND_PREFIX",
5302
+ "CODEX_BIN",
5303
+ "CODEX_MODEL",
5304
+ "CODEX_WORKDIR",
5305
+ "CODEX_DANGEROUS_BYPASS",
5306
+ "CODEX_EXEC_TIMEOUT_MS",
5307
+ "CODEX_SANDBOX_MODE",
5308
+ "CODEX_APPROVAL_POLICY",
5309
+ "CODEX_EXTRA_ARGS",
5310
+ "CODEX_EXTRA_ENV_JSON",
5311
+ "AGENT_WORKFLOW_ENABLED",
5312
+ "AGENT_WORKFLOW_AUTO_REPAIR_MAX_ROUNDS",
5313
+ "STATE_DB_PATH",
5314
+ "STATE_PATH",
5315
+ "MAX_PROCESSED_EVENTS_PER_SESSION",
5316
+ "MAX_SESSION_AGE_DAYS",
5317
+ "MAX_SESSIONS",
5318
+ "REPLY_CHUNK_SIZE",
5319
+ "MATRIX_PROGRESS_UPDATES",
5320
+ "MATRIX_PROGRESS_MIN_INTERVAL_MS",
5321
+ "MATRIX_TYPING_TIMEOUT_MS",
5322
+ "SESSION_ACTIVE_WINDOW_MINUTES",
5323
+ "GROUP_TRIGGER_ALLOW_MENTION",
5324
+ "GROUP_TRIGGER_ALLOW_REPLY",
5325
+ "GROUP_TRIGGER_ALLOW_ACTIVE_WINDOW",
5326
+ "GROUP_TRIGGER_ALLOW_PREFIX",
5327
+ "ROOM_TRIGGER_POLICY_JSON",
5328
+ "RATE_LIMIT_WINDOW_SECONDS",
5329
+ "RATE_LIMIT_MAX_REQUESTS_PER_USER",
5330
+ "RATE_LIMIT_MAX_REQUESTS_PER_ROOM",
5331
+ "RATE_LIMIT_MAX_CONCURRENT_GLOBAL",
5332
+ "RATE_LIMIT_MAX_CONCURRENT_PER_USER",
5333
+ "RATE_LIMIT_MAX_CONCURRENT_PER_ROOM",
5334
+ "CLI_COMPAT_MODE",
5335
+ "CLI_COMPAT_PASSTHROUGH_EVENTS",
5336
+ "CLI_COMPAT_PRESERVE_WHITESPACE",
5337
+ "CLI_COMPAT_DISABLE_REPLY_CHUNK_SPLIT",
5338
+ "CLI_COMPAT_PROGRESS_THROTTLE_MS",
5339
+ "CLI_COMPAT_FETCH_MEDIA",
5340
+ "CLI_COMPAT_RECORD_PATH",
5341
+ "DOCTOR_HTTP_TIMEOUT_MS",
5342
+ "ADMIN_BIND_HOST",
5343
+ "ADMIN_PORT",
5344
+ "ADMIN_TOKEN",
5345
+ "ADMIN_IP_ALLOWLIST",
5346
+ "ADMIN_ALLOWED_ORIGINS",
5347
+ "LOG_LEVEL"
5348
+ ];
5349
+ var BOOLEAN_STRING = /^(true|false)$/i;
5350
+ var INTEGER_STRING = /^-?\d+$/;
5351
+ var LOG_LEVELS = ["debug", "info", "warn", "error"];
5352
+ var roomSnapshotSchema = import_zod2.z.object({
5353
+ roomId: import_zod2.z.string().min(1),
5354
+ enabled: import_zod2.z.boolean(),
5355
+ allowMention: import_zod2.z.boolean(),
5356
+ allowReply: import_zod2.z.boolean(),
5357
+ allowActiveWindow: import_zod2.z.boolean(),
5358
+ allowPrefix: import_zod2.z.boolean(),
5359
+ workdir: import_zod2.z.string().min(1)
5360
+ }).strict();
5361
+ var envSnapshotSchema = import_zod2.z.object({
5362
+ MATRIX_HOMESERVER: import_zod2.z.string().url(),
5363
+ MATRIX_USER_ID: import_zod2.z.string().min(1),
5364
+ MATRIX_ACCESS_TOKEN: import_zod2.z.string().min(1),
5365
+ MATRIX_COMMAND_PREFIX: import_zod2.z.string(),
5366
+ CODEX_BIN: import_zod2.z.string().min(1),
5367
+ CODEX_MODEL: import_zod2.z.string(),
5368
+ CODEX_WORKDIR: import_zod2.z.string().min(1),
5369
+ CODEX_DANGEROUS_BYPASS: booleanStringSchema("CODEX_DANGEROUS_BYPASS"),
5370
+ CODEX_EXEC_TIMEOUT_MS: integerStringSchema("CODEX_EXEC_TIMEOUT_MS", 1),
5371
+ CODEX_SANDBOX_MODE: import_zod2.z.string(),
5372
+ CODEX_APPROVAL_POLICY: import_zod2.z.string(),
5373
+ CODEX_EXTRA_ARGS: import_zod2.z.string(),
5374
+ CODEX_EXTRA_ENV_JSON: jsonObjectStringSchema("CODEX_EXTRA_ENV_JSON", true),
5375
+ AGENT_WORKFLOW_ENABLED: booleanStringSchema("AGENT_WORKFLOW_ENABLED"),
5376
+ AGENT_WORKFLOW_AUTO_REPAIR_MAX_ROUNDS: integerStringSchema("AGENT_WORKFLOW_AUTO_REPAIR_MAX_ROUNDS", 0, 10),
5377
+ STATE_DB_PATH: import_zod2.z.string().min(1),
5378
+ STATE_PATH: import_zod2.z.string(),
5379
+ MAX_PROCESSED_EVENTS_PER_SESSION: integerStringSchema("MAX_PROCESSED_EVENTS_PER_SESSION", 1),
5380
+ MAX_SESSION_AGE_DAYS: integerStringSchema("MAX_SESSION_AGE_DAYS", 1),
5381
+ MAX_SESSIONS: integerStringSchema("MAX_SESSIONS", 1),
5382
+ REPLY_CHUNK_SIZE: integerStringSchema("REPLY_CHUNK_SIZE", 1),
5383
+ MATRIX_PROGRESS_UPDATES: booleanStringSchema("MATRIX_PROGRESS_UPDATES"),
5384
+ MATRIX_PROGRESS_MIN_INTERVAL_MS: integerStringSchema("MATRIX_PROGRESS_MIN_INTERVAL_MS", 1),
5385
+ MATRIX_TYPING_TIMEOUT_MS: integerStringSchema("MATRIX_TYPING_TIMEOUT_MS", 1),
5386
+ SESSION_ACTIVE_WINDOW_MINUTES: integerStringSchema("SESSION_ACTIVE_WINDOW_MINUTES", 1),
5387
+ GROUP_TRIGGER_ALLOW_MENTION: booleanStringSchema("GROUP_TRIGGER_ALLOW_MENTION"),
5388
+ GROUP_TRIGGER_ALLOW_REPLY: booleanStringSchema("GROUP_TRIGGER_ALLOW_REPLY"),
5389
+ GROUP_TRIGGER_ALLOW_ACTIVE_WINDOW: booleanStringSchema("GROUP_TRIGGER_ALLOW_ACTIVE_WINDOW"),
5390
+ GROUP_TRIGGER_ALLOW_PREFIX: booleanStringSchema("GROUP_TRIGGER_ALLOW_PREFIX"),
5391
+ ROOM_TRIGGER_POLICY_JSON: jsonObjectStringSchema("ROOM_TRIGGER_POLICY_JSON", true),
5392
+ RATE_LIMIT_WINDOW_SECONDS: integerStringSchema("RATE_LIMIT_WINDOW_SECONDS", 1),
5393
+ RATE_LIMIT_MAX_REQUESTS_PER_USER: integerStringSchema("RATE_LIMIT_MAX_REQUESTS_PER_USER", 0),
5394
+ RATE_LIMIT_MAX_REQUESTS_PER_ROOM: integerStringSchema("RATE_LIMIT_MAX_REQUESTS_PER_ROOM", 0),
5395
+ RATE_LIMIT_MAX_CONCURRENT_GLOBAL: integerStringSchema("RATE_LIMIT_MAX_CONCURRENT_GLOBAL", 0),
5396
+ RATE_LIMIT_MAX_CONCURRENT_PER_USER: integerStringSchema("RATE_LIMIT_MAX_CONCURRENT_PER_USER", 0),
5397
+ RATE_LIMIT_MAX_CONCURRENT_PER_ROOM: integerStringSchema("RATE_LIMIT_MAX_CONCURRENT_PER_ROOM", 0),
5398
+ CLI_COMPAT_MODE: booleanStringSchema("CLI_COMPAT_MODE"),
5399
+ CLI_COMPAT_PASSTHROUGH_EVENTS: booleanStringSchema("CLI_COMPAT_PASSTHROUGH_EVENTS"),
5400
+ CLI_COMPAT_PRESERVE_WHITESPACE: booleanStringSchema("CLI_COMPAT_PRESERVE_WHITESPACE"),
5401
+ CLI_COMPAT_DISABLE_REPLY_CHUNK_SPLIT: booleanStringSchema("CLI_COMPAT_DISABLE_REPLY_CHUNK_SPLIT"),
5402
+ CLI_COMPAT_PROGRESS_THROTTLE_MS: integerStringSchema("CLI_COMPAT_PROGRESS_THROTTLE_MS", 0),
5403
+ CLI_COMPAT_FETCH_MEDIA: booleanStringSchema("CLI_COMPAT_FETCH_MEDIA"),
5404
+ CLI_COMPAT_RECORD_PATH: import_zod2.z.string(),
5405
+ DOCTOR_HTTP_TIMEOUT_MS: integerStringSchema("DOCTOR_HTTP_TIMEOUT_MS", 1),
5406
+ ADMIN_BIND_HOST: import_zod2.z.string(),
5407
+ ADMIN_PORT: integerStringSchema("ADMIN_PORT", 1, 65535),
5408
+ ADMIN_TOKEN: import_zod2.z.string(),
5409
+ ADMIN_IP_ALLOWLIST: import_zod2.z.string(),
5410
+ ADMIN_ALLOWED_ORIGINS: import_zod2.z.string().default(""),
5411
+ LOG_LEVEL: import_zod2.z.enum(LOG_LEVELS)
5412
+ }).strict();
5413
+ var configSnapshotSchema = import_zod2.z.object({
5414
+ schemaVersion: import_zod2.z.literal(CONFIG_SNAPSHOT_SCHEMA_VERSION),
5415
+ exportedAt: import_zod2.z.string().datetime({ offset: true }),
5416
+ env: envSnapshotSchema,
5417
+ rooms: import_zod2.z.array(roomSnapshotSchema)
5418
+ }).strict().superRefine((value, ctx) => {
5419
+ const seen = /* @__PURE__ */ new Set();
5420
+ for (const room of value.rooms) {
5421
+ const roomId = room.roomId.trim();
5422
+ if (!roomId) {
5423
+ ctx.addIssue({
5424
+ code: import_zod2.z.ZodIssueCode.custom,
5425
+ message: "rooms[].roomId cannot be empty."
5426
+ });
5427
+ continue;
5428
+ }
5429
+ if (seen.has(roomId)) {
5430
+ ctx.addIssue({
5431
+ code: import_zod2.z.ZodIssueCode.custom,
5432
+ message: `Duplicate room id in snapshot: ${roomId}`
5433
+ });
5434
+ }
5435
+ seen.add(roomId);
5435
5436
  }
5436
- return resolveUserRuntimeHome(env);
5437
- }
5438
- function resolveUserRuntimeHome(env = process.env) {
5439
- const home = env.HOME?.trim() || import_node_os2.default.homedir();
5440
- return import_node_path10.default.resolve(home, USER_RUNTIME_HOME_DIR);
5437
+ });
5438
+ function buildConfigSnapshot(config, roomSettings, now = /* @__PURE__ */ new Date()) {
5439
+ return {
5440
+ schemaVersion: CONFIG_SNAPSHOT_SCHEMA_VERSION,
5441
+ exportedAt: now.toISOString(),
5442
+ env: buildSnapshotEnv(config),
5443
+ rooms: roomSettings.map((room) => ({
5444
+ roomId: room.roomId,
5445
+ enabled: room.enabled,
5446
+ allowMention: room.allowMention,
5447
+ allowReply: room.allowReply,
5448
+ allowActiveWindow: room.allowActiveWindow,
5449
+ allowPrefix: room.allowPrefix,
5450
+ workdir: room.workdir
5451
+ }))
5452
+ };
5441
5453
  }
5442
-
5443
- // src/service-manager.ts
5444
- var import_node_child_process5 = require("child_process");
5445
- var import_node_fs10 = __toESM(require("fs"));
5446
- var import_node_os3 = __toESM(require("os"));
5447
- var import_node_path11 = __toESM(require("path"));
5448
- var SYSTEMD_DIR = "/etc/systemd/system";
5449
- var MAIN_SERVICE_NAME = "codeharbor.service";
5450
- var ADMIN_SERVICE_NAME = "codeharbor-admin.service";
5451
- function resolveDefaultRunUser(env = process.env) {
5452
- const sudoUser = env.SUDO_USER?.trim();
5453
- if (sudoUser) {
5454
- return sudoUser;
5455
- }
5456
- const user = env.USER?.trim();
5457
- if (user) {
5458
- return user;
5454
+ function parseConfigSnapshot(raw) {
5455
+ const parsed = configSnapshotSchema.safeParse(raw);
5456
+ if (!parsed.success) {
5457
+ const message = parsed.error.issues.map((issue) => `${issue.path.join(".") || "snapshot"}: ${issue.message}`).join("; ");
5458
+ throw new Error(`Invalid config snapshot: ${message}`);
5459
5459
  }
5460
+ return parsed.data;
5461
+ }
5462
+ function serializeConfigSnapshot(snapshot) {
5463
+ return `${JSON.stringify(snapshot, null, 2)}
5464
+ `;
5465
+ }
5466
+ async function runConfigExportCommand(options = {}) {
5467
+ const cwd = options.cwd ?? process.cwd();
5468
+ const output = options.output ?? process.stdout;
5469
+ const config = loadConfig(options.env ?? process.env);
5470
+ const stateStore = new StateStore(
5471
+ config.stateDbPath,
5472
+ config.legacyStateJsonPath,
5473
+ config.maxProcessedEventsPerSession,
5474
+ config.maxSessionAgeDays,
5475
+ config.maxSessions
5476
+ );
5460
5477
  try {
5461
- return import_node_os3.default.userInfo().username;
5462
- } catch {
5463
- return "root";
5478
+ const snapshot = buildConfigSnapshot(config, stateStore.listRoomSettings(), options.now ?? /* @__PURE__ */ new Date());
5479
+ const serialized = serializeConfigSnapshot(snapshot);
5480
+ if (options.outputPath) {
5481
+ const targetPath = import_node_path10.default.resolve(cwd, options.outputPath);
5482
+ import_node_fs9.default.mkdirSync(import_node_path10.default.dirname(targetPath), { recursive: true });
5483
+ import_node_fs9.default.writeFileSync(targetPath, serialized, "utf8");
5484
+ output.write(`Exported config snapshot to ${targetPath}
5485
+ `);
5486
+ return;
5487
+ }
5488
+ output.write(serialized);
5489
+ } finally {
5490
+ await stateStore.flush();
5464
5491
  }
5465
5492
  }
5466
- function resolveRuntimeHomeForUser(runUser, env = process.env, explicitRuntimeHome) {
5467
- const configuredRuntimeHome = explicitRuntimeHome?.trim() || env[RUNTIME_HOME_ENV_KEY]?.trim();
5468
- if (configuredRuntimeHome) {
5469
- return import_node_path11.default.resolve(configuredRuntimeHome);
5493
+ async function runConfigImportCommand(options) {
5494
+ const cwd = options.cwd ?? process.cwd();
5495
+ const output = options.output ?? process.stdout;
5496
+ const actor = options.actor?.trim() || "cli:config-import";
5497
+ const sourcePath = import_node_path10.default.resolve(cwd, options.filePath);
5498
+ if (!import_node_fs9.default.existsSync(sourcePath)) {
5499
+ throw new Error(`Config snapshot file not found: ${sourcePath}`);
5470
5500
  }
5471
- const userHome = resolveUserHome(runUser);
5472
- if (userHome) {
5473
- return import_node_path11.default.resolve(userHome, USER_RUNTIME_HOME_DIR);
5501
+ const snapshot = parseConfigSnapshot(parseJsonFile(sourcePath));
5502
+ const normalizedEnv = normalizeSnapshotEnv(snapshot.env, cwd);
5503
+ ensureDirectory2(normalizedEnv.CODEX_WORKDIR, "CODEX_WORKDIR");
5504
+ const normalizedRooms = normalizeSnapshotRooms(snapshot.rooms, cwd);
5505
+ if (options.dryRun) {
5506
+ output.write(
5507
+ [
5508
+ `Config snapshot is valid: ${sourcePath}`,
5509
+ `- schemaVersion: ${snapshot.schemaVersion}`,
5510
+ `- rooms: ${normalizedRooms.length}`,
5511
+ "- dry-run: no changes were written"
5512
+ ].join("\n") + "\n"
5513
+ );
5514
+ return;
5515
+ }
5516
+ persistEnvSnapshot(cwd, normalizedEnv);
5517
+ const stateStore = new StateStore(
5518
+ normalizedEnv.STATE_DB_PATH,
5519
+ normalizedEnv.STATE_PATH ? normalizedEnv.STATE_PATH : null,
5520
+ parseIntStrict(normalizedEnv.MAX_PROCESSED_EVENTS_PER_SESSION),
5521
+ parseIntStrict(normalizedEnv.MAX_SESSION_AGE_DAYS),
5522
+ parseIntStrict(normalizedEnv.MAX_SESSIONS)
5523
+ );
5524
+ try {
5525
+ synchronizeRoomSettings(stateStore, normalizedRooms);
5526
+ stateStore.appendConfigRevision(
5527
+ actor,
5528
+ `import config snapshot from ${import_node_path10.default.basename(sourcePath)}`,
5529
+ JSON.stringify({
5530
+ type: "config_snapshot_import",
5531
+ sourcePath,
5532
+ roomCount: normalizedRooms.length,
5533
+ envKeyCount: CONFIG_SNAPSHOT_ENV_KEYS.length
5534
+ })
5535
+ );
5536
+ } finally {
5537
+ await stateStore.flush();
5474
5538
  }
5475
- return import_node_path11.default.resolve(import_node_os3.default.homedir(), USER_RUNTIME_HOME_DIR);
5539
+ output.write(
5540
+ [
5541
+ `Imported config snapshot from ${sourcePath}`,
5542
+ `- updated .env in ${import_node_path10.default.resolve(cwd, ".env")}`,
5543
+ `- synchronized room settings: ${normalizedRooms.length}`,
5544
+ "- restart required: yes (global env settings are restart-scoped)"
5545
+ ].join("\n") + "\n"
5546
+ );
5476
5547
  }
5477
- function buildMainServiceUnit(options) {
5478
- validateUnitOptions(options);
5479
- const runtimeHome2 = import_node_path11.default.resolve(options.runtimeHome);
5480
- return [
5481
- "[Unit]",
5482
- "Description=CodeHarbor main service",
5483
- "After=network-online.target",
5484
- "Wants=network-online.target",
5485
- "",
5486
- "[Service]",
5487
- "Type=simple",
5488
- `User=${options.runUser}`,
5489
- `WorkingDirectory=${runtimeHome2}`,
5490
- `Environment=CODEHARBOR_HOME=${runtimeHome2}`,
5491
- `ExecStart=${import_node_path11.default.resolve(options.nodeBinPath)} ${import_node_path11.default.resolve(options.cliScriptPath)} start`,
5492
- "Restart=always",
5493
- "RestartSec=3",
5494
- "NoNewPrivileges=true",
5495
- "PrivateTmp=true",
5496
- "ProtectSystem=full",
5497
- "ProtectHome=false",
5498
- `ReadWritePaths=${runtimeHome2}`,
5499
- "",
5500
- "[Install]",
5501
- "WantedBy=multi-user.target",
5502
- ""
5503
- ].join("\n");
5548
+ function buildSnapshotEnv(config) {
5549
+ return {
5550
+ MATRIX_HOMESERVER: config.matrixHomeserver,
5551
+ MATRIX_USER_ID: config.matrixUserId,
5552
+ MATRIX_ACCESS_TOKEN: config.matrixAccessToken,
5553
+ MATRIX_COMMAND_PREFIX: config.matrixCommandPrefix,
5554
+ CODEX_BIN: config.codexBin,
5555
+ CODEX_MODEL: config.codexModel ?? "",
5556
+ CODEX_WORKDIR: config.codexWorkdir,
5557
+ CODEX_DANGEROUS_BYPASS: String(config.codexDangerousBypass),
5558
+ CODEX_EXEC_TIMEOUT_MS: String(config.codexExecTimeoutMs),
5559
+ CODEX_SANDBOX_MODE: config.codexSandboxMode ?? "",
5560
+ CODEX_APPROVAL_POLICY: config.codexApprovalPolicy ?? "",
5561
+ CODEX_EXTRA_ARGS: config.codexExtraArgs.join(" "),
5562
+ CODEX_EXTRA_ENV_JSON: serializeJsonObject(config.codexExtraEnv),
5563
+ AGENT_WORKFLOW_ENABLED: String(config.agentWorkflow.enabled),
5564
+ AGENT_WORKFLOW_AUTO_REPAIR_MAX_ROUNDS: String(config.agentWorkflow.autoRepairMaxRounds),
5565
+ STATE_DB_PATH: config.stateDbPath,
5566
+ STATE_PATH: config.legacyStateJsonPath ?? "",
5567
+ MAX_PROCESSED_EVENTS_PER_SESSION: String(config.maxProcessedEventsPerSession),
5568
+ MAX_SESSION_AGE_DAYS: String(config.maxSessionAgeDays),
5569
+ MAX_SESSIONS: String(config.maxSessions),
5570
+ REPLY_CHUNK_SIZE: String(config.replyChunkSize),
5571
+ MATRIX_PROGRESS_UPDATES: String(config.matrixProgressUpdates),
5572
+ MATRIX_PROGRESS_MIN_INTERVAL_MS: String(config.matrixProgressMinIntervalMs),
5573
+ MATRIX_TYPING_TIMEOUT_MS: String(config.matrixTypingTimeoutMs),
5574
+ SESSION_ACTIVE_WINDOW_MINUTES: String(config.sessionActiveWindowMinutes),
5575
+ GROUP_TRIGGER_ALLOW_MENTION: String(config.defaultGroupTriggerPolicy.allowMention),
5576
+ GROUP_TRIGGER_ALLOW_REPLY: String(config.defaultGroupTriggerPolicy.allowReply),
5577
+ GROUP_TRIGGER_ALLOW_ACTIVE_WINDOW: String(config.defaultGroupTriggerPolicy.allowActiveWindow),
5578
+ GROUP_TRIGGER_ALLOW_PREFIX: String(config.defaultGroupTriggerPolicy.allowPrefix),
5579
+ ROOM_TRIGGER_POLICY_JSON: serializeJsonObject(config.roomTriggerPolicies),
5580
+ RATE_LIMIT_WINDOW_SECONDS: String(Math.max(1, Math.round(config.rateLimiter.windowMs / 1e3))),
5581
+ RATE_LIMIT_MAX_REQUESTS_PER_USER: String(config.rateLimiter.maxRequestsPerUser),
5582
+ RATE_LIMIT_MAX_REQUESTS_PER_ROOM: String(config.rateLimiter.maxRequestsPerRoom),
5583
+ RATE_LIMIT_MAX_CONCURRENT_GLOBAL: String(config.rateLimiter.maxConcurrentGlobal),
5584
+ RATE_LIMIT_MAX_CONCURRENT_PER_USER: String(config.rateLimiter.maxConcurrentPerUser),
5585
+ RATE_LIMIT_MAX_CONCURRENT_PER_ROOM: String(config.rateLimiter.maxConcurrentPerRoom),
5586
+ CLI_COMPAT_MODE: String(config.cliCompat.enabled),
5587
+ CLI_COMPAT_PASSTHROUGH_EVENTS: String(config.cliCompat.passThroughEvents),
5588
+ CLI_COMPAT_PRESERVE_WHITESPACE: String(config.cliCompat.preserveWhitespace),
5589
+ CLI_COMPAT_DISABLE_REPLY_CHUNK_SPLIT: String(config.cliCompat.disableReplyChunkSplit),
5590
+ CLI_COMPAT_PROGRESS_THROTTLE_MS: String(config.cliCompat.progressThrottleMs),
5591
+ CLI_COMPAT_FETCH_MEDIA: String(config.cliCompat.fetchMedia),
5592
+ CLI_COMPAT_RECORD_PATH: config.cliCompat.recordPath ?? "",
5593
+ DOCTOR_HTTP_TIMEOUT_MS: String(config.doctorHttpTimeoutMs),
5594
+ ADMIN_BIND_HOST: config.adminBindHost,
5595
+ ADMIN_PORT: String(config.adminPort),
5596
+ ADMIN_TOKEN: config.adminToken ?? "",
5597
+ ADMIN_IP_ALLOWLIST: config.adminIpAllowlist.join(","),
5598
+ ADMIN_ALLOWED_ORIGINS: config.adminAllowedOrigins.join(","),
5599
+ LOG_LEVEL: config.logLevel
5600
+ };
5504
5601
  }
5505
- function buildAdminServiceUnit(options) {
5506
- validateUnitOptions(options);
5507
- const runtimeHome2 = import_node_path11.default.resolve(options.runtimeHome);
5508
- return [
5509
- "[Unit]",
5510
- "Description=CodeHarbor admin service",
5511
- "After=network-online.target",
5512
- "Wants=network-online.target",
5513
- "",
5514
- "[Service]",
5515
- "Type=simple",
5516
- `User=${options.runUser}`,
5517
- `WorkingDirectory=${runtimeHome2}`,
5518
- `Environment=CODEHARBOR_HOME=${runtimeHome2}`,
5519
- `ExecStart=${import_node_path11.default.resolve(options.nodeBinPath)} ${import_node_path11.default.resolve(options.cliScriptPath)} admin serve`,
5520
- "Restart=always",
5521
- "RestartSec=3",
5522
- "NoNewPrivileges=true",
5523
- "PrivateTmp=true",
5524
- "ProtectSystem=full",
5525
- "ProtectHome=false",
5526
- `ReadWritePaths=${runtimeHome2}`,
5527
- "",
5528
- "[Install]",
5529
- "WantedBy=multi-user.target",
5530
- ""
5531
- ].join("\n");
5602
+ function parseJsonFile(filePath) {
5603
+ try {
5604
+ const raw = import_node_fs9.default.readFileSync(filePath, "utf8");
5605
+ return JSON.parse(raw);
5606
+ } catch (error) {
5607
+ const message = error instanceof Error ? error.message : String(error);
5608
+ throw new Error(`Failed to parse snapshot JSON: ${message}`, {
5609
+ cause: error
5610
+ });
5611
+ }
5532
5612
  }
5533
- function installSystemdServices(options) {
5534
- assertLinuxWithSystemd();
5535
- assertRootPrivileges();
5536
- const output = options.output ?? process.stdout;
5537
- const runUser = options.runUser.trim();
5538
- const runtimeHome2 = import_node_path11.default.resolve(options.runtimeHome);
5539
- validateSimpleValue(runUser, "runUser");
5540
- validateSimpleValue(runtimeHome2, "runtimeHome");
5541
- validateSimpleValue(options.nodeBinPath, "nodeBinPath");
5542
- validateSimpleValue(options.cliScriptPath, "cliScriptPath");
5543
- ensureUserExists(runUser);
5544
- const runGroup = resolveUserGroup(runUser);
5545
- import_node_fs10.default.mkdirSync(runtimeHome2, { recursive: true });
5546
- runCommand("chown", ["-R", `${runUser}:${runGroup}`, runtimeHome2]);
5547
- const mainPath = import_node_path11.default.join(SYSTEMD_DIR, MAIN_SERVICE_NAME);
5548
- const adminPath = import_node_path11.default.join(SYSTEMD_DIR, ADMIN_SERVICE_NAME);
5549
- const unitOptions = {
5550
- runUser,
5551
- runtimeHome: runtimeHome2,
5552
- nodeBinPath: options.nodeBinPath,
5553
- cliScriptPath: options.cliScriptPath
5613
+ function normalizeSnapshotEnv(env, cwd) {
5614
+ return {
5615
+ ...env,
5616
+ CODEX_WORKDIR: import_node_path10.default.resolve(cwd, env.CODEX_WORKDIR),
5617
+ STATE_DB_PATH: import_node_path10.default.resolve(cwd, env.STATE_DB_PATH),
5618
+ STATE_PATH: env.STATE_PATH.trim() ? import_node_path10.default.resolve(cwd, env.STATE_PATH) : "",
5619
+ CLI_COMPAT_RECORD_PATH: env.CLI_COMPAT_RECORD_PATH.trim() ? import_node_path10.default.resolve(cwd, env.CLI_COMPAT_RECORD_PATH) : ""
5554
5620
  };
5555
- import_node_fs10.default.writeFileSync(mainPath, buildMainServiceUnit(unitOptions), "utf8");
5556
- if (options.installAdmin) {
5557
- import_node_fs10.default.writeFileSync(adminPath, buildAdminServiceUnit(unitOptions), "utf8");
5558
- }
5559
- runSystemctl(["daemon-reload"]);
5560
- if (options.startNow) {
5561
- runSystemctl(["enable", "--now", MAIN_SERVICE_NAME]);
5562
- if (options.installAdmin) {
5563
- runSystemctl(["enable", "--now", ADMIN_SERVICE_NAME]);
5621
+ }
5622
+ function normalizeSnapshotRooms(rooms, cwd) {
5623
+ const normalized = [];
5624
+ const seen = /* @__PURE__ */ new Set();
5625
+ for (const room of rooms) {
5626
+ const roomId = room.roomId.trim();
5627
+ if (!roomId) {
5628
+ throw new Error("roomId is required for every room in snapshot.");
5564
5629
  }
5565
- } else {
5566
- runSystemctl(["enable", MAIN_SERVICE_NAME]);
5567
- if (options.installAdmin) {
5568
- runSystemctl(["enable", ADMIN_SERVICE_NAME]);
5630
+ if (seen.has(roomId)) {
5631
+ throw new Error(`Duplicate roomId in snapshot: ${roomId}`);
5632
+ }
5633
+ seen.add(roomId);
5634
+ const workdir = import_node_path10.default.resolve(cwd, room.workdir);
5635
+ ensureDirectory2(workdir, `room workdir (${roomId})`);
5636
+ normalized.push({
5637
+ roomId,
5638
+ enabled: room.enabled,
5639
+ allowMention: room.allowMention,
5640
+ allowReply: room.allowReply,
5641
+ allowActiveWindow: room.allowActiveWindow,
5642
+ allowPrefix: room.allowPrefix,
5643
+ workdir
5644
+ });
5645
+ }
5646
+ return normalized;
5647
+ }
5648
+ function synchronizeRoomSettings(stateStore, rooms) {
5649
+ const incoming = new Map(rooms.map((room) => [room.roomId, room]));
5650
+ const existing = stateStore.listRoomSettings();
5651
+ for (const room of existing) {
5652
+ if (!incoming.has(room.roomId)) {
5653
+ stateStore.deleteRoomSettings(room.roomId);
5569
5654
  }
5570
5655
  }
5571
- output.write(`Installed systemd unit: ${mainPath}
5572
- `);
5573
- if (options.installAdmin) {
5574
- output.write(`Installed systemd unit: ${adminPath}
5575
- `);
5656
+ for (const room of rooms) {
5657
+ stateStore.upsertRoomSettings(room);
5576
5658
  }
5577
- output.write("Done. Check status with: systemctl status codeharbor --no-pager\n");
5578
5659
  }
5579
- function uninstallSystemdServices(options) {
5580
- assertLinuxWithSystemd();
5581
- assertRootPrivileges();
5582
- const output = options.output ?? process.stdout;
5583
- const mainPath = import_node_path11.default.join(SYSTEMD_DIR, MAIN_SERVICE_NAME);
5584
- const adminPath = import_node_path11.default.join(SYSTEMD_DIR, ADMIN_SERVICE_NAME);
5585
- stopAndDisableIfPresent(MAIN_SERVICE_NAME);
5586
- if (import_node_fs10.default.existsSync(mainPath)) {
5587
- import_node_fs10.default.unlinkSync(mainPath);
5588
- }
5589
- if (options.removeAdmin) {
5590
- stopAndDisableIfPresent(ADMIN_SERVICE_NAME);
5591
- if (import_node_fs10.default.existsSync(adminPath)) {
5592
- import_node_fs10.default.unlinkSync(adminPath);
5593
- }
5660
+ function persistEnvSnapshot(cwd, env) {
5661
+ const envPath = import_node_path10.default.resolve(cwd, ".env");
5662
+ const examplePath = import_node_path10.default.resolve(cwd, ".env.example");
5663
+ const template = import_node_fs9.default.existsSync(envPath) ? import_node_fs9.default.readFileSync(envPath, "utf8") : import_node_fs9.default.existsSync(examplePath) ? import_node_fs9.default.readFileSync(examplePath, "utf8") : "";
5664
+ const overrides = {};
5665
+ for (const key of CONFIG_SNAPSHOT_ENV_KEYS) {
5666
+ overrides[key] = env[key];
5594
5667
  }
5595
- runSystemctl(["daemon-reload"]);
5596
- runSystemctlIgnoreFailure(["reset-failed"]);
5597
- output.write(`Removed systemd unit: ${mainPath}
5598
- `);
5599
- if (options.removeAdmin) {
5600
- output.write(`Removed systemd unit: ${adminPath}
5601
- `);
5668
+ const next = applyEnvOverrides(template, overrides);
5669
+ import_node_fs9.default.writeFileSync(envPath, next, "utf8");
5670
+ }
5671
+ function ensureDirectory2(dirPath, label) {
5672
+ if (!import_node_fs9.default.existsSync(dirPath) || !import_node_fs9.default.statSync(dirPath).isDirectory()) {
5673
+ throw new Error(`${label} does not exist or is not a directory: ${dirPath}`);
5602
5674
  }
5603
- output.write("Done.\n");
5604
5675
  }
5605
- function restartSystemdServices(options) {
5606
- assertLinuxWithSystemd();
5607
- assertRootPrivileges();
5608
- const output = options.output ?? process.stdout;
5609
- runSystemctl(["restart", MAIN_SERVICE_NAME]);
5610
- output.write(`Restarted service: ${MAIN_SERVICE_NAME}
5611
- `);
5612
- if (options.restartAdmin) {
5613
- runSystemctl(["restart", ADMIN_SERVICE_NAME]);
5614
- output.write(`Restarted service: ${ADMIN_SERVICE_NAME}
5615
- `);
5676
+ function parseIntStrict(raw) {
5677
+ const value = Number.parseInt(raw, 10);
5678
+ if (!Number.isFinite(value)) {
5679
+ throw new Error(`Invalid integer value: ${raw}`);
5616
5680
  }
5617
- output.write("Done.\n");
5681
+ return value;
5618
5682
  }
5619
- function resolveUserHome(runUser) {
5620
- try {
5621
- const passwdRaw = import_node_fs10.default.readFileSync("/etc/passwd", "utf8");
5622
- const line = passwdRaw.split(/\r?\n/).find((item) => item.startsWith(`${runUser}:`));
5623
- if (!line) {
5624
- return null;
5683
+ function serializeJsonObject(value) {
5684
+ return Object.keys(value).length > 0 ? JSON.stringify(value) : "";
5685
+ }
5686
+ function booleanStringSchema(key) {
5687
+ return import_zod2.z.string().refine((value) => BOOLEAN_STRING.test(value), {
5688
+ message: `${key} must be a boolean string (true/false).`
5689
+ });
5690
+ }
5691
+ function integerStringSchema(key, min, max = Number.MAX_SAFE_INTEGER) {
5692
+ return import_zod2.z.string().refine((value) => {
5693
+ const trimmed = value.trim();
5694
+ if (!INTEGER_STRING.test(trimmed)) {
5695
+ return false;
5625
5696
  }
5626
- const fields = line.split(":");
5627
- return fields[5] ? fields[5].trim() : null;
5628
- } catch {
5629
- return null;
5630
- }
5697
+ const parsed = Number.parseInt(trimmed, 10);
5698
+ if (!Number.isFinite(parsed)) {
5699
+ return false;
5700
+ }
5701
+ return parsed >= min && parsed <= max;
5702
+ }, {
5703
+ message: `${key} must be an integer string in range [${min}, ${max}].`
5704
+ });
5631
5705
  }
5632
- function validateUnitOptions(options) {
5633
- validateSimpleValue(options.runUser, "runUser");
5634
- validateSimpleValue(options.runtimeHome, "runtimeHome");
5635
- validateSimpleValue(options.nodeBinPath, "nodeBinPath");
5636
- validateSimpleValue(options.cliScriptPath, "cliScriptPath");
5706
+ function jsonObjectStringSchema(key, allowEmpty) {
5707
+ return import_zod2.z.string().refine((value) => {
5708
+ const trimmed = value.trim();
5709
+ if (!trimmed) {
5710
+ return allowEmpty;
5711
+ }
5712
+ let parsed;
5713
+ try {
5714
+ parsed = JSON.parse(trimmed);
5715
+ } catch {
5716
+ return false;
5717
+ }
5718
+ return Boolean(parsed) && typeof parsed === "object" && !Array.isArray(parsed);
5719
+ }, {
5720
+ message: `${key} must be an empty string or a JSON object string.`
5721
+ });
5637
5722
  }
5638
- function validateSimpleValue(value, key) {
5639
- if (!value.trim()) {
5640
- throw new Error(`${key} cannot be empty.`);
5723
+
5724
+ // src/preflight.ts
5725
+ var import_node_child_process5 = require("child_process");
5726
+ var import_node_fs10 = __toESM(require("fs"));
5727
+ var import_node_path11 = __toESM(require("path"));
5728
+ var import_node_util3 = require("util");
5729
+ var execFileAsync3 = (0, import_node_util3.promisify)(import_node_child_process5.execFile);
5730
+ var REQUIRED_ENV_KEYS = ["MATRIX_HOMESERVER", "MATRIX_USER_ID", "MATRIX_ACCESS_TOKEN"];
5731
+ async function runStartupPreflight(options = {}) {
5732
+ const env = options.env ?? process.env;
5733
+ const cwd = options.cwd ?? process.cwd();
5734
+ const checkCodexBinary = options.checkCodexBinary ?? defaultCheckCodexBinary;
5735
+ const fileExists = options.fileExists ?? import_node_fs10.default.existsSync;
5736
+ const isDirectory = options.isDirectory ?? defaultIsDirectory;
5737
+ const issues = [];
5738
+ const envPath = import_node_path11.default.resolve(cwd, ".env");
5739
+ if (!fileExists(envPath)) {
5740
+ issues.push({
5741
+ level: "warn",
5742
+ code: "missing_dotenv",
5743
+ check: ".env",
5744
+ message: `No .env file found at ${envPath}.`,
5745
+ fix: 'Run "codeharbor init" to create baseline config.'
5746
+ });
5641
5747
  }
5642
- if (/[\r\n]/.test(value)) {
5643
- throw new Error(`${key} contains invalid newline characters.`);
5748
+ for (const key of REQUIRED_ENV_KEYS) {
5749
+ if (!readEnv(env, key)) {
5750
+ issues.push({
5751
+ level: "error",
5752
+ code: "missing_env",
5753
+ check: key,
5754
+ message: `${key} is required.`,
5755
+ fix: `Run "codeharbor init" or set ${key} in .env.`
5756
+ });
5757
+ }
5644
5758
  }
5645
- }
5646
- function assertLinuxWithSystemd() {
5647
- if (process.platform !== "linux") {
5648
- throw new Error("Systemd service install only supports Linux.");
5759
+ const matrixHomeserver = readEnv(env, "MATRIX_HOMESERVER");
5760
+ if (matrixHomeserver) {
5761
+ try {
5762
+ new URL(matrixHomeserver);
5763
+ } catch {
5764
+ issues.push({
5765
+ level: "error",
5766
+ code: "invalid_matrix_homeserver",
5767
+ check: "MATRIX_HOMESERVER",
5768
+ message: `Invalid URL: "${matrixHomeserver}".`,
5769
+ fix: "Set MATRIX_HOMESERVER to a full URL, for example https://matrix.example.com."
5770
+ });
5771
+ }
5772
+ }
5773
+ const matrixUserId = readEnv(env, "MATRIX_USER_ID");
5774
+ if (matrixUserId && !/^@[^:\s]+:.+/.test(matrixUserId)) {
5775
+ issues.push({
5776
+ level: "error",
5777
+ code: "invalid_matrix_user_id",
5778
+ check: "MATRIX_USER_ID",
5779
+ message: `Unexpected Matrix user id format: "${matrixUserId}".`,
5780
+ fix: "Set MATRIX_USER_ID like @bot:example.com."
5781
+ });
5649
5782
  }
5783
+ const codexBin = readEnv(env, "CODEX_BIN") || "codex";
5650
5784
  try {
5651
- (0, import_node_child_process5.execFileSync)("systemctl", ["--version"], { stdio: "ignore" });
5652
- } catch {
5653
- throw new Error("systemctl is required but not found.");
5785
+ await checkCodexBinary(codexBin);
5786
+ } catch (error) {
5787
+ const reason = error instanceof Error && error.message ? ` (${error.message})` : "";
5788
+ issues.push({
5789
+ level: "error",
5790
+ code: "missing_codex_bin",
5791
+ check: "CODEX_BIN",
5792
+ message: `Unable to execute "${codexBin}"${reason}.`,
5793
+ fix: `Install Codex CLI and ensure "${codexBin}" is in PATH, or set CODEX_BIN=/absolute/path/to/codex.`
5794
+ });
5795
+ }
5796
+ const configuredWorkdir = readEnv(env, "CODEX_WORKDIR");
5797
+ const workdir = import_node_path11.default.resolve(cwd, configuredWorkdir || cwd);
5798
+ if (!fileExists(workdir) || !isDirectory(workdir)) {
5799
+ issues.push({
5800
+ level: "error",
5801
+ code: "invalid_codex_workdir",
5802
+ check: "CODEX_WORKDIR",
5803
+ message: `Working directory does not exist or is not a directory: ${workdir}.`,
5804
+ fix: `Set CODEX_WORKDIR to an existing directory, for example CODEX_WORKDIR=${cwd}.`
5805
+ });
5654
5806
  }
5807
+ return {
5808
+ ok: issues.every((issue) => issue.level !== "error"),
5809
+ issues
5810
+ };
5655
5811
  }
5656
- function assertRootPrivileges() {
5657
- if (typeof process.getuid !== "function") {
5658
- return;
5812
+ function formatPreflightReport(result, commandName) {
5813
+ const lines = [];
5814
+ const errors = result.issues.filter((issue) => issue.level === "error").length;
5815
+ const warnings = result.issues.filter((issue) => issue.level === "warn").length;
5816
+ if (result.ok) {
5817
+ lines.push(`Preflight check passed for "codeharbor ${commandName}" with ${warnings} warning(s).`);
5818
+ } else {
5819
+ lines.push(
5820
+ `Preflight check failed for "codeharbor ${commandName}" with ${errors} error(s) and ${warnings} warning(s).`
5821
+ );
5659
5822
  }
5660
- if (process.getuid() !== 0) {
5661
- throw new Error("Root privileges are required. Run with sudo.");
5823
+ for (const issue of result.issues) {
5824
+ const level = issue.level.toUpperCase();
5825
+ lines.push(`- [${level}] ${issue.check}: ${issue.message}`);
5826
+ lines.push(` fix: ${issue.fix}`);
5662
5827
  }
5828
+ return `${lines.join("\n")}
5829
+ `;
5663
5830
  }
5664
- function ensureUserExists(runUser) {
5665
- runCommand("id", ["-u", runUser]);
5666
- }
5667
- function resolveUserGroup(runUser) {
5668
- return runCommand("id", ["-gn", runUser]).trim();
5669
- }
5670
- function runSystemctl(args) {
5671
- runCommand("systemctl", args);
5672
- }
5673
- function stopAndDisableIfPresent(unitName) {
5674
- runSystemctlIgnoreFailure(["disable", "--now", unitName]);
5831
+ async function defaultCheckCodexBinary(bin) {
5832
+ await execFileAsync3(bin, ["--version"]);
5675
5833
  }
5676
- function runSystemctlIgnoreFailure(args) {
5834
+ function defaultIsDirectory(targetPath) {
5677
5835
  try {
5678
- runCommand("systemctl", args);
5836
+ return import_node_fs10.default.statSync(targetPath).isDirectory();
5679
5837
  } catch {
5838
+ return false;
5680
5839
  }
5681
5840
  }
5682
- function runCommand(file, args) {
5683
- try {
5684
- return (0, import_node_child_process5.execFileSync)(file, args, {
5685
- encoding: "utf8",
5686
- stdio: ["ignore", "pipe", "pipe"]
5687
- });
5688
- } catch (error) {
5689
- throw new Error(formatCommandError(file, args, error), { cause: error });
5690
- }
5691
- }
5692
- function formatCommandError(file, args, error) {
5693
- const command = `${file} ${args.join(" ")}`.trim();
5694
- if (error && typeof error === "object") {
5695
- const maybeError = error;
5696
- const stderr = bufferToTrimmedString(maybeError.stderr);
5697
- const stdout = bufferToTrimmedString(maybeError.stdout);
5698
- const details = stderr || stdout || maybeError.message || "command failed";
5699
- return `Command failed: ${command}. ${details}`;
5700
- }
5701
- return `Command failed: ${command}. ${String(error)}`;
5702
- }
5703
- function bufferToTrimmedString(value) {
5704
- if (!value) {
5705
- return "";
5706
- }
5707
- const text = typeof value === "string" ? value : value.toString("utf8");
5708
- return text.trim();
5841
+ function readEnv(env, key) {
5842
+ return env[key]?.trim() ?? "";
5709
5843
  }
5710
5844
 
5711
5845
  // src/cli.ts