codeharbor 0.1.0 → 0.1.2

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 (4) hide show
  1. package/.env.example +78 -0
  2. package/README.md +229 -2
  3. package/dist/cli.js +2792 -49
  4. package/package.json +18 -5
package/dist/cli.js CHANGED
@@ -24,16 +24,1770 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
24
24
  ));
25
25
 
26
26
  // src/cli.ts
27
+ var import_node_fs9 = __toESM(require("fs"));
28
+ var import_node_path11 = __toESM(require("path"));
27
29
  var import_commander = require("commander");
28
30
 
29
31
  // src/app.ts
30
- var import_node_child_process2 = require("child_process");
32
+ var import_node_child_process3 = require("child_process");
33
+ var import_node_util2 = require("util");
34
+
35
+ // src/admin-server.ts
36
+ var import_node_child_process = require("child_process");
37
+ var import_node_fs2 = __toESM(require("fs"));
38
+ var import_node_http = __toESM(require("http"));
39
+ var import_node_path2 = __toESM(require("path"));
31
40
  var import_node_util = require("util");
32
41
 
42
+ // src/init.ts
43
+ var import_node_fs = __toESM(require("fs"));
44
+ var import_node_path = __toESM(require("path"));
45
+ var import_promises = require("readline/promises");
46
+ var import_dotenv = __toESM(require("dotenv"));
47
+ async function runInitCommand(options = {}) {
48
+ const cwd = options.cwd ?? process.cwd();
49
+ const envPath = import_node_path.default.resolve(cwd, ".env");
50
+ const templatePath = resolveInitTemplatePath(cwd);
51
+ const input = options.input ?? process.stdin;
52
+ const output = options.output ?? process.stdout;
53
+ const templateContent = import_node_fs.default.readFileSync(templatePath, "utf8");
54
+ const existingContent = import_node_fs.default.existsSync(envPath) ? import_node_fs.default.readFileSync(envPath, "utf8") : "";
55
+ const existingValues = existingContent ? import_dotenv.default.parse(existingContent) : {};
56
+ const rl = (0, import_promises.createInterface)({ input, output });
57
+ try {
58
+ if (existingContent && !options.force) {
59
+ const overwrite = await askYesNo(
60
+ rl,
61
+ "Detected existing .env file. Overwrite with guided setup?",
62
+ false
63
+ );
64
+ if (!overwrite) {
65
+ output.write("Init aborted. Keep existing .env unchanged.\n");
66
+ return;
67
+ }
68
+ }
69
+ output.write("CodeHarbor setup wizard\n");
70
+ output.write(`Target file: ${envPath}
71
+ `);
72
+ const questions = [
73
+ {
74
+ key: "MATRIX_HOMESERVER",
75
+ label: "Matrix homeserver URL",
76
+ required: true,
77
+ validate: (value) => {
78
+ try {
79
+ new URL(value);
80
+ return null;
81
+ } catch {
82
+ return "Please enter a valid URL, for example https://matrix.example.com";
83
+ }
84
+ }
85
+ },
86
+ {
87
+ key: "MATRIX_USER_ID",
88
+ label: "Matrix bot user id",
89
+ required: true,
90
+ validate: (value) => {
91
+ if (!/^@[^:\s]+:.+/.test(value)) {
92
+ return "Please enter a Matrix user id like @bot:example.com";
93
+ }
94
+ return null;
95
+ }
96
+ },
97
+ {
98
+ key: "MATRIX_ACCESS_TOKEN",
99
+ label: "Matrix access token",
100
+ required: true,
101
+ hiddenDefault: true
102
+ },
103
+ {
104
+ key: "MATRIX_COMMAND_PREFIX",
105
+ label: "Group command prefix",
106
+ fallbackValue: "!code"
107
+ },
108
+ {
109
+ key: "CODEX_BIN",
110
+ label: "Codex binary",
111
+ fallbackValue: "codex"
112
+ },
113
+ {
114
+ key: "CODEX_WORKDIR",
115
+ label: "Codex working directory",
116
+ fallbackValue: cwd,
117
+ validate: (value) => {
118
+ const resolved = import_node_path.default.resolve(cwd, value);
119
+ if (!import_node_fs.default.existsSync(resolved) || !import_node_fs.default.statSync(resolved).isDirectory()) {
120
+ return `Directory does not exist: ${resolved}`;
121
+ }
122
+ return null;
123
+ }
124
+ }
125
+ ];
126
+ const updates = {};
127
+ for (const question of questions) {
128
+ const existingValue = (existingValues[question.key] ?? "").trim();
129
+ const value = await askValue(rl, question, existingValue);
130
+ updates[question.key] = value;
131
+ }
132
+ const mergedContent = applyEnvOverrides(templateContent, updates);
133
+ import_node_fs.default.writeFileSync(envPath, mergedContent, "utf8");
134
+ output.write(`Wrote ${envPath}
135
+ `);
136
+ output.write("Next steps:\n");
137
+ output.write("1. codex login\n");
138
+ output.write("2. codeharbor doctor\n");
139
+ output.write("3. codeharbor start\n");
140
+ } finally {
141
+ rl.close();
142
+ }
143
+ }
144
+ function resolveInitTemplatePath(cwd) {
145
+ const candidates = [
146
+ import_node_path.default.resolve(cwd, ".env.example"),
147
+ import_node_path.default.resolve(__dirname, "..", ".env.example")
148
+ ];
149
+ for (const candidate of candidates) {
150
+ if (import_node_fs.default.existsSync(candidate)) {
151
+ return candidate;
152
+ }
153
+ }
154
+ throw new Error(`Cannot find template file. Tried: ${candidates.join(", ")}`);
155
+ }
156
+ function applyEnvOverrides(template, overrides) {
157
+ const lines = template.split(/\r?\n/);
158
+ const seen = /* @__PURE__ */ new Set();
159
+ for (let i = 0; i < lines.length; i += 1) {
160
+ const line = lines[i];
161
+ const match = /^([A-Z0-9_]+)=(.*)$/.exec(line.trim());
162
+ if (!match) {
163
+ continue;
164
+ }
165
+ const key = match[1];
166
+ if (!(key in overrides)) {
167
+ continue;
168
+ }
169
+ lines[i] = `${key}=${formatEnvValue(overrides[key] ?? "")}`;
170
+ seen.add(key);
171
+ }
172
+ for (const [key, value] of Object.entries(overrides)) {
173
+ if (seen.has(key)) {
174
+ continue;
175
+ }
176
+ lines.push(`${key}=${formatEnvValue(value)}`);
177
+ }
178
+ const content = lines.join("\n");
179
+ return content.endsWith("\n") ? content : `${content}
180
+ `;
181
+ }
182
+ function formatEnvValue(value) {
183
+ if (!value) {
184
+ return "";
185
+ }
186
+ if (/^[A-Za-z0-9_./:@+-]+$/.test(value)) {
187
+ return value;
188
+ }
189
+ return JSON.stringify(value);
190
+ }
191
+ async function askValue(rl, question, existingValue) {
192
+ while (true) {
193
+ const fallback = question.fallbackValue ?? "";
194
+ const displayDefault = existingValue || fallback;
195
+ const hint = displayDefault ? question.hiddenDefault ? "[already set]" : `[${displayDefault}]` : "";
196
+ const answer = (await rl.question(`${question.label} ${hint}: `)).trim();
197
+ const finalValue = answer || existingValue || fallback;
198
+ if (question.required && !finalValue) {
199
+ rl.write("This value is required.\n");
200
+ continue;
201
+ }
202
+ if (question.validate) {
203
+ const reason = question.validate(finalValue);
204
+ if (reason) {
205
+ rl.write(`${reason}
206
+ `);
207
+ continue;
208
+ }
209
+ }
210
+ return finalValue;
211
+ }
212
+ }
213
+ async function askYesNo(rl, question, defaultValue) {
214
+ const defaultHint = defaultValue ? "[Y/n]" : "[y/N]";
215
+ const answer = (await rl.question(`${question} ${defaultHint}: `)).trim().toLowerCase();
216
+ if (!answer) {
217
+ return defaultValue;
218
+ }
219
+ return answer === "y" || answer === "yes";
220
+ }
221
+
222
+ // src/admin-server.ts
223
+ var execFileAsync = (0, import_node_util.promisify)(import_node_child_process.execFile);
224
+ var HttpError = class extends Error {
225
+ statusCode;
226
+ constructor(statusCode, message) {
227
+ super(message);
228
+ this.statusCode = statusCode;
229
+ }
230
+ };
231
+ var AdminServer = class {
232
+ config;
233
+ logger;
234
+ stateStore;
235
+ configService;
236
+ host;
237
+ port;
238
+ adminToken;
239
+ adminIpAllowlist;
240
+ adminAllowedOrigins;
241
+ cwd;
242
+ checkCodex;
243
+ checkMatrix;
244
+ server = null;
245
+ address = null;
246
+ constructor(config, logger, stateStore, configService, options) {
247
+ this.config = config;
248
+ this.logger = logger;
249
+ this.stateStore = stateStore;
250
+ this.configService = configService;
251
+ this.host = options.host;
252
+ this.port = options.port;
253
+ this.adminToken = options.adminToken;
254
+ this.adminIpAllowlist = normalizeAllowlist(options.adminIpAllowlist ?? []);
255
+ this.adminAllowedOrigins = normalizeOriginAllowlist(options.adminAllowedOrigins ?? []);
256
+ this.cwd = options.cwd ?? process.cwd();
257
+ this.checkCodex = options.checkCodex ?? defaultCheckCodex;
258
+ this.checkMatrix = options.checkMatrix ?? defaultCheckMatrix;
259
+ }
260
+ getAddress() {
261
+ return this.address;
262
+ }
263
+ async start() {
264
+ if (this.server) {
265
+ return;
266
+ }
267
+ this.server = import_node_http.default.createServer((req, res) => {
268
+ void this.handleRequest(req, res);
269
+ });
270
+ await new Promise((resolve, reject) => {
271
+ if (!this.server) {
272
+ reject(new Error("admin server is not initialized"));
273
+ return;
274
+ }
275
+ this.server.once("error", reject);
276
+ this.server.listen(this.port, this.host, () => {
277
+ this.server?.removeListener("error", reject);
278
+ const address = this.server?.address();
279
+ if (!address || typeof address === "string") {
280
+ reject(new Error("failed to resolve admin server address"));
281
+ return;
282
+ }
283
+ this.address = {
284
+ host: address.address,
285
+ port: address.port
286
+ };
287
+ resolve();
288
+ });
289
+ });
290
+ }
291
+ async stop() {
292
+ if (!this.server) {
293
+ return;
294
+ }
295
+ const server = this.server;
296
+ this.server = null;
297
+ this.address = null;
298
+ await new Promise((resolve, reject) => {
299
+ server.close((error) => {
300
+ if (error) {
301
+ reject(error);
302
+ return;
303
+ }
304
+ resolve();
305
+ });
306
+ });
307
+ }
308
+ async handleRequest(req, res) {
309
+ try {
310
+ const url = new URL(req.url ?? "/", "http://localhost");
311
+ this.setSecurityHeaders(res);
312
+ const corsDecision = this.resolveCors(req);
313
+ this.setCorsHeaders(res, corsDecision);
314
+ if (!this.isClientAllowed(req)) {
315
+ this.sendJson(res, 403, {
316
+ ok: false,
317
+ error: "Forbidden by ADMIN_IP_ALLOWLIST."
318
+ });
319
+ return;
320
+ }
321
+ if (url.pathname.startsWith("/api/admin/") && corsDecision.origin && !corsDecision.allowed) {
322
+ this.sendJson(res, 403, {
323
+ ok: false,
324
+ error: "Forbidden by ADMIN_ALLOWED_ORIGINS."
325
+ });
326
+ return;
327
+ }
328
+ if (req.method === "OPTIONS") {
329
+ if (corsDecision.origin && !corsDecision.allowed) {
330
+ this.sendJson(res, 403, {
331
+ ok: false,
332
+ error: "Forbidden by ADMIN_ALLOWED_ORIGINS."
333
+ });
334
+ return;
335
+ }
336
+ res.writeHead(204);
337
+ res.end();
338
+ return;
339
+ }
340
+ if (req.method === "GET" && isUiPath(url.pathname)) {
341
+ this.sendHtml(res, renderAdminConsoleHtml());
342
+ return;
343
+ }
344
+ if (url.pathname.startsWith("/api/admin/") && !this.isAuthorized(req)) {
345
+ this.sendJson(res, 401, {
346
+ ok: false,
347
+ error: "Unauthorized. Provide Authorization: Bearer <ADMIN_TOKEN>."
348
+ });
349
+ return;
350
+ }
351
+ if (req.method === "GET" && url.pathname === "/api/admin/config/global") {
352
+ this.sendJson(res, 200, {
353
+ ok: true,
354
+ data: buildGlobalConfigSnapshot(this.config),
355
+ effective: "next_start_for_env_changes"
356
+ });
357
+ return;
358
+ }
359
+ if (req.method === "PUT" && url.pathname === "/api/admin/config/global") {
360
+ const body = await readJsonBody(req);
361
+ const actor = readActor(req);
362
+ const result = this.updateGlobalConfig(body, actor);
363
+ this.sendJson(res, 200, {
364
+ ok: true,
365
+ ...result
366
+ });
367
+ return;
368
+ }
369
+ if (req.method === "GET" && url.pathname === "/api/admin/config/rooms") {
370
+ this.sendJson(res, 200, {
371
+ ok: true,
372
+ data: this.configService.listRoomSettings()
373
+ });
374
+ return;
375
+ }
376
+ const roomMatch = /^\/api\/admin\/config\/rooms\/(.+)$/.exec(url.pathname);
377
+ if (roomMatch) {
378
+ const roomId = decodeURIComponent(roomMatch[1]);
379
+ if (req.method === "GET") {
380
+ const room = this.configService.getRoomSettings(roomId);
381
+ if (!room) {
382
+ throw new HttpError(404, `room settings not found for ${roomId}`);
383
+ }
384
+ this.sendJson(res, 200, { ok: true, data: room });
385
+ return;
386
+ }
387
+ if (req.method === "PUT") {
388
+ const body = await readJsonBody(req);
389
+ const actor = readActor(req);
390
+ const room = this.updateRoomConfig(roomId, body, actor);
391
+ this.sendJson(res, 200, { ok: true, data: room });
392
+ return;
393
+ }
394
+ if (req.method === "DELETE") {
395
+ const actor = readActor(req);
396
+ this.configService.deleteRoomSettings(roomId, actor);
397
+ this.sendJson(res, 200, { ok: true, roomId });
398
+ return;
399
+ }
400
+ }
401
+ if (req.method === "GET" && url.pathname === "/api/admin/audit") {
402
+ const limit = normalizePositiveInt(url.searchParams.get("limit"), 20, 1, 200);
403
+ this.sendJson(res, 200, {
404
+ ok: true,
405
+ data: this.stateStore.listConfigRevisions(limit).map((entry) => formatAuditEntry(entry))
406
+ });
407
+ return;
408
+ }
409
+ if (req.method === "GET" && url.pathname === "/api/admin/health") {
410
+ const [codex, matrix] = await Promise.all([
411
+ this.checkCodex(this.config.codexBin),
412
+ this.checkMatrix(this.config.matrixHomeserver, this.config.doctorHttpTimeoutMs)
413
+ ]);
414
+ this.sendJson(res, 200, {
415
+ ok: codex.ok && matrix.ok,
416
+ codex,
417
+ matrix,
418
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
419
+ });
420
+ return;
421
+ }
422
+ this.sendJson(res, 404, {
423
+ ok: false,
424
+ error: `Not found: ${req.method ?? "GET"} ${url.pathname}`
425
+ });
426
+ } catch (error) {
427
+ if (error instanceof HttpError) {
428
+ this.sendJson(res, error.statusCode, {
429
+ ok: false,
430
+ error: error.message
431
+ });
432
+ return;
433
+ }
434
+ this.logger.error("Admin API request failed", error);
435
+ this.sendJson(res, 500, {
436
+ ok: false,
437
+ error: formatError(error)
438
+ });
439
+ }
440
+ }
441
+ updateGlobalConfig(rawBody, actor) {
442
+ const body = asObject(rawBody, "global config payload");
443
+ const envUpdates = {};
444
+ const updatedKeys = [];
445
+ if ("matrixCommandPrefix" in body) {
446
+ const value = String(body.matrixCommandPrefix ?? "");
447
+ this.config.matrixCommandPrefix = value;
448
+ envUpdates.MATRIX_COMMAND_PREFIX = value;
449
+ updatedKeys.push("matrixCommandPrefix");
450
+ }
451
+ if ("codexWorkdir" in body) {
452
+ const workdir = import_node_path2.default.resolve(String(body.codexWorkdir ?? "").trim());
453
+ ensureDirectory(workdir, "codexWorkdir");
454
+ this.config.codexWorkdir = workdir;
455
+ envUpdates.CODEX_WORKDIR = workdir;
456
+ updatedKeys.push("codexWorkdir");
457
+ }
458
+ if ("rateLimiter" in body) {
459
+ const limiter = asObject(body.rateLimiter, "rateLimiter");
460
+ if ("windowMs" in limiter) {
461
+ const value = normalizePositiveInt(limiter.windowMs, this.config.rateLimiter.windowMs, 1, Number.MAX_SAFE_INTEGER);
462
+ this.config.rateLimiter.windowMs = value;
463
+ envUpdates.RATE_LIMIT_WINDOW_SECONDS = String(Math.max(1, Math.round(value / 1e3)));
464
+ updatedKeys.push("rateLimiter.windowMs");
465
+ }
466
+ if ("maxRequestsPerUser" in limiter) {
467
+ const value = normalizeNonNegativeInt(limiter.maxRequestsPerUser, this.config.rateLimiter.maxRequestsPerUser);
468
+ this.config.rateLimiter.maxRequestsPerUser = value;
469
+ envUpdates.RATE_LIMIT_MAX_REQUESTS_PER_USER = String(value);
470
+ updatedKeys.push("rateLimiter.maxRequestsPerUser");
471
+ }
472
+ if ("maxRequestsPerRoom" in limiter) {
473
+ const value = normalizeNonNegativeInt(limiter.maxRequestsPerRoom, this.config.rateLimiter.maxRequestsPerRoom);
474
+ this.config.rateLimiter.maxRequestsPerRoom = value;
475
+ envUpdates.RATE_LIMIT_MAX_REQUESTS_PER_ROOM = String(value);
476
+ updatedKeys.push("rateLimiter.maxRequestsPerRoom");
477
+ }
478
+ if ("maxConcurrentGlobal" in limiter) {
479
+ const value = normalizeNonNegativeInt(limiter.maxConcurrentGlobal, this.config.rateLimiter.maxConcurrentGlobal);
480
+ this.config.rateLimiter.maxConcurrentGlobal = value;
481
+ envUpdates.RATE_LIMIT_MAX_CONCURRENT_GLOBAL = String(value);
482
+ updatedKeys.push("rateLimiter.maxConcurrentGlobal");
483
+ }
484
+ if ("maxConcurrentPerUser" in limiter) {
485
+ const value = normalizeNonNegativeInt(limiter.maxConcurrentPerUser, this.config.rateLimiter.maxConcurrentPerUser);
486
+ this.config.rateLimiter.maxConcurrentPerUser = value;
487
+ envUpdates.RATE_LIMIT_MAX_CONCURRENT_PER_USER = String(value);
488
+ updatedKeys.push("rateLimiter.maxConcurrentPerUser");
489
+ }
490
+ if ("maxConcurrentPerRoom" in limiter) {
491
+ const value = normalizeNonNegativeInt(limiter.maxConcurrentPerRoom, this.config.rateLimiter.maxConcurrentPerRoom);
492
+ this.config.rateLimiter.maxConcurrentPerRoom = value;
493
+ envUpdates.RATE_LIMIT_MAX_CONCURRENT_PER_ROOM = String(value);
494
+ updatedKeys.push("rateLimiter.maxConcurrentPerRoom");
495
+ }
496
+ }
497
+ if ("defaultGroupTriggerPolicy" in body) {
498
+ const policy = asObject(body.defaultGroupTriggerPolicy, "defaultGroupTriggerPolicy");
499
+ if ("allowMention" in policy) {
500
+ const value = normalizeBoolean(policy.allowMention, this.config.defaultGroupTriggerPolicy.allowMention);
501
+ this.config.defaultGroupTriggerPolicy.allowMention = value;
502
+ envUpdates.GROUP_TRIGGER_ALLOW_MENTION = String(value);
503
+ updatedKeys.push("defaultGroupTriggerPolicy.allowMention");
504
+ }
505
+ if ("allowReply" in policy) {
506
+ const value = normalizeBoolean(policy.allowReply, this.config.defaultGroupTriggerPolicy.allowReply);
507
+ this.config.defaultGroupTriggerPolicy.allowReply = value;
508
+ envUpdates.GROUP_TRIGGER_ALLOW_REPLY = String(value);
509
+ updatedKeys.push("defaultGroupTriggerPolicy.allowReply");
510
+ }
511
+ if ("allowActiveWindow" in policy) {
512
+ const value = normalizeBoolean(policy.allowActiveWindow, this.config.defaultGroupTriggerPolicy.allowActiveWindow);
513
+ this.config.defaultGroupTriggerPolicy.allowActiveWindow = value;
514
+ envUpdates.GROUP_TRIGGER_ALLOW_ACTIVE_WINDOW = String(value);
515
+ updatedKeys.push("defaultGroupTriggerPolicy.allowActiveWindow");
516
+ }
517
+ if ("allowPrefix" in policy) {
518
+ const value = normalizeBoolean(policy.allowPrefix, this.config.defaultGroupTriggerPolicy.allowPrefix);
519
+ this.config.defaultGroupTriggerPolicy.allowPrefix = value;
520
+ envUpdates.GROUP_TRIGGER_ALLOW_PREFIX = String(value);
521
+ updatedKeys.push("defaultGroupTriggerPolicy.allowPrefix");
522
+ }
523
+ }
524
+ if ("matrixProgressUpdates" in body) {
525
+ const value = normalizeBoolean(body.matrixProgressUpdates, this.config.matrixProgressUpdates);
526
+ this.config.matrixProgressUpdates = value;
527
+ envUpdates.MATRIX_PROGRESS_UPDATES = String(value);
528
+ updatedKeys.push("matrixProgressUpdates");
529
+ }
530
+ if ("matrixProgressMinIntervalMs" in body) {
531
+ const value = normalizePositiveInt(
532
+ body.matrixProgressMinIntervalMs,
533
+ this.config.matrixProgressMinIntervalMs,
534
+ 1,
535
+ Number.MAX_SAFE_INTEGER
536
+ );
537
+ this.config.matrixProgressMinIntervalMs = value;
538
+ envUpdates.MATRIX_PROGRESS_MIN_INTERVAL_MS = String(value);
539
+ updatedKeys.push("matrixProgressMinIntervalMs");
540
+ }
541
+ if ("matrixTypingTimeoutMs" in body) {
542
+ const value = normalizePositiveInt(
543
+ body.matrixTypingTimeoutMs,
544
+ this.config.matrixTypingTimeoutMs,
545
+ 1,
546
+ Number.MAX_SAFE_INTEGER
547
+ );
548
+ this.config.matrixTypingTimeoutMs = value;
549
+ envUpdates.MATRIX_TYPING_TIMEOUT_MS = String(value);
550
+ updatedKeys.push("matrixTypingTimeoutMs");
551
+ }
552
+ if ("sessionActiveWindowMinutes" in body) {
553
+ const value = normalizePositiveInt(
554
+ body.sessionActiveWindowMinutes,
555
+ this.config.sessionActiveWindowMinutes,
556
+ 1,
557
+ Number.MAX_SAFE_INTEGER
558
+ );
559
+ this.config.sessionActiveWindowMinutes = value;
560
+ envUpdates.SESSION_ACTIVE_WINDOW_MINUTES = String(value);
561
+ updatedKeys.push("sessionActiveWindowMinutes");
562
+ }
563
+ if ("cliCompat" in body) {
564
+ const compat = asObject(body.cliCompat, "cliCompat");
565
+ if ("enabled" in compat) {
566
+ const value = normalizeBoolean(compat.enabled, this.config.cliCompat.enabled);
567
+ this.config.cliCompat.enabled = value;
568
+ envUpdates.CLI_COMPAT_MODE = String(value);
569
+ updatedKeys.push("cliCompat.enabled");
570
+ }
571
+ if ("passThroughEvents" in compat) {
572
+ const value = normalizeBoolean(compat.passThroughEvents, this.config.cliCompat.passThroughEvents);
573
+ this.config.cliCompat.passThroughEvents = value;
574
+ envUpdates.CLI_COMPAT_PASSTHROUGH_EVENTS = String(value);
575
+ updatedKeys.push("cliCompat.passThroughEvents");
576
+ }
577
+ if ("preserveWhitespace" in compat) {
578
+ const value = normalizeBoolean(compat.preserveWhitespace, this.config.cliCompat.preserveWhitespace);
579
+ this.config.cliCompat.preserveWhitespace = value;
580
+ envUpdates.CLI_COMPAT_PRESERVE_WHITESPACE = String(value);
581
+ updatedKeys.push("cliCompat.preserveWhitespace");
582
+ }
583
+ if ("disableReplyChunkSplit" in compat) {
584
+ const value = normalizeBoolean(compat.disableReplyChunkSplit, this.config.cliCompat.disableReplyChunkSplit);
585
+ this.config.cliCompat.disableReplyChunkSplit = value;
586
+ envUpdates.CLI_COMPAT_DISABLE_REPLY_CHUNK_SPLIT = String(value);
587
+ updatedKeys.push("cliCompat.disableReplyChunkSplit");
588
+ }
589
+ if ("progressThrottleMs" in compat) {
590
+ const value = normalizeNonNegativeInt(compat.progressThrottleMs, this.config.cliCompat.progressThrottleMs);
591
+ this.config.cliCompat.progressThrottleMs = value;
592
+ envUpdates.CLI_COMPAT_PROGRESS_THROTTLE_MS = String(value);
593
+ updatedKeys.push("cliCompat.progressThrottleMs");
594
+ }
595
+ if ("fetchMedia" in compat) {
596
+ const value = normalizeBoolean(compat.fetchMedia, this.config.cliCompat.fetchMedia);
597
+ this.config.cliCompat.fetchMedia = value;
598
+ envUpdates.CLI_COMPAT_FETCH_MEDIA = String(value);
599
+ updatedKeys.push("cliCompat.fetchMedia");
600
+ }
601
+ }
602
+ if (updatedKeys.length === 0) {
603
+ throw new HttpError(400, "No supported global config fields provided.");
604
+ }
605
+ this.persistEnvUpdates(envUpdates);
606
+ this.stateStore.appendConfigRevision(
607
+ actor,
608
+ `update global config: ${updatedKeys.join(", ")}`,
609
+ JSON.stringify({
610
+ type: "global_config_update",
611
+ updates: envUpdates
612
+ })
613
+ );
614
+ return {
615
+ data: buildGlobalConfigSnapshot(this.config),
616
+ updatedKeys,
617
+ restartRequired: true
618
+ };
619
+ }
620
+ updateRoomConfig(roomId, rawBody, actor) {
621
+ const body = asObject(rawBody, "room config payload");
622
+ const current = this.configService.getRoomSettings(roomId);
623
+ return this.configService.updateRoomSettings({
624
+ roomId,
625
+ enabled: normalizeBoolean(body.enabled, current?.enabled ?? true),
626
+ allowMention: normalizeBoolean(body.allowMention, current?.allowMention ?? true),
627
+ allowReply: normalizeBoolean(body.allowReply, current?.allowReply ?? true),
628
+ allowActiveWindow: normalizeBoolean(body.allowActiveWindow, current?.allowActiveWindow ?? true),
629
+ allowPrefix: normalizeBoolean(body.allowPrefix, current?.allowPrefix ?? true),
630
+ workdir: normalizeString(body.workdir, current?.workdir ?? this.config.codexWorkdir, "workdir"),
631
+ actor,
632
+ summary: normalizeOptionalString(body.summary)
633
+ });
634
+ }
635
+ isAuthorized(req) {
636
+ if (!this.adminToken) {
637
+ return true;
638
+ }
639
+ const authorization = req.headers.authorization ?? "";
640
+ const bearer = authorization.startsWith("Bearer ") ? authorization.slice("Bearer ".length).trim() : "";
641
+ const fromHeader = normalizeHeaderValue(req.headers["x-admin-token"]);
642
+ return bearer === this.adminToken || fromHeader === this.adminToken;
643
+ }
644
+ isClientAllowed(req) {
645
+ if (this.adminIpAllowlist.length === 0) {
646
+ return true;
647
+ }
648
+ const normalizedRemote = normalizeRemoteAddress(req.socket.remoteAddress);
649
+ if (!normalizedRemote) {
650
+ return false;
651
+ }
652
+ return this.adminIpAllowlist.includes(normalizedRemote);
653
+ }
654
+ persistEnvUpdates(updates) {
655
+ const envPath = import_node_path2.default.resolve(this.cwd, ".env");
656
+ const examplePath = import_node_path2.default.resolve(this.cwd, ".env.example");
657
+ 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") : "";
658
+ const next = applyEnvOverrides(template, updates);
659
+ import_node_fs2.default.writeFileSync(envPath, next, "utf8");
660
+ }
661
+ resolveCors(req) {
662
+ const origin = normalizeOriginHeader(req.headers.origin);
663
+ if (!origin) {
664
+ return { origin: null, allowed: true };
665
+ }
666
+ if (isSameOriginRequest(req, origin)) {
667
+ return { origin, allowed: true };
668
+ }
669
+ if (this.adminAllowedOrigins.includes("*")) {
670
+ return { origin, allowed: true };
671
+ }
672
+ if (this.adminAllowedOrigins.length === 0) {
673
+ return { origin, allowed: false };
674
+ }
675
+ return {
676
+ origin,
677
+ allowed: this.adminAllowedOrigins.includes(origin)
678
+ };
679
+ }
680
+ setCorsHeaders(res, corsDecision) {
681
+ if (!corsDecision.origin || !corsDecision.allowed) {
682
+ return;
683
+ }
684
+ res.setHeader("Access-Control-Allow-Origin", corsDecision.origin);
685
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Admin-Token, X-Admin-Actor");
686
+ res.setHeader("Access-Control-Allow-Methods", "GET, PUT, DELETE, OPTIONS");
687
+ appendVaryHeader(res, "Origin");
688
+ }
689
+ setSecurityHeaders(res) {
690
+ res.setHeader("X-Content-Type-Options", "nosniff");
691
+ res.setHeader("X-Frame-Options", "DENY");
692
+ res.setHeader("Referrer-Policy", "no-referrer");
693
+ res.setHeader("Permissions-Policy", "camera=(), microphone=(), geolocation=()");
694
+ res.setHeader("Cross-Origin-Resource-Policy", "same-origin");
695
+ res.setHeader("Cross-Origin-Opener-Policy", "same-origin");
696
+ res.setHeader(
697
+ "Content-Security-Policy",
698
+ "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"
699
+ );
700
+ }
701
+ sendHtml(res, html) {
702
+ res.statusCode = 200;
703
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
704
+ res.end(html);
705
+ }
706
+ sendJson(res, statusCode, payload) {
707
+ res.statusCode = statusCode;
708
+ res.setHeader("Content-Type", "application/json; charset=utf-8");
709
+ res.end(JSON.stringify(payload));
710
+ }
711
+ };
712
+ function buildGlobalConfigSnapshot(config) {
713
+ return {
714
+ matrixCommandPrefix: config.matrixCommandPrefix,
715
+ codexWorkdir: config.codexWorkdir,
716
+ rateLimiter: { ...config.rateLimiter },
717
+ defaultGroupTriggerPolicy: { ...config.defaultGroupTriggerPolicy },
718
+ matrixProgressUpdates: config.matrixProgressUpdates,
719
+ matrixProgressMinIntervalMs: config.matrixProgressMinIntervalMs,
720
+ matrixTypingTimeoutMs: config.matrixTypingTimeoutMs,
721
+ sessionActiveWindowMinutes: config.sessionActiveWindowMinutes,
722
+ cliCompat: { ...config.cliCompat }
723
+ };
724
+ }
725
+ function formatAuditEntry(entry) {
726
+ return {
727
+ id: entry.id,
728
+ actor: entry.actor,
729
+ summary: entry.summary,
730
+ payloadJson: entry.payloadJson,
731
+ payload: parseJsonLoose(entry.payloadJson),
732
+ createdAt: entry.createdAt,
733
+ createdAtIso: new Date(entry.createdAt).toISOString()
734
+ };
735
+ }
736
+ function parseJsonLoose(raw) {
737
+ try {
738
+ return JSON.parse(raw);
739
+ } catch {
740
+ return raw;
741
+ }
742
+ }
743
+ function isUiPath(pathname) {
744
+ return pathname === "/" || pathname === "/index.html" || pathname === "/settings/global" || pathname === "/settings/rooms" || pathname === "/health" || pathname === "/audit";
745
+ }
746
+ function normalizeAllowlist(entries) {
747
+ const output = /* @__PURE__ */ new Set();
748
+ for (const entry of entries) {
749
+ const normalized = normalizeRemoteAddress(entry);
750
+ if (normalized) {
751
+ output.add(normalized);
752
+ }
753
+ }
754
+ return [...output];
755
+ }
756
+ function normalizeOriginAllowlist(entries) {
757
+ const output = /* @__PURE__ */ new Set();
758
+ for (const entry of entries) {
759
+ const normalized = normalizeOrigin(entry);
760
+ if (normalized) {
761
+ output.add(normalized);
762
+ }
763
+ }
764
+ return [...output];
765
+ }
766
+ function normalizeRemoteAddress(value) {
767
+ if (!value) {
768
+ return null;
769
+ }
770
+ const trimmed = value.trim().toLowerCase();
771
+ if (!trimmed) {
772
+ return null;
773
+ }
774
+ const withoutZone = trimmed.includes("%") ? trimmed.slice(0, trimmed.indexOf("%")) : trimmed;
775
+ if (withoutZone === "::1" || withoutZone === "0:0:0:0:0:0:0:1") {
776
+ return "127.0.0.1";
777
+ }
778
+ if (withoutZone.startsWith("::ffff:")) {
779
+ return withoutZone.slice("::ffff:".length);
780
+ }
781
+ return withoutZone;
782
+ }
783
+ function normalizeOriginHeader(value) {
784
+ if (!value) {
785
+ return null;
786
+ }
787
+ const raw = Array.isArray(value) ? value[0] ?? "" : value;
788
+ return normalizeOrigin(raw);
789
+ }
790
+ function normalizeOrigin(value) {
791
+ const trimmed = value.trim();
792
+ if (!trimmed) {
793
+ return null;
794
+ }
795
+ if (trimmed === "*") {
796
+ return "*";
797
+ }
798
+ try {
799
+ const parsed = new URL(trimmed);
800
+ return `${parsed.protocol}//${parsed.host}`.toLowerCase();
801
+ } catch {
802
+ return null;
803
+ }
804
+ }
805
+ function isSameOriginRequest(req, origin) {
806
+ const host = normalizeHeaderValue(req.headers.host);
807
+ if (!host) {
808
+ return false;
809
+ }
810
+ const forwardedProto = normalizeHeaderValue(req.headers["x-forwarded-proto"]);
811
+ const protocol = forwardedProto || "http";
812
+ return origin === `${protocol}://${host}`.toLowerCase();
813
+ }
814
+ function appendVaryHeader(res, headerName) {
815
+ const current = res.getHeader("Vary");
816
+ const existing = typeof current === "string" ? current.split(",").map((v) => v.trim()).filter(Boolean) : [];
817
+ if (!existing.includes(headerName)) {
818
+ existing.push(headerName);
819
+ }
820
+ res.setHeader("Vary", existing.join(", "));
821
+ }
822
+ function renderAdminConsoleHtml() {
823
+ return ADMIN_CONSOLE_HTML;
824
+ }
825
+ async function readJsonBody(req) {
826
+ const chunks = [];
827
+ for await (const chunk of req) {
828
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
829
+ }
830
+ if (chunks.length === 0) {
831
+ return {};
832
+ }
833
+ const raw = Buffer.concat(chunks).toString("utf8").trim();
834
+ if (!raw) {
835
+ return {};
836
+ }
837
+ try {
838
+ return JSON.parse(raw);
839
+ } catch {
840
+ throw new HttpError(400, "Request body must be valid JSON.");
841
+ }
842
+ }
843
+ function asObject(value, name) {
844
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
845
+ throw new HttpError(400, `${name} must be a JSON object.`);
846
+ }
847
+ return value;
848
+ }
849
+ function normalizeBoolean(value, fallback) {
850
+ if (value === void 0) {
851
+ return fallback;
852
+ }
853
+ if (typeof value !== "boolean") {
854
+ throw new HttpError(400, "Expected boolean value.");
855
+ }
856
+ return value;
857
+ }
858
+ function normalizeString(value, fallback, fieldName) {
859
+ if (value === void 0) {
860
+ return fallback;
861
+ }
862
+ if (typeof value !== "string") {
863
+ throw new HttpError(400, `Expected string value for ${fieldName}.`);
864
+ }
865
+ return value.trim();
866
+ }
867
+ function normalizeOptionalString(value) {
868
+ if (value === void 0 || value === null) {
869
+ return null;
870
+ }
871
+ if (typeof value !== "string") {
872
+ throw new HttpError(400, "Expected string value.");
873
+ }
874
+ const trimmed = value.trim();
875
+ return trimmed || null;
876
+ }
877
+ function normalizePositiveInt(value, fallback, min, max) {
878
+ if (value === void 0 || value === null) {
879
+ return fallback;
880
+ }
881
+ const parsed = Number.parseInt(String(value), 10);
882
+ if (!Number.isFinite(parsed) || parsed < min || parsed > max) {
883
+ throw new HttpError(400, `Expected integer in range [${min}, ${max}].`);
884
+ }
885
+ return parsed;
886
+ }
887
+ function normalizeNonNegativeInt(value, fallback) {
888
+ return normalizePositiveInt(value, fallback, 0, Number.MAX_SAFE_INTEGER);
889
+ }
890
+ function ensureDirectory(targetPath, fieldName) {
891
+ if (!import_node_fs2.default.existsSync(targetPath) || !import_node_fs2.default.statSync(targetPath).isDirectory()) {
892
+ throw new HttpError(400, `${fieldName} must be an existing directory: ${targetPath}`);
893
+ }
894
+ }
895
+ function normalizeHeaderValue(value) {
896
+ if (!value) {
897
+ return "";
898
+ }
899
+ if (Array.isArray(value)) {
900
+ return value[0]?.trim() ?? "";
901
+ }
902
+ return value.trim();
903
+ }
904
+ function readActor(req) {
905
+ const actor = normalizeHeaderValue(req.headers["x-admin-actor"]);
906
+ return actor || null;
907
+ }
908
+ function formatError(error) {
909
+ if (error instanceof Error) {
910
+ return error.message;
911
+ }
912
+ return String(error);
913
+ }
914
+ var ADMIN_CONSOLE_HTML = `<!doctype html>
915
+ <html lang="en">
916
+ <head>
917
+ <meta charset="utf-8" />
918
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
919
+ <title>CodeHarbor Admin Console</title>
920
+ <style>
921
+ :root {
922
+ --bg-start: #0f172a;
923
+ --bg-end: #1e293b;
924
+ --panel: #0b1224cc;
925
+ --panel-border: #334155;
926
+ --text: #e2e8f0;
927
+ --muted: #94a3b8;
928
+ --accent: #22d3ee;
929
+ --accent-strong: #06b6d4;
930
+ --danger: #f43f5e;
931
+ --ok: #10b981;
932
+ --warn: #f59e0b;
933
+ }
934
+ * {
935
+ box-sizing: border-box;
936
+ }
937
+ body {
938
+ margin: 0;
939
+ font-family: "IBM Plex Sans", "Segoe UI", "Helvetica Neue", sans-serif;
940
+ color: var(--text);
941
+ background: radial-gradient(1200px 600px at 20% -10%, #1d4ed8 0%, transparent 55%),
942
+ radial-gradient(1000px 500px at 100% 0%, #0f766e 0%, transparent 55%),
943
+ linear-gradient(135deg, var(--bg-start), var(--bg-end));
944
+ min-height: 100vh;
945
+ }
946
+ .shell {
947
+ max-width: 1100px;
948
+ margin: 0 auto;
949
+ padding: 20px 16px 40px;
950
+ }
951
+ .header {
952
+ background: var(--panel);
953
+ border: 1px solid var(--panel-border);
954
+ border-radius: 16px;
955
+ padding: 16px;
956
+ backdrop-filter: blur(8px);
957
+ }
958
+ .title {
959
+ margin: 0 0 8px;
960
+ font-size: 24px;
961
+ letter-spacing: 0.2px;
962
+ }
963
+ .subtitle {
964
+ margin: 0 0 14px;
965
+ color: var(--muted);
966
+ font-size: 14px;
967
+ }
968
+ .tabs {
969
+ display: flex;
970
+ gap: 8px;
971
+ flex-wrap: wrap;
972
+ margin-bottom: 12px;
973
+ }
974
+ .tab {
975
+ color: var(--text);
976
+ text-decoration: none;
977
+ border: 1px solid var(--panel-border);
978
+ border-radius: 999px;
979
+ padding: 6px 12px;
980
+ font-size: 13px;
981
+ }
982
+ .tab.active {
983
+ border-color: var(--accent);
984
+ background: #155e7555;
985
+ }
986
+ .auth-row {
987
+ display: grid;
988
+ grid-template-columns: repeat(2, minmax(220px, 1fr)) auto auto;
989
+ gap: 8px;
990
+ align-items: end;
991
+ }
992
+ .field {
993
+ display: flex;
994
+ flex-direction: column;
995
+ gap: 4px;
996
+ }
997
+ .field-label {
998
+ font-size: 12px;
999
+ color: var(--muted);
1000
+ }
1001
+ input,
1002
+ button,
1003
+ textarea {
1004
+ font: inherit;
1005
+ }
1006
+ input[type="text"],
1007
+ input[type="password"],
1008
+ input[type="number"] {
1009
+ border: 1px solid var(--panel-border);
1010
+ background: #0f172acc;
1011
+ color: var(--text);
1012
+ border-radius: 10px;
1013
+ padding: 8px 10px;
1014
+ }
1015
+ button {
1016
+ border: 1px solid var(--accent);
1017
+ background: #164e63;
1018
+ color: #ecfeff;
1019
+ border-radius: 10px;
1020
+ padding: 8px 12px;
1021
+ cursor: pointer;
1022
+ }
1023
+ button.secondary {
1024
+ border-color: var(--panel-border);
1025
+ background: #1e293b;
1026
+ color: var(--text);
1027
+ }
1028
+ button.danger {
1029
+ border-color: var(--danger);
1030
+ background: #881337;
1031
+ }
1032
+ .notice {
1033
+ margin: 12px 0 0;
1034
+ border-radius: 10px;
1035
+ padding: 8px 10px;
1036
+ font-size: 13px;
1037
+ border: 1px solid #334155;
1038
+ color: var(--muted);
1039
+ }
1040
+ .notice.ok {
1041
+ border-color: #065f46;
1042
+ color: #d1fae5;
1043
+ background: #064e3b88;
1044
+ }
1045
+ .notice.error {
1046
+ border-color: #881337;
1047
+ color: #ffe4e6;
1048
+ background: #4c051988;
1049
+ }
1050
+ .notice.warn {
1051
+ border-color: #92400e;
1052
+ color: #fef3c7;
1053
+ background: #78350f88;
1054
+ }
1055
+ .panel {
1056
+ margin-top: 14px;
1057
+ background: var(--panel);
1058
+ border: 1px solid var(--panel-border);
1059
+ border-radius: 16px;
1060
+ padding: 16px;
1061
+ }
1062
+ .panel[hidden] {
1063
+ display: none;
1064
+ }
1065
+ .panel-title {
1066
+ margin: 0 0 12px;
1067
+ }
1068
+ .grid {
1069
+ display: grid;
1070
+ grid-template-columns: repeat(2, minmax(0, 1fr));
1071
+ gap: 10px;
1072
+ }
1073
+ .full {
1074
+ grid-column: 1 / -1;
1075
+ }
1076
+ .checkbox {
1077
+ display: flex;
1078
+ gap: 8px;
1079
+ align-items: center;
1080
+ font-size: 14px;
1081
+ }
1082
+ .actions {
1083
+ display: flex;
1084
+ gap: 8px;
1085
+ flex-wrap: wrap;
1086
+ margin-top: 12px;
1087
+ }
1088
+ .table-wrap {
1089
+ overflow-x: auto;
1090
+ border: 1px solid #334155;
1091
+ border-radius: 12px;
1092
+ margin-top: 12px;
1093
+ }
1094
+ table {
1095
+ width: 100%;
1096
+ border-collapse: collapse;
1097
+ min-width: 720px;
1098
+ }
1099
+ th,
1100
+ td {
1101
+ border-bottom: 1px solid #334155;
1102
+ text-align: left;
1103
+ padding: 8px;
1104
+ font-size: 12px;
1105
+ vertical-align: top;
1106
+ }
1107
+ th {
1108
+ color: var(--muted);
1109
+ }
1110
+ pre {
1111
+ margin: 0;
1112
+ white-space: pre-wrap;
1113
+ word-break: break-word;
1114
+ font-size: 11px;
1115
+ color: #cbd5e1;
1116
+ }
1117
+ .muted {
1118
+ color: var(--muted);
1119
+ font-size: 12px;
1120
+ }
1121
+ @media (max-width: 900px) {
1122
+ .auth-row {
1123
+ grid-template-columns: 1fr;
1124
+ }
1125
+ .grid {
1126
+ grid-template-columns: 1fr;
1127
+ }
1128
+ }
1129
+ </style>
1130
+ </head>
1131
+ <body>
1132
+ <main class="shell">
1133
+ <section class="header">
1134
+ <h1 class="title">CodeHarbor Admin Console</h1>
1135
+ <p class="subtitle">Manage global settings, room policies, health checks, and config audit records.</p>
1136
+ <nav class="tabs">
1137
+ <a class="tab" data-page="settings-global" href="#/settings/global">Global</a>
1138
+ <a class="tab" data-page="settings-rooms" href="#/settings/rooms">Rooms</a>
1139
+ <a class="tab" data-page="health" href="#/health">Health</a>
1140
+ <a class="tab" data-page="audit" href="#/audit">Audit</a>
1141
+ </nav>
1142
+ <div class="auth-row">
1143
+ <label class="field">
1144
+ <span class="field-label">Admin Token (optional)</span>
1145
+ <input id="auth-token" type="password" placeholder="ADMIN_TOKEN" />
1146
+ </label>
1147
+ <label class="field">
1148
+ <span class="field-label">Actor (for audit logs)</span>
1149
+ <input id="auth-actor" type="text" placeholder="your-name" />
1150
+ </label>
1151
+ <button id="auth-save-btn" type="button" class="secondary">Save Auth</button>
1152
+ <button id="auth-clear-btn" type="button" class="secondary">Clear Auth</button>
1153
+ </div>
1154
+ <div id="notice" class="notice">Ready.</div>
1155
+ </section>
1156
+
1157
+ <section class="panel" data-view="settings-global">
1158
+ <h2 class="panel-title">Global Config</h2>
1159
+ <div class="grid">
1160
+ <label class="field">
1161
+ <span class="field-label">Command Prefix</span>
1162
+ <input id="global-matrix-prefix" type="text" />
1163
+ </label>
1164
+ <label class="field">
1165
+ <span class="field-label">Default Workdir</span>
1166
+ <input id="global-workdir" type="text" />
1167
+ </label>
1168
+ <label class="field">
1169
+ <span class="field-label">Progress Interval (ms)</span>
1170
+ <input id="global-progress-interval" type="number" min="1" />
1171
+ </label>
1172
+ <label class="field">
1173
+ <span class="field-label">Typing Timeout (ms)</span>
1174
+ <input id="global-typing-timeout" type="number" min="1" />
1175
+ </label>
1176
+ <label class="field">
1177
+ <span class="field-label">Session Active Window (minutes)</span>
1178
+ <input id="global-active-window" type="number" min="1" />
1179
+ </label>
1180
+ <label class="checkbox">
1181
+ <input id="global-progress-enabled" type="checkbox" />
1182
+ <span>Enable progress updates</span>
1183
+ </label>
1184
+
1185
+ <label class="field">
1186
+ <span class="field-label">Rate Window (ms)</span>
1187
+ <input id="global-rate-window" type="number" min="1" />
1188
+ </label>
1189
+ <label class="field">
1190
+ <span class="field-label">Rate Max Requests / User</span>
1191
+ <input id="global-rate-user" type="number" min="0" />
1192
+ </label>
1193
+ <label class="field">
1194
+ <span class="field-label">Rate Max Requests / Room</span>
1195
+ <input id="global-rate-room" type="number" min="0" />
1196
+ </label>
1197
+ <label class="field">
1198
+ <span class="field-label">Max Concurrent Global</span>
1199
+ <input id="global-concurrency-global" type="number" min="0" />
1200
+ </label>
1201
+ <label class="field">
1202
+ <span class="field-label">Max Concurrent / User</span>
1203
+ <input id="global-concurrency-user" type="number" min="0" />
1204
+ </label>
1205
+ <label class="field">
1206
+ <span class="field-label">Max Concurrent / Room</span>
1207
+ <input id="global-concurrency-room" type="number" min="0" />
1208
+ </label>
1209
+
1210
+ <label class="checkbox"><input id="global-trigger-mention" type="checkbox" /><span>Trigger: mention</span></label>
1211
+ <label class="checkbox"><input id="global-trigger-reply" type="checkbox" /><span>Trigger: reply</span></label>
1212
+ <label class="checkbox"><input id="global-trigger-window" type="checkbox" /><span>Trigger: active window</span></label>
1213
+ <label class="checkbox"><input id="global-trigger-prefix" type="checkbox" /><span>Trigger: prefix</span></label>
1214
+
1215
+ <label class="checkbox"><input id="global-cli-enabled" type="checkbox" /><span>CLI compat mode</span></label>
1216
+ <label class="checkbox"><input id="global-cli-pass" type="checkbox" /><span>CLI passthrough events</span></label>
1217
+ <label class="checkbox"><input id="global-cli-whitespace" type="checkbox" /><span>Preserve whitespace</span></label>
1218
+ <label class="checkbox"><input id="global-cli-disable-split" type="checkbox" /><span>Disable reply split</span></label>
1219
+ <label class="field">
1220
+ <span class="field-label">CLI progress throttle (ms)</span>
1221
+ <input id="global-cli-throttle" type="number" min="0" />
1222
+ </label>
1223
+ <label class="checkbox"><input id="global-cli-fetch-media" type="checkbox" /><span>Fetch media attachments</span></label>
1224
+ </div>
1225
+ <div class="actions">
1226
+ <button id="global-save-btn" type="button">Save Global Config</button>
1227
+ <button id="global-reload-btn" type="button" class="secondary">Reload</button>
1228
+ </div>
1229
+ <p class="muted">Saving global config updates .env and requires restart to fully take effect.</p>
1230
+ </section>
1231
+
1232
+ <section class="panel" data-view="settings-rooms" hidden>
1233
+ <h2 class="panel-title">Room Config</h2>
1234
+ <div class="grid">
1235
+ <label class="field">
1236
+ <span class="field-label">Room ID</span>
1237
+ <input id="room-id" type="text" placeholder="!room:example.com" />
1238
+ </label>
1239
+ <label class="field">
1240
+ <span class="field-label">Audit Summary (optional)</span>
1241
+ <input id="room-summary" type="text" placeholder="bind room to project A" />
1242
+ </label>
1243
+ <label class="field full">
1244
+ <span class="field-label">Workdir</span>
1245
+ <input id="room-workdir" type="text" />
1246
+ </label>
1247
+ <label class="checkbox"><input id="room-enabled" type="checkbox" /><span>Enabled</span></label>
1248
+ <label class="checkbox"><input id="room-mention" type="checkbox" /><span>Allow mention trigger</span></label>
1249
+ <label class="checkbox"><input id="room-reply" type="checkbox" /><span>Allow reply trigger</span></label>
1250
+ <label class="checkbox"><input id="room-window" type="checkbox" /><span>Allow active-window trigger</span></label>
1251
+ <label class="checkbox"><input id="room-prefix" type="checkbox" /><span>Allow prefix trigger</span></label>
1252
+ </div>
1253
+ <div class="actions">
1254
+ <button id="room-load-btn" type="button" class="secondary">Load Room</button>
1255
+ <button id="room-save-btn" type="button">Save Room</button>
1256
+ <button id="room-delete-btn" type="button" class="danger">Delete Room</button>
1257
+ <button id="room-refresh-btn" type="button" class="secondary">Refresh List</button>
1258
+ </div>
1259
+ <div class="table-wrap">
1260
+ <table>
1261
+ <thead>
1262
+ <tr>
1263
+ <th>Room ID</th>
1264
+ <th>Enabled</th>
1265
+ <th>Workdir</th>
1266
+ <th>Updated At</th>
1267
+ </tr>
1268
+ </thead>
1269
+ <tbody id="room-list-body"></tbody>
1270
+ </table>
1271
+ </div>
1272
+ </section>
1273
+
1274
+ <section class="panel" data-view="health" hidden>
1275
+ <h2 class="panel-title">Health Check</h2>
1276
+ <div class="actions">
1277
+ <button id="health-refresh-btn" type="button">Run Health Check</button>
1278
+ </div>
1279
+ <div class="table-wrap">
1280
+ <table>
1281
+ <thead>
1282
+ <tr>
1283
+ <th>Component</th>
1284
+ <th>Status</th>
1285
+ <th>Details</th>
1286
+ </tr>
1287
+ </thead>
1288
+ <tbody id="health-body"></tbody>
1289
+ </table>
1290
+ </div>
1291
+ </section>
1292
+
1293
+ <section class="panel" data-view="audit" hidden>
1294
+ <h2 class="panel-title">Config Audit</h2>
1295
+ <div class="actions">
1296
+ <label class="field" style="max-width: 120px;">
1297
+ <span class="field-label">Limit</span>
1298
+ <input id="audit-limit" type="number" min="1" max="200" value="30" />
1299
+ </label>
1300
+ <button id="audit-refresh-btn" type="button">Refresh Audit</button>
1301
+ </div>
1302
+ <div class="table-wrap">
1303
+ <table>
1304
+ <thead>
1305
+ <tr>
1306
+ <th>ID</th>
1307
+ <th>Time</th>
1308
+ <th>Actor</th>
1309
+ <th>Summary</th>
1310
+ <th>Payload</th>
1311
+ </tr>
1312
+ </thead>
1313
+ <tbody id="audit-body"></tbody>
1314
+ </table>
1315
+ </div>
1316
+ </section>
1317
+ </main>
1318
+
1319
+ <script>
1320
+ (function () {
1321
+ "use strict";
1322
+
1323
+ var routeToView = {
1324
+ "#/settings/global": "settings-global",
1325
+ "#/settings/rooms": "settings-rooms",
1326
+ "#/health": "health",
1327
+ "#/audit": "audit"
1328
+ };
1329
+ var pathToRoute = {
1330
+ "/settings/global": "#/settings/global",
1331
+ "/settings/rooms": "#/settings/rooms",
1332
+ "/health": "#/health",
1333
+ "/audit": "#/audit"
1334
+ };
1335
+ var storageTokenKey = "codeharbor.admin.token";
1336
+ var storageActorKey = "codeharbor.admin.actor";
1337
+ var loaded = {
1338
+ "settings-global": false,
1339
+ "settings-rooms": false,
1340
+ health: false,
1341
+ audit: false
1342
+ };
1343
+
1344
+ var tokenInput = document.getElementById("auth-token");
1345
+ var actorInput = document.getElementById("auth-actor");
1346
+ var noticeNode = document.getElementById("notice");
1347
+ var roomListBody = document.getElementById("room-list-body");
1348
+ var healthBody = document.getElementById("health-body");
1349
+ var auditBody = document.getElementById("audit-body");
1350
+
1351
+ tokenInput.value = localStorage.getItem(storageTokenKey) || "";
1352
+ actorInput.value = localStorage.getItem(storageActorKey) || "";
1353
+
1354
+ document.getElementById("auth-save-btn").addEventListener("click", function () {
1355
+ localStorage.setItem(storageTokenKey, tokenInput.value.trim());
1356
+ localStorage.setItem(storageActorKey, actorInput.value.trim());
1357
+ showNotice("ok", "Auth settings saved to localStorage.");
1358
+ });
1359
+
1360
+ document.getElementById("auth-clear-btn").addEventListener("click", function () {
1361
+ tokenInput.value = "";
1362
+ actorInput.value = "";
1363
+ localStorage.removeItem(storageTokenKey);
1364
+ localStorage.removeItem(storageActorKey);
1365
+ showNotice("warn", "Auth settings cleared.");
1366
+ });
1367
+
1368
+ document.getElementById("global-save-btn").addEventListener("click", saveGlobal);
1369
+ document.getElementById("global-reload-btn").addEventListener("click", loadGlobal);
1370
+ document.getElementById("room-load-btn").addEventListener("click", loadRoom);
1371
+ document.getElementById("room-save-btn").addEventListener("click", saveRoom);
1372
+ document.getElementById("room-delete-btn").addEventListener("click", deleteRoom);
1373
+ document.getElementById("room-refresh-btn").addEventListener("click", refreshRoomList);
1374
+ document.getElementById("health-refresh-btn").addEventListener("click", loadHealth);
1375
+ document.getElementById("audit-refresh-btn").addEventListener("click", loadAudit);
1376
+
1377
+ window.addEventListener("hashchange", handleRoute);
1378
+
1379
+ if (!window.location.hash) {
1380
+ window.location.hash = pathToRoute[window.location.pathname] || "#/settings/global";
1381
+ } else {
1382
+ handleRoute();
1383
+ }
1384
+
1385
+ function getCurrentView() {
1386
+ return routeToView[window.location.hash] || "settings-global";
1387
+ }
1388
+
1389
+ function handleRoute() {
1390
+ var view = getCurrentView();
1391
+ var panels = document.querySelectorAll("[data-view]");
1392
+ for (var i = 0; i < panels.length; i += 1) {
1393
+ var panel = panels[i];
1394
+ panel.hidden = panel.getAttribute("data-view") !== view;
1395
+ }
1396
+ var tabs = document.querySelectorAll(".tab");
1397
+ for (var j = 0; j < tabs.length; j += 1) {
1398
+ var tab = tabs[j];
1399
+ if (tab.getAttribute("data-page") === view) {
1400
+ tab.classList.add("active");
1401
+ } else {
1402
+ tab.classList.remove("active");
1403
+ }
1404
+ }
1405
+ ensureLoaded(view);
1406
+ }
1407
+
1408
+ function ensureLoaded(view) {
1409
+ if (loaded[view]) {
1410
+ return;
1411
+ }
1412
+ if (view === "settings-global") {
1413
+ loadGlobal();
1414
+ } else if (view === "settings-rooms") {
1415
+ refreshRoomList();
1416
+ } else if (view === "health") {
1417
+ loadHealth();
1418
+ } else if (view === "audit") {
1419
+ loadAudit();
1420
+ }
1421
+ loaded[view] = true;
1422
+ }
1423
+
1424
+ async function apiRequest(path, method, body) {
1425
+ var headers = {};
1426
+ var token = tokenInput.value.trim();
1427
+ var actor = actorInput.value.trim();
1428
+ if (token) {
1429
+ headers.authorization = "Bearer " + token;
1430
+ }
1431
+ if (actor) {
1432
+ headers["x-admin-actor"] = actor;
1433
+ }
1434
+ if (body !== undefined) {
1435
+ headers["content-type"] = "application/json";
1436
+ }
1437
+ var response = await fetch(path, {
1438
+ method: method || "GET",
1439
+ headers: headers,
1440
+ body: body === undefined ? undefined : JSON.stringify(body)
1441
+ });
1442
+ var text = await response.text();
1443
+ var payload;
1444
+ try {
1445
+ payload = text ? JSON.parse(text) : {};
1446
+ } catch (error) {
1447
+ payload = { raw: text };
1448
+ }
1449
+ if (!response.ok) {
1450
+ var message = payload && payload.error ? payload.error : response.status + " " + response.statusText;
1451
+ throw new Error(message);
1452
+ }
1453
+ return payload;
1454
+ }
1455
+
1456
+ function asNumber(inputId, fallback) {
1457
+ var value = Number.parseInt(document.getElementById(inputId).value, 10);
1458
+ return Number.isFinite(value) ? value : fallback;
1459
+ }
1460
+
1461
+ function asBool(inputId) {
1462
+ return Boolean(document.getElementById(inputId).checked);
1463
+ }
1464
+
1465
+ function asText(inputId) {
1466
+ return document.getElementById(inputId).value.trim();
1467
+ }
1468
+
1469
+ function showNotice(type, message) {
1470
+ noticeNode.className = "notice " + type;
1471
+ noticeNode.textContent = message;
1472
+ }
1473
+
1474
+ function renderEmptyRow(body, columns, text) {
1475
+ body.innerHTML = "";
1476
+ var row = document.createElement("tr");
1477
+ var cell = document.createElement("td");
1478
+ cell.colSpan = columns;
1479
+ cell.textContent = text;
1480
+ row.appendChild(cell);
1481
+ body.appendChild(row);
1482
+ }
1483
+
1484
+ async function loadGlobal() {
1485
+ try {
1486
+ var response = await apiRequest("/api/admin/config/global", "GET");
1487
+ var data = response.data || {};
1488
+ var rateLimiter = data.rateLimiter || {};
1489
+ var trigger = data.defaultGroupTriggerPolicy || {};
1490
+ var cliCompat = data.cliCompat || {};
1491
+
1492
+ document.getElementById("global-matrix-prefix").value = data.matrixCommandPrefix || "";
1493
+ document.getElementById("global-workdir").value = data.codexWorkdir || "";
1494
+ document.getElementById("global-progress-enabled").checked = Boolean(data.matrixProgressUpdates);
1495
+ document.getElementById("global-progress-interval").value = String(data.matrixProgressMinIntervalMs || 2500);
1496
+ document.getElementById("global-typing-timeout").value = String(data.matrixTypingTimeoutMs || 10000);
1497
+ document.getElementById("global-active-window").value = String(data.sessionActiveWindowMinutes || 20);
1498
+ document.getElementById("global-rate-window").value = String(rateLimiter.windowMs || 60000);
1499
+ document.getElementById("global-rate-user").value = String(rateLimiter.maxRequestsPerUser || 0);
1500
+ document.getElementById("global-rate-room").value = String(rateLimiter.maxRequestsPerRoom || 0);
1501
+ document.getElementById("global-concurrency-global").value = String(rateLimiter.maxConcurrentGlobal || 0);
1502
+ document.getElementById("global-concurrency-user").value = String(rateLimiter.maxConcurrentPerUser || 0);
1503
+ document.getElementById("global-concurrency-room").value = String(rateLimiter.maxConcurrentPerRoom || 0);
1504
+
1505
+ document.getElementById("global-trigger-mention").checked = Boolean(trigger.allowMention);
1506
+ document.getElementById("global-trigger-reply").checked = Boolean(trigger.allowReply);
1507
+ document.getElementById("global-trigger-window").checked = Boolean(trigger.allowActiveWindow);
1508
+ document.getElementById("global-trigger-prefix").checked = Boolean(trigger.allowPrefix);
1509
+
1510
+ document.getElementById("global-cli-enabled").checked = Boolean(cliCompat.enabled);
1511
+ document.getElementById("global-cli-pass").checked = Boolean(cliCompat.passThroughEvents);
1512
+ document.getElementById("global-cli-whitespace").checked = Boolean(cliCompat.preserveWhitespace);
1513
+ document.getElementById("global-cli-disable-split").checked = Boolean(cliCompat.disableReplyChunkSplit);
1514
+ document.getElementById("global-cli-throttle").value = String(cliCompat.progressThrottleMs || 0);
1515
+ document.getElementById("global-cli-fetch-media").checked = Boolean(cliCompat.fetchMedia);
1516
+
1517
+ showNotice("ok", "Global config loaded.");
1518
+ } catch (error) {
1519
+ showNotice("error", "Failed to load global config: " + error.message);
1520
+ }
1521
+ }
1522
+
1523
+ async function saveGlobal() {
1524
+ try {
1525
+ var body = {
1526
+ matrixCommandPrefix: asText("global-matrix-prefix"),
1527
+ codexWorkdir: asText("global-workdir"),
1528
+ matrixProgressUpdates: asBool("global-progress-enabled"),
1529
+ matrixProgressMinIntervalMs: asNumber("global-progress-interval", 2500),
1530
+ matrixTypingTimeoutMs: asNumber("global-typing-timeout", 10000),
1531
+ sessionActiveWindowMinutes: asNumber("global-active-window", 20),
1532
+ rateLimiter: {
1533
+ windowMs: asNumber("global-rate-window", 60000),
1534
+ maxRequestsPerUser: asNumber("global-rate-user", 20),
1535
+ maxRequestsPerRoom: asNumber("global-rate-room", 120),
1536
+ maxConcurrentGlobal: asNumber("global-concurrency-global", 8),
1537
+ maxConcurrentPerUser: asNumber("global-concurrency-user", 1),
1538
+ maxConcurrentPerRoom: asNumber("global-concurrency-room", 4)
1539
+ },
1540
+ defaultGroupTriggerPolicy: {
1541
+ allowMention: asBool("global-trigger-mention"),
1542
+ allowReply: asBool("global-trigger-reply"),
1543
+ allowActiveWindow: asBool("global-trigger-window"),
1544
+ allowPrefix: asBool("global-trigger-prefix")
1545
+ },
1546
+ cliCompat: {
1547
+ enabled: asBool("global-cli-enabled"),
1548
+ passThroughEvents: asBool("global-cli-pass"),
1549
+ preserveWhitespace: asBool("global-cli-whitespace"),
1550
+ disableReplyChunkSplit: asBool("global-cli-disable-split"),
1551
+ progressThrottleMs: asNumber("global-cli-throttle", 300),
1552
+ fetchMedia: asBool("global-cli-fetch-media")
1553
+ }
1554
+ };
1555
+ var response = await apiRequest("/api/admin/config/global", "PUT", body);
1556
+ var keys = Array.isArray(response.updatedKeys) ? response.updatedKeys.join(", ") : "global config";
1557
+ showNotice("warn", "Saved: " + keys + ". Restart is required.");
1558
+ await loadAudit();
1559
+ } catch (error) {
1560
+ showNotice("error", "Failed to save global config: " + error.message);
1561
+ }
1562
+ }
1563
+
1564
+ async function refreshRoomList() {
1565
+ try {
1566
+ var response = await apiRequest("/api/admin/config/rooms", "GET");
1567
+ var items = Array.isArray(response.data) ? response.data : [];
1568
+ roomListBody.innerHTML = "";
1569
+ if (items.length === 0) {
1570
+ renderEmptyRow(roomListBody, 4, "No room settings.");
1571
+ return;
1572
+ }
1573
+ for (var i = 0; i < items.length; i += 1) {
1574
+ var item = items[i];
1575
+ var row = document.createElement("tr");
1576
+ appendCell(row, item.roomId || "");
1577
+ appendCell(row, String(Boolean(item.enabled)));
1578
+ appendCell(row, item.workdir || "");
1579
+ appendCell(row, item.updatedAt ? new Date(item.updatedAt).toISOString() : "-");
1580
+ roomListBody.appendChild(row);
1581
+ }
1582
+ showNotice("ok", "Loaded " + items.length + " room setting(s).");
1583
+ } catch (error) {
1584
+ showNotice("error", "Failed to load room list: " + error.message);
1585
+ renderEmptyRow(roomListBody, 4, "Failed to load room settings.");
1586
+ }
1587
+ }
1588
+
1589
+ function appendCell(row, text) {
1590
+ var cell = document.createElement("td");
1591
+ cell.textContent = text;
1592
+ row.appendChild(cell);
1593
+ }
1594
+
1595
+ async function loadRoom() {
1596
+ var roomId = asText("room-id");
1597
+ if (!roomId) {
1598
+ showNotice("warn", "Room ID is required.");
1599
+ return;
1600
+ }
1601
+ try {
1602
+ var response = await apiRequest("/api/admin/config/rooms/" + encodeURIComponent(roomId), "GET");
1603
+ fillRoomForm(response.data || {});
1604
+ showNotice("ok", "Room config loaded for " + roomId + ".");
1605
+ } catch (error) {
1606
+ showNotice("error", "Failed to load room config: " + error.message);
1607
+ }
1608
+ }
1609
+
1610
+ function fillRoomForm(data) {
1611
+ document.getElementById("room-enabled").checked = Boolean(data.enabled);
1612
+ document.getElementById("room-mention").checked = Boolean(data.allowMention);
1613
+ document.getElementById("room-reply").checked = Boolean(data.allowReply);
1614
+ document.getElementById("room-window").checked = Boolean(data.allowActiveWindow);
1615
+ document.getElementById("room-prefix").checked = Boolean(data.allowPrefix);
1616
+ document.getElementById("room-workdir").value = data.workdir || "";
1617
+ }
1618
+
1619
+ async function saveRoom() {
1620
+ var roomId = asText("room-id");
1621
+ if (!roomId) {
1622
+ showNotice("warn", "Room ID is required.");
1623
+ return;
1624
+ }
1625
+ try {
1626
+ var body = {
1627
+ enabled: asBool("room-enabled"),
1628
+ allowMention: asBool("room-mention"),
1629
+ allowReply: asBool("room-reply"),
1630
+ allowActiveWindow: asBool("room-window"),
1631
+ allowPrefix: asBool("room-prefix"),
1632
+ workdir: asText("room-workdir"),
1633
+ summary: asText("room-summary")
1634
+ };
1635
+ await apiRequest("/api/admin/config/rooms/" + encodeURIComponent(roomId), "PUT", body);
1636
+ showNotice("ok", "Room config saved for " + roomId + ".");
1637
+ await refreshRoomList();
1638
+ await loadAudit();
1639
+ } catch (error) {
1640
+ showNotice("error", "Failed to save room config: " + error.message);
1641
+ }
1642
+ }
1643
+
1644
+ async function deleteRoom() {
1645
+ var roomId = asText("room-id");
1646
+ if (!roomId) {
1647
+ showNotice("warn", "Room ID is required.");
1648
+ return;
1649
+ }
1650
+ if (!window.confirm("Delete room config for " + roomId + "?")) {
1651
+ return;
1652
+ }
1653
+ try {
1654
+ await apiRequest("/api/admin/config/rooms/" + encodeURIComponent(roomId), "DELETE");
1655
+ showNotice("ok", "Room config deleted for " + roomId + ".");
1656
+ await refreshRoomList();
1657
+ await loadAudit();
1658
+ } catch (error) {
1659
+ showNotice("error", "Failed to delete room config: " + error.message);
1660
+ }
1661
+ }
1662
+
1663
+ async function loadHealth() {
1664
+ try {
1665
+ var response = await apiRequest("/api/admin/health", "GET");
1666
+ healthBody.innerHTML = "";
1667
+
1668
+ var codex = response.codex || {};
1669
+ var matrix = response.matrix || {};
1670
+
1671
+ appendHealthRow("Codex", Boolean(codex.ok), codex.ok ? (codex.version || "ok") : (codex.error || "failed"));
1672
+ appendHealthRow(
1673
+ "Matrix",
1674
+ Boolean(matrix.ok),
1675
+ matrix.ok ? "HTTP " + matrix.status + " " + JSON.stringify(matrix.versions || []) : (matrix.error || "failed")
1676
+ );
1677
+ appendHealthRow("Overall", Boolean(response.ok), response.timestamp || "");
1678
+ showNotice("ok", "Health check completed.");
1679
+ } catch (error) {
1680
+ showNotice("error", "Health check failed: " + error.message);
1681
+ renderEmptyRow(healthBody, 3, "Failed to run health check.");
1682
+ }
1683
+ }
1684
+
1685
+ function appendHealthRow(component, ok, detail) {
1686
+ var row = document.createElement("tr");
1687
+ appendCell(row, component);
1688
+ appendCell(row, ok ? "OK" : "FAIL");
1689
+ appendCell(row, detail);
1690
+ healthBody.appendChild(row);
1691
+ }
1692
+
1693
+ async function loadAudit() {
1694
+ var limit = asNumber("audit-limit", 30);
1695
+ if (limit < 1) {
1696
+ limit = 1;
1697
+ }
1698
+ if (limit > 200) {
1699
+ limit = 200;
1700
+ }
1701
+ try {
1702
+ var response = await apiRequest("/api/admin/audit?limit=" + limit, "GET");
1703
+ var items = Array.isArray(response.data) ? response.data : [];
1704
+ auditBody.innerHTML = "";
1705
+ if (items.length === 0) {
1706
+ renderEmptyRow(auditBody, 5, "No audit records.");
1707
+ return;
1708
+ }
1709
+ for (var i = 0; i < items.length; i += 1) {
1710
+ var item = items[i];
1711
+ var row = document.createElement("tr");
1712
+ appendCell(row, String(item.id || ""));
1713
+ appendCell(row, item.createdAtIso || "");
1714
+ appendCell(row, item.actor || "-");
1715
+ appendCell(row, item.summary || "");
1716
+ var payloadCell = document.createElement("td");
1717
+ var payloadNode = document.createElement("pre");
1718
+ payloadNode.textContent = formatPayload(item);
1719
+ payloadCell.appendChild(payloadNode);
1720
+ row.appendChild(payloadCell);
1721
+ auditBody.appendChild(row);
1722
+ }
1723
+ showNotice("ok", "Audit loaded: " + items.length + " record(s).");
1724
+ } catch (error) {
1725
+ showNotice("error", "Failed to load audit: " + error.message);
1726
+ renderEmptyRow(auditBody, 5, "Failed to load audit records.");
1727
+ }
1728
+ }
1729
+
1730
+ function formatPayload(item) {
1731
+ if (item.payload && typeof item.payload === "object") {
1732
+ return JSON.stringify(item.payload, null, 2);
1733
+ }
1734
+ if (typeof item.payloadJson === "string" && item.payloadJson) {
1735
+ return item.payloadJson;
1736
+ }
1737
+ return "";
1738
+ }
1739
+ })();
1740
+ </script>
1741
+ </body>
1742
+ </html>
1743
+ `;
1744
+ async function defaultCheckCodex(bin) {
1745
+ try {
1746
+ const { stdout } = await execFileAsync(bin, ["--version"]);
1747
+ return {
1748
+ ok: true,
1749
+ version: stdout.trim() || null,
1750
+ error: null
1751
+ };
1752
+ } catch (error) {
1753
+ return {
1754
+ ok: false,
1755
+ version: null,
1756
+ error: formatError(error)
1757
+ };
1758
+ }
1759
+ }
1760
+ async function defaultCheckMatrix(homeserver, timeoutMs) {
1761
+ const controller = new AbortController();
1762
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
1763
+ timer.unref?.();
1764
+ try {
1765
+ const response = await fetch(`${homeserver}/_matrix/client/versions`, {
1766
+ signal: controller.signal
1767
+ });
1768
+ const versions = response.ok ? (await response.json()).versions ?? [] : [];
1769
+ return {
1770
+ ok: response.ok,
1771
+ status: response.status,
1772
+ versions,
1773
+ error: response.ok ? null : `HTTP ${response.status}`
1774
+ };
1775
+ } catch (error) {
1776
+ return {
1777
+ ok: false,
1778
+ status: null,
1779
+ versions: [],
1780
+ error: formatError(error)
1781
+ };
1782
+ } finally {
1783
+ clearTimeout(timer);
1784
+ }
1785
+ }
1786
+
33
1787
  // src/channels/matrix-channel.ts
34
- var import_promises = __toESM(require("fs/promises"));
1788
+ var import_promises2 = __toESM(require("fs/promises"));
35
1789
  var import_node_os = __toESM(require("os"));
36
- var import_node_path = __toESM(require("path"));
1790
+ var import_node_path3 = __toESM(require("path"));
37
1791
  var import_matrix_js_sdk = require("matrix-js-sdk");
38
1792
 
39
1793
  // src/utils/message.ts
@@ -456,11 +2210,11 @@ var MatrixChannel = class {
456
2210
  }
457
2211
  const bytes = Buffer.from(await response.arrayBuffer());
458
2212
  const extension = resolveFileExtension(fileName, mimeType);
459
- const directory = import_node_path.default.join(import_node_os.default.tmpdir(), "codeharbor-media");
460
- await import_promises.default.mkdir(directory, { recursive: true });
2213
+ const directory = import_node_path3.default.join(import_node_os.default.tmpdir(), "codeharbor-media");
2214
+ await import_promises2.default.mkdir(directory, { recursive: true });
461
2215
  const safeEventId = sanitizeFilename(eventId);
462
- const targetPath = import_node_path.default.join(directory, `${safeEventId}-${index}${extension}`);
463
- await import_promises.default.writeFile(targetPath, bytes);
2216
+ const targetPath = import_node_path3.default.join(directory, `${safeEventId}-${index}${extension}`);
2217
+ await import_promises2.default.writeFile(targetPath, bytes);
464
2218
  return targetPath;
465
2219
  }
466
2220
  };
@@ -544,7 +2298,7 @@ function sanitizeFilename(value) {
544
2298
  return value.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 80);
545
2299
  }
546
2300
  function resolveFileExtension(fileName, mimeType) {
547
- const ext = import_node_path.default.extname(fileName).trim();
2301
+ const ext = import_node_path3.default.extname(fileName).trim();
548
2302
  if (ext) {
549
2303
  return ext;
550
2304
  }
@@ -560,8 +2314,102 @@ function resolveFileExtension(fileName, mimeType) {
560
2314
  return ".bin";
561
2315
  }
562
2316
 
2317
+ // src/config-service.ts
2318
+ var import_node_fs3 = __toESM(require("fs"));
2319
+ var import_node_path4 = __toESM(require("path"));
2320
+ var ConfigService = class {
2321
+ stateStore;
2322
+ defaultWorkdir;
2323
+ constructor(stateStore, defaultWorkdir) {
2324
+ this.stateStore = stateStore;
2325
+ this.defaultWorkdir = import_node_path4.default.resolve(defaultWorkdir);
2326
+ }
2327
+ resolveRoomConfig(roomId, fallbackPolicy) {
2328
+ const room = this.stateStore.getRoomSettings(roomId);
2329
+ if (!room) {
2330
+ return {
2331
+ source: "default",
2332
+ enabled: true,
2333
+ triggerPolicy: fallbackPolicy,
2334
+ workdir: this.defaultWorkdir
2335
+ };
2336
+ }
2337
+ return {
2338
+ source: "room",
2339
+ enabled: room.enabled,
2340
+ triggerPolicy: {
2341
+ allowMention: room.allowMention,
2342
+ allowReply: room.allowReply,
2343
+ allowActiveWindow: room.allowActiveWindow,
2344
+ allowPrefix: room.allowPrefix
2345
+ },
2346
+ workdir: room.workdir
2347
+ };
2348
+ }
2349
+ getRoomSettings(roomId) {
2350
+ return this.stateStore.getRoomSettings(roomId);
2351
+ }
2352
+ listRoomSettings() {
2353
+ return this.stateStore.listRoomSettings();
2354
+ }
2355
+ updateRoomSettings(input) {
2356
+ const normalized = normalizeRoomSettingsInput(input);
2357
+ this.stateStore.upsertRoomSettings(normalized);
2358
+ const revisionPayload = JSON.stringify({
2359
+ type: "room_settings_upsert",
2360
+ roomId: normalized.roomId,
2361
+ enabled: normalized.enabled,
2362
+ allowMention: normalized.allowMention,
2363
+ allowReply: normalized.allowReply,
2364
+ allowActiveWindow: normalized.allowActiveWindow,
2365
+ allowPrefix: normalized.allowPrefix,
2366
+ workdir: normalized.workdir
2367
+ });
2368
+ const summary = input.summary?.trim() || `upsert room settings for ${normalized.roomId}`;
2369
+ const actor = input.actor?.trim() || null;
2370
+ this.stateStore.appendConfigRevision(actor, summary, revisionPayload);
2371
+ const latest = this.stateStore.getRoomSettings(normalized.roomId);
2372
+ if (!latest) {
2373
+ throw new Error(`Failed to persist room settings for ${normalized.roomId}`);
2374
+ }
2375
+ return latest;
2376
+ }
2377
+ deleteRoomSettings(roomId, actor) {
2378
+ const normalizedRoomId = roomId.trim();
2379
+ if (!normalizedRoomId) {
2380
+ throw new Error("roomId is required.");
2381
+ }
2382
+ this.stateStore.deleteRoomSettings(normalizedRoomId);
2383
+ const summary = `delete room settings for ${normalizedRoomId}`;
2384
+ const payload = JSON.stringify({
2385
+ type: "room_settings_delete",
2386
+ roomId: normalizedRoomId
2387
+ });
2388
+ this.stateStore.appendConfigRevision(actor?.trim() || null, summary, payload);
2389
+ }
2390
+ };
2391
+ function normalizeRoomSettingsInput(input) {
2392
+ const roomId = input.roomId.trim();
2393
+ if (!roomId) {
2394
+ throw new Error("roomId is required.");
2395
+ }
2396
+ const workdir = import_node_path4.default.resolve(input.workdir);
2397
+ if (!import_node_fs3.default.existsSync(workdir) || !import_node_fs3.default.statSync(workdir).isDirectory()) {
2398
+ throw new Error(`workdir does not exist or is not a directory: ${workdir}`);
2399
+ }
2400
+ return {
2401
+ roomId,
2402
+ enabled: input.enabled,
2403
+ allowMention: input.allowMention,
2404
+ allowReply: input.allowReply,
2405
+ allowActiveWindow: input.allowActiveWindow,
2406
+ allowPrefix: input.allowPrefix,
2407
+ workdir
2408
+ };
2409
+ }
2410
+
563
2411
  // src/executor/codex-executor.ts
564
- var import_node_child_process = require("child_process");
2412
+ var import_node_child_process2 = require("child_process");
565
2413
  var import_node_readline = __toESM(require("readline"));
566
2414
  var CodexExecutionCancelledError = class extends Error {
567
2415
  constructor(message = "codex execution cancelled") {
@@ -579,8 +2427,8 @@ var CodexExecutor = class {
579
2427
  }
580
2428
  startExecution(prompt, sessionId, onProgress, startOptions) {
581
2429
  const args = buildCodexArgs(prompt, sessionId, this.options, startOptions);
582
- const child = (0, import_node_child_process.spawn)(this.options.bin, args, {
583
- cwd: this.options.workdir,
2430
+ const child = (0, import_node_child_process2.spawn)(this.options.bin, args, {
2431
+ cwd: startOptions?.workdir ?? this.options.workdir,
584
2432
  env: {
585
2433
  ...process.env,
586
2434
  ...this.options.extraEnv
@@ -812,23 +2660,23 @@ function stringify(value) {
812
2660
 
813
2661
  // src/orchestrator.ts
814
2662
  var import_async_mutex = require("async-mutex");
815
- var import_promises2 = __toESM(require("fs/promises"));
2663
+ var import_promises3 = __toESM(require("fs/promises"));
816
2664
 
817
2665
  // src/compat/cli-compat-recorder.ts
818
- var import_node_fs = __toESM(require("fs"));
819
- var import_node_path2 = __toESM(require("path"));
2666
+ var import_node_fs4 = __toESM(require("fs"));
2667
+ var import_node_path5 = __toESM(require("path"));
820
2668
  var CliCompatRecorder = class {
821
2669
  filePath;
822
2670
  chain = Promise.resolve();
823
2671
  constructor(filePath) {
824
- this.filePath = import_node_path2.default.resolve(filePath);
825
- import_node_fs.default.mkdirSync(import_node_path2.default.dirname(this.filePath), { recursive: true });
2672
+ this.filePath = import_node_path5.default.resolve(filePath);
2673
+ import_node_fs4.default.mkdirSync(import_node_path5.default.dirname(this.filePath), { recursive: true });
826
2674
  }
827
2675
  append(entry) {
828
2676
  const payload = `${JSON.stringify(entry)}
829
2677
  `;
830
2678
  this.chain = this.chain.then(async () => {
831
- await import_node_fs.default.promises.appendFile(this.filePath, payload, "utf8");
2679
+ await import_node_fs4.default.promises.appendFile(this.filePath, payload, "utf8");
832
2680
  });
833
2681
  return this.chain;
834
2682
  }
@@ -1112,6 +2960,8 @@ var Orchestrator = class {
1112
2960
  sessionActiveWindowMs;
1113
2961
  defaultGroupTriggerPolicy;
1114
2962
  roomTriggerPolicies;
2963
+ configService;
2964
+ defaultCodexWorkdir;
1115
2965
  rateLimiter;
1116
2966
  cliCompat;
1117
2967
  cliCompatRecorder;
@@ -1149,6 +2999,8 @@ var Orchestrator = class {
1149
2999
  allowPrefix: true
1150
3000
  };
1151
3001
  this.roomTriggerPolicies = options?.roomTriggerPolicies ?? {};
3002
+ this.configService = options?.configService ?? null;
3003
+ this.defaultCodexWorkdir = options?.defaultCodexWorkdir ?? process.cwd();
1152
3004
  this.rateLimiter = new RateLimiter(
1153
3005
  options?.rateLimiterOptions ?? {
1154
3006
  windowMs: 6e4,
@@ -1184,7 +3036,8 @@ var Orchestrator = class {
1184
3036
  this.logger.debug("Duplicate event ignored", { requestId, eventId: message.eventId, sessionKey, queueWaitMs });
1185
3037
  return;
1186
3038
  }
1187
- const route = this.routeMessage(message, sessionKey);
3039
+ const roomConfig = this.resolveRoomRuntimeConfig(message.conversationId);
3040
+ const route = this.routeMessage(message, sessionKey, roomConfig);
1188
3041
  if (route.kind === "ignore") {
1189
3042
  this.metrics.record("ignored", queueWaitMs, 0, 0);
1190
3043
  this.logger.debug("Message ignored by routing policy", {
@@ -1253,6 +3106,8 @@ var Orchestrator = class {
1253
3106
  hasCodexSession: Boolean(previousCodexSessionId),
1254
3107
  queueWaitMs,
1255
3108
  attachmentCount: message.attachments.length,
3109
+ workdir: roomConfig.workdir,
3110
+ roomConfigSource: roomConfig.source,
1256
3111
  isDirectMessage: message.isDirectMessage,
1257
3112
  mentionsBot: message.mentionsBot,
1258
3113
  repliesToBot: message.repliesToBot
@@ -1289,7 +3144,8 @@ var Orchestrator = class {
1289
3144
  },
1290
3145
  {
1291
3146
  passThroughRawEvents: this.cliCompat.enabled && this.cliCompat.passThroughEvents,
1292
- imagePaths
3147
+ imagePaths,
3148
+ workdir: roomConfig.workdir
1293
3149
  }
1294
3150
  );
1295
3151
  const running = this.runningExecutions.get(sessionKey);
@@ -1350,7 +3206,7 @@ var Orchestrator = class {
1350
3206
  try {
1351
3207
  await this.channel.sendMessage(
1352
3208
  message.conversationId,
1353
- `[CodeHarbor] Failed to process request: ${formatError(error)}`
3209
+ `[CodeHarbor] Failed to process request: ${formatError2(error)}`
1354
3210
  );
1355
3211
  } catch (sendError) {
1356
3212
  this.logger.error("Failed to send error reply to Matrix", sendError);
@@ -1365,7 +3221,7 @@ var Orchestrator = class {
1365
3221
  queueWaitMs,
1366
3222
  executionDurationMs,
1367
3223
  totalDurationMs: Date.now() - receivedAt,
1368
- error: formatError(error)
3224
+ error: formatError2(error)
1369
3225
  });
1370
3226
  } finally {
1371
3227
  const running = this.runningExecutions.get(sessionKey);
@@ -1378,13 +3234,16 @@ var Orchestrator = class {
1378
3234
  }
1379
3235
  });
1380
3236
  }
1381
- routeMessage(message, sessionKey) {
3237
+ routeMessage(message, sessionKey, roomConfig) {
1382
3238
  const incomingRaw = message.text;
1383
3239
  const incomingTrimmed = incomingRaw.trim();
1384
3240
  if (!incomingTrimmed && message.attachments.length === 0) {
1385
3241
  return { kind: "ignore" };
1386
3242
  }
1387
- const groupPolicy = message.isDirectMessage ? null : this.resolveGroupPolicy(message.conversationId);
3243
+ if (!message.isDirectMessage && !roomConfig.enabled) {
3244
+ return { kind: "ignore" };
3245
+ }
3246
+ const groupPolicy = message.isDirectMessage ? null : roomConfig.triggerPolicy;
1388
3247
  const prefixAllowed = message.isDirectMessage || Boolean(groupPolicy?.allowPrefix);
1389
3248
  const prefixTriggered = prefixAllowed && this.commandPrefix.length > 0;
1390
3249
  const prefixedText = prefixTriggered ? extractCommandText(incomingTrimmed, this.commandPrefix) : null;
@@ -1426,6 +3285,7 @@ var Orchestrator = class {
1426
3285
  return;
1427
3286
  }
1428
3287
  const status = this.stateStore.getSessionStatus(sessionKey);
3288
+ const roomConfig = this.resolveRoomRuntimeConfig(message.conversationId);
1429
3289
  const scope = message.isDirectMessage ? "\u79C1\u804A\uFF08\u514D\u524D\u7F00\uFF09" : "\u7FA4\u804A\uFF08\u6309\u623F\u95F4\u89E6\u53D1\u7B56\u7565\uFF09";
1430
3290
  const activeUntil = status.activeUntil ?? "\u672A\u6FC0\u6D3B";
1431
3291
  const metrics = this.metrics.snapshot(this.runningExecutions.size);
@@ -1438,6 +3298,7 @@ var Orchestrator = class {
1438
3298
  - \u6FC0\u6D3B\u4E2D: ${status.isActive ? "\u662F" : "\u5426"}
1439
3299
  - activeUntil: ${activeUntil}
1440
3300
  - \u5DF2\u7ED1\u5B9A Codex \u4F1A\u8BDD: ${status.hasCodexSession ? "\u662F" : "\u5426"}
3301
+ - \u5F53\u524D\u5DE5\u4F5C\u76EE\u5F55: ${roomConfig.workdir}
1441
3302
  - \u8FD0\u884C\u4E2D\u4EFB\u52A1: ${metrics.activeExecutions}
1442
3303
  - \u6307\u6807: total=${metrics.total}, success=${metrics.success}, failed=${metrics.failed}, timeout=${metrics.timeout}, cancelled=${metrics.cancelled}, rate_limited=${metrics.rateLimited}
1443
3304
  - \u5E73\u5747\u8017\u65F6: queue=${metrics.avgQueueMs}ms, exec=${metrics.avgExecMs}ms, send=${metrics.avgSendMs}ms
@@ -1559,6 +3420,18 @@ var Orchestrator = class {
1559
3420
  allowPrefix: override.allowPrefix ?? this.defaultGroupTriggerPolicy.allowPrefix
1560
3421
  };
1561
3422
  }
3423
+ resolveRoomRuntimeConfig(conversationId) {
3424
+ const fallbackPolicy = this.resolveGroupPolicy(conversationId);
3425
+ if (!this.configService) {
3426
+ return {
3427
+ source: "default",
3428
+ enabled: true,
3429
+ triggerPolicy: fallbackPolicy,
3430
+ workdir: this.defaultCodexWorkdir
3431
+ };
3432
+ }
3433
+ return this.configService.resolveRoomConfig(conversationId, fallbackPolicy);
3434
+ }
1562
3435
  buildExecutionPrompt(prompt, message) {
1563
3436
  if (message.attachments.length === 0) {
1564
3437
  return prompt;
@@ -1631,7 +3504,7 @@ ${attachmentSummary}
1631
3504
  function buildSessionKey(message) {
1632
3505
  return `${message.channel}:${message.conversationId}:${message.senderId}`;
1633
3506
  }
1634
- function formatError(error) {
3507
+ function formatError2(error) {
1635
3508
  if (error instanceof Error) {
1636
3509
  return error.message;
1637
3510
  }
@@ -1651,7 +3524,7 @@ async function cleanupAttachmentFiles(imagePaths) {
1651
3524
  await Promise.all(
1652
3525
  imagePaths.map(async (imagePath) => {
1653
3526
  try {
1654
- await import_promises2.default.unlink(imagePath);
3527
+ await import_promises3.default.unlink(imagePath);
1655
3528
  } catch {
1656
3529
  }
1657
3530
  })
@@ -1730,7 +3603,7 @@ function classifyExecutionOutcome(error) {
1730
3603
  if (error instanceof CodexExecutionCancelledError) {
1731
3604
  return "cancelled";
1732
3605
  }
1733
- const message = formatError(error).toLowerCase();
3606
+ const message = formatError2(error).toLowerCase();
1734
3607
  if (message.includes("timed out")) {
1735
3608
  return "timeout";
1736
3609
  }
@@ -1742,14 +3615,14 @@ function buildFailureProgressSummary(status, startedAt, error) {
1742
3615
  return `\u5904\u7406\u5DF2\u53D6\u6D88\uFF08\u8017\u65F6 ${elapsed}\uFF09`;
1743
3616
  }
1744
3617
  if (status === "timeout") {
1745
- return `\u5904\u7406\u8D85\u65F6\uFF08\u8017\u65F6 ${elapsed}\uFF09: ${formatError(error)}`;
3618
+ return `\u5904\u7406\u8D85\u65F6\uFF08\u8017\u65F6 ${elapsed}\uFF09: ${formatError2(error)}`;
1746
3619
  }
1747
- return `\u5904\u7406\u5931\u8D25\uFF08\u8017\u65F6 ${elapsed}\uFF09: ${formatError(error)}`;
3620
+ return `\u5904\u7406\u5931\u8D25\uFF08\u8017\u65F6 ${elapsed}\uFF09: ${formatError2(error)}`;
1748
3621
  }
1749
3622
 
1750
3623
  // src/store/state-store.ts
1751
- var import_node_fs2 = __toESM(require("fs"));
1752
- var import_node_path3 = __toESM(require("path"));
3624
+ var import_node_fs5 = __toESM(require("fs"));
3625
+ var import_node_path6 = __toESM(require("path"));
1753
3626
  var ONE_DAY_MS = 24 * 60 * 60 * 1e3;
1754
3627
  var PRUNE_INTERVAL_MS = 5 * 60 * 1e3;
1755
3628
  var SQLITE_MODULE_ID = `node:${"sqlite"}`;
@@ -1775,7 +3648,7 @@ var StateStore = class {
1775
3648
  this.maxProcessedEventsPerSession = maxProcessedEventsPerSession;
1776
3649
  this.maxSessionAgeMs = maxSessionAgeDays * ONE_DAY_MS;
1777
3650
  this.maxSessions = maxSessions;
1778
- import_node_fs2.default.mkdirSync(import_node_path3.default.dirname(this.dbPath), { recursive: true });
3651
+ import_node_fs5.default.mkdirSync(import_node_path6.default.dirname(this.dbPath), { recursive: true });
1779
3652
  this.db = new DatabaseSync(this.dbPath);
1780
3653
  this.initializeSchema();
1781
3654
  this.importLegacyStateIfNeeded();
@@ -1874,6 +3747,83 @@ var StateStore = class {
1874
3747
  throw error;
1875
3748
  }
1876
3749
  }
3750
+ getRoomSettings(roomId) {
3751
+ const row = this.db.prepare(
3752
+ "SELECT room_id, enabled, allow_mention, allow_reply, allow_active_window, allow_prefix, workdir, updated_at FROM room_settings WHERE room_id = ?1"
3753
+ ).get(roomId);
3754
+ if (!row) {
3755
+ return null;
3756
+ }
3757
+ return {
3758
+ roomId: row.room_id,
3759
+ enabled: row.enabled === 1,
3760
+ allowMention: row.allow_mention === 1,
3761
+ allowReply: row.allow_reply === 1,
3762
+ allowActiveWindow: row.allow_active_window === 1,
3763
+ allowPrefix: row.allow_prefix === 1,
3764
+ workdir: row.workdir,
3765
+ updatedAt: row.updated_at
3766
+ };
3767
+ }
3768
+ listRoomSettings() {
3769
+ const rows = this.db.prepare(
3770
+ "SELECT room_id, enabled, allow_mention, allow_reply, allow_active_window, allow_prefix, workdir, updated_at FROM room_settings ORDER BY room_id ASC"
3771
+ ).all();
3772
+ return rows.map((row) => ({
3773
+ roomId: row.room_id,
3774
+ enabled: row.enabled === 1,
3775
+ allowMention: row.allow_mention === 1,
3776
+ allowReply: row.allow_reply === 1,
3777
+ allowActiveWindow: row.allow_active_window === 1,
3778
+ allowPrefix: row.allow_prefix === 1,
3779
+ workdir: row.workdir,
3780
+ updatedAt: row.updated_at
3781
+ }));
3782
+ }
3783
+ upsertRoomSettings(input) {
3784
+ const now = Date.now();
3785
+ this.db.prepare(
3786
+ `INSERT INTO room_settings
3787
+ (room_id, enabled, allow_mention, allow_reply, allow_active_window, allow_prefix, workdir, updated_at)
3788
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
3789
+ ON CONFLICT(room_id) DO UPDATE SET
3790
+ enabled = excluded.enabled,
3791
+ allow_mention = excluded.allow_mention,
3792
+ allow_reply = excluded.allow_reply,
3793
+ allow_active_window = excluded.allow_active_window,
3794
+ allow_prefix = excluded.allow_prefix,
3795
+ workdir = excluded.workdir,
3796
+ updated_at = excluded.updated_at`
3797
+ ).run(
3798
+ input.roomId,
3799
+ boolToInt(input.enabled),
3800
+ boolToInt(input.allowMention),
3801
+ boolToInt(input.allowReply),
3802
+ boolToInt(input.allowActiveWindow),
3803
+ boolToInt(input.allowPrefix),
3804
+ input.workdir,
3805
+ now
3806
+ );
3807
+ }
3808
+ deleteRoomSettings(roomId) {
3809
+ this.db.prepare("DELETE FROM room_settings WHERE room_id = ?1").run(roomId);
3810
+ }
3811
+ appendConfigRevision(actor, summary, payloadJson) {
3812
+ this.db.prepare("INSERT INTO config_revisions (actor, summary, payload_json, created_at) VALUES (?1, ?2, ?3, ?4)").run(actor, summary, payloadJson, Date.now());
3813
+ }
3814
+ listConfigRevisions(limit = 20) {
3815
+ const safeLimit = Math.max(1, Math.floor(limit));
3816
+ const rows = this.db.prepare(
3817
+ "SELECT id, actor, summary, payload_json, created_at FROM config_revisions ORDER BY id DESC LIMIT ?1"
3818
+ ).all(safeLimit);
3819
+ return rows.map((row) => ({
3820
+ id: row.id,
3821
+ actor: row.actor,
3822
+ summary: row.summary,
3823
+ payloadJson: row.payload_json,
3824
+ createdAt: row.created_at
3825
+ }));
3826
+ }
1877
3827
  async flush() {
1878
3828
  this.touchDatabase();
1879
3829
  }
@@ -1919,10 +3869,31 @@ var StateStore = class {
1919
3869
 
1920
3870
  CREATE INDEX IF NOT EXISTS idx_sessions_updated_at ON sessions(updated_at);
1921
3871
  CREATE INDEX IF NOT EXISTS idx_events_created_at ON processed_events(created_at);
3872
+
3873
+ CREATE TABLE IF NOT EXISTS room_settings (
3874
+ room_id TEXT PRIMARY KEY,
3875
+ enabled INTEGER NOT NULL DEFAULT 1,
3876
+ allow_mention INTEGER NOT NULL DEFAULT 1,
3877
+ allow_reply INTEGER NOT NULL DEFAULT 1,
3878
+ allow_active_window INTEGER NOT NULL DEFAULT 1,
3879
+ allow_prefix INTEGER NOT NULL DEFAULT 1,
3880
+ workdir TEXT NOT NULL,
3881
+ updated_at INTEGER NOT NULL
3882
+ );
3883
+
3884
+ CREATE TABLE IF NOT EXISTS config_revisions (
3885
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
3886
+ actor TEXT,
3887
+ summary TEXT NOT NULL,
3888
+ payload_json TEXT NOT NULL,
3889
+ created_at INTEGER NOT NULL
3890
+ );
3891
+
3892
+ CREATE INDEX IF NOT EXISTS idx_config_revisions_created_at ON config_revisions(created_at);
1922
3893
  `);
1923
3894
  }
1924
3895
  importLegacyStateIfNeeded() {
1925
- if (!this.legacyJsonPath || !import_node_fs2.default.existsSync(this.legacyJsonPath)) {
3896
+ if (!this.legacyJsonPath || !import_node_fs5.default.existsSync(this.legacyJsonPath)) {
1926
3897
  return;
1927
3898
  }
1928
3899
  const countRow = this.db.prepare("SELECT COUNT(*) AS count FROM sessions").get();
@@ -2005,7 +3976,7 @@ var StateStore = class {
2005
3976
  };
2006
3977
  function loadLegacyState(filePath) {
2007
3978
  try {
2008
- const raw = import_node_fs2.default.readFileSync(filePath, "utf8");
3979
+ const raw = import_node_fs5.default.readFileSync(filePath, "utf8");
2009
3980
  const parsed = JSON.parse(raw);
2010
3981
  if (!parsed.sessions || typeof parsed.sessions !== "object") {
2011
3982
  return null;
@@ -2043,15 +4014,19 @@ function normalizeLegacyState(state) {
2043
4014
  }
2044
4015
  }
2045
4016
  }
4017
+ function boolToInt(value) {
4018
+ return value ? 1 : 0;
4019
+ }
2046
4020
 
2047
4021
  // src/app.ts
2048
- var execFileAsync = (0, import_node_util.promisify)(import_node_child_process2.execFile);
4022
+ var execFileAsync2 = (0, import_node_util2.promisify)(import_node_child_process3.execFile);
2049
4023
  var CodeHarborApp = class {
2050
4024
  config;
2051
4025
  logger;
2052
4026
  stateStore;
2053
4027
  channel;
2054
4028
  orchestrator;
4029
+ configService;
2055
4030
  constructor(config) {
2056
4031
  this.config = config;
2057
4032
  this.logger = new Logger(config.logLevel);
@@ -2062,6 +4037,7 @@ var CodeHarborApp = class {
2062
4037
  config.maxSessionAgeDays,
2063
4038
  config.maxSessions
2064
4039
  );
4040
+ this.configService = new ConfigService(this.stateStore, config.codexWorkdir);
2065
4041
  const executor = new CodexExecutor({
2066
4042
  bin: config.codexBin,
2067
4043
  model: config.codexModel,
@@ -2084,7 +4060,9 @@ var CodeHarborApp = class {
2084
4060
  defaultGroupTriggerPolicy: config.defaultGroupTriggerPolicy,
2085
4061
  roomTriggerPolicies: config.roomTriggerPolicies,
2086
4062
  rateLimiterOptions: config.rateLimiter,
2087
- cliCompat: config.cliCompat
4063
+ cliCompat: config.cliCompat,
4064
+ configService: this.configService,
4065
+ defaultCodexWorkdir: config.codexWorkdir
2088
4066
  });
2089
4067
  }
2090
4068
  async start() {
@@ -2105,11 +4083,54 @@ var CodeHarborApp = class {
2105
4083
  }
2106
4084
  }
2107
4085
  };
4086
+ var CodeHarborAdminApp = class {
4087
+ config;
4088
+ logger;
4089
+ stateStore;
4090
+ configService;
4091
+ adminServer;
4092
+ constructor(config, options) {
4093
+ this.config = config;
4094
+ this.logger = new Logger(config.logLevel);
4095
+ this.stateStore = new StateStore(
4096
+ config.stateDbPath,
4097
+ config.legacyStateJsonPath,
4098
+ config.maxProcessedEventsPerSession,
4099
+ config.maxSessionAgeDays,
4100
+ config.maxSessions
4101
+ );
4102
+ this.configService = new ConfigService(this.stateStore, config.codexWorkdir);
4103
+ this.adminServer = new AdminServer(config, this.logger, this.stateStore, this.configService, {
4104
+ host: options?.host ?? config.adminBindHost,
4105
+ port: options?.port ?? config.adminPort,
4106
+ adminToken: config.adminToken,
4107
+ adminIpAllowlist: config.adminIpAllowlist,
4108
+ adminAllowedOrigins: config.adminAllowedOrigins
4109
+ });
4110
+ }
4111
+ async start() {
4112
+ await this.adminServer.start();
4113
+ const address = this.adminServer.getAddress();
4114
+ this.logger.info("CodeHarbor admin server started", {
4115
+ host: address?.host ?? this.config.adminBindHost,
4116
+ port: address?.port ?? this.config.adminPort,
4117
+ tokenProtected: Boolean(this.config.adminToken)
4118
+ });
4119
+ }
4120
+ async stop() {
4121
+ this.logger.info("CodeHarbor admin server stopping.");
4122
+ try {
4123
+ await this.adminServer.stop();
4124
+ } finally {
4125
+ await this.stateStore.flush();
4126
+ }
4127
+ }
4128
+ };
2108
4129
  async function runDoctor(config) {
2109
4130
  const logger = new Logger(config.logLevel);
2110
4131
  logger.info("Doctor check started");
2111
4132
  try {
2112
- const { stdout } = await execFileAsync(config.codexBin, ["--version"]);
4133
+ const { stdout } = await execFileAsync2(config.codexBin, ["--version"]);
2113
4134
  logger.info("codex available", { version: stdout.trim() });
2114
4135
  } catch (error) {
2115
4136
  logger.error("codex unavailable", error);
@@ -2136,12 +4157,20 @@ async function runDoctor(config) {
2136
4157
  logger.info("Doctor check passed");
2137
4158
  }
2138
4159
 
4160
+ // src/utils/admin-host.ts
4161
+ function isNonLoopbackHost(host) {
4162
+ const normalized = host.trim().toLowerCase();
4163
+ if (!normalized) {
4164
+ return false;
4165
+ }
4166
+ return !(normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1" || normalized === "[::1]");
4167
+ }
4168
+
2139
4169
  // src/config.ts
2140
- var import_node_fs3 = __toESM(require("fs"));
2141
- var import_node_path4 = __toESM(require("path"));
2142
- var import_dotenv = __toESM(require("dotenv"));
4170
+ var import_node_fs6 = __toESM(require("fs"));
4171
+ var import_node_path7 = __toESM(require("path"));
4172
+ var import_dotenv2 = __toESM(require("dotenv"));
2143
4173
  var import_zod = require("zod");
2144
- import_dotenv.default.config();
2145
4174
  var configSchema = import_zod.z.object({
2146
4175
  MATRIX_HOMESERVER: import_zod.z.string().url(),
2147
4176
  MATRIX_USER_ID: import_zod.z.string().min(1),
@@ -2149,7 +4178,7 @@ var configSchema = import_zod.z.object({
2149
4178
  MATRIX_COMMAND_PREFIX: import_zod.z.string().default("!code"),
2150
4179
  CODEX_BIN: import_zod.z.string().default("codex"),
2151
4180
  CODEX_MODEL: import_zod.z.string().optional(),
2152
- CODEX_WORKDIR: import_zod.z.string().default(process.cwd()),
4181
+ CODEX_WORKDIR: import_zod.z.string().default("."),
2153
4182
  CODEX_DANGEROUS_BYPASS: import_zod.z.string().default("false").transform((v) => v.toLowerCase() === "true"),
2154
4183
  CODEX_EXEC_TIMEOUT_MS: import_zod.z.string().default("600000").transform((v) => Number.parseInt(v, 10)).pipe(import_zod.z.number().int().positive()),
2155
4184
  CODEX_SANDBOX_MODE: import_zod.z.string().optional(),
@@ -2185,6 +4214,11 @@ var configSchema = import_zod.z.object({
2185
4214
  CLI_COMPAT_FETCH_MEDIA: import_zod.z.string().default("true").transform((v) => v.toLowerCase() === "true"),
2186
4215
  CLI_COMPAT_RECORD_PATH: import_zod.z.string().default(""),
2187
4216
  DOCTOR_HTTP_TIMEOUT_MS: import_zod.z.string().default("10000").transform((v) => Number.parseInt(v, 10)).pipe(import_zod.z.number().int().positive()),
4217
+ ADMIN_BIND_HOST: import_zod.z.string().default("127.0.0.1"),
4218
+ ADMIN_PORT: import_zod.z.string().default("8787").transform((v) => Number.parseInt(v, 10)).pipe(import_zod.z.number().int().min(1).max(65535)),
4219
+ ADMIN_TOKEN: import_zod.z.string().default(""),
4220
+ ADMIN_IP_ALLOWLIST: import_zod.z.string().default(""),
4221
+ ADMIN_ALLOWED_ORIGINS: import_zod.z.string().default(""),
2188
4222
  LOG_LEVEL: import_zod.z.enum(["debug", "info", "warn", "error"]).default("info")
2189
4223
  }).transform((v) => ({
2190
4224
  matrixHomeserver: v.MATRIX_HOMESERVER,
@@ -2193,15 +4227,15 @@ var configSchema = import_zod.z.object({
2193
4227
  matrixCommandPrefix: v.MATRIX_COMMAND_PREFIX,
2194
4228
  codexBin: v.CODEX_BIN,
2195
4229
  codexModel: v.CODEX_MODEL?.trim() || null,
2196
- codexWorkdir: import_node_path4.default.resolve(v.CODEX_WORKDIR),
4230
+ codexWorkdir: import_node_path7.default.resolve(v.CODEX_WORKDIR),
2197
4231
  codexDangerousBypass: v.CODEX_DANGEROUS_BYPASS,
2198
4232
  codexExecTimeoutMs: v.CODEX_EXEC_TIMEOUT_MS,
2199
4233
  codexSandboxMode: v.CODEX_SANDBOX_MODE?.trim() || null,
2200
4234
  codexApprovalPolicy: v.CODEX_APPROVAL_POLICY?.trim() || null,
2201
4235
  codexExtraArgs: parseExtraArgs(v.CODEX_EXTRA_ARGS),
2202
4236
  codexExtraEnv: parseExtraEnv(v.CODEX_EXTRA_ENV_JSON),
2203
- stateDbPath: import_node_path4.default.resolve(v.STATE_DB_PATH),
2204
- legacyStateJsonPath: v.STATE_PATH.trim() ? import_node_path4.default.resolve(v.STATE_PATH) : null,
4237
+ stateDbPath: import_node_path7.default.resolve(v.STATE_DB_PATH),
4238
+ legacyStateJsonPath: v.STATE_PATH.trim() ? import_node_path7.default.resolve(v.STATE_PATH) : null,
2205
4239
  maxProcessedEventsPerSession: v.MAX_PROCESSED_EVENTS_PER_SESSION,
2206
4240
  maxSessionAgeDays: v.MAX_SESSION_AGE_DAYS,
2207
4241
  maxSessions: v.MAX_SESSIONS,
@@ -2232,20 +4266,32 @@ var configSchema = import_zod.z.object({
2232
4266
  disableReplyChunkSplit: v.CLI_COMPAT_DISABLE_REPLY_CHUNK_SPLIT,
2233
4267
  progressThrottleMs: v.CLI_COMPAT_PROGRESS_THROTTLE_MS,
2234
4268
  fetchMedia: v.CLI_COMPAT_FETCH_MEDIA,
2235
- recordPath: v.CLI_COMPAT_RECORD_PATH.trim() ? import_node_path4.default.resolve(v.CLI_COMPAT_RECORD_PATH) : null
4269
+ recordPath: v.CLI_COMPAT_RECORD_PATH.trim() ? import_node_path7.default.resolve(v.CLI_COMPAT_RECORD_PATH) : null
2236
4270
  },
2237
4271
  doctorHttpTimeoutMs: v.DOCTOR_HTTP_TIMEOUT_MS,
4272
+ adminBindHost: v.ADMIN_BIND_HOST.trim() || "127.0.0.1",
4273
+ adminPort: v.ADMIN_PORT,
4274
+ adminToken: v.ADMIN_TOKEN.trim() || null,
4275
+ adminIpAllowlist: parseCsvList(v.ADMIN_IP_ALLOWLIST),
4276
+ adminAllowedOrigins: parseCsvList(v.ADMIN_ALLOWED_ORIGINS),
2238
4277
  logLevel: v.LOG_LEVEL
2239
4278
  }));
4279
+ function loadEnvFromFile(filePath = import_node_path7.default.resolve(process.cwd(), ".env"), env = process.env) {
4280
+ import_dotenv2.default.config({
4281
+ path: filePath,
4282
+ processEnv: env,
4283
+ quiet: true
4284
+ });
4285
+ }
2240
4286
  function loadConfig(env = process.env) {
2241
4287
  const parsed = configSchema.safeParse(env);
2242
4288
  if (!parsed.success) {
2243
4289
  const message = parsed.error.issues.map((issue) => `${issue.path.join(".") || "config"}: ${issue.message}`).join("; ");
2244
4290
  throw new Error(`Invalid configuration: ${message}`);
2245
4291
  }
2246
- import_node_fs3.default.mkdirSync(import_node_path4.default.dirname(parsed.data.stateDbPath), { recursive: true });
4292
+ import_node_fs6.default.mkdirSync(import_node_path7.default.dirname(parsed.data.stateDbPath), { recursive: true });
2247
4293
  if (parsed.data.legacyStateJsonPath) {
2248
- import_node_fs3.default.mkdirSync(import_node_path4.default.dirname(parsed.data.legacyStateJsonPath), { recursive: true });
4294
+ import_node_fs6.default.mkdirSync(import_node_path7.default.dirname(parsed.data.legacyStateJsonPath), { recursive: true });
2249
4295
  }
2250
4296
  return parsed.data;
2251
4297
  }
@@ -2313,12 +4359,584 @@ function parseExtraEnv(raw) {
2313
4359
  }
2314
4360
  return output;
2315
4361
  }
4362
+ function parseCsvList(raw) {
4363
+ return raw.split(",").map((entry) => entry.trim()).filter((entry) => entry.length > 0);
4364
+ }
4365
+
4366
+ // src/config-snapshot.ts
4367
+ var import_node_fs7 = __toESM(require("fs"));
4368
+ var import_node_path8 = __toESM(require("path"));
4369
+ var import_zod2 = require("zod");
4370
+ var CONFIG_SNAPSHOT_SCHEMA_VERSION = 1;
4371
+ var CONFIG_SNAPSHOT_ENV_KEYS = [
4372
+ "MATRIX_HOMESERVER",
4373
+ "MATRIX_USER_ID",
4374
+ "MATRIX_ACCESS_TOKEN",
4375
+ "MATRIX_COMMAND_PREFIX",
4376
+ "CODEX_BIN",
4377
+ "CODEX_MODEL",
4378
+ "CODEX_WORKDIR",
4379
+ "CODEX_DANGEROUS_BYPASS",
4380
+ "CODEX_EXEC_TIMEOUT_MS",
4381
+ "CODEX_SANDBOX_MODE",
4382
+ "CODEX_APPROVAL_POLICY",
4383
+ "CODEX_EXTRA_ARGS",
4384
+ "CODEX_EXTRA_ENV_JSON",
4385
+ "STATE_DB_PATH",
4386
+ "STATE_PATH",
4387
+ "MAX_PROCESSED_EVENTS_PER_SESSION",
4388
+ "MAX_SESSION_AGE_DAYS",
4389
+ "MAX_SESSIONS",
4390
+ "REPLY_CHUNK_SIZE",
4391
+ "MATRIX_PROGRESS_UPDATES",
4392
+ "MATRIX_PROGRESS_MIN_INTERVAL_MS",
4393
+ "MATRIX_TYPING_TIMEOUT_MS",
4394
+ "SESSION_ACTIVE_WINDOW_MINUTES",
4395
+ "GROUP_TRIGGER_ALLOW_MENTION",
4396
+ "GROUP_TRIGGER_ALLOW_REPLY",
4397
+ "GROUP_TRIGGER_ALLOW_ACTIVE_WINDOW",
4398
+ "GROUP_TRIGGER_ALLOW_PREFIX",
4399
+ "ROOM_TRIGGER_POLICY_JSON",
4400
+ "RATE_LIMIT_WINDOW_SECONDS",
4401
+ "RATE_LIMIT_MAX_REQUESTS_PER_USER",
4402
+ "RATE_LIMIT_MAX_REQUESTS_PER_ROOM",
4403
+ "RATE_LIMIT_MAX_CONCURRENT_GLOBAL",
4404
+ "RATE_LIMIT_MAX_CONCURRENT_PER_USER",
4405
+ "RATE_LIMIT_MAX_CONCURRENT_PER_ROOM",
4406
+ "CLI_COMPAT_MODE",
4407
+ "CLI_COMPAT_PASSTHROUGH_EVENTS",
4408
+ "CLI_COMPAT_PRESERVE_WHITESPACE",
4409
+ "CLI_COMPAT_DISABLE_REPLY_CHUNK_SPLIT",
4410
+ "CLI_COMPAT_PROGRESS_THROTTLE_MS",
4411
+ "CLI_COMPAT_FETCH_MEDIA",
4412
+ "CLI_COMPAT_RECORD_PATH",
4413
+ "DOCTOR_HTTP_TIMEOUT_MS",
4414
+ "ADMIN_BIND_HOST",
4415
+ "ADMIN_PORT",
4416
+ "ADMIN_TOKEN",
4417
+ "ADMIN_IP_ALLOWLIST",
4418
+ "ADMIN_ALLOWED_ORIGINS",
4419
+ "LOG_LEVEL"
4420
+ ];
4421
+ var BOOLEAN_STRING = /^(true|false)$/i;
4422
+ var INTEGER_STRING = /^-?\d+$/;
4423
+ var LOG_LEVELS = ["debug", "info", "warn", "error"];
4424
+ var roomSnapshotSchema = import_zod2.z.object({
4425
+ roomId: import_zod2.z.string().min(1),
4426
+ enabled: import_zod2.z.boolean(),
4427
+ allowMention: import_zod2.z.boolean(),
4428
+ allowReply: import_zod2.z.boolean(),
4429
+ allowActiveWindow: import_zod2.z.boolean(),
4430
+ allowPrefix: import_zod2.z.boolean(),
4431
+ workdir: import_zod2.z.string().min(1)
4432
+ }).strict();
4433
+ var envSnapshotSchema = import_zod2.z.object({
4434
+ MATRIX_HOMESERVER: import_zod2.z.string().url(),
4435
+ MATRIX_USER_ID: import_zod2.z.string().min(1),
4436
+ MATRIX_ACCESS_TOKEN: import_zod2.z.string().min(1),
4437
+ MATRIX_COMMAND_PREFIX: import_zod2.z.string(),
4438
+ CODEX_BIN: import_zod2.z.string().min(1),
4439
+ CODEX_MODEL: import_zod2.z.string(),
4440
+ CODEX_WORKDIR: import_zod2.z.string().min(1),
4441
+ CODEX_DANGEROUS_BYPASS: booleanStringSchema("CODEX_DANGEROUS_BYPASS"),
4442
+ CODEX_EXEC_TIMEOUT_MS: integerStringSchema("CODEX_EXEC_TIMEOUT_MS", 1),
4443
+ CODEX_SANDBOX_MODE: import_zod2.z.string(),
4444
+ CODEX_APPROVAL_POLICY: import_zod2.z.string(),
4445
+ CODEX_EXTRA_ARGS: import_zod2.z.string(),
4446
+ CODEX_EXTRA_ENV_JSON: jsonObjectStringSchema("CODEX_EXTRA_ENV_JSON", true),
4447
+ STATE_DB_PATH: import_zod2.z.string().min(1),
4448
+ STATE_PATH: import_zod2.z.string(),
4449
+ MAX_PROCESSED_EVENTS_PER_SESSION: integerStringSchema("MAX_PROCESSED_EVENTS_PER_SESSION", 1),
4450
+ MAX_SESSION_AGE_DAYS: integerStringSchema("MAX_SESSION_AGE_DAYS", 1),
4451
+ MAX_SESSIONS: integerStringSchema("MAX_SESSIONS", 1),
4452
+ REPLY_CHUNK_SIZE: integerStringSchema("REPLY_CHUNK_SIZE", 1),
4453
+ MATRIX_PROGRESS_UPDATES: booleanStringSchema("MATRIX_PROGRESS_UPDATES"),
4454
+ MATRIX_PROGRESS_MIN_INTERVAL_MS: integerStringSchema("MATRIX_PROGRESS_MIN_INTERVAL_MS", 1),
4455
+ MATRIX_TYPING_TIMEOUT_MS: integerStringSchema("MATRIX_TYPING_TIMEOUT_MS", 1),
4456
+ SESSION_ACTIVE_WINDOW_MINUTES: integerStringSchema("SESSION_ACTIVE_WINDOW_MINUTES", 1),
4457
+ GROUP_TRIGGER_ALLOW_MENTION: booleanStringSchema("GROUP_TRIGGER_ALLOW_MENTION"),
4458
+ GROUP_TRIGGER_ALLOW_REPLY: booleanStringSchema("GROUP_TRIGGER_ALLOW_REPLY"),
4459
+ GROUP_TRIGGER_ALLOW_ACTIVE_WINDOW: booleanStringSchema("GROUP_TRIGGER_ALLOW_ACTIVE_WINDOW"),
4460
+ GROUP_TRIGGER_ALLOW_PREFIX: booleanStringSchema("GROUP_TRIGGER_ALLOW_PREFIX"),
4461
+ ROOM_TRIGGER_POLICY_JSON: jsonObjectStringSchema("ROOM_TRIGGER_POLICY_JSON", true),
4462
+ RATE_LIMIT_WINDOW_SECONDS: integerStringSchema("RATE_LIMIT_WINDOW_SECONDS", 1),
4463
+ RATE_LIMIT_MAX_REQUESTS_PER_USER: integerStringSchema("RATE_LIMIT_MAX_REQUESTS_PER_USER", 0),
4464
+ RATE_LIMIT_MAX_REQUESTS_PER_ROOM: integerStringSchema("RATE_LIMIT_MAX_REQUESTS_PER_ROOM", 0),
4465
+ RATE_LIMIT_MAX_CONCURRENT_GLOBAL: integerStringSchema("RATE_LIMIT_MAX_CONCURRENT_GLOBAL", 0),
4466
+ RATE_LIMIT_MAX_CONCURRENT_PER_USER: integerStringSchema("RATE_LIMIT_MAX_CONCURRENT_PER_USER", 0),
4467
+ RATE_LIMIT_MAX_CONCURRENT_PER_ROOM: integerStringSchema("RATE_LIMIT_MAX_CONCURRENT_PER_ROOM", 0),
4468
+ CLI_COMPAT_MODE: booleanStringSchema("CLI_COMPAT_MODE"),
4469
+ CLI_COMPAT_PASSTHROUGH_EVENTS: booleanStringSchema("CLI_COMPAT_PASSTHROUGH_EVENTS"),
4470
+ CLI_COMPAT_PRESERVE_WHITESPACE: booleanStringSchema("CLI_COMPAT_PRESERVE_WHITESPACE"),
4471
+ CLI_COMPAT_DISABLE_REPLY_CHUNK_SPLIT: booleanStringSchema("CLI_COMPAT_DISABLE_REPLY_CHUNK_SPLIT"),
4472
+ CLI_COMPAT_PROGRESS_THROTTLE_MS: integerStringSchema("CLI_COMPAT_PROGRESS_THROTTLE_MS", 0),
4473
+ CLI_COMPAT_FETCH_MEDIA: booleanStringSchema("CLI_COMPAT_FETCH_MEDIA"),
4474
+ CLI_COMPAT_RECORD_PATH: import_zod2.z.string(),
4475
+ DOCTOR_HTTP_TIMEOUT_MS: integerStringSchema("DOCTOR_HTTP_TIMEOUT_MS", 1),
4476
+ ADMIN_BIND_HOST: import_zod2.z.string(),
4477
+ ADMIN_PORT: integerStringSchema("ADMIN_PORT", 1, 65535),
4478
+ ADMIN_TOKEN: import_zod2.z.string(),
4479
+ ADMIN_IP_ALLOWLIST: import_zod2.z.string(),
4480
+ ADMIN_ALLOWED_ORIGINS: import_zod2.z.string().default(""),
4481
+ LOG_LEVEL: import_zod2.z.enum(LOG_LEVELS)
4482
+ }).strict();
4483
+ var configSnapshotSchema = import_zod2.z.object({
4484
+ schemaVersion: import_zod2.z.literal(CONFIG_SNAPSHOT_SCHEMA_VERSION),
4485
+ exportedAt: import_zod2.z.string().datetime({ offset: true }),
4486
+ env: envSnapshotSchema,
4487
+ rooms: import_zod2.z.array(roomSnapshotSchema)
4488
+ }).strict().superRefine((value, ctx) => {
4489
+ const seen = /* @__PURE__ */ new Set();
4490
+ for (const room of value.rooms) {
4491
+ const roomId = room.roomId.trim();
4492
+ if (!roomId) {
4493
+ ctx.addIssue({
4494
+ code: import_zod2.z.ZodIssueCode.custom,
4495
+ message: "rooms[].roomId cannot be empty."
4496
+ });
4497
+ continue;
4498
+ }
4499
+ if (seen.has(roomId)) {
4500
+ ctx.addIssue({
4501
+ code: import_zod2.z.ZodIssueCode.custom,
4502
+ message: `Duplicate room id in snapshot: ${roomId}`
4503
+ });
4504
+ }
4505
+ seen.add(roomId);
4506
+ }
4507
+ });
4508
+ function buildConfigSnapshot(config, roomSettings, now = /* @__PURE__ */ new Date()) {
4509
+ return {
4510
+ schemaVersion: CONFIG_SNAPSHOT_SCHEMA_VERSION,
4511
+ exportedAt: now.toISOString(),
4512
+ env: buildSnapshotEnv(config),
4513
+ rooms: roomSettings.map((room) => ({
4514
+ roomId: room.roomId,
4515
+ enabled: room.enabled,
4516
+ allowMention: room.allowMention,
4517
+ allowReply: room.allowReply,
4518
+ allowActiveWindow: room.allowActiveWindow,
4519
+ allowPrefix: room.allowPrefix,
4520
+ workdir: room.workdir
4521
+ }))
4522
+ };
4523
+ }
4524
+ function parseConfigSnapshot(raw) {
4525
+ const parsed = configSnapshotSchema.safeParse(raw);
4526
+ if (!parsed.success) {
4527
+ const message = parsed.error.issues.map((issue) => `${issue.path.join(".") || "snapshot"}: ${issue.message}`).join("; ");
4528
+ throw new Error(`Invalid config snapshot: ${message}`);
4529
+ }
4530
+ return parsed.data;
4531
+ }
4532
+ function serializeConfigSnapshot(snapshot) {
4533
+ return `${JSON.stringify(snapshot, null, 2)}
4534
+ `;
4535
+ }
4536
+ async function runConfigExportCommand(options = {}) {
4537
+ const cwd = options.cwd ?? process.cwd();
4538
+ const output = options.output ?? process.stdout;
4539
+ const config = loadConfig(options.env ?? process.env);
4540
+ const stateStore = new StateStore(
4541
+ config.stateDbPath,
4542
+ config.legacyStateJsonPath,
4543
+ config.maxProcessedEventsPerSession,
4544
+ config.maxSessionAgeDays,
4545
+ config.maxSessions
4546
+ );
4547
+ try {
4548
+ const snapshot = buildConfigSnapshot(config, stateStore.listRoomSettings(), options.now ?? /* @__PURE__ */ new Date());
4549
+ const serialized = serializeConfigSnapshot(snapshot);
4550
+ if (options.outputPath) {
4551
+ const targetPath = import_node_path8.default.resolve(cwd, options.outputPath);
4552
+ import_node_fs7.default.mkdirSync(import_node_path8.default.dirname(targetPath), { recursive: true });
4553
+ import_node_fs7.default.writeFileSync(targetPath, serialized, "utf8");
4554
+ output.write(`Exported config snapshot to ${targetPath}
4555
+ `);
4556
+ return;
4557
+ }
4558
+ output.write(serialized);
4559
+ } finally {
4560
+ await stateStore.flush();
4561
+ }
4562
+ }
4563
+ async function runConfigImportCommand(options) {
4564
+ const cwd = options.cwd ?? process.cwd();
4565
+ const output = options.output ?? process.stdout;
4566
+ const actor = options.actor?.trim() || "cli:config-import";
4567
+ const sourcePath = import_node_path8.default.resolve(cwd, options.filePath);
4568
+ if (!import_node_fs7.default.existsSync(sourcePath)) {
4569
+ throw new Error(`Config snapshot file not found: ${sourcePath}`);
4570
+ }
4571
+ const snapshot = parseConfigSnapshot(parseJsonFile(sourcePath));
4572
+ const normalizedEnv = normalizeSnapshotEnv(snapshot.env, cwd);
4573
+ ensureDirectory2(normalizedEnv.CODEX_WORKDIR, "CODEX_WORKDIR");
4574
+ const normalizedRooms = normalizeSnapshotRooms(snapshot.rooms, cwd);
4575
+ if (options.dryRun) {
4576
+ output.write(
4577
+ [
4578
+ `Config snapshot is valid: ${sourcePath}`,
4579
+ `- schemaVersion: ${snapshot.schemaVersion}`,
4580
+ `- rooms: ${normalizedRooms.length}`,
4581
+ "- dry-run: no changes were written"
4582
+ ].join("\n") + "\n"
4583
+ );
4584
+ return;
4585
+ }
4586
+ persistEnvSnapshot(cwd, normalizedEnv);
4587
+ const stateStore = new StateStore(
4588
+ normalizedEnv.STATE_DB_PATH,
4589
+ normalizedEnv.STATE_PATH ? normalizedEnv.STATE_PATH : null,
4590
+ parseIntStrict(normalizedEnv.MAX_PROCESSED_EVENTS_PER_SESSION),
4591
+ parseIntStrict(normalizedEnv.MAX_SESSION_AGE_DAYS),
4592
+ parseIntStrict(normalizedEnv.MAX_SESSIONS)
4593
+ );
4594
+ try {
4595
+ synchronizeRoomSettings(stateStore, normalizedRooms);
4596
+ stateStore.appendConfigRevision(
4597
+ actor,
4598
+ `import config snapshot from ${import_node_path8.default.basename(sourcePath)}`,
4599
+ JSON.stringify({
4600
+ type: "config_snapshot_import",
4601
+ sourcePath,
4602
+ roomCount: normalizedRooms.length,
4603
+ envKeyCount: CONFIG_SNAPSHOT_ENV_KEYS.length
4604
+ })
4605
+ );
4606
+ } finally {
4607
+ await stateStore.flush();
4608
+ }
4609
+ output.write(
4610
+ [
4611
+ `Imported config snapshot from ${sourcePath}`,
4612
+ `- updated .env in ${import_node_path8.default.resolve(cwd, ".env")}`,
4613
+ `- synchronized room settings: ${normalizedRooms.length}`,
4614
+ "- restart required: yes (global env settings are restart-scoped)"
4615
+ ].join("\n") + "\n"
4616
+ );
4617
+ }
4618
+ function buildSnapshotEnv(config) {
4619
+ return {
4620
+ MATRIX_HOMESERVER: config.matrixHomeserver,
4621
+ MATRIX_USER_ID: config.matrixUserId,
4622
+ MATRIX_ACCESS_TOKEN: config.matrixAccessToken,
4623
+ MATRIX_COMMAND_PREFIX: config.matrixCommandPrefix,
4624
+ CODEX_BIN: config.codexBin,
4625
+ CODEX_MODEL: config.codexModel ?? "",
4626
+ CODEX_WORKDIR: config.codexWorkdir,
4627
+ CODEX_DANGEROUS_BYPASS: String(config.codexDangerousBypass),
4628
+ CODEX_EXEC_TIMEOUT_MS: String(config.codexExecTimeoutMs),
4629
+ CODEX_SANDBOX_MODE: config.codexSandboxMode ?? "",
4630
+ CODEX_APPROVAL_POLICY: config.codexApprovalPolicy ?? "",
4631
+ CODEX_EXTRA_ARGS: config.codexExtraArgs.join(" "),
4632
+ CODEX_EXTRA_ENV_JSON: serializeJsonObject(config.codexExtraEnv),
4633
+ STATE_DB_PATH: config.stateDbPath,
4634
+ STATE_PATH: config.legacyStateJsonPath ?? "",
4635
+ MAX_PROCESSED_EVENTS_PER_SESSION: String(config.maxProcessedEventsPerSession),
4636
+ MAX_SESSION_AGE_DAYS: String(config.maxSessionAgeDays),
4637
+ MAX_SESSIONS: String(config.maxSessions),
4638
+ REPLY_CHUNK_SIZE: String(config.replyChunkSize),
4639
+ MATRIX_PROGRESS_UPDATES: String(config.matrixProgressUpdates),
4640
+ MATRIX_PROGRESS_MIN_INTERVAL_MS: String(config.matrixProgressMinIntervalMs),
4641
+ MATRIX_TYPING_TIMEOUT_MS: String(config.matrixTypingTimeoutMs),
4642
+ SESSION_ACTIVE_WINDOW_MINUTES: String(config.sessionActiveWindowMinutes),
4643
+ GROUP_TRIGGER_ALLOW_MENTION: String(config.defaultGroupTriggerPolicy.allowMention),
4644
+ GROUP_TRIGGER_ALLOW_REPLY: String(config.defaultGroupTriggerPolicy.allowReply),
4645
+ GROUP_TRIGGER_ALLOW_ACTIVE_WINDOW: String(config.defaultGroupTriggerPolicy.allowActiveWindow),
4646
+ GROUP_TRIGGER_ALLOW_PREFIX: String(config.defaultGroupTriggerPolicy.allowPrefix),
4647
+ ROOM_TRIGGER_POLICY_JSON: serializeJsonObject(config.roomTriggerPolicies),
4648
+ RATE_LIMIT_WINDOW_SECONDS: String(Math.max(1, Math.round(config.rateLimiter.windowMs / 1e3))),
4649
+ RATE_LIMIT_MAX_REQUESTS_PER_USER: String(config.rateLimiter.maxRequestsPerUser),
4650
+ RATE_LIMIT_MAX_REQUESTS_PER_ROOM: String(config.rateLimiter.maxRequestsPerRoom),
4651
+ RATE_LIMIT_MAX_CONCURRENT_GLOBAL: String(config.rateLimiter.maxConcurrentGlobal),
4652
+ RATE_LIMIT_MAX_CONCURRENT_PER_USER: String(config.rateLimiter.maxConcurrentPerUser),
4653
+ RATE_LIMIT_MAX_CONCURRENT_PER_ROOM: String(config.rateLimiter.maxConcurrentPerRoom),
4654
+ CLI_COMPAT_MODE: String(config.cliCompat.enabled),
4655
+ CLI_COMPAT_PASSTHROUGH_EVENTS: String(config.cliCompat.passThroughEvents),
4656
+ CLI_COMPAT_PRESERVE_WHITESPACE: String(config.cliCompat.preserveWhitespace),
4657
+ CLI_COMPAT_DISABLE_REPLY_CHUNK_SPLIT: String(config.cliCompat.disableReplyChunkSplit),
4658
+ CLI_COMPAT_PROGRESS_THROTTLE_MS: String(config.cliCompat.progressThrottleMs),
4659
+ CLI_COMPAT_FETCH_MEDIA: String(config.cliCompat.fetchMedia),
4660
+ CLI_COMPAT_RECORD_PATH: config.cliCompat.recordPath ?? "",
4661
+ DOCTOR_HTTP_TIMEOUT_MS: String(config.doctorHttpTimeoutMs),
4662
+ ADMIN_BIND_HOST: config.adminBindHost,
4663
+ ADMIN_PORT: String(config.adminPort),
4664
+ ADMIN_TOKEN: config.adminToken ?? "",
4665
+ ADMIN_IP_ALLOWLIST: config.adminIpAllowlist.join(","),
4666
+ ADMIN_ALLOWED_ORIGINS: config.adminAllowedOrigins.join(","),
4667
+ LOG_LEVEL: config.logLevel
4668
+ };
4669
+ }
4670
+ function parseJsonFile(filePath) {
4671
+ try {
4672
+ const raw = import_node_fs7.default.readFileSync(filePath, "utf8");
4673
+ return JSON.parse(raw);
4674
+ } catch (error) {
4675
+ const message = error instanceof Error ? error.message : String(error);
4676
+ throw new Error(`Failed to parse snapshot JSON: ${message}`, {
4677
+ cause: error
4678
+ });
4679
+ }
4680
+ }
4681
+ function normalizeSnapshotEnv(env, cwd) {
4682
+ return {
4683
+ ...env,
4684
+ CODEX_WORKDIR: import_node_path8.default.resolve(cwd, env.CODEX_WORKDIR),
4685
+ STATE_DB_PATH: import_node_path8.default.resolve(cwd, env.STATE_DB_PATH),
4686
+ STATE_PATH: env.STATE_PATH.trim() ? import_node_path8.default.resolve(cwd, env.STATE_PATH) : "",
4687
+ CLI_COMPAT_RECORD_PATH: env.CLI_COMPAT_RECORD_PATH.trim() ? import_node_path8.default.resolve(cwd, env.CLI_COMPAT_RECORD_PATH) : ""
4688
+ };
4689
+ }
4690
+ function normalizeSnapshotRooms(rooms, cwd) {
4691
+ const normalized = [];
4692
+ const seen = /* @__PURE__ */ new Set();
4693
+ for (const room of rooms) {
4694
+ const roomId = room.roomId.trim();
4695
+ if (!roomId) {
4696
+ throw new Error("roomId is required for every room in snapshot.");
4697
+ }
4698
+ if (seen.has(roomId)) {
4699
+ throw new Error(`Duplicate roomId in snapshot: ${roomId}`);
4700
+ }
4701
+ seen.add(roomId);
4702
+ const workdir = import_node_path8.default.resolve(cwd, room.workdir);
4703
+ ensureDirectory2(workdir, `room workdir (${roomId})`);
4704
+ normalized.push({
4705
+ roomId,
4706
+ enabled: room.enabled,
4707
+ allowMention: room.allowMention,
4708
+ allowReply: room.allowReply,
4709
+ allowActiveWindow: room.allowActiveWindow,
4710
+ allowPrefix: room.allowPrefix,
4711
+ workdir
4712
+ });
4713
+ }
4714
+ return normalized;
4715
+ }
4716
+ function synchronizeRoomSettings(stateStore, rooms) {
4717
+ const incoming = new Map(rooms.map((room) => [room.roomId, room]));
4718
+ const existing = stateStore.listRoomSettings();
4719
+ for (const room of existing) {
4720
+ if (!incoming.has(room.roomId)) {
4721
+ stateStore.deleteRoomSettings(room.roomId);
4722
+ }
4723
+ }
4724
+ for (const room of rooms) {
4725
+ stateStore.upsertRoomSettings(room);
4726
+ }
4727
+ }
4728
+ function persistEnvSnapshot(cwd, env) {
4729
+ const envPath = import_node_path8.default.resolve(cwd, ".env");
4730
+ const examplePath = import_node_path8.default.resolve(cwd, ".env.example");
4731
+ 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") : "";
4732
+ const overrides = {};
4733
+ for (const key of CONFIG_SNAPSHOT_ENV_KEYS) {
4734
+ overrides[key] = env[key];
4735
+ }
4736
+ const next = applyEnvOverrides(template, overrides);
4737
+ import_node_fs7.default.writeFileSync(envPath, next, "utf8");
4738
+ }
4739
+ function ensureDirectory2(dirPath, label) {
4740
+ if (!import_node_fs7.default.existsSync(dirPath) || !import_node_fs7.default.statSync(dirPath).isDirectory()) {
4741
+ throw new Error(`${label} does not exist or is not a directory: ${dirPath}`);
4742
+ }
4743
+ }
4744
+ function parseIntStrict(raw) {
4745
+ const value = Number.parseInt(raw, 10);
4746
+ if (!Number.isFinite(value)) {
4747
+ throw new Error(`Invalid integer value: ${raw}`);
4748
+ }
4749
+ return value;
4750
+ }
4751
+ function serializeJsonObject(value) {
4752
+ return Object.keys(value).length > 0 ? JSON.stringify(value) : "";
4753
+ }
4754
+ function booleanStringSchema(key) {
4755
+ return import_zod2.z.string().refine((value) => BOOLEAN_STRING.test(value), {
4756
+ message: `${key} must be a boolean string (true/false).`
4757
+ });
4758
+ }
4759
+ function integerStringSchema(key, min, max = Number.MAX_SAFE_INTEGER) {
4760
+ return import_zod2.z.string().refine((value) => {
4761
+ const trimmed = value.trim();
4762
+ if (!INTEGER_STRING.test(trimmed)) {
4763
+ return false;
4764
+ }
4765
+ const parsed = Number.parseInt(trimmed, 10);
4766
+ if (!Number.isFinite(parsed)) {
4767
+ return false;
4768
+ }
4769
+ return parsed >= min && parsed <= max;
4770
+ }, {
4771
+ message: `${key} must be an integer string in range [${min}, ${max}].`
4772
+ });
4773
+ }
4774
+ function jsonObjectStringSchema(key, allowEmpty) {
4775
+ return import_zod2.z.string().refine((value) => {
4776
+ const trimmed = value.trim();
4777
+ if (!trimmed) {
4778
+ return allowEmpty;
4779
+ }
4780
+ let parsed;
4781
+ try {
4782
+ parsed = JSON.parse(trimmed);
4783
+ } catch {
4784
+ return false;
4785
+ }
4786
+ return Boolean(parsed) && typeof parsed === "object" && !Array.isArray(parsed);
4787
+ }, {
4788
+ message: `${key} must be an empty string or a JSON object string.`
4789
+ });
4790
+ }
4791
+
4792
+ // src/preflight.ts
4793
+ var import_node_child_process4 = require("child_process");
4794
+ var import_node_fs8 = __toESM(require("fs"));
4795
+ var import_node_path9 = __toESM(require("path"));
4796
+ var import_node_util3 = require("util");
4797
+ var execFileAsync3 = (0, import_node_util3.promisify)(import_node_child_process4.execFile);
4798
+ var REQUIRED_ENV_KEYS = ["MATRIX_HOMESERVER", "MATRIX_USER_ID", "MATRIX_ACCESS_TOKEN"];
4799
+ async function runStartupPreflight(options = {}) {
4800
+ const env = options.env ?? process.env;
4801
+ const cwd = options.cwd ?? process.cwd();
4802
+ const checkCodexBinary = options.checkCodexBinary ?? defaultCheckCodexBinary;
4803
+ const fileExists = options.fileExists ?? import_node_fs8.default.existsSync;
4804
+ const isDirectory = options.isDirectory ?? defaultIsDirectory;
4805
+ const issues = [];
4806
+ const envPath = import_node_path9.default.resolve(cwd, ".env");
4807
+ if (!fileExists(envPath)) {
4808
+ issues.push({
4809
+ level: "warn",
4810
+ code: "missing_dotenv",
4811
+ check: ".env",
4812
+ message: `No .env file found at ${envPath}.`,
4813
+ fix: 'Run "codeharbor init" to create baseline config.'
4814
+ });
4815
+ }
4816
+ for (const key of REQUIRED_ENV_KEYS) {
4817
+ if (!readEnv(env, key)) {
4818
+ issues.push({
4819
+ level: "error",
4820
+ code: "missing_env",
4821
+ check: key,
4822
+ message: `${key} is required.`,
4823
+ fix: `Run "codeharbor init" or set ${key} in .env.`
4824
+ });
4825
+ }
4826
+ }
4827
+ const matrixHomeserver = readEnv(env, "MATRIX_HOMESERVER");
4828
+ if (matrixHomeserver) {
4829
+ try {
4830
+ new URL(matrixHomeserver);
4831
+ } catch {
4832
+ issues.push({
4833
+ level: "error",
4834
+ code: "invalid_matrix_homeserver",
4835
+ check: "MATRIX_HOMESERVER",
4836
+ message: `Invalid URL: "${matrixHomeserver}".`,
4837
+ fix: "Set MATRIX_HOMESERVER to a full URL, for example https://matrix.example.com."
4838
+ });
4839
+ }
4840
+ }
4841
+ const matrixUserId = readEnv(env, "MATRIX_USER_ID");
4842
+ if (matrixUserId && !/^@[^:\s]+:.+/.test(matrixUserId)) {
4843
+ issues.push({
4844
+ level: "error",
4845
+ code: "invalid_matrix_user_id",
4846
+ check: "MATRIX_USER_ID",
4847
+ message: `Unexpected Matrix user id format: "${matrixUserId}".`,
4848
+ fix: "Set MATRIX_USER_ID like @bot:example.com."
4849
+ });
4850
+ }
4851
+ const codexBin = readEnv(env, "CODEX_BIN") || "codex";
4852
+ try {
4853
+ await checkCodexBinary(codexBin);
4854
+ } catch (error) {
4855
+ const reason = error instanceof Error && error.message ? ` (${error.message})` : "";
4856
+ issues.push({
4857
+ level: "error",
4858
+ code: "missing_codex_bin",
4859
+ check: "CODEX_BIN",
4860
+ message: `Unable to execute "${codexBin}"${reason}.`,
4861
+ fix: `Install Codex CLI and ensure "${codexBin}" is in PATH, or set CODEX_BIN=/absolute/path/to/codex.`
4862
+ });
4863
+ }
4864
+ const configuredWorkdir = readEnv(env, "CODEX_WORKDIR");
4865
+ const workdir = import_node_path9.default.resolve(cwd, configuredWorkdir || cwd);
4866
+ if (!fileExists(workdir) || !isDirectory(workdir)) {
4867
+ issues.push({
4868
+ level: "error",
4869
+ code: "invalid_codex_workdir",
4870
+ check: "CODEX_WORKDIR",
4871
+ message: `Working directory does not exist or is not a directory: ${workdir}.`,
4872
+ fix: `Set CODEX_WORKDIR to an existing directory, for example CODEX_WORKDIR=${cwd}.`
4873
+ });
4874
+ }
4875
+ return {
4876
+ ok: issues.every((issue) => issue.level !== "error"),
4877
+ issues
4878
+ };
4879
+ }
4880
+ function formatPreflightReport(result, commandName) {
4881
+ const lines = [];
4882
+ const errors = result.issues.filter((issue) => issue.level === "error").length;
4883
+ const warnings = result.issues.filter((issue) => issue.level === "warn").length;
4884
+ if (result.ok) {
4885
+ lines.push(`Preflight check passed for "codeharbor ${commandName}" with ${warnings} warning(s).`);
4886
+ } else {
4887
+ lines.push(
4888
+ `Preflight check failed for "codeharbor ${commandName}" with ${errors} error(s) and ${warnings} warning(s).`
4889
+ );
4890
+ }
4891
+ for (const issue of result.issues) {
4892
+ const level = issue.level.toUpperCase();
4893
+ lines.push(`- [${level}] ${issue.check}: ${issue.message}`);
4894
+ lines.push(` fix: ${issue.fix}`);
4895
+ }
4896
+ return `${lines.join("\n")}
4897
+ `;
4898
+ }
4899
+ async function defaultCheckCodexBinary(bin) {
4900
+ await execFileAsync3(bin, ["--version"]);
4901
+ }
4902
+ function defaultIsDirectory(targetPath) {
4903
+ try {
4904
+ return import_node_fs8.default.statSync(targetPath).isDirectory();
4905
+ } catch {
4906
+ return false;
4907
+ }
4908
+ }
4909
+ function readEnv(env, key) {
4910
+ return env[key]?.trim() ?? "";
4911
+ }
4912
+
4913
+ // src/runtime-home.ts
4914
+ var import_node_path10 = __toESM(require("path"));
4915
+ var DEFAULT_RUNTIME_HOME = "/opt/codeharbor";
4916
+ var RUNTIME_HOME_ENV_KEY = "CODEHARBOR_HOME";
4917
+ function resolveRuntimeHome(env = process.env) {
4918
+ const configured = env[RUNTIME_HOME_ENV_KEY]?.trim();
4919
+ if (configured) {
4920
+ return import_node_path10.default.resolve(configured);
4921
+ }
4922
+ return DEFAULT_RUNTIME_HOME;
4923
+ }
2316
4924
 
2317
4925
  // src/cli.ts
4926
+ var runtimeHome = null;
2318
4927
  var program = new import_commander.Command();
2319
4928
  program.name("codeharbor").description("Instant-messaging bridge for Codex CLI sessions").version("0.1.0");
4929
+ program.command("init").description("Create or update .env via guided prompts").option("-f, --force", "overwrite existing .env without confirmation").action(async (options) => {
4930
+ const home = ensureRuntimeHomeOrExit();
4931
+ await runInitCommand({ force: options.force ?? false, cwd: home });
4932
+ });
2320
4933
  program.command("start").description("Start CodeHarbor service").action(async () => {
2321
- const config = loadConfig();
4934
+ const home = ensureRuntimeHomeOrExit();
4935
+ const config = await loadConfigWithPreflight("start", home);
4936
+ if (!config) {
4937
+ process.exitCode = 1;
4938
+ return;
4939
+ }
2322
4940
  const app = new CodeHarborApp(config);
2323
4941
  await app.start();
2324
4942
  const stop = async () => {
@@ -2333,10 +4951,135 @@ program.command("start").description("Start CodeHarbor service").action(async ()
2333
4951
  });
2334
4952
  });
2335
4953
  program.command("doctor").description("Check codex and matrix connectivity").action(async () => {
2336
- const config = loadConfig();
4954
+ const home = ensureRuntimeHomeOrExit();
4955
+ const config = await loadConfigWithPreflight("doctor", home);
4956
+ if (!config) {
4957
+ process.exitCode = 1;
4958
+ return;
4959
+ }
2337
4960
  await runDoctor(config);
2338
4961
  });
4962
+ var admin = program.command("admin").description("Admin utilities");
4963
+ var configCommand = program.command("config").description("Config snapshot utilities");
4964
+ admin.command("serve").description("Start admin config API server").option("--host <host>", "override admin bind host").option("--port <port>", "override admin bind port").option(
4965
+ "--allow-insecure-no-token",
4966
+ "allow serving admin API without ADMIN_TOKEN on non-loopback host (not recommended)"
4967
+ ).action(async (options) => {
4968
+ ensureRuntimeHomeOrExit();
4969
+ const config = loadConfig();
4970
+ const host = options.host?.trim() || config.adminBindHost;
4971
+ const port = options.port ? parsePortOption(options.port, config.adminPort) : config.adminPort;
4972
+ const allowInsecureNoToken = options.allowInsecureNoToken ?? false;
4973
+ if (!config.adminToken && !allowInsecureNoToken && isNonLoopbackHost(host)) {
4974
+ process.stderr.write(
4975
+ [
4976
+ "Refusing to start admin server on non-loopback host without ADMIN_TOKEN.",
4977
+ "Fix: set ADMIN_TOKEN in .env, or explicitly pass --allow-insecure-no-token.",
4978
+ ""
4979
+ ].join("\n")
4980
+ );
4981
+ process.exitCode = 1;
4982
+ return;
4983
+ }
4984
+ const app = new CodeHarborAdminApp(config, { host, port });
4985
+ await app.start();
4986
+ const stop = async () => {
4987
+ await app.stop();
4988
+ process.exit(0);
4989
+ };
4990
+ process.once("SIGINT", () => {
4991
+ void stop();
4992
+ });
4993
+ process.once("SIGTERM", () => {
4994
+ void stop();
4995
+ });
4996
+ });
4997
+ configCommand.command("export").description("Export config snapshot as JSON").option("-o, --output <path>", "write snapshot to file instead of stdout").action(async (options) => {
4998
+ try {
4999
+ const home = ensureRuntimeHomeOrExit();
5000
+ await runConfigExportCommand({ outputPath: options.output, cwd: home });
5001
+ } catch (error) {
5002
+ process.stderr.write(`Config export failed: ${formatError3(error)}
5003
+ `);
5004
+ process.exitCode = 1;
5005
+ }
5006
+ });
5007
+ configCommand.command("import").description("Import config snapshot from JSON").argument("<file>", "snapshot file path").option("--dry-run", "validate snapshot without writing changes").action(async (file, options) => {
5008
+ try {
5009
+ const home = ensureRuntimeHomeOrExit();
5010
+ await runConfigImportCommand({
5011
+ filePath: file,
5012
+ dryRun: options.dryRun ?? false,
5013
+ cwd: home
5014
+ });
5015
+ } catch (error) {
5016
+ process.stderr.write(`Config import failed: ${formatError3(error)}
5017
+ `);
5018
+ process.exitCode = 1;
5019
+ }
5020
+ });
2339
5021
  if (process.argv.length <= 2) {
2340
5022
  process.argv.push("start");
2341
5023
  }
2342
5024
  void program.parseAsync(process.argv);
5025
+ async function loadConfigWithPreflight(commandName, runtimeHomePath) {
5026
+ const preflight = await runStartupPreflight({ cwd: runtimeHomePath });
5027
+ if (preflight.issues.length > 0) {
5028
+ const report = formatPreflightReport(preflight, commandName);
5029
+ if (preflight.ok) {
5030
+ process.stdout.write(report);
5031
+ } else {
5032
+ process.stderr.write(report);
5033
+ return null;
5034
+ }
5035
+ }
5036
+ try {
5037
+ return loadConfig();
5038
+ } catch (error) {
5039
+ const message = error instanceof Error ? error.message : String(error);
5040
+ process.stderr.write(`Configuration error: ${message}
5041
+ `);
5042
+ process.stderr.write('Fix: run "codeharbor init" and then retry.\n');
5043
+ return null;
5044
+ }
5045
+ }
5046
+ function ensureRuntimeHomeOrExit() {
5047
+ if (runtimeHome) {
5048
+ return runtimeHome;
5049
+ }
5050
+ const home = resolveRuntimeHome();
5051
+ try {
5052
+ import_node_fs9.default.mkdirSync(home, { recursive: true });
5053
+ } catch (error) {
5054
+ const message = error instanceof Error ? error.message : String(error);
5055
+ process.stderr.write(`Runtime setup failed: cannot create ${home}. ${message}
5056
+ `);
5057
+ process.exit(1);
5058
+ }
5059
+ try {
5060
+ process.chdir(home);
5061
+ } catch (error) {
5062
+ const message = error instanceof Error ? error.message : String(error);
5063
+ process.stderr.write(`Runtime setup failed: cannot switch to ${home}. ${message}
5064
+ `);
5065
+ process.exit(1);
5066
+ }
5067
+ loadEnvFromFile(import_node_path11.default.resolve(home, ".env"));
5068
+ runtimeHome = home;
5069
+ return runtimeHome;
5070
+ }
5071
+ function parsePortOption(raw, fallback) {
5072
+ const parsed = Number.parseInt(raw, 10);
5073
+ if (!Number.isFinite(parsed) || parsed < 1 || parsed > 65535) {
5074
+ process.stderr.write(`Invalid --port value: ${raw}; fallback to ${fallback}
5075
+ `);
5076
+ return fallback;
5077
+ }
5078
+ return parsed;
5079
+ }
5080
+ function formatError3(error) {
5081
+ if (error instanceof Error) {
5082
+ return error.message;
5083
+ }
5084
+ return String(error);
5085
+ }