agent-loft 0.2.33 → 0.2.35

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 CHANGED
@@ -54,6 +54,7 @@ Logo 就是这个意思:一大两小几颗星(多个 agent)站在同一张
54
54
  - 侧栏按页加载:「最近」先显示 10 条、每个项目目录先显示 5 条,点「展开显示」每次再拉 10 条。
55
55
  - 侧栏底部显示 Claude / Codex 的订阅额度(5 小时 / 7 天窗口,以及多久后重置);关了额度查询就整块不显示。
56
56
  - 右上角「更多」最下面显示当前会话累计消耗的 token 分布(输入 / 缓存写入 / 缓存读取 / 输出,Claude 还有按 API 价格估算的费用)。设置也在「更多」里。在桌面端里,这个菜单并进桌面端顶栏的「更多」,排在最上面。
57
+ - 设置 → 用量:按日 / 月 / 年(默认按月)统计 token 和金额,有每天(每月)的柱状图,按 agent、模型、用户(管理员才有)分开看;成员只看到自己的。金额都是按 API 价格算的参考值:Claude 用它自己报的费用,Codex 不报费用,按模型单价(LiteLLM 公开价格表,内置一份快照、联网时每天更新)× token 估算。从 0.2.34 开始记录,之前的用量没有日期,不计入。
57
58
  - 「添加到主屏幕」后当 App 用;切到后台再回来会自动重连,补上错过的内容。
58
59
  - **系统通知**:Web 会话回复完成、出错、需要授权或提问时推送到手机 / 电脑(设置 → 通知 → 「在此设备开启通知」,可以发测试通知)。飞书会话由飞书提醒,正在前台看着的会话不推。需要 HTTPS 访问,iPhone 需 iOS 16.4+ 且从主屏幕图标打开。
59
60
  - **跨渠道同步**:飞书里的会话在 Web 上也能打开接着聊。同一个授权请求同时出现在飞书卡片和网页上,哪边先点算哪边,另一边自动变成「已处理」。
@@ -64,6 +65,10 @@ Logo 就是这个意思:一大两小几颗星(多个 agent)站在同一张
64
65
  - **记忆**:像 ChatGPT 的记忆,设置 → 记忆。「关于我」加上一条条记住的事,每次对话都带上;agent 在聊天里遇到长期有用的信息会自己用 `remember` 记一条(回复里会说),记错了用 `forget` 删。**自动整理**:把上次整理之后新说的话交给模型(不留会话记录的一次性调用),补充、更新或删掉过时的记忆,用户自己加的条目它不动。什么时候整理每个人自己定:每天一次(默认凌晨 4 点,把当天的对话合起来一次整理,最省)/每段对话聊完后(停多久可选,改了什么会在对话里说一声)/只在点「现在整理」时/不整理;整理哪些对话也能选:全部/只整理聊天模式的/只整理工作模式的;用哪个 agent 的哪个模型也能选,默认 Claude Haiku,没有 Claude 就用 Codex 的默认模型、思考程度 low。**参考以前的聊天**:agent 遇到「上次说的那个……」会用 `search_past_chats` 搜你以前的对话(关键词搜索,只有你自己的、没被删掉的对话;第一次用时会把已有对话收录进来)。两项都能在设置里单独关。每个用户一份,Claude 和 Codex、Web 和飞书共用。只用在有主人的对话里:你的 Web 对话,以及绑定了飞书账号后和机器人的私聊;飞书群聊不用。别人(包括管理员)看不到你的记忆,管理员只能清空它;删用户时记忆一起删,不转给管理员。
65
66
  - **定时任务**:像 ChatGPT 的 Tasks,侧栏「最近」上面的「定时任务」。到点把一段提示词交给 agent,结果发回对话:一次 / 每天 / 工作日 / 每周 / 每月 / 每隔几小时 / 自定义 cron,按服务端时区,两次至少隔 15 分钟。**有需要才提醒**(默认):agent 自己判断这次有没有要告诉你的事(比如「有新版本才告诉我」),没有就在回复第一行写 `[[静默]]`,推送、飞书都不发,对话里折叠成一行;没写暗号一律照常提醒。结果发到**专用对话**(任务自己的一条对话,第一次运行时新建、之后续用,可选聊天 / 工作模式和目录),或者**原来的对话**。也能直接在对话里说「每天早上 9 点帮我……」:agent 用 `create_task` / `update_task` / `delete_task` / `list_tasks` 工具管理,新建、修改、删除都会先弹授权卡让你确认(只有暂停不用;没有「总是允许」),任务自己运行的那一轮里不能再建、改、删任务。按人隔离:每人只看得到、改得了自己的任务(最多 30 个),删用户时一起删;到点再核对一次对话还是不是他的,对话被删或换了主人就暂停任务。到点对话在忙(正在回复或挂着授权)就等它,最多 30 分钟;服务停着时错过 2 小时以内的启动后补跑一次。工具和记忆一样只给有主人的对话,飞书群聊里没有。
66
67
  - **技能(skills)**:设置 → 技能。技能就是 Claude Code / Codex 用的那种 SKILL.md(可以带脚本和资料),由 agent-loft 按人管理,两个 agent 都能用。**来源**:在页面上写、上传技能包(`.zip` / `.skill` / 单个 SKILL.md / 整个文件夹),或者在对话里让 agent「把刚才的做法存成技能」(agent 用 `create_skill` / `update_skill`,先弹授权卡)。每人管自己的,不做分享和技能广场(以前从别人那装过的照常能用,可以卸载)。**使用**:工作模式下 agent 遇到对得上的事自己用;在输入框或定时任务的「要做的事」里打 `@` 选技能,点名这次用哪个(聊天模式下会把技能内容直接附上);飞书里发 `/skills` 看能 @ 哪些。**变量**:技能可以声明要用的环境变量(接口地址、密钥),每个人填自己的值,只注入自己的会话;密钥再写上「只允许发往」的地址就成了**受保护的密钥**——不进环境变量,agent 看不到值,只能用 `skill_request` 工具让 agent-loft 代发请求(在地址、请求头、请求体里写 `{{变量名}}`),只发往你确认过的地址、不跟随跳转、响应里出现的值会被抹掉;作者后来改了地址,要你再确认一次才放行。**机器上原有的技能**(`~/.claude/skills`、`~/.agents/skills`、插件市场和 claude.ai 同步的)只给管理员:成员的会话里看不到、调不了(Claude 用技能白名单,`/技能名` 也会被拦下;Codex 关掉它自己的技能清单),管理员在「设置 → 技能 → 本机技能」里能看到。
68
+ - **远程控制**:侧栏「定时任务」下面的「远程控制」,直接操作这台机器的**桌面**和**终端**,上面一键切换;切回对话时连接留在后台(侧栏入口上亮个小绿点),再进来接着用。这台机器没有图形桌面(服务器、NAS)时只有终端。
69
+ - 桌面:走 VNC 协议,网页里用 noVNC 显示,鼠标、键盘、剪贴板都能用,可以缩放到窗口或按原始大小看、浏览器全屏、「只看不控制」、发送 Ctrl+Alt+Del 之类的组合键。手机上有两种手势(右上角切换,「?」看说明):**触控板模式**(默认)屏幕上有个指针,单指滑动移动、轻点单击、双指轻点右键、长按后拖动是按住左键拖、双指滑动滚动;**触摸模式**点哪就点哪。两种模式都能双指捏合在本地放大画面(放大后小按钮也点得准,右下角「还原」),画面下面一排 Esc / Tab / Ctrl / Alt / Win / 方向键,Ctrl、Alt、Win 点一下会粘住,带给下一个按键或点击;键盘按钮弹出软键盘打字,中文输入法也能用。Linux 上 agent-loft 在有人连时用 x11vnc 共享当前的桌面(Xorg 会话;只听 127.0.0.1、随机端口、每次随机密码,需要 `sudo apt install x11vnc`),没人连着 30 分钟后停掉;macOS 上在 设置 → 通用 → 远程控制 里改用「已有的 VNC 服务」,接系统自带的屏幕共享。
70
+ - 终端:新终端开在当前对话的工作目录里,可以开多个标签;`Ctrl+Shift+C` / `Ctrl+Shift+V` 复制粘贴。`` Ctrl+` `` 在对话下面拉出终端面板(高度可拖动),面板和远程控制里的是同一批终端。关掉页面、手机切到后台、断线都不结束 shell,重新打开时接着用、补上错过的输出,别的设备上也能接上;没有页面连着超过 30 分钟(设置里可改)才结束,点标签上的 × 立即结束。依赖可选的原生模块 `node-pty`:Linux 上安装时要现场编译(需要 `make`、`g++`、`python3`),没装好时终端用不了,其他功能照常。手机上单指上下滑翻看历史(在 less、vim 里是翻页),长按选中文字、接着拖动扩大选区,再点「复制」;终端下面多一排 Esc / Tab / Ctrl / Alt / 方向键 / Home / End / PgUp / PgDn / 粘贴。iOS 上软键盘弹出来时这排键会垫到键盘上面。
71
+ - 管理员能用;成员要管理员在 设置 → 用户 里单独「开通远程控制」。终端、桌面在 设置 → 通用 → 远程控制 里各有总开关。
67
72
  - **飞书账号绑定**:设置 → 账号 → 飞书账号 →「生成绑定码」,再在飞书私聊机器人发 `/bind 绑定码`(10 分钟内有效,群里发不算)。绑定后这个私聊就是你的个人会话:用你的记忆,`/sessions` 只列你的会话,`/web` 只能接上你自己的 Web 会话,管理员在 Web 上也看不到它。必须本人在两边各操作一次,管理员不能替人绑定(只能解除),否则就能借飞书读到别人的记忆。
68
73
 
69
74
  ## 安装
@@ -94,8 +99,9 @@ agent-loft service install # 注册为开机自启服务(systemd / laun
94
99
 
95
100
  - 同时连多台服务器,顶部标签栏切换、拖动排序;每台各自登录,切走了也在后台连着,回复完成、需要授权时弹系统通知,程序坞图标显示未读数。
96
101
  - 菜单栏图标:悬停看各服务器的 Claude / Codex 额度、待处理和运行中的会话;双击打开主窗口。同一个账号的额度只查一次。(Windows 上点托盘图标弹出;Linux 上在托盘菜单里点「额度和待处理」。)
102
+ - 菜单栏图标可以换成额度条:Claude、Codex 各是一只像素小宠物,看样子就知道是谁,身子是 5 小时额度,下面一条是周额度。桌面端设置 → 额度显示 →「菜单栏显示额度」,选所有服务器合起来(取最高),或者按服务器分别选 Claude / Codex(图标上不写服务器名);都不选就只显示图标。「额度条显示」选实心表示剩余额度(默认,像电池)还是已用额度。只有 macOS 有,Windows / Linux 的托盘图标太小放不下。
97
103
  - 支持前面挡着 Cloudflare Access 的服务器:验证页直接在窗口里完成,过期会提醒;也可以配 Service Token 免验证。
98
- - 首次打开可以选「这台 Mac 也作为服务端」:一键在终端里安装 agent-loft(没有 Node.js 会先用 Homebrew 装)并开机自启,装好自动连上。Linux 上一样(需要先装好 Node.js 22+,开机自启用 systemd 用户服务);Windows 上服务端还没有开机自启,可以在 WSL 里装,桌面端会自动检测到 `127.0.0.1` 上的它。可选开启「本机有会话在跑时不休眠」。
104
+ - 首次打开可以选「这台 Mac 也作为服务端」:一键在终端里安装 agent-loft(没有 Node.js 会先用 Homebrew 装)并开机自启,装好自动连上。Linux 上一样(需要先装好 Node.js 22+,开机自启用 systemd 用户服务);Windows 上服务端还没有开机自启,可以在 WSL 里装,桌面端会自动检测到 `127.0.0.1` 上的它。防休眠:本机有会话在跑时不休眠;另有永不休眠、定时休眠(设休眠时段,时段外不休眠,时段内本机没有会话在跑、屏幕锁上后让 Mac 睡下去)和合盖不休眠(macOS,要管理员密码)。
99
105
  - 设置(`⌘,`)的服务器列表里展开某台服务器,能看它的 Agent 环境和版本,有新版本时点「升级服务端」;桌面端自己的更新在同一页的「更新」里点「更新并重启」。
100
106
 
101
107
  桌面端只是个壳(代码在 `desktop/`),服务端照常用 npm 安装和升级。
@@ -147,6 +153,7 @@ agent-loft service install
147
153
  - 从老版本升级:原来的登录密码自动变成管理员 `admin`,已登录的设备不用重新登录。
148
154
  - 登录态通过 HttpOnly cookie 保存,服务端只存它的哈希。同一 IP 连续输错 5 次锁 15 分钟。所有写请求和 WebSocket 都校验 Origin。页面开启了严格的 CSP。
149
155
  - 暴露到公网时,只通过 HTTPS 反向代理(比如 Lucky)或 Cloudflare Tunnel 访问,并把不在本机的反代 IP 加进 `web.trustedProxies`(只有从这些地址来的连接才采信 `X-Forwarded-*`)。不要直接把 8800 端口映射出去。局域网访问可以在 设置 → 系统 → 网络访问 里关掉。
156
+ - 远程控制不经过授权卡:拿到终端就是拿到运行 agent-loft 的那个系统用户的 shell,拿到桌面就是坐在登录桌面的那个人面前。所以默认只给管理员,成员要管理员单独开通(开通时会再提醒一次);收回权限、停用或删除用户、在设置里关掉终端 / 桌面时,他开着的 shell 立刻结束、桌面立刻断开。终端和桌面的 WebSocket 和其他接口一样校验登录态和 Origin。shell 拿不到本服务自己的凭据(和 agent 子进程一样剔除环境变量)。共享桌面用的 x11vnc 只听本机、密码每次随机生成、经文件传过去(不出现在命令行里);用「已有的 VNC 服务」时,配置里的 VNC 密码会交给开通了远程控制的人的页面。
150
157
  - 审批超时一律按拒绝处理。Codex 默认沙箱为 `workspace-write`,不会因为 `~/.codex/config.toml` 里配了 `never` 或 `danger-full-access` 就绕过审批。
151
158
  - agent 子进程拿不到本服务自己的凭据(`FEISHU_*`、`AGENT_LOFT_*` 等环境变量会被剔除)。
152
159
  - 技能存在 `data/skills/`:技能本体在 `files/<技能 id>/`,每个人装了哪些、变量的值在 `users/<用户 id>.json`(0600)。普通变量注入到会话环境里,agent 能读到;受保护的密钥 agent 看不到值,但和多用户一样不是安全边界——工作模式下 agent 能直接读这些文件。别人分享的技能会在你的会话里照着执行,装之前在技能页点开看一眼它要做什么、要哪些变量。
@@ -173,6 +180,8 @@ src/
173
180
  skills.ts 技能的存储:可见性、安装、停用、变量和受保护密钥确认过的地址;zip.ts 解技能包
174
181
  skill-runtime.ts 技能怎么交给 agent:Claude 的插件目录和成员白名单、Codex 的清单、@ 技能、机器上原有的技能
175
182
  skills-bridge.ts 技能工具在主进程这头:建 / 改技能(弹授权卡)、skill_request 代发带密钥的请求
183
+ terminals.ts 网页终端:用 node-pty 起 shell、留最近的输出给重连补发、没人连着到点结束、权限收回时结束
184
+ desktop.ts 远程桌面:找这台机器的 X 桌面、按需起 x11vnc(或用已有的 VNC 服务)、没人连着到点停掉
176
185
  pairing.ts uploads.ts commands.ts …
177
186
  mcp/
178
187
  memory-server.ts 记忆工具(remember / forget / search_past_chats)的 stdio MCP 服务,两个 agent 共用
@@ -182,7 +191,7 @@ src/
182
191
  codex/ codex app-server 驱动
183
192
  channels/
184
193
  feishu/ 飞书:一个 FeishuBot 对应一个机器人,支持多个
185
- web/ HTTP + WebSocket + 鉴权
194
+ web/ HTTP + WebSocket + 鉴权;terminal.ts 是终端的接口和 /ws/term,desktop.ts 是桌面的接口和 /ws/desktop(转给 VNC)
186
195
  web/ 前端(原生 JS,不需要构建)
187
196
  desktop/ 桌面端(Electron 外壳,macOS / Windows / Linux)
188
197
  docs/ 产品介绍和截图
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-loft",
3
- "version": "0.2.33",
3
+ "version": "0.2.35",
4
4
  "type": "module",
5
5
  "description": "多个 agent 的工作台:把本机 Claude Code / Codex 放到一处统一管理,用飞书和 Web 页面随时驱动——会话、审批、模型切换都共用",
6
6
  "keywords": [
@@ -47,6 +47,9 @@
47
47
  "@anthropic-ai/claude-agent-sdk": "^0.3.302",
48
48
  "@highlightjs/cdn-assets": "^11.12.0",
49
49
  "@larksuiteoapi/node-sdk": "^1.73.0",
50
+ "@novnc/novnc": "^1.7.0",
51
+ "@xterm/addon-fit": "^0.11.0",
52
+ "@xterm/xterm": "^6.0.0",
50
53
  "dompurify": "^3.2.0",
51
54
  "marked": "^16.0.0",
52
55
  "qrcode": "^1.5.4",
@@ -62,5 +65,8 @@
62
65
  "publishConfig": {
63
66
  "access": "public",
64
67
  "registry": "https://registry.npmjs.org/"
68
+ },
69
+ "optionalDependencies": {
70
+ "node-pty": "^1.1.0"
65
71
  }
66
72
  }
@@ -17,6 +17,7 @@ import {
17
17
  isAdmin,
18
18
  legacyOwnerId,
19
19
  listUsers,
20
+ ownedBy,
20
21
  removeUser,
21
22
  verifyLogin,
22
23
  verifyPassword,
@@ -24,6 +25,7 @@ import {
24
25
  type User,
25
26
  } from "../../core/users.js";
26
27
  import type { SavedAttachment } from "../../core/types.js";
28
+ import { dailyUsage } from "../../core/usage.js";
27
29
  import {
28
30
  addMemoryItem,
29
31
  clearMemory,
@@ -65,6 +67,8 @@ import { clampLimit, dirsOf, inDir, inRecent, pageOf, parseCursor } from "./conv
65
67
  import { clientIp, cookies, HttpError, isHttps, readBody, readJsonBody, SECURITY_HEADERS, sendJson } from "./http.js";
66
68
  import { handleTasksApi } from "./tasks-api.js";
67
69
  import { handleSkillsApi } from "./skills-api.js";
70
+ import { handleTerminalApi } from "./terminal.js";
71
+ import { handleDesktopApi, remoteStatus } from "./desktop.js";
68
72
  import { addSubscription, listSubscriptions, pushToAll, removeSubscription, removeSubscriptionsOf, vapidPublicKey } from "./push.js";
69
73
 
70
74
  export const COOKIE = "aim_session";
@@ -192,6 +196,8 @@ function publicConfig(): unknown {
192
196
  ...cfg,
193
197
  web: { ...cfg.web, passwordHash: undefined },
194
198
  feishu: { ...cfg.feishu, bots: cfg.feishu.bots.map((b) => ({ ...b, appSecret: maskSecret(b.appSecret) })) },
199
+ // VNC 密码最多 8 位,不能像 appSecret 那样露出后 4 位
200
+ remote: { ...cfg.remote, vncPassword: cfg.remote.vncPassword ? MASK : "" },
195
201
  };
196
202
  }
197
203
 
@@ -223,6 +229,24 @@ function applyConfig(input: Partial<AppConfig>): void {
223
229
  next.web.quotaRefreshMin = Number.isFinite(q) ? Math.max(0, Math.min(1440, Math.round(q))) : 10;
224
230
  next.web.quotaEnabled = next.web.quotaEnabled !== false;
225
231
  }
232
+ if (input.remote) {
233
+ const r = input.remote as Partial<AppConfig["remote"]>;
234
+ if (typeof r.terminal === "boolean") next.remote.terminal = r.terminal;
235
+ if (typeof r.desktop === "boolean") next.remote.desktop = r.desktop;
236
+ const k = Number(r.keepAliveMin);
237
+ if (r.keepAliveMin !== undefined && Number.isFinite(k)) next.remote.keepAliveMin = Math.max(1, Math.min(1440, Math.round(k)));
238
+ if (r.desktopSource === "auto" || r.desktopSource === "vnc") next.remote.desktopSource = r.desktopSource;
239
+ if (typeof r.display === "string") {
240
+ const d = r.display.trim();
241
+ if (d && !/^:\d{1,3}(\.\d)?$/.test(d)) throw new HttpError(400, "X 显示要写成 :0、:1 这样,留空自动找");
242
+ next.remote.display = d;
243
+ }
244
+ if (typeof r.vncHost === "string" && r.vncHost.trim()) next.remote.vncHost = r.vncHost.trim();
245
+ const port = Number(r.vncPort);
246
+ if (r.vncPort !== undefined && Number.isInteger(port) && port >= 1 && port <= 65535) next.remote.vncPort = port;
247
+ // 回传的是打了码的旧值:不改
248
+ if (typeof r.vncPassword === "string" && !r.vncPassword.startsWith(MASK)) next.remote.vncPassword = r.vncPassword;
249
+ }
226
250
  if (input.commands) {
227
251
  // 只收字符串数组,别的一律当没传
228
252
  const names = (v: unknown) =>
@@ -536,6 +560,7 @@ export async function handleApi(req: IncomingMessage, res: ServerResponse, url:
536
560
  },
537
561
  pending,
538
562
  user: publicMe(user),
563
+ remote: remoteStatus(user),
539
564
  serverTime: Date.now(),
540
565
  });
541
566
  return;
@@ -574,6 +599,8 @@ export async function handleApi(req: IncomingMessage, res: ServerResponse, url:
574
599
  // ----- 定时任务 -----
575
600
  if (p.startsWith("/api/tasks") && (await handleTasksApi(req, res, p, user))) return;
576
601
  if (p.startsWith("/api/skills") && (await handleSkillsApi(req, res, url, user))) return;
602
+ if (p.startsWith("/api/terminals") && (await handleTerminalApi(req, res, p, user))) return;
603
+ if ((p === "/api/remote" || p.startsWith("/api/desktop/")) && (await handleDesktopApi(req, res, p, user))) return;
577
604
 
578
605
  // ----- 目录选择器:列工作区内的子目录 -----
579
606
  if (p === "/api/dirs" && method === "GET") {
@@ -855,6 +882,20 @@ export async function handleApi(req: IncomingMessage, res: ServerResponse, url:
855
882
  sendJson(res, 200, { agents: await agentHealth(url.searchParams.get("refresh") === "1") });
856
883
  return;
857
884
  }
885
+ if (p === "/api/usage" && method === "GET") {
886
+ // 按天的 token / 费用账(设置 →「用量」)。?from=&to= 是 YYYY-MM-DD(含两头)。
887
+ // 管理员看所有人的(只有数字,看不到内容),成员只看自己对话的
888
+ const day = /^\d{4}-\d{2}-\d{2}$/;
889
+ const from = url.searchParams.get("from") ?? "";
890
+ const to = url.searchParams.get("to") ?? "";
891
+ if (!day.test(from) || !day.test(to)) throw new HttpError(400, "from / to 要是 YYYY-MM-DD");
892
+ const admin = isAdmin(user);
893
+ const rows = dailyUsage(from, to, admin ? undefined : (owner) => ownedBy(user, owner));
894
+ const names: Record<string, string> = {};
895
+ for (const r of rows) if (r.owner && !(r.owner in names)) names[r.owner] = getUser(r.owner)?.name ?? "已删除的用户";
896
+ sendJson(res, 200, { rows, users: names, admin });
897
+ return;
898
+ }
858
899
  if (p === "/api/quota" && method === "GET") {
859
900
  // ?agent=claude,codex:只问这几个。桌面端连着多台同账号的服务器时,同一个账号只在一台上问
860
901
  // 设置里关了查额度:不去问任何 agent
@@ -1081,7 +1122,7 @@ export async function handleApi(req: IncomingMessage, res: ServerResponse, url:
1081
1122
  const id = userMatch[1]!;
1082
1123
  if (!getUser(id)) throw new HttpError(404, "用户不存在");
1083
1124
  if (method === "PATCH") {
1084
- const body = await readJsonBody<{ name?: string; role?: Role; disabled?: boolean; password?: string }>(req);
1125
+ const body = await readJsonBody<{ name?: string; role?: Role; disabled?: boolean; password?: string; remote?: boolean }>(req);
1085
1126
  if (id === user.id && (body.disabled === true || (body.role !== undefined && body.role !== "admin"))) {
1086
1127
  throw new HttpError(400, "不能停用自己,也不能取消自己的管理员");
1087
1128
  }
@@ -1092,6 +1133,7 @@ export async function handleApi(req: IncomingMessage, res: ServerResponse, url:
1092
1133
  ...(body.role === "admin" || body.role === "member" ? { role: body.role } : {}),
1093
1134
  ...(typeof body.disabled === "boolean" ? { disabled: body.disabled } : {}),
1094
1135
  ...(typeof body.password === "string" ? { password: body.password } : {}),
1136
+ ...(typeof body.remote === "boolean" ? { remote: body.remote } : {}),
1095
1137
  }),
1096
1138
  );
1097
1139
  // 管理员在用户列表里重置了自己的密码:当前这台换个新登录态,别把自己踢出去
@@ -0,0 +1,183 @@
1
+ // 远程控制的接口:GET /api/remote 告诉页面终端、桌面各自能不能用;
2
+ // POST /api/desktop/session 准备好桌面(必要时起 x11vnc),把这次的 VNC 密码给页面;
3
+ // /ws/desktop 把页面(noVNC)的 WebSocket 原样转到 VNC 服务的 TCP 端口,和 websockify 一样。
4
+ import net from "node:net";
5
+ import type { IncomingMessage, ServerResponse } from "node:http";
6
+ import type { Duplex } from "node:stream";
7
+ import type { WebSocket, WebSocketServer } from "ws";
8
+ import { canUseDesktop, desktopAvailability, desktopTarget, desktopViewer } from "../../core/desktop.js";
9
+ import { canUseRemote, isAdmin, type User } from "../../core/users.js";
10
+ import { config } from "../../core/config.js";
11
+ import { sessionUser } from "./auth.js";
12
+ import { HttpError, sendJson } from "./http.js";
13
+ import { terminalStatus } from "./terminal.js";
14
+
15
+ /** 页面要知道的:能不能用远程控制,终端、桌面各自开着没、能不能用 */
16
+ export function remoteStatus(user: User) {
17
+ const desktop = desktopAvailability();
18
+ return {
19
+ allowed: canUseRemote(user),
20
+ terminal: terminalStatus(user),
21
+ desktop: {
22
+ allowed: canUseDesktop(user),
23
+ available: desktop.available,
24
+ source: desktop.source,
25
+ // 为什么用不了、怎么装:只给管理员看(成员只知道没有桌面)
26
+ ...(desktop.reason && isAdmin(user) ? { reason: desktop.reason } : {}),
27
+ },
28
+ };
29
+ }
30
+
31
+ export async function handleDesktopApi(req: IncomingMessage, res: ServerResponse, p: string, user: User): Promise<boolean> {
32
+ const method = req.method ?? "GET";
33
+ if (p === "/api/remote" && method === "GET") {
34
+ sendJson(res, 200, remoteStatus(user));
35
+ return true;
36
+ }
37
+ if (p === "/api/desktop/session" && method === "POST") {
38
+ if (!canUseDesktop(user)) throw new HttpError(403, "你没有使用远程桌面的权限,找管理员在 设置 → 用户 里开通");
39
+ let target;
40
+ try {
41
+ target = await desktopTarget();
42
+ } catch (err) {
43
+ throw new HttpError(503, err instanceof Error ? err.message : String(err));
44
+ }
45
+ // 已有的 VNC 服务(比如 macOS 屏幕共享)没开:现在就说清楚,别让页面一直「正在重连」
46
+ if (config().remote.desktopSource === "vnc" && !(await reachable(target.host, target.port))) {
47
+ throw new HttpError(503, `连不上 VNC 服务 ${target.host}:${target.port},先确认它开着(macOS:系统设置 → 通用 → 共享 → 屏幕共享)`);
48
+ }
49
+ sendJson(res, 200, { password: target.password, source: config().remote.desktopSource });
50
+ return true;
51
+ }
52
+ return false;
53
+ }
54
+
55
+ /** 3 秒内能不能连上这个 TCP 端口 */
56
+ function reachable(host: string, port: number): Promise<boolean> {
57
+ return new Promise((resolve) => {
58
+ const s = net.connect(port, host);
59
+ const done = (ok: boolean) => {
60
+ s.destroy();
61
+ resolve(ok);
62
+ };
63
+ s.setTimeout(3000, () => done(false));
64
+ s.once("connect", () => done(true));
65
+ s.once("error", () => done(false));
66
+ });
67
+ }
68
+
69
+ // ---------- WebSocket ↔ VNC ----------
70
+
71
+ type DesktopClient = { ws: WebSocket; tcp: net.Socket; token: string; alive: boolean };
72
+ const clients = new Set<DesktopClient>();
73
+
74
+ /** 同时连着桌面的页面最多这么多(每个都是一整路画面) */
75
+ const MAX_CLIENTS = 6;
76
+ /** 页面收得慢:攒到这么多就先不读 VNC 那头,等它消化掉 */
77
+ const HIGH_WATER = 4 * 1024 * 1024;
78
+
79
+ function drop(client: DesktopClient): void {
80
+ if (!clients.delete(client)) return;
81
+ client.tcp.destroy();
82
+ desktopViewer(-1);
83
+ }
84
+
85
+ function stillAllowed(client: DesktopClient): boolean {
86
+ const user = sessionUser(client.token);
87
+ if (user && canUseDesktop(user)) return true;
88
+ client.ws.close(user ? 4403 : 4401, user ? "远程桌面权限已被收回" : "登录已失效");
89
+ drop(client);
90
+ return false;
91
+ }
92
+
93
+ function reject(socket: Duplex, status: string): void {
94
+ socket.write(`HTTP/1.1 ${status}\r\n\r\n`);
95
+ socket.destroy();
96
+ }
97
+
98
+ /** /ws/desktop 的握手:user 已经由调用方核对过登录和 Origin */
99
+ export function upgradeDesktop(wss: WebSocketServer, req: IncomingMessage, socket: Duplex, head: Buffer, user: User, token: string): void {
100
+ if (!canUseDesktop(user)) return reject(socket, "403 Forbidden");
101
+ if (clients.size >= MAX_CLIENTS) return reject(socket, "429 Too Many Requests");
102
+ // 下面要等 x11vnc 起来(最多 10 秒):这期间浏览器断开的话,socket 上没人接 error 会把进程带崩
103
+ const onError = () => socket.destroy();
104
+ socket.on("error", onError);
105
+ void desktopTarget().then(
106
+ (target) => {
107
+ if (socket.destroyed) return;
108
+ const tcp = net.connect(target.port, target.host);
109
+ tcp.once("error", () => {
110
+ if (!socket.destroyed) reject(socket, "502 Bad Gateway");
111
+ });
112
+ tcp.once("connect", () => {
113
+ if (socket.destroyed) return void tcp.destroy();
114
+ socket.off("error", onError);
115
+ wss.handleUpgrade(req, socket, head, (ws) => bridge(ws, tcp, token));
116
+ });
117
+ },
118
+ (err) => {
119
+ console.error("[桌面] 准备桌面失败:", err instanceof Error ? err.message : err);
120
+ reject(socket, "503 Service Unavailable");
121
+ },
122
+ );
123
+ }
124
+
125
+ function bridge(ws: WebSocket, tcp: net.Socket, token: string): void {
126
+ const client: DesktopClient = { ws, tcp, token, alive: true };
127
+ clients.add(client);
128
+ desktopViewer(1);
129
+ tcp.removeAllListeners("error");
130
+ tcp.setNoDelay(true);
131
+ let paused = false;
132
+ const resumeWhenDrained = () => {
133
+ if (ws.readyState !== ws.OPEN) return;
134
+ if (ws.bufferedAmount < HIGH_WATER / 2) {
135
+ paused = false;
136
+ tcp.resume();
137
+ } else setTimeout(resumeWhenDrained, 30);
138
+ };
139
+ tcp.on("data", (chunk) => {
140
+ if (ws.readyState !== ws.OPEN) return;
141
+ ws.send(chunk, { binary: true });
142
+ if (!paused && ws.bufferedAmount > HIGH_WATER) {
143
+ paused = true;
144
+ tcp.pause();
145
+ setTimeout(resumeWhenDrained, 30);
146
+ }
147
+ });
148
+ tcp.on("close", () => {
149
+ if (ws.readyState === ws.OPEN) ws.close(4000, "桌面连接已断开");
150
+ drop(client);
151
+ });
152
+ tcp.on("error", () => drop(client));
153
+ ws.on("pong", () => (client.alive = true));
154
+ // 页面 → VNC:按键、鼠标都在这里,逐条复查登录态太密了;登录态 / 权限变了由 recheckDesktopClients 统一断开
155
+ ws.on("message", (data: Buffer) => tcp.write(data));
156
+ ws.on("close", () => drop(client));
157
+ ws.on("error", () => drop(client));
158
+ }
159
+
160
+ /** 退出登录、被踢、权限收回、设置里关了桌面:把还连着的断开 */
161
+ export function recheckDesktopClients(): void {
162
+ for (const client of [...clients]) stillAllowed(client);
163
+ }
164
+
165
+ export function pingDesktopClients(): void {
166
+ for (const client of [...clients]) {
167
+ if (!stillAllowed(client)) continue;
168
+ if (!client.alive) {
169
+ client.ws.terminate();
170
+ drop(client);
171
+ continue;
172
+ }
173
+ client.alive = false;
174
+ client.ws.ping();
175
+ }
176
+ }
177
+
178
+ export function closeDesktopClients(): void {
179
+ for (const client of [...clients]) {
180
+ client.ws.close(1001, "服务重启");
181
+ drop(client);
182
+ }
183
+ }
@@ -15,9 +15,10 @@ import { interactions, type Interaction } from "../../core/interactions.js";
15
15
  import { getConversation } from "../../core/store.js";
16
16
  import { taskEvents } from "../../core/tasks.js";
17
17
  import { skillEvents } from "../../core/skills.js";
18
+ import { enforceTerminalAccess, killAllTerminals, terminalEvents, watchTerminalAccess } from "../../core/terminals.js";
18
19
  import { RUNNING_VERSION } from "../../core/upgrade.js";
19
20
  import type { Settlement } from "../../core/types.js";
20
- import { getUser, isAdmin, legacyOwnerId, type User } from "../../core/users.js";
21
+ import { getUser, isAdmin, legacyOwnerId, userEvents, type User } from "../../core/users.js";
21
22
  import { currentUser, handleApi, takeUploads } from "./api.js";
22
23
  import { authEvents, ensureSetupToken, sessionUser } from "./auth.js";
23
24
  import { canSeeKey, sessionVisibleTo } from "./access.js";
@@ -26,6 +27,9 @@ import { COOKIE } from "./api.js";
26
27
  import { HttpError, sameOrigin, SECURITY_HEADERS, sendJson } from "./http.js";
27
28
  import { hasSubscriptions, pushToAll } from "./push.js";
28
29
  import { closeListener, listen, listening, rebind } from "./listen.js";
30
+ import { closeTerminalClients, pingTerminalClients, recheckTerminalClients, upgradeTerminal } from "./terminal.js";
31
+ import { closeDesktopClients, pingDesktopClients, recheckDesktopClients, upgradeDesktop } from "./desktop.js";
32
+ import { stopDesktop } from "../../core/desktop.js";
29
33
 
30
34
  const require = createRequire(import.meta.url);
31
35
  const webRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../web");
@@ -52,6 +56,9 @@ const VENDOR: Record<string, () => string> = {
52
56
  "/vendor/hljs.js": () => pkgFile("@highlightjs/cdn-assets", "es/highlight.min.js"),
53
57
  "/vendor/hljs-light.css": () => pkgFile("@highlightjs/cdn-assets", "styles/github.min.css"),
54
58
  "/vendor/hljs-dark.css": () => pkgFile("@highlightjs/cdn-assets", "styles/github-dark.min.css"),
59
+ "/vendor/xterm.js": () => pkgFile("@xterm/xterm", "lib/xterm.mjs"),
60
+ "/vendor/xterm.css": () => pkgFile("@xterm/xterm", "css/xterm.css"),
61
+ "/vendor/xterm-fit.js": () => pkgFile("@xterm/addon-fit", "lib/addon-fit.mjs"),
55
62
  };
56
63
 
57
64
  const MIME: Record<string, string> = {
@@ -97,7 +104,21 @@ function serveFile(req: http.IncomingMessage, res: http.ServerResponse, file: st
97
104
  });
98
105
  }
99
106
 
107
+ /** noVNC 是一堆互相 import 的 ES module:整个 core/、vendor/ 目录按原样给,只给 .js */
108
+ function novncFile(pathname: string): string | undefined {
109
+ const rel = pathname.slice("/vendor/novnc/".length);
110
+ if (!/^(core|vendor)\/[\w./-]+\.js$/.test(rel) || rel.split("/").includes("..")) return undefined;
111
+ return path.join(pkgDir("@novnc/novnc"), rel);
112
+ }
113
+
100
114
  function serveStatic(req: http.IncomingMessage, res: http.ServerResponse, url: URL): void {
115
+ if (url.pathname.startsWith("/vendor/novnc/")) {
116
+ const file = novncFile(url.pathname);
117
+ if (file) return serveFile(req, res, file);
118
+ res.writeHead(404, SECURITY_HEADERS);
119
+ res.end();
120
+ return;
121
+ }
101
122
  const vendor = VENDOR[url.pathname];
102
123
  if (vendor) {
103
124
  try {
@@ -189,6 +210,16 @@ function broadcastTasks(owner: string): void {
189
210
  for (const client of clients) if (client.user.id === owner) send(client, { t: "tasks" });
190
211
  }
191
212
 
213
+ /** 某人的终端开了 / 结束了:他开着的页面重新拉终端列表(别的设备上开的也能接上) */
214
+ function broadcastTerminals(owner: string): void {
215
+ for (const client of clients) if (client.user.id === owner) send(client, { t: "remote" });
216
+ }
217
+
218
+ /** 用户改了(比如远程控制权限被开通 / 收回)、远程控制的设置改了:页面重新问一下能用什么 */
219
+ function broadcastUsersChanged(): void {
220
+ for (const client of clients) send(client, { t: "remote" });
221
+ }
222
+
192
223
  /** 技能变了:以前装过别人技能的人也受影响,通知所有人重新拉(列表很小) */
193
224
  function broadcastSkills(): void {
194
225
  for (const client of clients) send(client, { t: "skills" });
@@ -409,6 +440,7 @@ let server: http.Server | null = null;
409
440
  let wss: WebSocketServer | null = null;
410
441
  let heartbeat: NodeJS.Timeout | null = null;
411
442
  let stopWatchingListen: (() => void) | null = null;
443
+ let stopWatchingRemote: (() => void) | null = null;
412
444
 
413
445
  export async function startWeb(): Promise<void> {
414
446
  const { host, port } = config().web;
@@ -449,13 +481,15 @@ export async function startWeb(): Promise<void> {
449
481
  server.on("upgrade", (req, socket, head) => {
450
482
  try {
451
483
  const url = new URL(req.url ?? "/", "http://local");
452
- const user = url.pathname === "/ws" && sameOrigin(req) ? currentUser(req) : undefined;
484
+ const user = ["/ws", "/ws/term", "/ws/desktop"].includes(url.pathname) && sameOrigin(req) ? currentUser(req) : undefined;
453
485
  if (!user) {
454
486
  socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
455
487
  socket.destroy();
456
488
  return;
457
489
  }
458
490
  const token = cookies(req)[COOKIE] ?? "";
491
+ if (url.pathname === "/ws/term") return upgradeTerminal(wss!, req, socket, head, user, token, url);
492
+ if (url.pathname === "/ws/desktop") return upgradeDesktop(wss!, req, socket, head, user, token);
459
493
  wss!.handleUpgrade(req, socket, head, (ws) => {
460
494
  const client: Client = { ws, subs: new Set(), alive: true, token, user, visible: false, viewing: null, seen: Date.now() };
461
495
  clients.add(client);
@@ -490,6 +524,9 @@ export async function startWeb(): Promise<void> {
490
524
  client.alive = false;
491
525
  client.ws.ping();
492
526
  }
527
+ enforceTerminalAccess();
528
+ pingTerminalClients();
529
+ pingDesktopClients();
493
530
  }, 30_000);
494
531
  heartbeat.unref();
495
532
 
@@ -501,6 +538,21 @@ export async function startWeb(): Promise<void> {
501
538
  interactions.on("settled", broadcastInteraction);
502
539
  taskEvents.on("changed", broadcastTasks);
503
540
  skillEvents.on("changed", broadcastSkills);
541
+ terminalEvents.on("changed", broadcastTerminals);
542
+ userEvents.on("changed", broadcastUsersChanged);
543
+ watchTerminalAccess();
544
+ // 设置里关了远程桌面 / 换了桌面来源:连着的断开,自己起的 x11vnc 停掉(下次连时按新设置起)
545
+ stopWatchingRemote = onConfigChange((next, prev) => {
546
+ if (JSON.stringify(next.remote) === JSON.stringify(prev.remote)) return;
547
+ const r = next.remote;
548
+ const p = prev.remote;
549
+ if (!r.desktop || r.desktopSource !== p.desktopSource || r.display !== p.display) {
550
+ closeDesktopClients();
551
+ stopDesktop();
552
+ }
553
+ recheckDesktopClients();
554
+ broadcastUsersChanged();
555
+ });
504
556
 
505
557
  await listen(server, host, port);
506
558
  console.log(`[web] 已监听 http://${host}:${port}`);
@@ -520,6 +572,8 @@ export async function startWeb(): Promise<void> {
520
572
 
521
573
  function onRevoked(): void {
522
574
  for (const client of [...clients]) checkClient(client);
575
+ recheckTerminalClients();
576
+ recheckDesktopClients();
523
577
  }
524
578
 
525
579
  export async function stopWeb(): Promise<void> {
@@ -532,6 +586,14 @@ export async function stopWeb(): Promise<void> {
532
586
  interactions.off("settled", broadcastInteraction);
533
587
  taskEvents.off("changed", broadcastTasks);
534
588
  skillEvents.off("changed", broadcastSkills);
589
+ terminalEvents.off("changed", broadcastTerminals);
590
+ userEvents.off("changed", broadcastUsersChanged);
591
+ stopWatchingRemote?.();
592
+ stopWatchingRemote = null;
593
+ closeTerminalClients();
594
+ killAllTerminals();
595
+ closeDesktopClients();
596
+ stopDesktop();
535
597
  if (heartbeat) clearInterval(heartbeat);
536
598
  for (const client of clients) client.ws.close(1001, "服务重启");
537
599
  clients.clear();