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