agent-loft 0.2.34 → 0.2.36

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.
@@ -72,6 +72,23 @@ export type AppConfig = {
72
72
  };
73
73
  /** 输入「/」时命令列表的顺序和隐藏项,按 agent 分开(设置 → 命令) */
74
74
  commands: { claude: CommandPrefs; codex: CommandPrefs };
75
+ /** 远程控制:网页终端和远程桌面。管理员能用;成员要在 设置 → 用户 里单独开(users.json 的 remote) */
76
+ remote: {
77
+ terminal: boolean;
78
+ desktop: boolean;
79
+ /** 没有页面连着多久后结束 shell、停掉共享桌面用的 x11vnc(分钟) */
80
+ keepAliveMin: number;
81
+ /**
82
+ * 桌面从哪来。auto:Linux 上用 x11vnc 共享这台机器当前的图形桌面(按需起、只听本机);
83
+ * vnc:连一个已有的 VNC 服务,比如 macOS 的屏幕共享(127.0.0.1:5900)
84
+ */
85
+ desktopSource: "auto" | "vnc";
86
+ /** auto 时用哪个 X 显示,留空自动找(如 :0、:1) */
87
+ display: string;
88
+ vncHost: string;
89
+ vncPort: number;
90
+ vncPassword: string;
91
+ };
75
92
  feishu: {
76
93
  /** 长连接掉线多久就重建,0 关闭 */
77
94
  watchdogMs: number;
@@ -100,6 +117,16 @@ export function defaultConfig(): AppConfig {
100
117
  },
101
118
  web: { enabled: true, host: "0.0.0.0", port: 8800, passwordHash: "", trustedProxies: ["127.0.0.1", "::1"], quotaRefreshMin: 10, quotaEnabled: true },
102
119
  commands: { claude: { order: [], hidden: [], custom: [] }, codex: { order: [], hidden: [], custom: [] } },
120
+ remote: {
121
+ terminal: true,
122
+ desktop: true,
123
+ keepAliveMin: 30,
124
+ desktopSource: process.platform === "darwin" ? "vnc" : "auto",
125
+ display: "",
126
+ vncHost: "127.0.0.1",
127
+ vncPort: 5900,
128
+ vncPassword: "",
129
+ },
103
130
  feishu: { watchdogMs: 10 * 60 * 1000, bots: [] },
104
131
  };
105
132
  }
@@ -0,0 +1,281 @@
1
+ // 远程桌面:把这台机器的图形桌面通过 VNC 给到网页(noVNC)。agent-loft 只做两件事:
2
+ // - 找到 VNC 服务:Linux 上按需起一个 x11vnc 共享当前桌面(只听 127.0.0.1、随机端口、每次随机密码),
3
+ // 没人连着 keepAliveMin 分钟后停掉;或者用配置里的现成 VNC 服务(macOS 的屏幕共享)。
4
+ // - 把页面的 WebSocket 原样转给它(见 channels/web/desktop.ts)。画面编码、鼠标键盘、剪贴板都是 VNC 协议自己的。
5
+ //
6
+ // 这台机器没有图形桌面(服务器、NAS)时远程桌面不可用,远程控制里就只有终端。
7
+ // 权限和终端一样(canUseRemote):拿到桌面就能以登录桌面的那个人的身份做任何事。
8
+ import { spawn, type ChildProcess } from "node:child_process";
9
+ import crypto from "node:crypto";
10
+ import fs from "node:fs";
11
+ import net from "node:net";
12
+ import os from "node:os";
13
+ import path from "node:path";
14
+ import { EventEmitter } from "node:events";
15
+ import { home } from "../home.js";
16
+ import { config } from "./config.js";
17
+ import { canUseRemote, type User } from "./users.js";
18
+
19
+ export function canUseDesktop(user: Pick<User, "role" | "disabled" | "remote"> | undefined): boolean {
20
+ return config().remote.desktop && canUseRemote(user);
21
+ }
22
+
23
+ // ---------- 找桌面 ----------
24
+
25
+ function which(cmd: string): string | undefined {
26
+ for (const dir of (process.env.PATH ?? "").split(path.delimiter).concat(["/usr/bin", "/usr/local/bin", "/opt/homebrew/bin"])) {
27
+ const file = path.join(dir, cmd);
28
+ try {
29
+ fs.accessSync(file, fs.constants.X_OK);
30
+ return file;
31
+ } catch {
32
+ // 下一个
33
+ }
34
+ }
35
+ return undefined;
36
+ }
37
+
38
+ const X_SOCKETS = "/tmp/.X11-unix";
39
+
40
+ /** 现在有哪些 X 显示(:0、:1 …),按编号排 */
41
+ function xDisplays(): string[] {
42
+ try {
43
+ return fs
44
+ .readdirSync(X_SOCKETS)
45
+ .map((f) => /^X(\d+)$/.exec(f)?.[1])
46
+ .filter((n): n is string => n !== undefined)
47
+ .sort((a, b) => Number(a) - Number(b))
48
+ .map((n) => `:${n}`);
49
+ } catch {
50
+ return [];
51
+ }
52
+ }
53
+
54
+ /**
55
+ * 用哪个显示:配置里写了就用它;否则环境变量里的 DISPLAY(前台运行时);
56
+ * 再不然取编号最大的那个 —— gdm 的登录界面通常是 :0,登录后的桌面排在后面。
57
+ */
58
+ function pickDisplay(): string | undefined {
59
+ const all = xDisplays();
60
+ const wanted = config().remote.display.trim();
61
+ if (wanted) return all.includes(wanted.replace(/\.\d$/, "")) ? wanted : undefined;
62
+ const env = process.env.DISPLAY?.trim();
63
+ if (env && all.includes(env.replace(/\.\d$/, ""))) return env;
64
+ return all[all.length - 1];
65
+ }
66
+
67
+ /** X 的授权文件:系统服务下没有 XAUTHORITY 环境变量,按常见位置找;都没有交给 x11vnc 自己猜 */
68
+ function xAuthority(): string {
69
+ const uid = os.userInfo().uid;
70
+ const candidates = [process.env.XAUTHORITY, `/run/user/${uid}/gdm/Xauthority`, path.join(os.homedir(), ".Xauthority")];
71
+ return candidates.find((f): f is string => !!f && fs.existsSync(f)) ?? "guess";
72
+ }
73
+
74
+ export type DesktopStatus = { available: boolean; reason?: string; source: "auto" | "vnc"; display?: string };
75
+
76
+ /** 远程桌面能不能用。不能用时 reason 写给管理员看(成员看到的只是「没有桌面」) */
77
+ export function desktopAvailability(): DesktopStatus {
78
+ const cfg = config().remote;
79
+ if (cfg.desktopSource === "vnc") return { available: true, source: "vnc" };
80
+ if (process.platform !== "linux") {
81
+ return {
82
+ available: false,
83
+ source: "auto",
84
+ reason:
85
+ process.platform === "darwin"
86
+ ? "macOS 上请改用「已有的 VNC 服务」:打开 系统设置 → 通用 → 共享 → 屏幕共享,在「电脑设置」里允许 VNC 用密码控制,再把密码填到这里"
87
+ : "这个系统上没法自动共享桌面,改用「已有的 VNC 服务」",
88
+ };
89
+ }
90
+ const display = pickDisplay();
91
+ if (!display) {
92
+ return {
93
+ available: false,
94
+ source: "auto",
95
+ reason: cfg.display ? `找不到显示 ${cfg.display}` : "这台机器没有图形桌面(没有运行中的 X 显示)。Wayland 桌面也共享不了,登录时选「Xorg」会话",
96
+ };
97
+ }
98
+ if (!which("x11vnc")) return { available: false, source: "auto", display, reason: "没装 x11vnc:sudo apt install x11vnc(或 dnf install x11vnc)" };
99
+ return { available: true, source: "auto", display };
100
+ }
101
+
102
+ // ---------- x11vnc ----------
103
+
104
+ type Vnc = { proc: ChildProcess; port: number; password: string; display: string; ready: Promise<void> };
105
+
106
+ let vnc: Vnc | null = null;
107
+ /** 正在起 x11vnc(找空闲端口是异步的):同时来的几个请求等同一个,别起两份 */
108
+ let starting: Promise<Vnc> | null = null;
109
+ /** 每停一次加一:起到一半时设置改了(停过了),起好的这份就不要了 */
110
+ let generation = 0;
111
+ /** 连着桌面的页面数:到 0 开始计时,keepAliveMin 分钟后停掉 x11vnc */
112
+ let viewers = 0;
113
+ let idleTimer: NodeJS.Timeout | null = null;
114
+ export const desktopEvents = new EventEmitter<{ stopped: [] }>();
115
+
116
+ function freePort(): Promise<number> {
117
+ return new Promise((resolve, reject) => {
118
+ const srv = net.createServer();
119
+ srv.unref();
120
+ srv.on("error", reject);
121
+ srv.listen(0, "127.0.0.1", () => {
122
+ const port = (srv.address() as net.AddressInfo).port;
123
+ srv.close(() => resolve(port));
124
+ });
125
+ });
126
+ }
127
+
128
+ /** 上一次 agent-loft 被强杀(没机会收尾)时留下的 x11vnc:按记下的 pid 停掉 */
129
+ function killStale(runDir: string): void {
130
+ const pidFile = path.join(runDir, "x11vnc.pid");
131
+ let pid = 0;
132
+ try {
133
+ pid = Number(fs.readFileSync(pidFile, "utf8").trim());
134
+ } catch {
135
+ return;
136
+ }
137
+ fs.rmSync(pidFile, { force: true });
138
+ if (!Number.isInteger(pid) || pid <= 1) return;
139
+ try {
140
+ // 只认我们自己起的那种:命令行里有 x11vnc 和我们的 run 目录(pid 可能已经被别的进程复用了)
141
+ const cmdline = fs.readFileSync(`/proc/${pid}/cmdline`, "utf8");
142
+ if (cmdline.includes("x11vnc") && cmdline.includes(runDir)) process.kill(pid);
143
+ } catch {
144
+ // 已经没了
145
+ }
146
+ }
147
+
148
+ async function startX11vnc(display: string): Promise<Vnc> {
149
+ const runDir = path.join(home, "run");
150
+ fs.mkdirSync(runDir, { recursive: true, mode: 0o700 });
151
+ killStale(runDir);
152
+ const port = await freePort();
153
+ // VNC 密码最多 8 个字符。只在本机能连,又是每次新起的,够了
154
+ const password = crypto.randomBytes(6).toString("base64url").slice(0, 8);
155
+ const pwFile = path.join(runDir, `x11vnc-${crypto.randomBytes(4).toString("hex")}.pw`);
156
+ // 密码放文件里(命令行参数别的用户用 ps 就能看到),x11vnc 读完就删(rm: 前缀)
157
+ fs.writeFileSync(pwFile, `${password}\n`, { mode: 0o600 });
158
+ const args = [
159
+ "-display", display,
160
+ "-auth", xAuthority(),
161
+ "-localhost",
162
+ "-rfbport", String(port),
163
+ "-passwdfile", `rm:${pwFile}`,
164
+ "-forever",
165
+ "-shared",
166
+ "-xkb",
167
+ "-noxdamage",
168
+ "-quiet",
169
+ ];
170
+ const proc = spawn(which("x11vnc")!, args, { stdio: ["ignore", "pipe", "pipe"] });
171
+ if (proc.pid) fs.writeFileSync(path.join(runDir, "x11vnc.pid"), String(proc.pid));
172
+ let stderr = "";
173
+ proc.stderr!.on("data", (d: Buffer) => (stderr = (stderr + d.toString()).slice(-4000)));
174
+ const ready = new Promise<void>((resolve, reject) => {
175
+ const timer = setTimeout(() => {
176
+ proc.kill();
177
+ reject(new Error("x11vnc 10 秒内没有启动起来"));
178
+ }, 10_000);
179
+ // 起不来(没权限执行之类):spawn 自己报错,不会有 exit
180
+ proc.on("error", (err) => {
181
+ clearTimeout(timer);
182
+ fs.rmSync(pwFile, { force: true });
183
+ reject(new Error(`x11vnc 起不来:${err.message}`));
184
+ });
185
+ // 准备好了会在标准输出打一行 PORT=xxxx
186
+ proc.stdout!.on("data", (d: Buffer) => {
187
+ if (/PORT=\d+/.test(d.toString())) {
188
+ clearTimeout(timer);
189
+ resolve();
190
+ }
191
+ });
192
+ proc.on("exit", (code) => {
193
+ clearTimeout(timer);
194
+ fs.rmSync(pwFile, { force: true });
195
+ const why = stderr.split("\n").find((l) => /fail|error|unable|cannot|refused/i.test(l))?.replace(/^\S+ \S+ /, "");
196
+ reject(new Error(`x11vnc 退出了(${code})${why ? `:${why}` : ""}`));
197
+ });
198
+ });
199
+ const v: Vnc = { proc, port, password, display, ready };
200
+ proc.on("exit", () => {
201
+ // 只删自己的:可能已经换上新的那份、pid 文件里记的是它
202
+ const pidFile = path.join(runDir, "x11vnc.pid");
203
+ try {
204
+ if (fs.readFileSync(pidFile, "utf8").trim() === String(proc.pid)) fs.rmSync(pidFile, { force: true });
205
+ } catch {
206
+ // 没有就算了
207
+ }
208
+ if (vnc !== v) return;
209
+ vnc = null;
210
+ console.log("[桌面] x11vnc 已停止");
211
+ desktopEvents.emit("stopped");
212
+ });
213
+ ready.then(() => console.log(`[桌面] 已在 ${display} 上起 x11vnc(127.0.0.1:${port})`), () => {});
214
+ return v;
215
+ }
216
+
217
+ export type DesktopTarget = { host: string; port: number; password: string };
218
+
219
+ /** 要连桌面了:拿到 VNC 服务的地址和密码,必要时先把 x11vnc 起来 */
220
+ export async function desktopTarget(): Promise<DesktopTarget> {
221
+ const status = desktopAvailability();
222
+ if (!status.available) throw new Error(status.reason ?? "这台机器上没有可用的桌面");
223
+ const cfg = config().remote;
224
+ if (status.source === "vnc") return { host: cfg.vncHost, port: cfg.vncPort, password: cfg.vncPassword };
225
+ // 换了显示(配置改了):旧的停掉
226
+ if (vnc && vnc.display !== status.display) stopDesktop();
227
+ const gen = generation;
228
+ if (!vnc) {
229
+ starting ??= startX11vnc(status.display!).finally(() => (starting = null));
230
+ const started = await starting;
231
+ // 等的这会儿里被停过(设置改了、服务在停):这一份也不要了
232
+ if (gen !== generation) {
233
+ started.proc.kill();
234
+ throw new Error("远程桌面的设置刚改过,再连一次");
235
+ }
236
+ vnc ??= started;
237
+ if (vnc !== started) started.proc.kill();
238
+ }
239
+ const current = vnc;
240
+ try {
241
+ await current.ready;
242
+ } catch (err) {
243
+ if (vnc === current) vnc = null;
244
+ // 没起好就被停了(设置改了):说清楚,别让人以为 x11vnc 坏了
245
+ if (gen !== generation) throw new Error("远程桌面的设置刚改过,再连一次");
246
+ throw err;
247
+ }
248
+ armIdle();
249
+ return { host: "127.0.0.1", port: current.port, password: current.password };
250
+ }
251
+
252
+ /** 页面连上 / 断开桌面:没人连着了就开始计时 */
253
+ export function desktopViewer(delta: 1 | -1): void {
254
+ viewers = Math.max(0, viewers + delta);
255
+ armIdle();
256
+ }
257
+
258
+ function armIdle(): void {
259
+ if (idleTimer) clearTimeout(idleTimer);
260
+ idleTimer = null;
261
+ if (viewers > 0 || !vnc) return;
262
+ idleTimer = setTimeout(() => stopDesktop(), Math.max(1, config().remote.keepAliveMin) * 60_000);
263
+ idleTimer.unref();
264
+ }
265
+
266
+ export function stopDesktop(): void {
267
+ generation++;
268
+ if (idleTimer) clearTimeout(idleTimer);
269
+ idleTimer = null;
270
+ const v = vnc;
271
+ if (!v) return;
272
+ vnc = null;
273
+ v.proc.kill();
274
+ console.log("[桌面] 已停掉 x11vnc");
275
+ desktopEvents.emit("stopped");
276
+ }
277
+
278
+ /** 是不是我们自己起的 x11vnc 在跑(给测试和状态页用) */
279
+ export function desktopRunning(): boolean {
280
+ return !!vnc;
281
+ }
@@ -0,0 +1,308 @@
1
+ // 网页终端:服务端起 shell(node-pty),页面用 WebSocket 连上来收发。
2
+ //
3
+ // shell 属于人,不属于页面:关掉页面、手机切到后台、断线都不结束它,重连后按 since 补上错过的输出;
4
+ // 没有页面连着超过 terminal.keepAliveMin 分钟才结束。同一个终端可以同时开在几个页面上。
5
+ //
6
+ // 谁能用:管理员;成员要管理员在 设置 → 用户 里单独开「远程控制」(users.json 的 remote)。
7
+ // 和 agent 不同,这里没有授权卡 —— 拿到终端就是拿到这台机器上 agent-loft 这个系统用户的 shell。
8
+ // 权限收回、用户被停用或删除、设置里关掉终端时,他开着的 shell 立刻结束。
9
+ import crypto from "node:crypto";
10
+ import fs from "node:fs";
11
+ import os from "node:os";
12
+ import path from "node:path";
13
+ import { createRequire } from "node:module";
14
+ import { EventEmitter } from "node:events";
15
+ import type { IPty } from "node-pty";
16
+ import { agentEnvironment } from "./agent-env.js";
17
+ import { config, onConfigChange } from "./config.js";
18
+ import { canUseRemote, getUser, userEvents, type User } from "./users.js";
19
+
20
+ // ---------- node-pty ----------
21
+
22
+ type PtyModule = typeof import("node-pty");
23
+
24
+ /** node-pty 是原生模块、可选依赖:装不上(没有编译工具)时终端整个不可用,其他功能照常 */
25
+ let ptyModule: PtyModule | null | undefined;
26
+ let ptyError = "";
27
+
28
+ function loadPty(): PtyModule | null {
29
+ if (ptyModule !== undefined) return ptyModule;
30
+ try {
31
+ ptyModule = createRequire(import.meta.url)("node-pty") as PtyModule;
32
+ } catch (err) {
33
+ ptyModule = null;
34
+ ptyError = err instanceof Error ? err.message.split("\n")[0]! : String(err);
35
+ }
36
+ return ptyModule;
37
+ }
38
+
39
+ /** 终端能不能用;不能用时 reason 写给管理员看 */
40
+ export function terminalAvailability(): { available: boolean; reason?: string } {
41
+ if (loadPty()) return { available: true };
42
+ return { available: false, reason: `没装好 node-pty(${ptyError})。在 agent-loft 的安装目录里运行 npm install node-pty 后重启` };
43
+ }
44
+
45
+ /** 这个人现在能不能用终端 */
46
+ export function canUseTerminal(user: Pick<User, "role" | "disabled" | "remote"> | undefined): boolean {
47
+ return config().remote.terminal && canUseRemote(user);
48
+ }
49
+
50
+ // ---------- 输出记录 ----------
51
+
52
+ /** 每个终端留多少输出(字符),断线重连、换个页面打开时补发 */
53
+ const LOG_LIMIT = 512 * 1024;
54
+
55
+ /**
56
+ * 最近的输出,加上从开始到现在一共输出了多少(total)。
57
+ * 页面记着自己收到第几个字符了(since),重连时只补后面的;早于记录开头的补不了,整屏重画。
58
+ */
59
+ export class OutputLog {
60
+ private buf = "";
61
+ total = 0;
62
+
63
+ constructor(private readonly limit = LOG_LIMIT) {}
64
+
65
+ append(data: string): void {
66
+ this.buf += data;
67
+ this.total += data.length;
68
+ if (this.buf.length > this.limit * 1.25) {
69
+ // 从换行处截:别从一个转义序列中间开始重放
70
+ const cut = this.buf.length - this.limit;
71
+ const nl = this.buf.indexOf("\n", cut);
72
+ this.buf = this.buf.slice(nl >= 0 && nl - cut < 4096 ? nl + 1 : cut);
73
+ }
74
+ }
75
+
76
+ /** since 之后的输出;reset:页面得先清屏,data 是能补的全部 */
77
+ since(since: number): { reset: boolean; data: string } {
78
+ const start = this.total - this.buf.length;
79
+ if (Number.isInteger(since) && since >= start && since <= this.total) return { reset: false, data: this.buf.slice(since - start) };
80
+ return { reset: true, data: this.buf };
81
+ }
82
+ }
83
+
84
+ // ---------- 终端 ----------
85
+
86
+ /** 连着这个终端的一个页面。由 Web 渠道实现(见 channels/web/terminal.ts) */
87
+ export type TermPeer = {
88
+ send(msg: unknown): void;
89
+ close(code: number, reason: string): void;
90
+ };
91
+
92
+ type Term = {
93
+ id: string;
94
+ owner: string;
95
+ cwd: string;
96
+ shell: string;
97
+ createdAt: number;
98
+ pty: IPty;
99
+ log: OutputLog;
100
+ peers: Set<TermPeer>;
101
+ cols: number;
102
+ rows: number;
103
+ /** 没有页面连着时开始计时,到点结束 shell */
104
+ idleTimer: NodeJS.Timeout | null;
105
+ /** 还没发出去的输出:pty 一次只吐几十个字节,攒一下再发 */
106
+ pending: string;
107
+ flushTimer: NodeJS.Timeout | null;
108
+ };
109
+
110
+ export type TermInfo = { id: string; cwd: string; shell: string; createdAt: number; attached: number };
111
+
112
+ const PER_USER = 10;
113
+ const TOTAL = 50;
114
+
115
+ const terms = new Map<string, Term>();
116
+ /** 某人的终端开了 / 结束了:他开着的页面重新拉列表 */
117
+ export const terminalEvents = new EventEmitter<{ changed: [owner: string] }>();
118
+
119
+ function info(t: Term): TermInfo {
120
+ return { id: t.id, cwd: t.cwd, shell: path.basename(t.shell), createdAt: t.createdAt, attached: t.peers.size };
121
+ }
122
+
123
+ export function listTerminals(owner: string): TermInfo[] {
124
+ return [...terms.values()].filter((t) => t.owner === owner).sort((a, b) => a.createdAt - b.createdAt).map(info);
125
+ }
126
+
127
+ /** 只给主人:别人的终端 id 拿到了也当不存在 */
128
+ export function getTerminal(id: string, owner: string): TermInfo | undefined {
129
+ const t = terms.get(id);
130
+ return t && t.owner === owner ? info(t) : undefined;
131
+ }
132
+
133
+ function defaultShell(): { file: string; args: string[] } {
134
+ if (process.platform === "win32") return { file: process.env.COMSPEC || "powershell.exe", args: [] };
135
+ const candidates = [process.env.SHELL, "/bin/bash", "/bin/zsh", "/bin/sh"].filter((s): s is string => !!s);
136
+ const file = candidates.find((s) => path.isAbsolute(s) && fs.existsSync(s)) ?? "/bin/sh";
137
+ // 登录 shell:systemd / launchd 下 PATH 很短,读一遍 profile 才像平时开的终端
138
+ return { file, args: ["-l"] };
139
+ }
140
+
141
+ function usableDir(dir: unknown): string | undefined {
142
+ if (typeof dir !== "string" || !path.isAbsolute(dir)) return undefined;
143
+ try {
144
+ return fs.statSync(dir).isDirectory() ? path.resolve(dir) : undefined;
145
+ } catch {
146
+ return undefined;
147
+ }
148
+ }
149
+
150
+ function clampSize(n: unknown, fallback: number, max: number): number {
151
+ const v = Math.round(Number(n));
152
+ return Number.isFinite(v) && v >= 2 ? Math.min(v, max) : fallback;
153
+ }
154
+
155
+ export function createTerminal(user: User, opts: { cwd?: unknown; cols?: unknown; rows?: unknown }): TermInfo {
156
+ if (!canUseTerminal(user)) throw new Error("你没有使用终端的权限");
157
+ const pty = loadPty();
158
+ if (!pty) throw new Error(terminalAvailability().reason);
159
+ if (listTerminals(user.id).length >= PER_USER) throw new Error(`最多同时开 ${PER_USER} 个终端,先关掉几个`);
160
+ if (terms.size >= TOTAL) throw new Error("服务器上开着的终端太多了");
161
+ const cwd = usableDir(opts.cwd) ?? usableDir(config().workspaceRoot) ?? os.homedir();
162
+ const cols = clampSize(opts.cols, 80, 500);
163
+ const rows = clampSize(opts.rows, 24, 200);
164
+ const shell = defaultShell();
165
+ const env: Record<string, string> = {};
166
+ for (const [k, v] of Object.entries(agentEnvironment())) if (typeof v === "string") env[k] = v;
167
+ env.TERM = "xterm-256color";
168
+ env.COLORTERM = "truecolor";
169
+ if (!env.LANG && !env.LC_ALL) env.LANG = "C.UTF-8";
170
+ const proc = pty.spawn(shell.file, shell.args, { name: "xterm-256color", cols, rows, cwd, env });
171
+ const t: Term = {
172
+ id: `t_${crypto.randomBytes(6).toString("hex")}`,
173
+ owner: user.id,
174
+ cwd,
175
+ shell: shell.file,
176
+ createdAt: Date.now(),
177
+ pty: proc,
178
+ log: new OutputLog(),
179
+ peers: new Set(),
180
+ cols,
181
+ rows,
182
+ idleTimer: null,
183
+ pending: "",
184
+ flushTimer: null,
185
+ };
186
+ terms.set(t.id, t);
187
+ proc.onData((data) => {
188
+ t.log.append(data);
189
+ t.pending += data;
190
+ t.flushTimer ??= setTimeout(() => flush(t), 8);
191
+ });
192
+ proc.onExit(({ exitCode }) => {
193
+ flush(t);
194
+ finish(t, `shell 已退出(${exitCode})`, exitCode);
195
+ });
196
+ armIdle(t);
197
+ console.log(`[终端] ${user.name} 开了终端 ${t.id}(${cwd})`);
198
+ terminalEvents.emit("changed", t.owner);
199
+ return info(t);
200
+ }
201
+
202
+ function flush(t: Term): void {
203
+ if (t.flushTimer) clearTimeout(t.flushTimer);
204
+ t.flushTimer = null;
205
+ if (!t.pending) return;
206
+ const msg = { t: "o", d: t.pending };
207
+ t.pending = "";
208
+ for (const peer of t.peers) peer.send(msg);
209
+ }
210
+
211
+ /** 没有页面连着了:keepAliveMin 分钟后结束 */
212
+ function armIdle(t: Term): void {
213
+ if (t.idleTimer) clearTimeout(t.idleTimer);
214
+ t.idleTimer = null;
215
+ if (t.peers.size > 0) return;
216
+ const ms = Math.max(1, config().remote.keepAliveMin) * 60_000;
217
+ t.idleTimer = setTimeout(() => killTerminal(t.id, "没有页面连着,已超时结束"), ms);
218
+ t.idleTimer.unref();
219
+ }
220
+
221
+ /** 从表里拿掉、告诉连着的页面。shell 自己退出和被结束都走这里 */
222
+ function finish(t: Term, reason: string, code: number | null): void {
223
+ if (terms.get(t.id) !== t) return;
224
+ terms.delete(t.id);
225
+ if (t.idleTimer) clearTimeout(t.idleTimer);
226
+ if (t.flushTimer) clearTimeout(t.flushTimer);
227
+ for (const peer of t.peers) {
228
+ peer.send({ t: "x", code, reason });
229
+ peer.close(4000, "终端已结束");
230
+ }
231
+ t.peers.clear();
232
+ terminalEvents.emit("changed", t.owner);
233
+ }
234
+
235
+ export function killTerminal(id: string, reason = "已关闭"): boolean {
236
+ const t = terms.get(id);
237
+ if (!t) return false;
238
+ finish(t, reason, null);
239
+ try {
240
+ t.pty.kill();
241
+ } catch {
242
+ // 已经退出了
243
+ }
244
+ return true;
245
+ }
246
+
247
+ /** 页面连上来:先补上 since 之后的输出 */
248
+ export function attachTerminal(id: string, owner: string, peer: TermPeer, since: number): boolean {
249
+ const t = terms.get(id);
250
+ if (!t || t.owner !== owner) return false;
251
+ flush(t);
252
+ const { reset, data } = t.log.since(since);
253
+ peer.send({ t: "a", reset, d: data, total: t.log.total, cwd: t.cwd });
254
+ t.peers.add(peer);
255
+ armIdle(t);
256
+ return true;
257
+ }
258
+
259
+ export function detachTerminal(id: string, peer: TermPeer): void {
260
+ const t = terms.get(id);
261
+ if (!t || !t.peers.delete(peer)) return;
262
+ armIdle(t);
263
+ }
264
+
265
+ export function writeTerminal(id: string, data: string): void {
266
+ const t = terms.get(id);
267
+ if (t && data) t.pty.write(data);
268
+ }
269
+
270
+ /** 几个页面开着同一个终端、窗口大小不一样:以最后调整的那个为准 */
271
+ export function resizeTerminal(id: string, cols: unknown, rows: unknown): void {
272
+ const t = terms.get(id);
273
+ if (!t) return;
274
+ const c = clampSize(cols, t.cols, 500);
275
+ const r = clampSize(rows, t.rows, 200);
276
+ if (c === t.cols && r === t.rows) return;
277
+ t.cols = c;
278
+ t.rows = r;
279
+ try {
280
+ t.pty.resize(c, r);
281
+ } catch {
282
+ // 正在退出
283
+ }
284
+ }
285
+
286
+ /**
287
+ * 权限变了(用户改了、设置里关了):现在不能用终端的人,shell 立刻结束。
288
+ * 用户改动由 userEvents 通知;终端里 agent-loft user … 直接改文件的没有事件,Web 的心跳会再调一次
289
+ */
290
+ export function enforceTerminalAccess(): void {
291
+ for (const t of [...terms.values()]) {
292
+ if (!canUseTerminal(getUser(t.owner))) killTerminal(t.id, "终端权限已被收回");
293
+ }
294
+ }
295
+
296
+ let watching = false;
297
+ /** 启动 Web 渠道时调用一次 */
298
+ export function watchTerminalAccess(): void {
299
+ if (watching) return;
300
+ watching = true;
301
+ userEvents.on("changed", enforceTerminalAccess);
302
+ onConfigChange(() => enforceTerminalAccess());
303
+ }
304
+
305
+ /** 服务停止:结束所有 shell */
306
+ export function killAllTerminals(): void {
307
+ for (const id of [...terms.keys()]) killTerminal(id, "服务已停止");
308
+ }
package/src/core/users.ts CHANGED
@@ -25,6 +25,8 @@ export type User = {
25
25
  passwordHash: string;
26
26
  createdAt: number;
27
27
  disabled?: boolean;
28
+ /** 成员能不能用远程控制(终端、桌面)。管理员一直能用,不看这个 */
29
+ remote?: boolean;
28
30
  };
29
31
 
30
32
  export type PublicUser = Omit<User, "passwordHash">;
@@ -165,6 +167,12 @@ export function isAdmin(user: Pick<User, "role"> | undefined): boolean {
165
167
  return user?.role === "admin";
166
168
  }
167
169
 
170
+ /** 能不能用远程控制(终端、桌面):管理员能用,成员要单独开。设置里各自还有总开关 */
171
+ export function canUseRemote(user: Pick<User, "role" | "disabled" | "remote"> | undefined): boolean {
172
+ if (!user || user.disabled) return false;
173
+ return user.role === "admin" || user.remote === true;
174
+ }
175
+
168
176
  /**
169
177
  * 升级前的登录态、推送订阅没有记是谁的:都算最早的那个管理员的(就是迁移出来的 admin)。
170
178
  */
@@ -220,7 +228,10 @@ export function createUser(params: { name: string; password: string; role?: Role
220
228
  return publicUser(user);
221
229
  }
222
230
 
223
- export function updateUser(id: string, patch: { name?: string; role?: Role; disabled?: boolean; password?: string }): PublicUser {
231
+ export function updateUser(
232
+ id: string,
233
+ patch: { name?: string; role?: Role; disabled?: boolean; password?: string; remote?: boolean },
234
+ ): PublicUser {
224
235
  const list = load().map((u) => ({ ...u }));
225
236
  const user = list.find((u) => u.id === id);
226
237
  if (!user) throw new Error("用户不存在");
@@ -234,6 +245,10 @@ export function updateUser(id: string, patch: { name?: string; role?: Role; disa
234
245
  if (patch.disabled) user.disabled = true;
235
246
  else delete user.disabled;
236
247
  }
248
+ if (patch.remote !== undefined) {
249
+ if (patch.remote) user.remote = true;
250
+ else delete user.remote;
251
+ }
237
252
  if (patch.password !== undefined) {
238
253
  checkPassword(patch.password);
239
254
  user.passwordHash = hashPassword(patch.password);