codeharbor 0.1.14 → 0.1.16

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/.env.example CHANGED
@@ -38,6 +38,7 @@ MATRIX_TYPING_TIMEOUT_MS=10000
38
38
  SESSION_ACTIVE_WINDOW_MINUTES=20
39
39
 
40
40
  # Group trigger defaults.
41
+ GROUP_DIRECT_MODE_ENABLED=false
41
42
  GROUP_TRIGGER_ALLOW_MENTION=true
42
43
  GROUP_TRIGGER_ALLOW_REPLY=true
43
44
  GROUP_TRIGGER_ALLOW_ACTIVE_WINDOW=true
@@ -74,6 +75,13 @@ ADMIN_PORT=8787
74
75
  # Strongly recommended for any non-localhost access.
75
76
  # Required when exposing admin via reverse proxy/tunnel/public domain.
76
77
  ADMIN_TOKEN=
78
+ # Optional multi-token RBAC (JSON array).
79
+ # Each item: {"token":"...","role":"admin|viewer","actor":"ops-name"}
80
+ # Example:
81
+ # ADMIN_TOKENS_JSON=[{"token":"admin-secret","role":"admin","actor":"ops-admin"},{"token":"viewer-secret","role":"viewer","actor":"ops-audit"}]
82
+ # Rotate helper:
83
+ # ./scripts/rotate-admin-token.sh --target rbac --role admin --actor ops-admin
84
+ ADMIN_TOKENS_JSON=
77
85
  # Optional IP allowlist (comma-separated, for example: 127.0.0.1,192.168.1.10).
78
86
  ADMIN_IP_ALLOWLIST=
79
87
  # Optional browser origin allowlist for CORS (comma-separated).
package/README.md CHANGED
@@ -311,7 +311,7 @@ Optional overrides:
311
311
  codeharbor admin serve --host 127.0.0.1 --port 8787
312
312
  ```
313
313
 
314
- If you bind Admin to a non-loopback host and `ADMIN_TOKEN` is empty, startup is rejected by default.
314
+ If you bind Admin to a non-loopback host and both `ADMIN_TOKEN` and `ADMIN_TOKENS_JSON` are empty, startup is rejected by default.
315
315
  Explicit bypass exists but is not recommended:
316
316
 
317
317
  ```bash
@@ -327,6 +327,7 @@ Open these UI routes in browser:
327
327
 
328
328
  Main endpoints:
329
329
 
330
+ - `GET /api/admin/auth/status`
330
331
  - `GET /api/admin/config/global`
331
332
  - `PUT /api/admin/config/global`
332
333
  - `GET /api/admin/config/rooms`
@@ -336,7 +337,7 @@ Main endpoints:
336
337
  - `GET /api/admin/health`
337
338
  - `GET /api/admin/audit?limit=50`
338
339
 
339
- When `ADMIN_TOKEN` is set, requests must include:
340
+ When `ADMIN_TOKEN` or `ADMIN_TOKENS_JSON` is set, requests must include:
340
341
 
341
342
  ```http
342
343
  Authorization: Bearer <ADMIN_TOKEN>
@@ -345,9 +346,24 @@ Authorization: Bearer <ADMIN_TOKEN>
345
346
  Access control options:
346
347
 
347
348
  - `ADMIN_TOKEN`: require bearer token for `/api/admin/*`
349
+ - `ADMIN_TOKENS_JSON`: optional multi-token RBAC list (supports `admin` and `viewer` roles)
348
350
  - `ADMIN_IP_ALLOWLIST`: optional comma-separated client IP whitelist (for example `127.0.0.1,192.168.1.10`)
349
351
  - `ADMIN_ALLOWED_ORIGINS`: optional CORS origin allowlist for browser-based cross-origin admin access
350
352
 
353
+ RBAC behavior:
354
+
355
+ - `viewer` tokens can call read endpoints (`GET /api/admin/*`)
356
+ - `admin` tokens can call read + write endpoints (`PUT/POST/DELETE /api/admin/*`)
357
+ - for `ADMIN_TOKENS_JSON`, audit actor is derived from token identity (`actor` field), not `x-admin-actor`
358
+ - Admin UI shows current permission status (role/source) after saving auth
359
+
360
+ Rotate tokens quickly (repository script):
361
+
362
+ ```bash
363
+ ./scripts/rotate-admin-token.sh --target rbac --role admin --actor ops-admin
364
+ ./scripts/rotate-admin-token.sh --target rbac --role viewer --actor ops-audit
365
+ ```
366
+
351
367
  Note: `PUT /api/admin/config/global` writes to `.env` and marks changes as restart-required.
352
368
 
353
369
  ### Admin UI Quick Walkthrough
@@ -386,12 +402,14 @@ If any check fails, it prints actionable fix commands (for example `codeharbor i
386
402
  - Direct Message (DM)
387
403
  - all text messages are processed by default (no prefix required)
388
404
  - Group Room
405
+ - when `GROUP_DIRECT_MODE_ENABLED=true`, all non-empty messages are processed directly (no prefix/mention/reply required)
389
406
  - processed when **any allowed trigger** matches:
390
407
  - message mentions bot user id
391
408
  - message replies to a bot message
392
409
  - sender has an active conversation window
393
410
  - optional explicit prefix match (`MATRIX_COMMAND_PREFIX`)
394
411
  - Trigger Policy
412
+ - `GROUP_DIRECT_MODE_ENABLED` controls whether groups bypass trigger matching entirely
395
413
  - global defaults via `GROUP_TRIGGER_ALLOW_*`
396
414
  - per-room overrides via `ROOM_TRIGGER_POLICY_JSON`
397
415
  - Active Conversation Window
package/dist/cli.js CHANGED
@@ -646,6 +646,7 @@ var AdminServer = class {
646
646
  host;
647
647
  port;
648
648
  adminToken;
649
+ adminTokens;
649
650
  adminIpAllowlist;
650
651
  adminAllowedOrigins;
651
652
  cwd;
@@ -662,6 +663,7 @@ var AdminServer = class {
662
663
  this.host = options.host;
663
664
  this.port = options.port;
664
665
  this.adminToken = options.adminToken;
666
+ this.adminTokens = buildAdminTokenMap(options.adminTokens ?? []);
665
667
  this.adminIpAllowlist = normalizeAllowlist(options.adminIpAllowlist ?? []);
666
668
  this.adminAllowedOrigins = normalizeOriginAllowlist(options.adminAllowedOrigins ?? []);
667
669
  this.cwd = options.cwd ?? process.cwd();
@@ -753,10 +755,32 @@ var AdminServer = class {
753
755
  this.sendHtml(res, renderAdminConsoleHtml());
754
756
  return;
755
757
  }
756
- if (url.pathname.startsWith("/api/admin/") && !this.isAuthorized(req)) {
758
+ const requiredRole = requiredAdminRoleForRequest(req.method, url.pathname);
759
+ const authIdentity = requiredRole ? this.resolveAdminIdentity(req) : null;
760
+ if (requiredRole && !authIdentity) {
757
761
  this.sendJson(res, 401, {
758
762
  ok: false,
759
- error: "Unauthorized. Provide Authorization: Bearer <ADMIN_TOKEN>."
763
+ error: "Unauthorized. Provide Authorization: Bearer <ADMIN_TOKEN> (or token from ADMIN_TOKENS_JSON)."
764
+ });
765
+ return;
766
+ }
767
+ if (requiredRole && authIdentity && !hasRequiredAdminRole(authIdentity.role, requiredRole)) {
768
+ this.sendJson(res, 403, {
769
+ ok: false,
770
+ error: "Forbidden. This endpoint requires admin write permission."
771
+ });
772
+ return;
773
+ }
774
+ if (req.method === "GET" && url.pathname === "/api/admin/auth/status") {
775
+ this.sendJson(res, 200, {
776
+ ok: true,
777
+ data: {
778
+ authenticated: Boolean(authIdentity),
779
+ role: authIdentity?.role ?? null,
780
+ source: authIdentity?.source ?? "none",
781
+ actor: resolveIdentityActor(authIdentity),
782
+ canWrite: authIdentity ? hasRequiredAdminRole(authIdentity.role, "admin") : false
783
+ }
760
784
  });
761
785
  return;
762
786
  }
@@ -770,7 +794,7 @@ var AdminServer = class {
770
794
  }
771
795
  if (req.method === "PUT" && url.pathname === "/api/admin/config/global") {
772
796
  const body = await readJsonBody(req);
773
- const actor = readActor(req);
797
+ const actor = resolveAuditActor(req, authIdentity);
774
798
  const result = this.updateGlobalConfig(body, actor);
775
799
  this.sendJson(res, 200, {
776
800
  ok: true,
@@ -798,13 +822,13 @@ var AdminServer = class {
798
822
  }
799
823
  if (req.method === "PUT") {
800
824
  const body = await readJsonBody(req);
801
- const actor = readActor(req);
825
+ const actor = resolveAuditActor(req, authIdentity);
802
826
  const room = this.updateRoomConfig(roomId, body, actor);
803
827
  this.sendJson(res, 200, { ok: true, data: room });
804
828
  return;
805
829
  }
806
830
  if (req.method === "DELETE") {
807
- const actor = readActor(req);
831
+ const actor = resolveAuditActor(req, authIdentity);
808
832
  this.configService.deleteRoomSettings(roomId, actor);
809
833
  this.sendJson(res, 200, { ok: true, roomId });
810
834
  return;
@@ -834,7 +858,7 @@ var AdminServer = class {
834
858
  if (req.method === "POST" && url.pathname === "/api/admin/service/restart") {
835
859
  const body = asObject(await readJsonBody(req), "service restart payload");
836
860
  const restartAdmin = normalizeBoolean(body.withAdmin, false);
837
- const actor = readActor(req);
861
+ const actor = resolveAuditActor(req, authIdentity);
838
862
  try {
839
863
  const result = await this.restartServices(restartAdmin);
840
864
  this.stateStore.appendConfigRevision(
@@ -999,6 +1023,12 @@ var AdminServer = class {
999
1023
  envUpdates.SESSION_ACTIVE_WINDOW_MINUTES = String(value);
1000
1024
  updatedKeys.push("sessionActiveWindowMinutes");
1001
1025
  }
1026
+ if ("groupDirectModeEnabled" in body) {
1027
+ const value = normalizeBoolean(body.groupDirectModeEnabled, this.config.groupDirectModeEnabled);
1028
+ this.config.groupDirectModeEnabled = value;
1029
+ envUpdates.GROUP_DIRECT_MODE_ENABLED = String(value);
1030
+ updatedKeys.push("groupDirectModeEnabled");
1031
+ }
1002
1032
  if ("cliCompat" in body) {
1003
1033
  const compat = asObject(body.cliCompat, "cliCompat");
1004
1034
  if ("enabled" in compat) {
@@ -1092,14 +1122,34 @@ var AdminServer = class {
1092
1122
  summary: normalizeOptionalString(body.summary)
1093
1123
  });
1094
1124
  }
1095
- isAuthorized(req) {
1096
- if (!this.adminToken) {
1097
- return true;
1125
+ resolveAdminIdentity(req) {
1126
+ if (!this.adminToken && this.adminTokens.size === 0) {
1127
+ return {
1128
+ role: "admin",
1129
+ actor: null,
1130
+ source: "open"
1131
+ };
1098
1132
  }
1099
- const authorization = req.headers.authorization ?? "";
1100
- const bearer = authorization.startsWith("Bearer ") ? authorization.slice("Bearer ".length).trim() : "";
1101
- const fromHeader = normalizeHeaderValue(req.headers["x-admin-token"]);
1102
- return bearer === this.adminToken || fromHeader === this.adminToken;
1133
+ const token = readAdminToken(req);
1134
+ if (!token) {
1135
+ return null;
1136
+ }
1137
+ if (this.adminToken && token === this.adminToken) {
1138
+ return {
1139
+ role: "admin",
1140
+ actor: null,
1141
+ source: "legacy"
1142
+ };
1143
+ }
1144
+ const mappedIdentity = this.adminTokens.get(token);
1145
+ if (!mappedIdentity) {
1146
+ return null;
1147
+ }
1148
+ return {
1149
+ role: mappedIdentity.role,
1150
+ actor: mappedIdentity.actor,
1151
+ source: "scoped"
1152
+ };
1103
1153
  }
1104
1154
  isClientAllowed(req) {
1105
1155
  if (this.adminIpAllowlist.length === 0) {
@@ -1174,6 +1224,7 @@ function buildGlobalConfigSnapshot(config) {
1174
1224
  matrixCommandPrefix: config.matrixCommandPrefix,
1175
1225
  codexWorkdir: config.codexWorkdir,
1176
1226
  rateLimiter: { ...config.rateLimiter },
1227
+ groupDirectModeEnabled: config.groupDirectModeEnabled,
1177
1228
  defaultGroupTriggerPolicy: { ...config.defaultGroupTriggerPolicy },
1178
1229
  matrixProgressUpdates: config.matrixProgressUpdates,
1179
1230
  matrixProgressMinIntervalMs: config.matrixProgressMinIntervalMs,
@@ -1391,10 +1442,61 @@ function normalizeHeaderValue(value) {
1391
1442
  }
1392
1443
  return value.trim();
1393
1444
  }
1394
- function readActor(req) {
1445
+ function readAdminToken(req) {
1446
+ const authorization = normalizeHeaderValue(req.headers.authorization);
1447
+ if (authorization) {
1448
+ const match = /^bearer\s+(.+)$/i.exec(authorization);
1449
+ const token = match?.[1]?.trim() ?? "";
1450
+ if (token) {
1451
+ return token;
1452
+ }
1453
+ }
1454
+ const fromHeader = normalizeHeaderValue(req.headers["x-admin-token"]);
1455
+ return fromHeader || null;
1456
+ }
1457
+ function resolveIdentityActor(identity) {
1458
+ if (!identity || identity.source !== "scoped") {
1459
+ return null;
1460
+ }
1461
+ if (identity.actor) {
1462
+ return identity.actor;
1463
+ }
1464
+ return identity.role === "admin" ? "admin-token" : "viewer-token";
1465
+ }
1466
+ function resolveAuditActor(req, identity) {
1467
+ const scopedActor = resolveIdentityActor(identity);
1468
+ if (scopedActor) {
1469
+ return scopedActor;
1470
+ }
1395
1471
  const actor = normalizeHeaderValue(req.headers["x-admin-actor"]);
1396
1472
  return actor || null;
1397
1473
  }
1474
+ function requiredAdminRoleForRequest(method, pathname) {
1475
+ if (!pathname.startsWith("/api/admin/")) {
1476
+ return null;
1477
+ }
1478
+ const normalizedMethod = (method ?? "GET").toUpperCase();
1479
+ if (normalizedMethod === "GET" || normalizedMethod === "HEAD") {
1480
+ return "viewer";
1481
+ }
1482
+ return "admin";
1483
+ }
1484
+ function hasRequiredAdminRole(role, requiredRole) {
1485
+ if (requiredRole === "viewer") {
1486
+ return role === "viewer" || role === "admin";
1487
+ }
1488
+ return role === "admin";
1489
+ }
1490
+ function buildAdminTokenMap(tokens) {
1491
+ const mapped = /* @__PURE__ */ new Map();
1492
+ for (const token of tokens) {
1493
+ mapped.set(token.token, {
1494
+ role: token.role,
1495
+ actor: token.actor
1496
+ });
1497
+ }
1498
+ return mapped;
1499
+ }
1398
1500
  function formatError(error) {
1399
1501
  if (error instanceof Error) {
1400
1502
  return error.message;
@@ -1642,6 +1744,7 @@ var ADMIN_CONSOLE_HTML = `<!doctype html>
1642
1744
  <button id="auth-clear-btn" type="button" class="secondary">Clear Auth</button>
1643
1745
  </div>
1644
1746
  <div id="notice" class="notice">Ready.</div>
1747
+ <p id="auth-role" class="muted">Permission: unknown</p>
1645
1748
  </section>
1646
1749
 
1647
1750
  <section class="panel" data-view="settings-global">
@@ -1697,6 +1800,7 @@ var ADMIN_CONSOLE_HTML = `<!doctype html>
1697
1800
  <input id="global-concurrency-room" type="number" min="0" />
1698
1801
  </label>
1699
1802
 
1803
+ <label class="checkbox"><input id="global-direct-mode" type="checkbox" /><span>Group direct mode (no trigger required)</span></label>
1700
1804
  <label class="checkbox"><input id="global-trigger-mention" type="checkbox" /><span>Trigger: mention</span></label>
1701
1805
  <label class="checkbox"><input id="global-trigger-reply" type="checkbox" /><span>Trigger: reply</span></label>
1702
1806
  <label class="checkbox"><input id="global-trigger-window" type="checkbox" /><span>Trigger: active window</span></label>
@@ -1841,6 +1945,7 @@ var ADMIN_CONSOLE_HTML = `<!doctype html>
1841
1945
  var tokenInput = document.getElementById("auth-token");
1842
1946
  var actorInput = document.getElementById("auth-actor");
1843
1947
  var noticeNode = document.getElementById("notice");
1948
+ var authRoleNode = document.getElementById("auth-role");
1844
1949
  var roomListBody = document.getElementById("room-list-body");
1845
1950
  var healthBody = document.getElementById("health-body");
1846
1951
  var auditBody = document.getElementById("audit-body");
@@ -1852,6 +1957,7 @@ var ADMIN_CONSOLE_HTML = `<!doctype html>
1852
1957
  localStorage.setItem(storageTokenKey, tokenInput.value.trim());
1853
1958
  localStorage.setItem(storageActorKey, actorInput.value.trim());
1854
1959
  showNotice("ok", "Auth settings saved to localStorage.");
1960
+ void refreshAuthStatus();
1855
1961
  });
1856
1962
 
1857
1963
  document.getElementById("auth-clear-btn").addEventListener("click", function () {
@@ -1860,6 +1966,7 @@ var ADMIN_CONSOLE_HTML = `<!doctype html>
1860
1966
  localStorage.removeItem(storageTokenKey);
1861
1967
  localStorage.removeItem(storageActorKey);
1862
1968
  showNotice("warn", "Auth settings cleared.");
1969
+ void refreshAuthStatus();
1863
1970
  });
1864
1971
 
1865
1972
  document.getElementById("global-save-btn").addEventListener("click", saveGlobal);
@@ -1884,6 +1991,7 @@ var ADMIN_CONSOLE_HTML = `<!doctype html>
1884
1991
  } else {
1885
1992
  handleRoute();
1886
1993
  }
1994
+ void refreshAuthStatus();
1887
1995
 
1888
1996
  function getCurrentView() {
1889
1997
  return routeToView[window.location.hash] || "settings-global";
@@ -1974,6 +2082,29 @@ var ADMIN_CONSOLE_HTML = `<!doctype html>
1974
2082
  noticeNode.textContent = message;
1975
2083
  }
1976
2084
 
2085
+ async function refreshAuthStatus() {
2086
+ try {
2087
+ var response = await apiRequest("/api/admin/auth/status", "GET");
2088
+ var data = response.data || {};
2089
+ if (!data.role) {
2090
+ authRoleNode.textContent = "Permission: unauthenticated";
2091
+ return;
2092
+ }
2093
+
2094
+ var role = String(data.role).toUpperCase();
2095
+ var source = data.source ? " (" + String(data.source) + ")" : "";
2096
+ var actor = data.actor ? " as " + String(data.actor) : "";
2097
+ authRoleNode.textContent = "Permission: " + role + source + actor;
2098
+ } catch (error) {
2099
+ var message = error && error.message ? String(error.message) : "";
2100
+ if (/Unauthorized/i.test(message)) {
2101
+ authRoleNode.textContent = "Permission: unauthenticated";
2102
+ return;
2103
+ }
2104
+ authRoleNode.textContent = "Permission: unknown";
2105
+ }
2106
+ }
2107
+
1977
2108
  function renderEmptyRow(body, columns, text) {
1978
2109
  body.innerHTML = "";
1979
2110
  var row = document.createElement("tr");
@@ -2005,6 +2136,7 @@ var ADMIN_CONSOLE_HTML = `<!doctype html>
2005
2136
  document.getElementById("global-concurrency-global").value = String(rateLimiter.maxConcurrentGlobal || 0);
2006
2137
  document.getElementById("global-concurrency-user").value = String(rateLimiter.maxConcurrentPerUser || 0);
2007
2138
  document.getElementById("global-concurrency-room").value = String(rateLimiter.maxConcurrentPerRoom || 0);
2139
+ document.getElementById("global-direct-mode").checked = Boolean(data.groupDirectModeEnabled);
2008
2140
 
2009
2141
  document.getElementById("global-trigger-mention").checked = Boolean(trigger.allowMention);
2010
2142
  document.getElementById("global-trigger-reply").checked = Boolean(trigger.allowReply);
@@ -2037,6 +2169,7 @@ var ADMIN_CONSOLE_HTML = `<!doctype html>
2037
2169
  matrixProgressMinIntervalMs: asNumber("global-progress-interval", 2500),
2038
2170
  matrixTypingTimeoutMs: asNumber("global-typing-timeout", 10000),
2039
2171
  sessionActiveWindowMinutes: asNumber("global-active-window", 20),
2172
+ groupDirectModeEnabled: asBool("global-direct-mode"),
2040
2173
  rateLimiter: {
2041
2174
  windowMs: asNumber("global-rate-window", 60000),
2042
2175
  maxRequestsPerUser: asNumber("global-rate-user", 20),
@@ -2460,6 +2593,7 @@ function findBreakIndex(candidate) {
2460
2593
  }
2461
2594
 
2462
2595
  // src/channels/matrix-channel.ts
2596
+ var LOCAL_TXN_PREFIX = "codeharbor-";
2463
2597
  var MatrixChannel = class {
2464
2598
  config;
2465
2599
  logger;
@@ -2500,7 +2634,7 @@ var MatrixChannel = class {
2500
2634
  }
2501
2635
  const chunks = this.splitReplies ? splitText(text, this.chunkSize) : [text];
2502
2636
  for (const chunk of chunks) {
2503
- await this.client.sendTextMessage(conversationId, chunk);
2637
+ await this.sendRichText(conversationId, chunk, "m.text");
2504
2638
  }
2505
2639
  }
2506
2640
  async sendNotice(conversationId, text) {
@@ -2509,7 +2643,7 @@ var MatrixChannel = class {
2509
2643
  }
2510
2644
  const chunks = this.splitReplies ? splitText(text, this.chunkSize) : [text];
2511
2645
  for (const chunk of chunks) {
2512
- await this.client.sendNotice(conversationId, chunk);
2646
+ await this.sendRichText(conversationId, chunk, "m.notice");
2513
2647
  }
2514
2648
  }
2515
2649
  async upsertProgressNotice(conversationId, text, replaceEventId) {
@@ -2521,7 +2655,10 @@ var MatrixChannel = class {
2521
2655
  throw new Error("Progress notice cannot be empty.");
2522
2656
  }
2523
2657
  if (!replaceEventId) {
2524
- const response2 = await this.client.sendNotice(conversationId, normalized);
2658
+ const response2 = await this.sendRawEvent(
2659
+ conversationId,
2660
+ buildMatrixRichMessageContent(normalized, "m.notice")
2661
+ );
2525
2662
  return response2.event_id;
2526
2663
  }
2527
2664
  const content = {
@@ -2536,8 +2673,7 @@ var MatrixChannel = class {
2536
2673
  event_id: replaceEventId
2537
2674
  }
2538
2675
  };
2539
- const sendEditEvent = this.client.sendEvent;
2540
- const response = await sendEditEvent(conversationId, import_matrix_js_sdk.EventType.RoomMessage, content);
2676
+ const response = await this.sendRawEvent(conversationId, content);
2541
2677
  return response.event_id;
2542
2678
  }
2543
2679
  async setTyping(conversationId, isTyping, timeoutMs) {
@@ -2573,7 +2709,10 @@ var MatrixChannel = class {
2573
2709
  return;
2574
2710
  }
2575
2711
  const senderId = event.getSender();
2576
- if (!senderId || senderId === this.config.matrixUserId) {
2712
+ if (!senderId) {
2713
+ return;
2714
+ }
2715
+ if (senderId === this.config.matrixUserId && isLikelyLocalEcho(event)) {
2577
2716
  return;
2578
2717
  }
2579
2718
  const content = event.getContent();
@@ -2677,6 +2816,32 @@ var MatrixChannel = class {
2677
2816
  await this.joinInvitedRoom(room.roomId);
2678
2817
  }
2679
2818
  }
2819
+ async sendRichText(conversationId, text, msgtype) {
2820
+ const payload = buildMatrixRichMessageContent(text, msgtype);
2821
+ await this.sendRawEvent(conversationId, payload);
2822
+ }
2823
+ async sendRawEvent(conversationId, content) {
2824
+ const txnId = `${LOCAL_TXN_PREFIX}${Date.now()}-${Math.floor(Math.random() * 1e6)}`;
2825
+ const response = await fetch(
2826
+ `${this.config.matrixHomeserver}/_matrix/client/v3/rooms/${encodeURIComponent(conversationId)}/send/m.room.message/${encodeURIComponent(txnId)}`,
2827
+ {
2828
+ method: "PUT",
2829
+ headers: {
2830
+ Authorization: `Bearer ${this.config.matrixAccessToken}`,
2831
+ "Content-Type": "application/json"
2832
+ },
2833
+ body: JSON.stringify(content)
2834
+ }
2835
+ );
2836
+ if (!response.ok) {
2837
+ throw new Error(`Matrix send failed (${response.status} ${response.statusText})`);
2838
+ }
2839
+ const payload = await response.json();
2840
+ if (!payload.event_id || typeof payload.event_id !== "string") {
2841
+ throw new Error("Matrix send failed (missing event_id)");
2842
+ }
2843
+ return { event_id: payload.event_id };
2844
+ }
2680
2845
  async hydrateAttachments(attachments, eventId) {
2681
2846
  if (!this.fetchMedia || attachments.length === 0) {
2682
2847
  return attachments;
@@ -2747,6 +2912,17 @@ function buildRequestId(eventId) {
2747
2912
  const suffix = Math.random().toString(36).slice(2, 8);
2748
2913
  return `${eventId}:${suffix}`;
2749
2914
  }
2915
+ function isLikelyLocalEcho(event) {
2916
+ const unsigned = event.getUnsigned();
2917
+ if (!unsigned || typeof unsigned !== "object") {
2918
+ return false;
2919
+ }
2920
+ const transactionId = unsigned.transaction_id;
2921
+ if (typeof transactionId !== "string" || !transactionId) {
2922
+ return false;
2923
+ }
2924
+ return transactionId.startsWith(LOCAL_TXN_PREFIX);
2925
+ }
2750
2926
  function isDirectRoom(room) {
2751
2927
  return room.getJoinedMemberCount() <= 2;
2752
2928
  }
@@ -2838,6 +3014,61 @@ function resolveFileExtension(fileName, mimeType) {
2838
3014
  }
2839
3015
  return ".bin";
2840
3016
  }
3017
+ function buildMatrixRichMessageContent(body, msgtype) {
3018
+ return {
3019
+ msgtype,
3020
+ body,
3021
+ format: "org.matrix.custom.html",
3022
+ formatted_body: renderMatrixHtml(body, msgtype)
3023
+ };
3024
+ }
3025
+ function renderMatrixHtml(body, msgtype) {
3026
+ const normalized = body.replace(/\r\n/g, "\n");
3027
+ const sections = [];
3028
+ const codeFencePattern = /```([^\n`]*)\n?([\s\S]*?)```/g;
3029
+ let cursor = 0;
3030
+ let match;
3031
+ while ((match = codeFencePattern.exec(normalized)) !== null) {
3032
+ const before = normalized.slice(cursor, match.index);
3033
+ const renderedBefore = renderTextSection(before);
3034
+ if (renderedBefore) {
3035
+ sections.push(renderedBefore);
3036
+ }
3037
+ const language = escapeHtml(match[1]?.trim() || "text");
3038
+ const code = escapeHtml(match[2].replace(/\n$/, ""));
3039
+ const label = language && language !== "text" ? `\u4EE3\u7801 (${language})` : "\u4EE3\u7801";
3040
+ sections.push(
3041
+ `<p><font color="#3558d1"><b>${label}</b></font></p><pre><code>${code}</code></pre>`
3042
+ );
3043
+ cursor = match.index + match[0].length;
3044
+ }
3045
+ const tail = normalized.slice(cursor);
3046
+ const renderedTail = renderTextSection(tail);
3047
+ if (renderedTail) {
3048
+ sections.push(renderedTail);
3049
+ }
3050
+ if (sections.length === 0) {
3051
+ sections.push("<p>(\u7A7A\u6D88\u606F)</p>");
3052
+ }
3053
+ const badge = msgtype === "m.notice" ? `<p><font color="#8a5a00"><b>\u{1F4E3} CodeHarbor \u63D0\u793A</b></font></p>` : `<p><font color="#1f7a5a"><b>\u{1F916} AI \u56DE\u590D</b></font></p>`;
3054
+ return `<div>${badge}${sections.join("")}</div>`;
3055
+ }
3056
+ function renderTextSection(raw) {
3057
+ if (!raw.trim()) {
3058
+ return "";
3059
+ }
3060
+ const normalized = raw.replace(/\r\n/g, "\n").trim();
3061
+ const paragraphs = normalized.split(/\n{2,}/);
3062
+ const rendered = paragraphs.map((paragraph) => {
3063
+ const escaped = escapeHtml(paragraph);
3064
+ const inlineCode = escaped.replace(/`([^`\n]+)`/g, "<code>$1</code>");
3065
+ return `<p>${inlineCode.replace(/\n/g, "<br/>")}</p>`;
3066
+ }).join("");
3067
+ return rendered;
3068
+ }
3069
+ function escapeHtml(value) {
3070
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
3071
+ }
2841
3072
 
2842
3073
  // src/config-service.ts
2843
3074
  var import_node_fs5 = __toESM(require("fs"));
@@ -4040,6 +4271,7 @@ var Orchestrator = class {
4040
4271
  commandPrefix;
4041
4272
  matrixUserId;
4042
4273
  sessionActiveWindowMs;
4274
+ groupDirectModeEnabled;
4043
4275
  defaultGroupTriggerPolicy;
4044
4276
  roomTriggerPolicies;
4045
4277
  configService;
@@ -4077,6 +4309,7 @@ var Orchestrator = class {
4077
4309
  this.matrixUserId = options?.matrixUserId ?? "";
4078
4310
  const sessionActiveWindowMinutes = options?.sessionActiveWindowMinutes ?? 20;
4079
4311
  this.sessionActiveWindowMs = Math.max(1, sessionActiveWindowMinutes) * 6e4;
4312
+ this.groupDirectModeEnabled = options?.groupDirectModeEnabled ?? false;
4080
4313
  this.defaultGroupTriggerPolicy = options?.defaultGroupTriggerPolicy ?? {
4081
4314
  allowMention: true,
4082
4315
  allowReply: true,
@@ -4411,7 +4644,7 @@ var Orchestrator = class {
4411
4644
  const prefixTriggered = prefixAllowed && this.commandPrefix.length > 0;
4412
4645
  const prefixedText = prefixTriggered ? extractCommandText(incomingTrimmed, this.commandPrefix) : null;
4413
4646
  const activeSession = message.isDirectMessage || groupPolicy?.allowActiveWindow ? this.stateStore.isSessionActive(sessionKey) : false;
4414
- const conversationalTrigger = message.isDirectMessage || Boolean(groupPolicy?.allowMention) && message.mentionsBot || Boolean(groupPolicy?.allowReply) && message.repliesToBot || activeSession;
4647
+ const conversationalTrigger = message.isDirectMessage || this.groupDirectModeEnabled || Boolean(groupPolicy?.allowMention) && message.mentionsBot || Boolean(groupPolicy?.allowReply) && message.repliesToBot || activeSession;
4415
4648
  if (!conversationalTrigger && prefixedText === null) {
4416
4649
  return { kind: "ignore" };
4417
4650
  }
@@ -4449,7 +4682,7 @@ var Orchestrator = class {
4449
4682
  }
4450
4683
  const status = this.stateStore.getSessionStatus(sessionKey);
4451
4684
  const roomConfig = this.resolveRoomRuntimeConfig(message.conversationId);
4452
- const scope = message.isDirectMessage ? "\u79C1\u804A\uFF08\u514D\u524D\u7F00\uFF09" : "\u7FA4\u804A\uFF08\u6309\u623F\u95F4\u89E6\u53D1\u7B56\u7565\uFF09";
4685
+ const scope = message.isDirectMessage ? "\u79C1\u804A\uFF08\u514D\u524D\u7F00\uFF09" : this.groupDirectModeEnabled ? "\u7FA4\u804A\uFF08\u9ED8\u8BA4\u76F4\u901A\uFF09" : "\u7FA4\u804A\uFF08\u6309\u623F\u95F4\u89E6\u53D1\u7B56\u7565\uFF09";
4453
4686
  const activeUntil = status.activeUntil ?? "\u672A\u6FC0\u6D3B";
4454
4687
  const metrics = this.metrics.snapshot(this.runningExecutions.size);
4455
4688
  const limiter = this.rateLimiter.snapshot();
@@ -5525,6 +5758,7 @@ var CodeHarborApp = class {
5525
5758
  commandPrefix: config.matrixCommandPrefix,
5526
5759
  matrixUserId: config.matrixUserId,
5527
5760
  sessionActiveWindowMinutes: config.sessionActiveWindowMinutes,
5761
+ groupDirectModeEnabled: config.groupDirectModeEnabled,
5528
5762
  defaultGroupTriggerPolicy: config.defaultGroupTriggerPolicy,
5529
5763
  roomTriggerPolicies: config.roomTriggerPolicies,
5530
5764
  rateLimiterOptions: config.rateLimiter,
@@ -5573,6 +5807,7 @@ var CodeHarborAdminApp = class {
5573
5807
  host: options?.host ?? config.adminBindHost,
5574
5808
  port: options?.port ?? config.adminPort,
5575
5809
  adminToken: config.adminToken,
5810
+ adminTokens: config.adminTokens,
5576
5811
  adminIpAllowlist: config.adminIpAllowlist,
5577
5812
  adminAllowedOrigins: config.adminAllowedOrigins
5578
5813
  });
@@ -5583,7 +5818,7 @@ var CodeHarborAdminApp = class {
5583
5818
  this.logger.info("CodeHarbor admin server started", {
5584
5819
  host: address?.host ?? this.config.adminBindHost,
5585
5820
  port: address?.port ?? this.config.adminPort,
5586
- tokenProtected: Boolean(this.config.adminToken)
5821
+ tokenProtected: Boolean(this.config.adminToken) || this.config.adminTokens.length > 0
5587
5822
  });
5588
5823
  }
5589
5824
  async stop() {
@@ -5666,6 +5901,7 @@ var configSchema = import_zod.z.object({
5666
5901
  MATRIX_PROGRESS_MIN_INTERVAL_MS: import_zod.z.string().default("2500").transform((v) => Number.parseInt(v, 10)).pipe(import_zod.z.number().int().positive()),
5667
5902
  MATRIX_TYPING_TIMEOUT_MS: import_zod.z.string().default("10000").transform((v) => Number.parseInt(v, 10)).pipe(import_zod.z.number().int().positive()),
5668
5903
  SESSION_ACTIVE_WINDOW_MINUTES: import_zod.z.string().default("20").transform((v) => Number.parseInt(v, 10)).pipe(import_zod.z.number().int().positive()),
5904
+ GROUP_DIRECT_MODE_ENABLED: import_zod.z.string().default("false").transform((v) => v.toLowerCase() === "true"),
5669
5905
  GROUP_TRIGGER_ALLOW_MENTION: import_zod.z.string().default("true").transform((v) => v.toLowerCase() === "true"),
5670
5906
  GROUP_TRIGGER_ALLOW_REPLY: import_zod.z.string().default("true").transform((v) => v.toLowerCase() === "true"),
5671
5907
  GROUP_TRIGGER_ALLOW_ACTIVE_WINDOW: import_zod.z.string().default("true").transform((v) => v.toLowerCase() === "true"),
@@ -5688,6 +5924,7 @@ var configSchema = import_zod.z.object({
5688
5924
  ADMIN_BIND_HOST: import_zod.z.string().default("127.0.0.1"),
5689
5925
  ADMIN_PORT: import_zod.z.string().default("8787").transform((v) => Number.parseInt(v, 10)).pipe(import_zod.z.number().int().min(1).max(65535)),
5690
5926
  ADMIN_TOKEN: import_zod.z.string().default(""),
5927
+ ADMIN_TOKENS_JSON: import_zod.z.string().default(""),
5691
5928
  ADMIN_IP_ALLOWLIST: import_zod.z.string().default(""),
5692
5929
  ADMIN_ALLOWED_ORIGINS: import_zod.z.string().default(""),
5693
5930
  LOG_LEVEL: import_zod.z.enum(["debug", "info", "warn", "error"]).default("info")
@@ -5719,6 +5956,7 @@ var configSchema = import_zod.z.object({
5719
5956
  matrixProgressMinIntervalMs: v.MATRIX_PROGRESS_MIN_INTERVAL_MS,
5720
5957
  matrixTypingTimeoutMs: v.MATRIX_TYPING_TIMEOUT_MS,
5721
5958
  sessionActiveWindowMinutes: v.SESSION_ACTIVE_WINDOW_MINUTES,
5959
+ groupDirectModeEnabled: v.GROUP_DIRECT_MODE_ENABLED,
5722
5960
  defaultGroupTriggerPolicy: {
5723
5961
  allowMention: v.GROUP_TRIGGER_ALLOW_MENTION,
5724
5962
  allowReply: v.GROUP_TRIGGER_ALLOW_REPLY,
@@ -5747,6 +5985,7 @@ var configSchema = import_zod.z.object({
5747
5985
  adminBindHost: v.ADMIN_BIND_HOST.trim() || "127.0.0.1",
5748
5986
  adminPort: v.ADMIN_PORT,
5749
5987
  adminToken: v.ADMIN_TOKEN.trim() || null,
5988
+ adminTokens: parseAdminTokens(v.ADMIN_TOKENS_JSON),
5750
5989
  adminIpAllowlist: parseCsvList(v.ADMIN_IP_ALLOWLIST),
5751
5990
  adminAllowedOrigins: parseCsvList(v.ADMIN_ALLOWED_ORIGINS),
5752
5991
  logLevel: v.LOG_LEVEL
@@ -5837,6 +6076,53 @@ function parseExtraEnv(raw) {
5837
6076
  function parseCsvList(raw) {
5838
6077
  return raw.split(",").map((entry) => entry.trim()).filter((entry) => entry.length > 0);
5839
6078
  }
6079
+ function parseAdminTokens(raw) {
6080
+ const trimmed = raw.trim();
6081
+ if (!trimmed) {
6082
+ return [];
6083
+ }
6084
+ let parsed;
6085
+ try {
6086
+ parsed = JSON.parse(trimmed);
6087
+ } catch {
6088
+ throw new Error("ADMIN_TOKENS_JSON must be valid JSON.");
6089
+ }
6090
+ if (!Array.isArray(parsed)) {
6091
+ throw new Error("ADMIN_TOKENS_JSON must be a JSON array.");
6092
+ }
6093
+ const seenTokens = /* @__PURE__ */ new Set();
6094
+ return parsed.map((entry, index) => {
6095
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
6096
+ throw new Error(`ADMIN_TOKENS_JSON[${index}] must be an object.`);
6097
+ }
6098
+ const payload = entry;
6099
+ const tokenValue = payload.token;
6100
+ if (typeof tokenValue !== "string" || !tokenValue.trim()) {
6101
+ throw new Error(`ADMIN_TOKENS_JSON[${index}].token must be a non-empty string.`);
6102
+ }
6103
+ const token = tokenValue.trim();
6104
+ if (seenTokens.has(token)) {
6105
+ throw new Error(`ADMIN_TOKENS_JSON contains duplicated token at index ${index}.`);
6106
+ }
6107
+ seenTokens.add(token);
6108
+ let role = "admin";
6109
+ if (payload.role !== void 0) {
6110
+ if (payload.role !== "admin" && payload.role !== "viewer") {
6111
+ throw new Error(`ADMIN_TOKENS_JSON[${index}].role must be "admin" or "viewer".`);
6112
+ }
6113
+ role = payload.role;
6114
+ }
6115
+ if (payload.actor !== void 0 && payload.actor !== null && typeof payload.actor !== "string") {
6116
+ throw new Error(`ADMIN_TOKENS_JSON[${index}].actor must be a string when provided.`);
6117
+ }
6118
+ const actor = typeof payload.actor === "string" ? payload.actor.trim() || null : null;
6119
+ return {
6120
+ token,
6121
+ role,
6122
+ actor
6123
+ };
6124
+ });
6125
+ }
5840
6126
 
5841
6127
  // src/config-snapshot.ts
5842
6128
  var import_node_fs9 = __toESM(require("fs"));
@@ -5869,6 +6155,7 @@ var CONFIG_SNAPSHOT_ENV_KEYS = [
5869
6155
  "MATRIX_PROGRESS_MIN_INTERVAL_MS",
5870
6156
  "MATRIX_TYPING_TIMEOUT_MS",
5871
6157
  "SESSION_ACTIVE_WINDOW_MINUTES",
6158
+ "GROUP_DIRECT_MODE_ENABLED",
5872
6159
  "GROUP_TRIGGER_ALLOW_MENTION",
5873
6160
  "GROUP_TRIGGER_ALLOW_REPLY",
5874
6161
  "GROUP_TRIGGER_ALLOW_ACTIVE_WINDOW",
@@ -5891,6 +6178,7 @@ var CONFIG_SNAPSHOT_ENV_KEYS = [
5891
6178
  "ADMIN_BIND_HOST",
5892
6179
  "ADMIN_PORT",
5893
6180
  "ADMIN_TOKEN",
6181
+ "ADMIN_TOKENS_JSON",
5894
6182
  "ADMIN_IP_ALLOWLIST",
5895
6183
  "ADMIN_ALLOWED_ORIGINS",
5896
6184
  "LOG_LEVEL"
@@ -5933,6 +6221,7 @@ var envSnapshotSchema = import_zod2.z.object({
5933
6221
  MATRIX_PROGRESS_MIN_INTERVAL_MS: integerStringSchema("MATRIX_PROGRESS_MIN_INTERVAL_MS", 1),
5934
6222
  MATRIX_TYPING_TIMEOUT_MS: integerStringSchema("MATRIX_TYPING_TIMEOUT_MS", 1),
5935
6223
  SESSION_ACTIVE_WINDOW_MINUTES: integerStringSchema("SESSION_ACTIVE_WINDOW_MINUTES", 1),
6224
+ GROUP_DIRECT_MODE_ENABLED: booleanStringSchema("GROUP_DIRECT_MODE_ENABLED").default("false"),
5936
6225
  GROUP_TRIGGER_ALLOW_MENTION: booleanStringSchema("GROUP_TRIGGER_ALLOW_MENTION"),
5937
6226
  GROUP_TRIGGER_ALLOW_REPLY: booleanStringSchema("GROUP_TRIGGER_ALLOW_REPLY"),
5938
6227
  GROUP_TRIGGER_ALLOW_ACTIVE_WINDOW: booleanStringSchema("GROUP_TRIGGER_ALLOW_ACTIVE_WINDOW"),
@@ -5955,6 +6244,7 @@ var envSnapshotSchema = import_zod2.z.object({
5955
6244
  ADMIN_BIND_HOST: import_zod2.z.string(),
5956
6245
  ADMIN_PORT: integerStringSchema("ADMIN_PORT", 1, 65535),
5957
6246
  ADMIN_TOKEN: import_zod2.z.string(),
6247
+ ADMIN_TOKENS_JSON: jsonArrayStringSchema("ADMIN_TOKENS_JSON", true).default(""),
5958
6248
  ADMIN_IP_ALLOWLIST: import_zod2.z.string(),
5959
6249
  ADMIN_ALLOWED_ORIGINS: import_zod2.z.string().default(""),
5960
6250
  LOG_LEVEL: import_zod2.z.enum(LOG_LEVELS)
@@ -6121,6 +6411,7 @@ function buildSnapshotEnv(config) {
6121
6411
  MATRIX_PROGRESS_MIN_INTERVAL_MS: String(config.matrixProgressMinIntervalMs),
6122
6412
  MATRIX_TYPING_TIMEOUT_MS: String(config.matrixTypingTimeoutMs),
6123
6413
  SESSION_ACTIVE_WINDOW_MINUTES: String(config.sessionActiveWindowMinutes),
6414
+ GROUP_DIRECT_MODE_ENABLED: String(config.groupDirectModeEnabled),
6124
6415
  GROUP_TRIGGER_ALLOW_MENTION: String(config.defaultGroupTriggerPolicy.allowMention),
6125
6416
  GROUP_TRIGGER_ALLOW_REPLY: String(config.defaultGroupTriggerPolicy.allowReply),
6126
6417
  GROUP_TRIGGER_ALLOW_ACTIVE_WINDOW: String(config.defaultGroupTriggerPolicy.allowActiveWindow),
@@ -6143,6 +6434,7 @@ function buildSnapshotEnv(config) {
6143
6434
  ADMIN_BIND_HOST: config.adminBindHost,
6144
6435
  ADMIN_PORT: String(config.adminPort),
6145
6436
  ADMIN_TOKEN: config.adminToken ?? "",
6437
+ ADMIN_TOKENS_JSON: serializeAdminTokens(config.adminTokens),
6146
6438
  ADMIN_IP_ALLOWLIST: config.adminIpAllowlist.join(","),
6147
6439
  ADMIN_ALLOWED_ORIGINS: config.adminAllowedOrigins.join(","),
6148
6440
  LOG_LEVEL: config.logLevel
@@ -6232,6 +6524,9 @@ function parseIntStrict(raw) {
6232
6524
  function serializeJsonObject(value) {
6233
6525
  return Object.keys(value).length > 0 ? JSON.stringify(value) : "";
6234
6526
  }
6527
+ function serializeAdminTokens(tokens) {
6528
+ return tokens.length > 0 ? JSON.stringify(tokens) : "";
6529
+ }
6235
6530
  function booleanStringSchema(key) {
6236
6531
  return import_zod2.z.string().refine((value) => BOOLEAN_STRING.test(value), {
6237
6532
  message: `${key} must be a boolean string (true/false).`
@@ -6269,6 +6564,23 @@ function jsonObjectStringSchema(key, allowEmpty) {
6269
6564
  message: `${key} must be an empty string or a JSON object string.`
6270
6565
  });
6271
6566
  }
6567
+ function jsonArrayStringSchema(key, allowEmpty) {
6568
+ return import_zod2.z.string().refine((value) => {
6569
+ const trimmed = value.trim();
6570
+ if (!trimmed) {
6571
+ return allowEmpty;
6572
+ }
6573
+ let parsed;
6574
+ try {
6575
+ parsed = JSON.parse(trimmed);
6576
+ } catch {
6577
+ return false;
6578
+ }
6579
+ return Array.isArray(parsed);
6580
+ }, {
6581
+ message: `${key} must be an empty string or a JSON array string.`
6582
+ });
6583
+ }
6272
6584
 
6273
6585
  // src/preflight.ts
6274
6586
  var import_node_child_process6 = require("child_process");
@@ -6461,11 +6773,12 @@ admin.command("serve").description("Start admin config API server").option("--ho
6461
6773
  const host = options.host?.trim() || config.adminBindHost;
6462
6774
  const port = options.port ? parsePortOption(options.port, config.adminPort) : config.adminPort;
6463
6775
  const allowInsecureNoToken = options.allowInsecureNoToken ?? false;
6464
- if (!config.adminToken && !allowInsecureNoToken && isNonLoopbackHost(host)) {
6776
+ const hasAdminAuth = Boolean(config.adminToken) || config.adminTokens.length > 0;
6777
+ if (!hasAdminAuth && !allowInsecureNoToken && isNonLoopbackHost(host)) {
6465
6778
  process.stderr.write(
6466
6779
  [
6467
- "Refusing to start admin server on non-loopback host without ADMIN_TOKEN.",
6468
- "Fix: set ADMIN_TOKEN in .env, or explicitly pass --allow-insecure-no-token.",
6780
+ "Refusing to start admin server on non-loopback host without admin auth token.",
6781
+ "Fix: set ADMIN_TOKEN or ADMIN_TOKENS_JSON in .env, or explicitly pass --allow-insecure-no-token.",
6469
6782
  ""
6470
6783
  ].join("\n")
6471
6784
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codeharbor",
3
- "version": "0.1.14",
3
+ "version": "0.1.16",
4
4
  "description": "Instant-messaging bridge for Codex CLI sessions",
5
5
  "license": "MIT",
6
6
  "main": "dist/cli.js",