bothouse-dsh 0.1.46 → 0.1.48

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/collab/desk.mjs CHANGED
@@ -2,8 +2,8 @@
2
2
  //
3
3
  // 事件契约见 Issue #876:`board-events.jsonl` 与 board.jsonl 同目录(BOARD_DIR),
4
4
  // 一行一个 JSON,只追加;这里只读不改,写入归 gate 的 board.py。
5
- import { closeSync, openSync, readFileSync, readSync, statSync } from 'node:fs';
6
- import { join } from 'node:path';
5
+ import { closeSync, mkdirSync, openSync, readFileSync, readSync, renameSync, statSync, writeFileSync } from 'node:fs';
6
+ import { dirname, join } from 'node:path';
7
7
  import { CollabError, publicMessage } from './store.mjs';
8
8
  // #876 P: 回放消息的 @ 由这里解析,规则与 hub 路由同一套(mentions.mjs),页面不再自己猜文本。
9
9
  import { mentions as mentionsOf } from './mentions.mjs';
@@ -130,8 +130,56 @@ export function deskPage() {
130
130
  try { return readFileSync(new URL('./desk/index.html', import.meta.url), 'utf8'); } catch { return PLACEHOLDER_PAGE; }
131
131
  }
132
132
 
133
- /** /collab/desk 与 /collab/api/desk/replay;返回 true 表示已处理,未匹配返回 undefined。 */
134
- export function deskRoute(req, res, url, { store, boardDir, members = [] }) {
133
+ // 名牌自定义名:成员 id → 名字,存插件数据目录,刷新、换设备都还在;空名 = 恢复默认(roster.name)。
134
+ export const DESK_NAMES_FILE = 'desk-names.json';
135
+ export const DESK_NAME_MAX = 24;
136
+
137
+ export function readDeskNames(file) {
138
+ if (!file) return {};
139
+ try {
140
+ const value = JSON.parse(readFileSync(file, 'utf8'));
141
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
142
+ } catch { return {}; }
143
+ }
144
+
145
+ /** 改一个成员的名牌名;name 去空白后为空就删掉,回到默认名。返回改后的全表。 */
146
+ export function setDeskName(file, id, name) {
147
+ const key = String(id ?? '').trim();
148
+ if (!key) throw new CollabError('invalid', 'id is required');
149
+ const value = String(name ?? '').replace(/\s+/g, ' ').trim();
150
+ if (Array.from(value).length > DESK_NAME_MAX) throw new CollabError('invalid', `name must be at most ${DESK_NAME_MAX} characters`);
151
+ const names = readDeskNames(file);
152
+ if (value) names[key] = value; else delete names[key];
153
+ mkdirSync(dirname(file), { recursive: true });
154
+ const tmp = `${file}.${process.pid}.tmp`;
155
+ writeFileSync(tmp, JSON.stringify(names, null, 2) + '\n');
156
+ renameSync(tmp, file);
157
+ return names;
158
+ }
159
+
160
+ function sendJson(res, status, value) {
161
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
162
+ res.end(JSON.stringify(value));
163
+ }
164
+
165
+ async function namesRoute(req, res, namesFile) {
166
+ if (!namesFile) return sendJson(res, 404, { error: 'desk names are not configured' });
167
+ if (req.method === 'GET') return sendJson(res, 200, { names: readDeskNames(namesFile) });
168
+ let raw = '';
169
+ try {
170
+ for await (const chunk of req) { raw += chunk; if (raw.length > 4096) return sendJson(res, 413, { error: 'body too large' }); }
171
+ const body = JSON.parse(raw || '{}');
172
+ return sendJson(res, 200, { names: setDeskName(namesFile, body?.id, body?.name) });
173
+ } catch (error) {
174
+ if (error instanceof CollabError) return sendJson(res, 400, { error: error.message, code: error.code });
175
+ if (error instanceof SyntaxError) return sendJson(res, 400, { error: 'invalid JSON body' });
176
+ return sendJson(res, 500, { error: 'save failed' });
177
+ }
178
+ }
179
+
180
+ /** /collab/desk、/collab/api/desk/replay、/collab/api/desk/names;返回 true(或 Promise)表示已处理,未匹配返回 undefined。 */
181
+ export function deskRoute(req, res, url, { store, boardDir, members = [], namesFile } = {}) {
182
+ if (url.pathname.replace(/\/+$/, '') === '/collab/api/desk/names' && (req.method === 'GET' || req.method === 'PUT')) return namesRoute(req, res, namesFile);
135
183
  if (url.pathname.replace(/\/+$/, '') !== '/collab/desk' && url.pathname.replace(/\/+$/, '') !== '/collab/api/desk/replay') return undefined;
136
184
  if (req.method !== 'GET') return undefined;
137
185
  if (url.pathname.replace(/\/+$/, '') === '/collab/desk') {
@@ -26,7 +26,7 @@ export function teamModeRules(mode = DEFAULT_DISPATCH_MODE) {
26
26
  /** #597 G24: the open-workspace command, for the selected primary role only, never in bridge prompts. */
27
27
  export function workspaceHint(hub, later, memberId, roomId) {
28
28
  if (!later?.workspaceInstructions || hub?.primaryIn?.(roomId)?.id !== memberId) return '';
29
- return later.workspaceInstructions(memberId, roomId);
29
+ return later.workspaceInstructions(memberId, roomId) + (later.teamInstructions?.(memberId, roomId) ?? '');
30
30
  }
31
31
  /**
32
32
  * #597 G24: DSH members have no bridge session, so the shared line about `bothouse workspace new` /
@@ -201,11 +201,39 @@ export function friendlyFailure(raw) {
201
201
  const text = String(raw);
202
202
  if (/no.{0,12}(api.?key|key)|missing.{0,12}(key|credential)|api.?key.{0,12}(missing|not set|required)|未配置.*key/i.test(text)) return '还没有配置模型的 API Key,请先去配置模型。';
203
203
  if (/model.{0,30}(not found|unavailable|not available|unsupported)|unknown model|模型.*不可用|404/i.test(text)) return '这个模型暂时不可用,请检查模型名称或换一个模型。';
204
+ // An unreachable provider is a setting or network problem. Echoing the transport wording
205
+ // ("Connection error.") behind "请检查配置" told the user nothing about which setting to fix.
206
+ if (/connection error|econnrefused|econnreset|enotfound|etimedout|eai_again|fetch failed|socket hang up|network ?(error|request failed)|timed out|timeout/i.test(text)) return '连不上模型服务:中转站地址或网络不通。请检查设置里的模型地址和 Key,或换一个中转站再试。';
204
207
  if (/login|log.?in|auth|unauthorized|401|未登录/i.test(text)) return '模型或 CLI 还没有登录,或登录已过期。请先登录后再试。';
205
208
  if (/exit|ENOENT|spawn|没有输出文字/i.test(text)) return '本机 CLI 未能正常运行或已退出,请检查安装和登录状态。';
209
+ // A Host older than this plugin has no scope lease on its preset registry, so no member can be
210
+ // composed. Name the cause and the fix instead of showing the bare TypeError.
211
+ if (/agentPresets\.\w+ is not a function/i.test(text)) return '当前 DSH 宿主缺少频道插件需要的预设接口(agentPresets),成员无法启动。请把 DSH 升级到与本插件匹配的版本后重试。';
206
212
  return `这次没能完成,请检查配置后重试。原始错误:${text}`;
207
213
  }
208
214
 
215
+ /**
216
+ * Lease one preset revision while an Agent joins it, or undefined when the Host's
217
+ * registry has no scope lease. `acquireScope` shipped in a later registry than
218
+ * `resolve`/`mount`, so a Host can serve presets without it; the lease only keeps
219
+ * that revision alive during assembly. Skipping it there keeps members usable
220
+ * instead of failing every turn with a bare "is not a function" TypeError.
221
+ * @param ctx - Host context carrying the preset registry.
222
+ * @param presetId - Preset identity the Agent joins.
223
+ * @returns Something with an async disposer, or undefined when unsupported.
224
+ */
225
+ export async function presetScope(ctx, presetId) {
226
+ const registry = ctx?.agentPresets;
227
+ if (typeof registry?.acquireScope !== 'function') return undefined;
228
+ return await registry.acquireScope(presetId);
229
+ }
230
+
231
+ /** Release a presetScope result; a Host without the lease releases nothing. */
232
+ export async function releasePresetScope(scope) {
233
+ const dispose = scope?.[Symbol.asyncDispose];
234
+ if (typeof dispose === 'function') await dispose();
235
+ }
236
+
209
237
  /**
210
238
  * Card text of one approval request, in cc-connect's permission prompt layout:
211
239
  * the tool, what it wants to run (the call's first argument), and DSH's reason.
@@ -838,6 +866,24 @@ export class DshMembers {
838
866
  if (this.closed) return;
839
867
  const job = this.store.job(jobId);
840
868
  if (job === undefined || !this.store.finishJob(jobId, state)) return;
869
+ // A restart is not a configuration problem: the member is healthy and only the turn it was
870
+ // running is lost, so it takes a retry button and leaves the member online. Reporting it as
871
+ // "成员出错 / 去配置模型" also cooled the provider quota for what was a process restart.
872
+ if (state === 'interrupted') {
873
+ this.hub.setError(job.member_id, undefined);
874
+ this.hub.post(job.room_id, {
875
+ authorKind: 'system', author: 'system', name: 'system', text: String(notice), clientKey: `dsh-job-${jobId}-${state}-${Date.now()}`,
876
+ card: JSON.stringify({
877
+ title: '被中断',
878
+ state: 'failed',
879
+ action: 'retry',
880
+ job_id: jobId,
881
+ member_id: job.member_id,
882
+ trigger_id: job.trigger_id,
883
+ }),
884
+ });
885
+ return;
886
+ }
841
887
  this.hub.quota?.observe(job.member_id, job.room_id, { error: String(notice) });
842
888
  const error = friendlyFailure(notice);
843
889
  this.hub.setError(job.member_id, error);
@@ -911,7 +957,7 @@ export class DshMembers {
911
957
  async createAgent(member, room) {
912
958
  const { ctx } = this.rt;
913
959
  const preset = await ctx.agentPresets.resolve(member.agentPreset);
914
- const scope = await ctx.agentPresets.acquireScope(preset.id);
960
+ const scope = await presetScope(ctx, preset.id);
915
961
  const sessionId = `collab-${randomUUID()}`;
916
962
  let handle;
917
963
  let workspace;
@@ -932,7 +978,7 @@ export class DshMembers {
932
978
  },
933
979
  });
934
980
  } finally {
935
- await scope[Symbol.asyncDispose]();
981
+ await releasePresetScope(scope);
936
982
  }
937
983
  await workspace.attachSession(sessionId);
938
984
  // setup ran before the Agent existed, so record it here: liveAgent must not hook it again.
@@ -1006,7 +1052,7 @@ export class DshMembers {
1006
1052
  // Installing only the request hook left a resumed Session with just the
1007
1053
  // built-in tools after a DSH restart.
1008
1054
  const preset = await ctx.agentPresets.resolve(member.agentPreset);
1009
- const scope = await ctx.agentPresets.acquireScope(preset.id);
1055
+ const scope = await presetScope(ctx, preset.id);
1010
1056
  let handle;
1011
1057
  try {
1012
1058
  handle = await ctx.agents.resume({
@@ -1018,7 +1064,7 @@ export class DshMembers {
1018
1064
  },
1019
1065
  });
1020
1066
  } finally {
1021
- await scope[Symbol.asyncDispose]();
1067
+ await releasePresetScope(scope);
1022
1068
  }
1023
1069
  this.hooked.add(handle.agent);
1024
1070
  if (member.permissionPreset !== undefined) ctx.permissionPresets.set(handle.agent.session, member.permissionPreset);
package/collab/later.mjs CHANGED
@@ -104,6 +104,18 @@ export class Later {
104
104
  const curl = `curl -sS -X POST ${quote(url)} -H 'content-type: application/json' -H ${quote(`X-Later-Ticket: ${ticket}`)} -d '{"title":"任务标题","owner":"接活人id(省略=你自己)","reason":"交接说明"}'`;
105
105
  return `\n【开工作区】整块活要交出去时,建工作区=这一条,任务和十分钟看门会自动开,不要另调 /tasks(它不认票据)。${local}或等价的接口 POST /collab/api/rooms/${room}/workspaces:\n${curl}\n一步建好工作区、开任务、挂十分钟看门并叫醒接活人;不要再在频道里 @ 同一件活。返回不是 200 就把返回原样告诉用户,不要悄悄自己做。工作区里的接活人是新会话,只看得到交接说明:里面要写清用户原话、参照的现成实现、以哪个分支为底、验收标准。\n`;
106
106
  }
107
+ /**
108
+ * 一句话拉团队 (user 11-04): how the primary role builds a channel of new roles in one call.
109
+ * Primary-only like workspaceInstructions (dsh-member.mjs workspaceHint).
110
+ */
111
+ teamInstructions(member, room) {
112
+ const quote = value => "'" + String(value).replaceAll("'", "'\\''") + "'";
113
+ const ticket = this.ticket(member, room);
114
+ const json = '{"channel":"小团队","model":"模型id(用户没说就省略=DSH默认模型)","members":[{"name":"写手","persona":"你是写手,负责写和改博客文章。"},{"name":"程序员","persona":"你是程序员,负责写代码和审代码。"}]}';
115
+ const local = this.origin ? `本机命令(改 JSON 里的内容):\n${quote(process.execPath)} ${quote(fileURLToPath(import.meta.url))} team ${quote(this.origin)} ${quote(ticket)} ${quote(json)}\n` : '';
116
+ const url = `${this.origin ?? 'http://<本机 DSH 地址>'}/collab/api/rooms/${room}/team`;
117
+ return `\n【建团队】用户要你建几个角色、拉个群/频道时,一条就建好:新频道、每个角色(名字、人设、模型),你是新频道的主角色。${local}或 POST ${url}(头 X-Later-Ticket: ${ticket},JSON 同上)。名字不带空格和 @;人设一两句写清分工;用户说了模型就填 model(如 deepseek-v4.1-flash),没说就省略。返回不是 200 就把返回原样告诉用户。建好后告诉用户频道名、每个角色和模型,问要不要丢个题目试跑。\n`;
118
+ }
107
119
  tick() {
108
120
  for (const row of this.db.prepare('SELECT * FROM member_later WHERE announced = 0').all()) {
109
121
  this.hub.post(row.room_id, { authorKind: 'system', author: 'system', name: '回查登记',
@@ -134,6 +146,15 @@ if (process.argv[1] === fileURLToPath(import.meta.url) && process.argv[2] === 'w
134
146
  const body = await res.text();
135
147
  if (!res.ok) { console.error(`workspace: ${res.status} ${body}`); process.exit(1); }
136
148
  console.log(body);
149
+ } else if (process.argv[1] === fileURLToPath(import.meta.url) && process.argv[2] === 'team') {
150
+ const [origin, ticket, spec] = process.argv.slice(3);
151
+ const room = ticketClaims(ticket)?.r;
152
+ if (!origin || !Number.isSafeInteger(room) || !spec) { console.error('usage: later.mjs team <origin> <ticket> \'{"channel":"…","model":"…","members":[{"name":"…","persona":"…"}]}\''); process.exit(2); }
153
+ const res = await fetch(new URL(`/collab/api/rooms/${room}/team`, origin), { method: 'POST',
154
+ headers: { 'content-type': 'application/json', 'x-later-ticket': ticket }, body: spec });
155
+ const body = await res.text();
156
+ if (!res.ok) { console.error(`team: ${res.status} ${body}`); process.exit(1); }
157
+ console.log(body);
137
158
  } else if (process.argv[1] === fileURLToPath(import.meta.url)) {
138
159
  const [origin, ticket, delay, title, todo] = process.argv.slice(2);
139
160
  const room = ticketClaims(ticket)?.r;
package/collab/page.html CHANGED
@@ -258,6 +258,27 @@
258
258
  font-variant-numeric: tabular-nums;
259
259
  }
260
260
  .msg.cont:hover .stamp { display: block; }
261
+ /* 自己的消息:悬停时右上角出现「撤回」,撤回会连同这条消息发起的回答一起停下。 */
262
+ .msg .ops {
263
+ position: absolute;
264
+ top: 2px;
265
+ right: max(var(--pad-x), env(safe-area-inset-right));
266
+ display: none;
267
+ gap: 4px;
268
+ }
269
+ .msg:hover .ops, .msg:focus-within .ops { display: flex; }
270
+ .msg .ops button {
271
+ font: inherit;
272
+ font-size: var(--fs-xs);
273
+ line-height: 1.4;
274
+ color: var(--muted);
275
+ background: var(--surface);
276
+ border: 1px solid var(--line);
277
+ border-radius: var(--r-sm);
278
+ padding: 1px 6px;
279
+ cursor: pointer;
280
+ }
281
+ .msg .ops button:hover { color: var(--text-strong); background: var(--hover); }
261
282
 
262
283
  .avatar {
263
284
  width: var(--avatar);
@@ -505,6 +526,8 @@
505
526
  <div class="scrim" id="scrim" aria-hidden="true"></div>
506
527
 
507
528
  <script>
529
+ // 经面板 /dsh/ 转发时页面挂在前缀下:接口和链接都按当前地址带上前缀,直连时前缀为空。
530
+ const ROOT = (location.pathname.match(/^(.*?)\/collab(?:\/|$)/) || ['', ''])[1];
508
531
  const state = { me: 'owner', rooms: [], members: [], thinking: {}, room: null, synced: 0, mode: 'mixed' };
509
532
  const $ = id => document.getElementById(id);
510
533
  // crypto.randomUUID only exists in secure contexts (https/localhost); plain-http LAN/Tailscale pages fall back to getRandomValues.
@@ -527,7 +550,7 @@ const GROUP_WINDOW_MS = 5 * 60 * 1000;
527
550
  // 768px 是 U2 交接里定的窄屏阈值
528
551
  const narrow = window.matchMedia('(max-width: 768px)');
529
552
 
530
- const avatarSrc = v => !v ? '' : /^https?:/.test(v) ? v : '/collab/avatars/' + encodeURIComponent(v) + '.png';
553
+ const avatarSrc = v => !v ? '' : /^https?:/.test(v) ? v : ROOT + '/collab/avatars/' + encodeURIComponent(v) + '.png';
531
554
  function avatar(id, name) {
532
555
  const m = state.members.find(m => m.id === id);
533
556
  const a = el('div', 'avatar', (name || id).slice(0, 1)); a.style.background = color(id);
@@ -634,7 +657,7 @@ function messageNode(m) {
634
657
  const bar = el('div', 'actions');
635
658
  for (const a of meta.actions) {
636
659
  const b = el('button', '', a.label);
637
- b.onclick = () => { bar.querySelectorAll('button').forEach(x => { x.disabled = true; }); api(`/collab/api/messages/${m.id}/permission`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ decision: a.decision }) }); };
660
+ b.onclick = () => { bar.querySelectorAll('button').forEach(x => { x.disabled = true; }); api(`${ROOT}/collab/api/messages/${m.id}/permission`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ decision: a.decision }) }); };
638
661
  bar.append(b);
639
662
  }
640
663
  card.append(bar);
@@ -642,6 +665,21 @@ function messageNode(m) {
642
665
  col.append(card);
643
666
  } else col.append(el('div', 'body', m.text));
644
667
  row.append(col);
668
+ // 自己发的普通消息:悬停出现「撤回」。撤回后后端广播 deleted,这一行随之消失;
669
+ // 这条消息触发的回答也会一起停下(后端 recall 会取消对应 turn)。
670
+ if (!system && !m.card && m.author === state.me) {
671
+ const ops = el('div', 'ops');
672
+ const b = el('button', '', '撤回');
673
+ b.type = 'button';
674
+ b.title = '撤回这条消息(正在进行的回答也会停下)';
675
+ b.onclick = async () => {
676
+ if (!confirm('撤回这条消息?正在进行的回答也会停下。')) return;
677
+ try { await api(`${ROOT}/collab/api/messages/${m.id}`, { method: 'DELETE' }); }
678
+ catch (err) { alert('撤回失败:' + err.message); }
679
+ };
680
+ ops.append(b);
681
+ row.append(ops);
682
+ }
645
683
  return row;
646
684
  }
647
685
 
@@ -702,10 +740,10 @@ async function openRoom(id) {
702
740
  $('input').disabled = true;
703
741
  closeDrawer();
704
742
  try {
705
- const setting = await api(`/collab/api/rooms/${id}/mode`);
743
+ const setting = await api(`${ROOT}/collab/api/rooms/${id}/mode`);
706
744
  state.mode = setting.dispatch_mode;
707
745
  renderRooms(); renderMembers();
708
- const { messages } = await api(`/collab/api/rooms/${id}/messages?limit=100`);
746
+ const { messages } = await api(`${ROOT}/collab/api/rooms/${id}/messages?limit=100`);
709
747
  if (state.room !== id) return;
710
748
  const log = $('log');
711
749
  log.querySelector('.state')?.remove();
@@ -725,7 +763,7 @@ async function openRoom(id) {
725
763
  $('mode').addEventListener('change', async (e) => {
726
764
  if (state.room == null) return;
727
765
  try {
728
- const result = await api(`/collab/api/rooms/${state.room}/mode`, {
766
+ const result = await api(`${ROOT}/collab/api/rooms/${state.room}/mode`, {
729
767
  method: 'PATCH', headers: { 'content-type': 'application/json' },
730
768
  body: JSON.stringify({ dispatch_mode: e.target.value }),
731
769
  });
@@ -739,7 +777,7 @@ async function catchUp() {
739
777
  const room = state.room;
740
778
  if (room == null) return;
741
779
  for (;;) {
742
- const { messages } = await api(`/collab/api/rooms/${room}/messages?after=${state.synced}&limit=200`);
780
+ const { messages } = await api(`${ROOT}/collab/api/rooms/${room}/messages?after=${state.synced}&limit=200`);
743
781
  if (state.room !== room) return;
744
782
  messages.forEach(upsert);
745
783
  if (messages.length) state.synced = messages[messages.length - 1].id;
@@ -748,7 +786,7 @@ async function catchUp() {
748
786
  }
749
787
 
750
788
  function connect() {
751
- const es = new EventSource('/collab/api/events');
789
+ const es = new EventSource(ROOT + '/collab/api/events');
752
790
  es.onopen = () => catchUp().catch(console.warn);
753
791
  es.onmessage = (e) => {
754
792
  const ev = JSON.parse(e.data);
@@ -769,7 +807,7 @@ $('form').addEventListener('submit', async (e) => {
769
807
  if (!text || state.room == null) return;
770
808
  $('input').value = ''; $('input').style.height = 'auto';
771
809
  try {
772
- const { message } = await api(`/collab/api/rooms/${state.room}/messages`, {
810
+ const { message } = await api(`${ROOT}/collab/api/rooms/${state.room}/messages`, {
773
811
  method: 'POST', headers: { 'content-type': 'application/json' },
774
812
  body: JSON.stringify({ text, client_key: uuid() }),
775
813
  });
@@ -778,7 +816,7 @@ $('form').addEventListener('submit', async (e) => {
778
816
  });
779
817
 
780
818
  (async () => {
781
- const s = await api('/collab/api/state');
819
+ const s = await api(ROOT + '/collab/api/state');
782
820
  Object.assign(state, { me: s.me, rooms: s.rooms, members: s.members, thinking: s.thinking });
783
821
  connect();
784
822
  await openRoom(s.rooms[0].id);
@@ -0,0 +1,160 @@
1
+ // The bothouse management panel inside DSH (#105): the sidebar's "bothouse" entry frames
2
+ // /collab/panel/, and this owner-only route forwards it to the bridge management server.
3
+ //
4
+ // The panel API needs the management token. The browser must not hold it, so on each
5
+ // request the route reads [management] from the bridge config file and signs the upstream
6
+ // call itself. The panel page learns it is behind such a proxy from an injected
7
+ // <meta name="bothouse-auth" content="proxy">, and <base href> is rewritten to the mount
8
+ // path so its relative asset and API URLs land back here (bridge/web/src/lib/basePath.ts).
9
+ import { readFileSync } from 'node:fs';
10
+ import { homedir } from 'node:os';
11
+ import { join } from 'node:path';
12
+ import { Readable } from 'node:stream';
13
+
14
+ export const PANEL_PREFIX = '/collab/panel';
15
+ // 来源:bothouse 管理接口默认端口(bridge config.example.toml [management] port)
16
+ const DEFAULT_PORT = 9820;
17
+ // Request headers the panel page may pass upstream; everything else (cookies, Authorization,
18
+ // Origin, Host) stays on this side.
19
+ const PASS_REQUEST = ['accept', 'accept-language', 'content-type', 'if-none-match', 'if-modified-since', 'range'];
20
+ // Response headers worth keeping; Set-Cookie and CORS headers from the panel are dropped.
21
+ const PASS_RESPONSE = ['content-type', 'cache-control', 'etag', 'last-modified', 'content-disposition', 'content-range', 'accept-ranges'];
22
+
23
+ /**
24
+ * Default bridge config path: $BOTHOUSE_CONFIG, else <data dir>/config.toml.
25
+ * @param {NodeJS.ProcessEnv} env
26
+ * @returns {string}
27
+ */
28
+ export function defaultBridgeConfig(env = process.env) {
29
+ const explicit = String(env.BOTHOUSE_CONFIG ?? '').trim();
30
+ if (explicit !== '') return explicit;
31
+ const dataDir = String(env.BOTHOUSE_DATA_DIR ?? '').trim() || join(env.HOME || homedir(), '.bothouse');
32
+ return join(dataDir, 'config.toml');
33
+ }
34
+
35
+ /** Value of one TOML scalar (quoted string, number or bare word); comments stripped. */
36
+ function scalar(raw) {
37
+ const s = raw.trim();
38
+ const quoted = /^"((?:[^"\\]|\\.)*)"|^'([^']*)'/.exec(s);
39
+ if (quoted !== null) return quoted[1] !== undefined ? JSON.parse(`"${quoted[1]}"`) : quoted[2];
40
+ return s.replace(/\s+#.*$/, '').trim();
41
+ }
42
+
43
+ /**
44
+ * Read host/port/token/enabled from the [management] table of a bridge config.
45
+ * Only flat `key = value` lines are needed, so a full TOML parser is not pulled in.
46
+ * @param {string} text - config.toml contents.
47
+ * @returns {{ enabled: boolean, host: string, port: number, token: string }}
48
+ */
49
+ export function managementSettings(text) {
50
+ const out = { enabled: false, host: '', port: DEFAULT_PORT, token: '' };
51
+ let inside = false;
52
+ for (const line of text.split(/\r?\n/)) {
53
+ const header = /^\s*\[\s*([^\]]+?)\s*\]/.exec(line);
54
+ if (header !== null) { inside = header[1] === 'management'; continue; }
55
+ if (!inside) continue;
56
+ const kv = /^\s*([A-Za-z_][A-Za-z0-9_-]*)\s*=\s*(.*)$/.exec(line);
57
+ if (kv === null) continue;
58
+ const value = scalar(kv[2]);
59
+ if (kv[1] === 'enabled') out.enabled = value === 'true';
60
+ else if (kv[1] === 'host') out.host = String(value);
61
+ else if (kv[1] === 'port' && Number.isInteger(Number(value)) && Number(value) > 0) out.port = Number(value);
62
+ else if (kv[1] === 'token') out.token = String(value);
63
+ }
64
+ return out;
65
+ }
66
+
67
+ /** Loopback address to dial for a configured listen host. */
68
+ function dialHost(host) {
69
+ if (host === '' || host === '0.0.0.0' || host === '::' || host === '[::]') return '127.0.0.1';
70
+ return host.includes(':') && !host.startsWith('[') ? `[${host}]` : host;
71
+ }
72
+
73
+ function sendJSON(res, status, body) {
74
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
75
+ res.end(JSON.stringify(body));
76
+ }
77
+
78
+ /** Text shown inside the frame when the panel cannot be reached. */
79
+ function sendNotice(res, status, reason) {
80
+ res.writeHead(status, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
81
+ res.end(`<!doctype html><meta charset="utf-8"><title>bothouse</title><body style="font:14px system-ui;padding:24px;color:#888">打不开 bothouse 管理面板:${reason
82
+ .replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c])}</body>`);
83
+ }
84
+
85
+ /**
86
+ * Point the panel page at the mount path and mark it as proxy-signed.
87
+ * @param {string} html - the panel's index.html.
88
+ * @param {string} prefix - mount path, e.g. /collab/panel.
89
+ * @returns {string}
90
+ */
91
+ export function rewritePanelHTML(html, prefix = PANEL_PREFIX) {
92
+ const head = `<base href="${prefix}/" />\n <meta name="bothouse-auth" content="proxy" />`;
93
+ if (/<base\s[^>]*>/i.test(html)) return html.replace(/<base\s[^>]*>/i, head);
94
+ return html.replace(/<head(\s[^>]*)?>/i, m => `${m}\n ${head}`);
95
+ }
96
+
97
+ /**
98
+ * Forward /collab/panel/* to the bridge management server. Callers must have checked the owner.
99
+ * @returns {Promise<true> | undefined} undefined when the path is not ours.
100
+ */
101
+ export function panelRoute(req, res, url, { bridgeConfig, fetchImpl = fetch }) {
102
+ if (url.pathname !== PANEL_PREFIX && !url.pathname.startsWith(`${PANEL_PREFIX}/`)) return undefined;
103
+ if (url.pathname === PANEL_PREFIX) {
104
+ res.writeHead(308, { location: `${PANEL_PREFIX}/${url.search}` });
105
+ res.end();
106
+ return Promise.resolve(true);
107
+ }
108
+ return (async () => {
109
+ const rest = url.pathname.slice(PANEL_PREFIX.length) || '/';
110
+ const isAPI = rest.startsWith('/api/');
111
+ const fail = (status, reason) => (isAPI ? sendJSON(res, status, { ok: false, error: reason }) : sendNotice(res, status, reason), true);
112
+ let settings;
113
+ try {
114
+ settings = managementSettings(readFileSync(bridgeConfig, 'utf8'));
115
+ } catch {
116
+ return fail(503, '读不到主桥配置');
117
+ }
118
+ if (!settings.enabled) return fail(503, '主桥配置里管理接口没打开');
119
+ const upstream = `http://${dialHost(settings.host)}:${settings.port}${rest}${url.search}`;
120
+ const headers = {};
121
+ for (const name of PASS_REQUEST) {
122
+ const v = req.headers[name];
123
+ if (typeof v === 'string') headers[name] = v;
124
+ }
125
+ if (settings.token !== '') headers.authorization = `Bearer ${settings.token}`;
126
+ const hasBody = req.method !== 'GET' && req.method !== 'HEAD';
127
+ let reply;
128
+ try {
129
+ reply = await fetchImpl(upstream, {
130
+ method: req.method,
131
+ headers,
132
+ body: hasBody ? Readable.toWeb(req) : undefined,
133
+ duplex: hasBody ? 'half' : undefined,
134
+ redirect: 'manual',
135
+ });
136
+ } catch {
137
+ return fail(502, '连不上主桥管理接口');
138
+ }
139
+ const out = {};
140
+ for (const name of PASS_RESPONSE) {
141
+ const v = reply.headers.get(name);
142
+ if (v !== null) out[name] = v;
143
+ }
144
+ const type = reply.headers.get('content-type') ?? '';
145
+ if (!isAPI && type.startsWith('text/html')) {
146
+ const html = rewritePanelHTML(await reply.text());
147
+ res.writeHead(reply.status, { ...out, 'cache-control': 'no-store' });
148
+ res.end(html);
149
+ return true;
150
+ }
151
+ res.writeHead(reply.status, out);
152
+ if (reply.body === null || req.method === 'HEAD') { res.end(); return true; }
153
+ // Stream so NDJSON/SSE answers (plugin install, logs) arrive as they are produced.
154
+ const body = Readable.fromWeb(reply.body);
155
+ body.on('error', () => res.destroy());
156
+ req.on('close', () => body.destroy());
157
+ body.pipe(res);
158
+ return true;
159
+ })();
160
+ }