@keyou007/agent-bot 0.1.24-alpha.5 → 0.1.24-alpha.6

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.
Files changed (96) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +27 -3
  3. package/README.zh.md +27 -3
  4. package/SECURITY.md +12 -0
  5. package/config.example.yaml +5 -0
  6. package/dist/cli/controlProtocol.js.map +1 -1
  7. package/dist/cli/serverStatus.js +1 -0
  8. package/dist/cli/serverStatus.js.map +1 -1
  9. package/dist/cli.js +6 -1
  10. package/dist/cli.js.map +1 -1
  11. package/dist/codex/CodexRuntime.js +202 -115
  12. package/dist/codex/CodexRuntime.js.map +1 -1
  13. package/dist/codex/ProviderSwitch.js +12 -13
  14. package/dist/codex/ProviderSwitch.js.map +1 -1
  15. package/dist/config/AgentExecutionDefaultsStore.js +4 -0
  16. package/dist/config/AgentExecutionDefaultsStore.js.map +1 -1
  17. package/dist/config/loadConfig.js +4 -0
  18. package/dist/config/loadConfig.js.map +1 -1
  19. package/dist/config/schema.js +3 -0
  20. package/dist/config/schema.js.map +1 -1
  21. package/dist/feishu/CardRenderer.js +66 -8
  22. package/dist/feishu/CardRenderer.js.map +1 -1
  23. package/dist/feishu/FeishuConnector.js +1 -0
  24. package/dist/feishu/FeishuConnector.js.map +1 -1
  25. package/dist/feishu/FeishuTurnPresenter.js +121 -17
  26. package/dist/feishu/FeishuTurnPresenter.js.map +1 -1
  27. package/dist/index.js +43 -6
  28. package/dist/index.js.map +1 -1
  29. package/dist/local-files/LocalFileViewerServer.js +51 -4
  30. package/dist/local-files/LocalFileViewerServer.js.map +1 -1
  31. package/dist/local-files/TurnPreviewPage.js +7 -2
  32. package/dist/local-files/TurnPreviewPage.js.map +1 -1
  33. package/dist/presentation/TurnStateReducer.js +41 -6
  34. package/dist/presentation/TurnStateReducer.js.map +1 -1
  35. package/dist/proxy/ProxySessionController.js +615 -214
  36. package/dist/proxy/ProxySessionController.js.map +1 -1
  37. package/dist/proxy/SessionOperationQueue.js +14 -0
  38. package/dist/proxy/SessionOperationQueue.js.map +1 -0
  39. package/dist/runtime/AcpRuntimeAdapter.js +4 -2
  40. package/dist/runtime/AcpRuntimeAdapter.js.map +1 -1
  41. package/dist/state/StateStore.js +22 -0
  42. package/dist/state/StateStore.js.map +1 -1
  43. package/dist/state/TurnPreviewJournal.js +19 -7
  44. package/dist/state/TurnPreviewJournal.js.map +1 -1
  45. package/dist/state/migrations.js +6 -0
  46. package/dist/state/migrations.js.map +1 -1
  47. package/dist/supervision/SafeRestartNotifier.js +1 -0
  48. package/dist/supervision/SafeRestartNotifier.js.map +1 -1
  49. package/dist/supervision/SafeRestartScheduler.js +19 -6
  50. package/dist/supervision/SafeRestartScheduler.js.map +1 -1
  51. package/dist/terminal/TerminalCards.js +13 -0
  52. package/dist/terminal/TerminalCards.js.map +1 -0
  53. package/dist/terminal/TerminalLinks.js +76 -0
  54. package/dist/terminal/TerminalLinks.js.map +1 -0
  55. package/dist/terminal/TerminalPage.js +212 -0
  56. package/dist/terminal/TerminalPage.js.map +1 -0
  57. package/dist/terminal/TerminalProcess.js +71 -0
  58. package/dist/terminal/TerminalProcess.js.map +1 -0
  59. package/dist/terminal/TerminalServer.js +639 -0
  60. package/dist/terminal/TerminalServer.js.map +1 -0
  61. package/docs/technical-reference.md +48 -0
  62. package/docs/technical-reference.zh.md +48 -0
  63. package/npm-shrinkwrap.json +55 -5
  64. package/package.json +11 -4
  65. package/skills/agent-bot/SKILL.md +9 -2
  66. package/src/cli/controlProtocol.ts +2 -1
  67. package/src/cli/serverStatus.ts +1 -0
  68. package/src/cli.ts +6 -1
  69. package/src/codex/CodexRuntime.ts +200 -123
  70. package/src/codex/ProviderSwitch.ts +11 -12
  71. package/src/config/AgentExecutionDefaultsStore.ts +2 -0
  72. package/src/config/loadConfig.ts +4 -0
  73. package/src/config/schema.ts +5 -1
  74. package/src/feishu/CardRenderer.ts +81 -8
  75. package/src/feishu/FeishuConnector.ts +1 -0
  76. package/src/feishu/FeishuTurnPresenter.ts +108 -16
  77. package/src/feishu/types.ts +1 -0
  78. package/src/index.ts +44 -7
  79. package/src/local-files/LocalFileViewerServer.ts +48 -4
  80. package/src/local-files/TurnPreviewPage.ts +7 -2
  81. package/src/presentation/TurnStateReducer.ts +46 -7
  82. package/src/presentation/turnViewTypes.ts +5 -0
  83. package/src/proxy/ProxySessionController.ts +625 -211
  84. package/src/proxy/SessionOperationQueue.ts +13 -0
  85. package/src/runtime/AcpRuntimeAdapter.ts +5 -3
  86. package/src/runtime/types.ts +8 -3
  87. package/src/state/StateStore.ts +38 -1
  88. package/src/state/TurnPreviewJournal.ts +19 -7
  89. package/src/state/migrations.ts +6 -0
  90. package/src/supervision/SafeRestartNotifier.ts +1 -0
  91. package/src/supervision/SafeRestartScheduler.ts +18 -6
  92. package/src/terminal/TerminalCards.ts +18 -0
  93. package/src/terminal/TerminalLinks.ts +61 -0
  94. package/src/terminal/TerminalPage.ts +213 -0
  95. package/src/terminal/TerminalProcess.ts +71 -0
  96. package/src/terminal/TerminalServer.ts +513 -0
@@ -0,0 +1,639 @@
1
+ import { createHash, randomBytes, randomInt, timingSafeEqual } from "node:crypto";
2
+ import fs from "node:fs";
3
+ import { createRequire } from "node:module";
4
+ import path from "node:path";
5
+ import { WebSocket, WebSocketServer } from "ws";
6
+ import { z } from "zod";
7
+ import { spawnTerminal } from "./TerminalProcess.js";
8
+ import { terminalPage, TERMINAL_CSS, TERMINAL_SCRIPT } from "./TerminalPage.js";
9
+ import { TerminalLinks } from "./TerminalLinks.js";
10
+ const require = createRequire(import.meta.url);
11
+ const AUTH_TTL = 5 * 60_000;
12
+ const IDLE_TTL = 30 * 60_000;
13
+ const DISCONNECT_TTL = 3 * 60_000;
14
+ const FOREGROUND_TTL = 90_000;
15
+ const MAX_TTL = 8 * 60 * 60_000;
16
+ const KEY = z.string().regex(/^[a-f0-9]{64}$/u);
17
+ const credentials = z.object({ requestId: z.string().regex(/^[a-f0-9]{32}$/u), browserKey: KEY });
18
+ const frame = z.discriminatedUnion("type", [
19
+ credentials.extend({ type: z.literal("auth") }),
20
+ z.object({ type: z.literal("input"), data: z.string().max(32_768) }),
21
+ z.object({ type: z.literal("resize"), cols: z.number().int().min(2).max(500), rows: z.number().int().min(2).max(200) }),
22
+ z.object({ type: z.literal("visibility"), foreground: z.boolean() }),
23
+ ]);
24
+ export class TerminalServer {
25
+ options;
26
+ unmount;
27
+ sockets = new WebSocketServer({ noServer: true, maxPayload: 64 * 1024, perMessageDeflate: false });
28
+ sessions = new Map();
29
+ recoveries = new Map();
30
+ cardUpdates = Promise.resolve();
31
+ requests = [];
32
+ timer;
33
+ links;
34
+ base;
35
+ closed = false;
36
+ lastUpgradeWarning = -Infinity;
37
+ now;
38
+ constructor(options) {
39
+ this.options = options;
40
+ this.now = options.now ?? Date.now;
41
+ }
42
+ async start(viewer) {
43
+ if (this.closed)
44
+ throw new Error("Terminal server is closed.");
45
+ if (this.base)
46
+ return this.base.toString();
47
+ fs.mkdirSync(this.options.stateDirectory, { recursive: true });
48
+ const secretPath = path.join(this.options.stateDirectory, "secret");
49
+ if (!fs.existsSync(secretPath))
50
+ fs.writeFileSync(secretPath, randomBytes(32), { flag: "wx", mode: 0o600 });
51
+ const secret = fs.readFileSync(secretPath);
52
+ if (secret.length !== 32)
53
+ throw new Error("Invalid terminal link secret.");
54
+ this.links = new TerminalLinks(path.join(this.options.stateDirectory, "open-links"), secret);
55
+ const mounted = viewer.mountService("terminal", {
56
+ handleRequest: (req, res) => {
57
+ void this.handleRequest(req, res).catch((error) => {
58
+ this.options.logger.warn({ error: error instanceof Error ? error.message : String(error) }, "Terminal HTTP request failed.");
59
+ if (res.headersSent)
60
+ res.destroy();
61
+ else
62
+ this.json(res, 400, { error: error instanceof Error ? error.message : "Terminal request failed." });
63
+ });
64
+ },
65
+ handleUpgrade: (req, socket, head) => this.handleUpgrade(req, socket, head),
66
+ close: () => this.close(),
67
+ });
68
+ this.base = new URL(mounted.publicBaseUrl);
69
+ this.unmount = mounted.unmount;
70
+ this.timer = setInterval(() => this.sweep(), 30_000);
71
+ this.timer.unref();
72
+ return this.base.toString();
73
+ }
74
+ handleUpgrade(req, socket, head) {
75
+ if (this.closed || !this.validOrigin(req) || req.url !== `${this.basePath}/socket` || this.sockets.clients.size >= 20) {
76
+ if (this.now() - this.lastUpgradeWarning >= 10_000) {
77
+ this.lastUpgradeWarning = this.now();
78
+ this.options.logger.warn({ stage: "upgrade", hostMatches: req.headers.host === this.base?.host,
79
+ originMatches: req.headers.origin === this.base?.origin }, "Terminal WebSocket upgrade rejected.");
80
+ }
81
+ socket.end("HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n");
82
+ return;
83
+ }
84
+ this.sockets.handleUpgrade(req, socket, head, (ws) => this.connect(ws));
85
+ }
86
+ createUrl(jobId) {
87
+ if (this.closed || !this.base || !this.links || !this.options.ownerId() || !/^[a-f0-9-]{36}$/u.test(jobId))
88
+ return undefined;
89
+ try {
90
+ return `${this.base.origin}${this.basePath}/${this.links.create(jobId)}`;
91
+ }
92
+ catch (error) {
93
+ this.options.logger.warn({ code: error.code }, "Failed to create terminal short link.");
94
+ return undefined;
95
+ }
96
+ }
97
+ get activeCount() {
98
+ return [...this.sessions.values()].filter((s) => s.pty || s.starting).length;
99
+ }
100
+ async resolveApproval(action) {
101
+ this.sweep();
102
+ const id = String(action.value.requestId ?? "");
103
+ const recovery = this.recoveries.get(id);
104
+ const authorization = recovery ?? this.sessions.get(id);
105
+ if (!authorization || action.userId !== this.options.ownerId() || action.userId !== authorization.ownerId
106
+ || !authorization.cardId || action.messageId !== authorization.cardId
107
+ || !equalText(authorization.approvalToken, String(action.value.token ?? "")))
108
+ throw new Error("终端授权无效或已过期。");
109
+ const session = this.sessions.get(recovery?.targetId ?? id);
110
+ if (!session)
111
+ throw new Error("终端已结束或服务已重启,请从命令卡片创建新终端。");
112
+ if (action.value.decision === "refresh") {
113
+ this.publishCards(session);
114
+ await this.cardUpdates;
115
+ return "已刷新终端管理卡。";
116
+ }
117
+ if (action.value.decision === "close") {
118
+ if (recovery && recovery.state !== "approved")
119
+ this.closeRecovery(recovery, "恢复连接请求已取消");
120
+ else
121
+ this.disposeSession(session, "所有者已在飞书关闭终端");
122
+ this.publishCards(session);
123
+ await this.cardUpdates;
124
+ return recovery?.state === "closed" && session.state !== "closed" ? "恢复连接请求已取消,原终端不受影响。" : "终端已关闭。";
125
+ }
126
+ if (authorization.state !== "pending")
127
+ throw new Error("该终端授权已处理或过期,请查看原会话中的终端管理卡。");
128
+ if (action.value.decision === "allow") {
129
+ if (recovery) {
130
+ if (!this.canReopen(session))
131
+ throw new Error("原终端已结束,不能恢复。");
132
+ if (session.state === "approved") {
133
+ this.assertCanStart();
134
+ session.expiresAt = this.now() + AUTH_TTL;
135
+ }
136
+ for (const other of this.recoveries.values()) {
137
+ if (other.targetId === session.requestId && other !== recovery && other.state !== "closed")
138
+ this.closeRecovery(other, "已由另一网页恢复连接");
139
+ }
140
+ recovery.state = "approved";
141
+ session.activeAccessId = recovery.requestId;
142
+ session.socket?.close(1000, "Reconnected from another page");
143
+ session.socket = undefined;
144
+ this.markDisconnected(session);
145
+ }
146
+ else
147
+ session.state = "approved";
148
+ this.publishCards(session);
149
+ await this.cardUpdates;
150
+ this.options.logger.info({ requestId: session.requestId }, "Terminal authorized by owner.");
151
+ return session.state === "approved"
152
+ ? "已授权,请返回网页连接;若原网页已关闭,点击本卡片的“打开终端”。"
153
+ : "已授权,请返回网页;也可点击本卡片的“重新打开终端”或“关闭终端”。";
154
+ }
155
+ if (action.value.decision !== "deny")
156
+ throw new Error("无效的终端授权操作。");
157
+ if (recovery)
158
+ this.closeRecovery(recovery, "恢复连接已拒绝,原终端不受影响");
159
+ else
160
+ this.disposeSession(session, "终端授权已拒绝");
161
+ this.publishCards(session);
162
+ await this.cardUpdates;
163
+ return "已拒绝终端连接。";
164
+ }
165
+ async close() {
166
+ if (this.closed)
167
+ return;
168
+ this.closed = true;
169
+ clearInterval(this.timer);
170
+ this.unmount?.();
171
+ this.unmount = undefined;
172
+ for (const s of this.sessions.values())
173
+ this.disposeSession(s, "AgentBot 正在关闭,终端已结束");
174
+ for (const ws of this.sockets.clients)
175
+ ws.terminate();
176
+ await Promise.all([...this.sessions.values()].map((s) => s.starting?.catch(() => undefined)));
177
+ await this.cardUpdates;
178
+ this.sockets.close();
179
+ this.base = undefined;
180
+ }
181
+ get basePath() { return this.base?.pathname.replace(/[/]$/u, "") ?? ""; }
182
+ reopenUrl(session) {
183
+ return `${this.base.origin}${this.basePath}/${session.reopenToken}`;
184
+ }
185
+ canReopen(session) {
186
+ return session.state === "approved" || (session.state === "running" && Boolean(session.pty));
187
+ }
188
+ markDisconnected(session) {
189
+ if (session.state !== "running" || session.socket || session.disconnectedAt !== undefined)
190
+ return;
191
+ this.leaveForeground(session);
192
+ session.disconnectedAt = this.now();
193
+ const expire = () => {
194
+ if (session.state !== "running" || session.socket || session.disconnectedAt === undefined)
195
+ return;
196
+ const remaining = DISCONNECT_TTL - (this.now() - session.disconnectedAt);
197
+ if (remaining <= 0)
198
+ this.disposeSession(session, "网页已断开 3 分钟,Shell 已自动关闭");
199
+ else {
200
+ session.disconnectTimer = setTimeout(expire, remaining);
201
+ session.disconnectTimer.unref();
202
+ }
203
+ };
204
+ session.disconnectTimer = setTimeout(expire, DISCONNECT_TTL);
205
+ session.disconnectTimer.unref();
206
+ }
207
+ clearDisconnectTimer(session) {
208
+ clearTimeout(session.disconnectTimer);
209
+ session.disconnectTimer = undefined;
210
+ session.disconnectedAt = undefined;
211
+ }
212
+ leaveForeground(session) {
213
+ if (session.foregroundUntil !== undefined) {
214
+ session.lastActivity = Math.max(session.lastActivity, Math.min(this.now(), session.foregroundUntil));
215
+ session.foregroundUntil = undefined;
216
+ }
217
+ }
218
+ resumeId(launch) {
219
+ if (!launch.startsWith(`${this.basePath}/`))
220
+ return undefined;
221
+ const token = /^[/]([A-Za-z0-9_-]{8})$/u.exec(launch.slice(this.basePath.length));
222
+ return token ? [...this.sessions.values()].find((session) => session.reopenToken === token[1])?.requestId : undefined;
223
+ }
224
+ cardView(session, recovery) {
225
+ const auth = recovery ?? session;
226
+ const state = recovery && recovery.state !== "approved" ? recovery.state : session.state;
227
+ return { requestId: auth.requestId, approvalToken: auth.approvalToken, code: auth.code, cwd: session.cwd,
228
+ contextKey: session.contextKey, commandCardMessageId: session.commandCardMessageId, shellStarted: Boolean(session.pty),
229
+ expiresAt: auth.expiresAt, terminalId: session.requestId, reopenToken: session.reopenToken, reopenUrl: this.reopenUrl(session), recovery: Boolean(recovery), state,
230
+ connected: session.socket?.readyState === WebSocket.OPEN, message: recovery?.message ?? session.message };
231
+ }
232
+ publishCards(session) {
233
+ if (!this.options.updateCard || !this.base)
234
+ return;
235
+ for (const auth of [session, ...[...this.recoveries.values()].filter((r) => r.targetId === session.requestId)]) {
236
+ const cardId = auth.cardId;
237
+ if (!cardId)
238
+ continue;
239
+ const view = this.cardView(session, "targetId" in auth ? auth : undefined);
240
+ this.cardUpdates = this.cardUpdates.then(() => this.options.updateCard(cardId, view)).catch(() => {
241
+ this.options.logger.warn({ requestId: auth.requestId }, "Failed to update terminal management card.");
242
+ });
243
+ }
244
+ }
245
+ closeRecovery(recovery, message) {
246
+ recovery.state = "closed";
247
+ recovery.message = message;
248
+ }
249
+ validOrigin(req) {
250
+ return Boolean(this.base && req.headers.host === this.base.host && req.headers.origin === this.base.origin);
251
+ }
252
+ launchJob(launch) {
253
+ if (!launch.startsWith(`${this.basePath}/`))
254
+ return undefined;
255
+ const token = /^[/]([A-Za-z0-9_-]{8})$/u.exec(launch.slice(this.basePath.length));
256
+ return token ? this.links?.resolve(token[1]) : undefined;
257
+ }
258
+ access(value) {
259
+ const parsed = credentials.parse(value);
260
+ this.sweep();
261
+ const recovery = this.recoveries.get(parsed.requestId);
262
+ const authorization = recovery ?? this.sessions.get(parsed.requestId);
263
+ const session = this.sessions.get(recovery?.targetId ?? parsed.requestId);
264
+ if (!authorization || !session || !timingSafeEqual(authorization.keyHash, hash(parsed.browserKey))
265
+ || (!recovery && session.activeAccessId !== parsed.requestId))
266
+ throw new Error("终端凭证无效或已由其他网页恢复,请从原会话的终端管理卡重新打开。");
267
+ return { authorization, session };
268
+ }
269
+ authenticated(value) {
270
+ const { authorization, session } = this.access(value);
271
+ if (authorization.state === "pending" || authorization.state === "closed" || session.activeAccessId !== authorization.requestId) {
272
+ throw new Error("终端尚未授权、已结束或已由其他网页恢复。");
273
+ }
274
+ return session;
275
+ }
276
+ async handleRequest(req, res) {
277
+ if (!this.base || req.headers.host !== this.base.host) {
278
+ this.json(res, 403, { error: "Invalid Host." });
279
+ return;
280
+ }
281
+ const url = new URL(req.url ?? "/", this.base);
282
+ if (url.search || !url.pathname.startsWith(`${this.basePath}/`)) {
283
+ this.json(res, 404, { error: "Not found." });
284
+ return;
285
+ }
286
+ const route = url.pathname.slice(this.basePath.length);
287
+ this.sweep();
288
+ if (req.method === "GET" || req.method === "HEAD") {
289
+ let body;
290
+ let type = "text/javascript; charset=utf-8";
291
+ if (route === "/assets/terminal.js")
292
+ body = TERMINAL_SCRIPT;
293
+ else if (route === "/assets/terminal.css") {
294
+ body = TERMINAL_CSS;
295
+ type = "text/css";
296
+ }
297
+ else if (route === "/assets/xterm.js")
298
+ body = fs.readFileSync(require.resolve("@xterm/xterm"), "utf8");
299
+ else if (route === "/assets/fit.js")
300
+ body = fs.readFileSync(require.resolve("@xterm/addon-fit"), "utf8");
301
+ else if (route === "/assets/xterm.css") {
302
+ body = fs.readFileSync(path.join(path.dirname(require.resolve("@xterm/xterm")), "../css/xterm.css"), "utf8");
303
+ type = "text/css";
304
+ }
305
+ else {
306
+ const resumeId = this.resumeId(url.pathname);
307
+ if (this.launchJob(url.pathname) || resumeId) {
308
+ body = terminalPage(this.basePath, Boolean(resumeId));
309
+ type = "text/html; charset=utf-8";
310
+ }
311
+ }
312
+ this.headers(res);
313
+ res.statusCode = body === undefined ? 404 : 200;
314
+ res.setHeader("Content-Type", type);
315
+ res.end(req.method === "HEAD" ? undefined : body ?? "Not found.");
316
+ return;
317
+ }
318
+ if (req.method !== "POST" || !this.validOrigin(req) || req.headers["content-type"] !== "application/json") {
319
+ this.json(res, 403, { error: "Origin or request method rejected." });
320
+ return;
321
+ }
322
+ const data = await readJson(req);
323
+ if (route === "/api/request") {
324
+ const parsed = z.object({ launch: z.string().max(300), browserKey: KEY }).parse(data);
325
+ const resumeId = this.resumeId(parsed.launch);
326
+ const jobId = this.launchJob(parsed.launch);
327
+ const ownerId = this.options.ownerId();
328
+ if ((!jobId && !resumeId) || !ownerId)
329
+ throw new Error("终端链接无效,或尚未配置飞书所有者。");
330
+ const existing = resumeId ? this.sessions.get(resumeId) : undefined;
331
+ if (resumeId && (!existing || !this.canReopen(existing) || existing.ownerId !== ownerId)) {
332
+ throw new Error("原终端尚未授权、已结束或服务已重启;请从原命令卡片创建新终端。");
333
+ }
334
+ if (!existing || existing.state === "approved")
335
+ this.assertCanStart();
336
+ const now = this.now();
337
+ while (this.requests[0] !== undefined && this.requests[0] < now - 3600_000)
338
+ this.requests.shift();
339
+ if (this.requests.length >= 20 || (this.requests.length > 0 && now - this.requests.at(-1) < 10_000)
340
+ || (existing ? [...this.recoveries.values()].filter((r) => r.state === "pending").length >= 3
341
+ : [...this.sessions.values()].filter((s) => s.state !== "closed").length >= 3)) {
342
+ this.json(res, 429, { error: "终端请求过于频繁或已达 3 个会话上限,请稍后重试。" });
343
+ return;
344
+ }
345
+ this.requests.push(now);
346
+ if (existing) {
347
+ const recovery = { requestId: randomBytes(16).toString("hex"), targetId: existing.requestId, ownerId,
348
+ keyHash: hash(parsed.browserKey), approvalToken: randomBytes(32).toString("hex"), code: String(randomInt(100000, 1000000)),
349
+ cwd: existing.cwd, expiresAt: now + AUTH_TTL, state: "pending" };
350
+ this.recoveries.set(recovery.requestId, recovery);
351
+ try {
352
+ recovery.cardId = await this.options.requestApproval(this.cardView(existing, recovery));
353
+ if (!recovery.cardId)
354
+ throw new Error("无法发送飞书授权卡片。");
355
+ }
356
+ catch (error) {
357
+ this.closeRecovery(recovery, "恢复授权通知失败");
358
+ throw error;
359
+ }
360
+ if (existing.state !== "running" || recovery.state !== "pending")
361
+ this.publishCards(existing);
362
+ this.json(res, 200, { requestId: recovery.requestId, code: recovery.code });
363
+ return;
364
+ }
365
+ const job = await this.options.readJob(jobId);
366
+ this.assertCanStart();
367
+ if (!job.cardMessageId || !job.contextKey?.trim())
368
+ throw new Error("命令卡片或原始会话信息尚未就绪。");
369
+ const requestId = randomBytes(16).toString("hex");
370
+ const session = { requestId, reopenToken: this.links.createRecovery(), activeAccessId: requestId, ownerId, cwd: job.cwd, keyHash: hash(parsed.browserKey),
371
+ contextKey: job.contextKey, commandCardMessageId: job.cardMessageId,
372
+ approvalToken: randomBytes(32).toString("hex"), code: String(randomInt(100000, 1000000)),
373
+ createdAt: now, lastActivity: now, expiresAt: now + AUTH_TTL, state: "pending", output: "" };
374
+ this.sessions.set(requestId, session);
375
+ try {
376
+ session.cardId = await this.options.requestApproval(this.cardView(session));
377
+ if (!session.cardId)
378
+ throw new Error("无法发送飞书授权卡片。");
379
+ if (session.state !== "pending")
380
+ this.publishCards(session);
381
+ }
382
+ catch (error) {
383
+ this.disposeSession(session, "授权通知失败");
384
+ throw error;
385
+ }
386
+ this.json(res, 200, { requestId, code: session.code });
387
+ return;
388
+ }
389
+ if (route !== "/api/status" && route !== "/api/close") {
390
+ this.json(res, 404, { error: "Not found." });
391
+ return;
392
+ }
393
+ const { authorization, session } = this.access(data);
394
+ if ("targetId" in authorization && authorization.state !== "approved") {
395
+ if (route === "/api/close") {
396
+ this.closeRecovery(authorization, "恢复连接请求已取消");
397
+ this.publishCards(session);
398
+ }
399
+ this.json(res, 200, { state: authorization.state, message: authorization.message });
400
+ return;
401
+ }
402
+ if (route === "/api/status" && session.state === "approved" && !session.approvalObserved) {
403
+ session.approvalObserved = true;
404
+ this.options.logger.info({ requestId: session.requestId }, "Terminal approval observed by browser.");
405
+ }
406
+ if (route === "/api/close")
407
+ this.disposeSession(session, "终端已关闭");
408
+ this.json(res, 200, { state: session.state, message: session.message });
409
+ }
410
+ connect(ws) {
411
+ let session;
412
+ let authenticating = false;
413
+ let accessId;
414
+ const timeout = setTimeout(() => {
415
+ this.options.logger.warn({ stage: "auth_timeout" }, "Terminal WebSocket authentication timed out.");
416
+ this.send(ws, { type: "error", stage: "auth", message: "终端连接鉴权超时,请重新连接。" });
417
+ ws.close(1008, "Authentication required");
418
+ }, 5000);
419
+ timeout.unref();
420
+ ws.on("error", () => ws.terminate());
421
+ ws.on("close", () => {
422
+ clearTimeout(timeout);
423
+ if (session?.socket === ws) {
424
+ session.socket = undefined;
425
+ this.markDisconnected(session);
426
+ this.publishCards(session);
427
+ }
428
+ });
429
+ ws.on("message", (data, binary) => {
430
+ let stage = "protocol";
431
+ let requestId;
432
+ void (async () => {
433
+ if (binary)
434
+ throw new Error("Binary input rejected.");
435
+ let parsed;
436
+ try {
437
+ parsed = JSON.parse(data.toString());
438
+ }
439
+ catch {
440
+ throw new Error("Invalid terminal JSON frame.");
441
+ }
442
+ const value = frame.parse(parsed);
443
+ if (!session) {
444
+ stage = "auth";
445
+ if (authenticating || value.type !== "auth")
446
+ throw new Error("Authentication required.");
447
+ authenticating = true;
448
+ const target = this.authenticated(value);
449
+ if (target.state !== "approved" && target.state !== "running")
450
+ throw new Error("终端尚未授权或已结束。");
451
+ requestId = target.requestId;
452
+ clearTimeout(timeout);
453
+ this.options.logger.info({ requestId }, "Terminal WebSocket authenticated.");
454
+ stage = "spawn";
455
+ await this.ensureProcess(target);
456
+ if (ws.readyState !== WebSocket.OPEN)
457
+ return;
458
+ if (target.activeAccessId !== value.requestId)
459
+ throw new Error("终端已由其他网页恢复。");
460
+ if (target.state !== "running")
461
+ throw new Error(target.message ?? "终端已结束。");
462
+ target.socket?.close(1000, "Reconnected from another page");
463
+ session = target;
464
+ session.socket = ws;
465
+ accessId = value.requestId;
466
+ clearTimeout(timeout);
467
+ this.clearDisconnectTimer(session);
468
+ session.lastActivity = this.now();
469
+ session.foregroundUntil = undefined;
470
+ // Only the first attachment may answer buffered Shell startup queries.
471
+ this.send(ws, { type: "ready", cwd: session.cwd, output: session.output, replay: session.attached === true });
472
+ session.attached = true;
473
+ this.publishCards(session);
474
+ return;
475
+ }
476
+ if (session.state !== "running" || session.socket !== ws || session.activeAccessId !== accessId)
477
+ throw new Error("Terminal is closed.");
478
+ if (value.type === "input") {
479
+ session.lastActivity = this.now();
480
+ session.pty?.write(value.data);
481
+ }
482
+ else if (value.type === "resize")
483
+ session.pty?.resize(value.cols, value.rows);
484
+ else if (value.type === "visibility") {
485
+ if (value.foreground)
486
+ session.foregroundUntil = this.now() + FOREGROUND_TTL;
487
+ else
488
+ this.leaveForeground(session);
489
+ }
490
+ else
491
+ throw new Error("Already authenticated.");
492
+ })().catch((error) => {
493
+ clearTimeout(timeout);
494
+ this.options.logger.warn({ requestId, stage }, "Terminal WebSocket request failed.");
495
+ this.send(ws, { type: "error", stage, message: error instanceof Error ? error.message : "Terminal error" });
496
+ ws.close(1008, "Terminal request rejected");
497
+ });
498
+ });
499
+ }
500
+ async ensureProcess(session) {
501
+ if (session.pty)
502
+ return;
503
+ if (session.starting)
504
+ return session.starting;
505
+ this.assertCanStart();
506
+ const starting = (async () => {
507
+ const pty = await (this.options.spawn ?? spawnTerminal)(session.cwd, 100, 30);
508
+ if (this.closed || this.options.canStart?.() === false || session.state === "closed"
509
+ || session.ownerId !== this.options.ownerId() || this.now() >= session.expiresAt) {
510
+ pty.kill();
511
+ this.disposeSession(session, "服务正在重启或授权已失效");
512
+ return;
513
+ }
514
+ session.pty = pty;
515
+ session.state = "running";
516
+ session.lastActivity = this.now();
517
+ this.markDisconnected(session);
518
+ pty.onData((data) => {
519
+ if (session.state !== "running")
520
+ return;
521
+ session.lastActivity = this.now();
522
+ session.output = (session.output + data).slice(-256 * 1024);
523
+ if (session.socket)
524
+ this.send(session.socket, { type: "data", data });
525
+ });
526
+ pty.onExit(({ exitCode }) => { session.pty = undefined; this.disposeSession(session, `Shell 已退出(${exitCode})`); });
527
+ this.options.logger.info({ requestId: session.requestId }, "Interactive terminal started.");
528
+ })();
529
+ session.starting = starting;
530
+ try {
531
+ await starting;
532
+ }
533
+ catch (error) {
534
+ this.disposeSession(session, "终端启动失败");
535
+ throw error;
536
+ }
537
+ finally {
538
+ session.starting = undefined;
539
+ }
540
+ }
541
+ assertCanStart() {
542
+ if (this.closed || this.options.canStart?.() === false)
543
+ throw new Error("AgentBot 正在等待重启或关闭,暂时不能创建新终端。");
544
+ }
545
+ sweep() {
546
+ const now = this.now();
547
+ for (const recovery of this.recoveries.values()) {
548
+ const target = this.sessions.get(recovery.targetId);
549
+ if (recovery.state === "pending" && now >= recovery.expiresAt) {
550
+ this.closeRecovery(recovery, "恢复授权已过期,原终端不受影响");
551
+ if (target)
552
+ this.publishCards(target);
553
+ }
554
+ if (!target || (recovery.state === "closed" && now >= recovery.expiresAt + AUTH_TTL))
555
+ this.recoveries.delete(recovery.requestId);
556
+ }
557
+ for (const s of this.sessions.values()) {
558
+ if (s.ownerId !== this.options.ownerId() || now - s.createdAt >= MAX_TTL
559
+ || (s.state !== "running" && s.state !== "closed" && now >= s.expiresAt)) {
560
+ this.disposeSession(s, "终端已超时或授权已失效,请重新连接");
561
+ }
562
+ if (s.state === "running" && s.disconnectedAt !== undefined && now - s.disconnectedAt >= DISCONNECT_TTL) {
563
+ this.disposeSession(s, "网页已断开 3 分钟,Shell 已自动关闭");
564
+ }
565
+ if (s.state === "running" && now - Math.max(s.lastActivity, s.foregroundUntil ?? 0) >= IDLE_TTL) {
566
+ this.disposeSession(s, "连续 30 分钟无输入或输出,Shell 已自动关闭");
567
+ }
568
+ if (s.state === "closed" && !s.starting && now - s.lastActivity >= AUTH_TTL)
569
+ this.sessions.delete(s.requestId);
570
+ }
571
+ }
572
+ disposeSession(s, message) {
573
+ if (s.state === "closed")
574
+ return;
575
+ this.clearDisconnectTimer(s);
576
+ s.state = "closed";
577
+ s.message = message;
578
+ s.lastActivity = this.now();
579
+ const pty = s.pty;
580
+ s.pty = undefined;
581
+ if (s.socket) {
582
+ this.send(s.socket, { type: "exit", message });
583
+ s.socket.close();
584
+ s.socket = undefined;
585
+ }
586
+ try {
587
+ pty?.kill();
588
+ }
589
+ catch (error) {
590
+ this.options.logger.warn({ error, requestId: s.requestId }, "Failed to close terminal process.");
591
+ }
592
+ s.output = "";
593
+ for (const recovery of this.recoveries.values()) {
594
+ if (recovery.targetId === s.requestId)
595
+ this.closeRecovery(recovery, message);
596
+ }
597
+ this.publishCards(s);
598
+ }
599
+ send(ws, value) {
600
+ if (ws.readyState !== WebSocket.OPEN)
601
+ return;
602
+ if (ws.bufferedAmount > 1024 * 1024) {
603
+ ws.close(1013, "Slow consumer");
604
+ return;
605
+ }
606
+ ws.send(JSON.stringify(value));
607
+ }
608
+ headers(res) {
609
+ res.setHeader("Cache-Control", "no-store");
610
+ res.setHeader("Referrer-Policy", "no-referrer");
611
+ res.setHeader("X-Content-Type-Options", "nosniff");
612
+ res.setHeader("X-Frame-Options", "DENY");
613
+ const socketOrigin = this.base?.origin.replace(/^http/u, "ws") ?? "";
614
+ res.setHeader("Content-Security-Policy", `default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' ${socketOrigin}; font-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'`);
615
+ }
616
+ json(res, status, value) {
617
+ this.headers(res);
618
+ res.statusCode = status;
619
+ res.setHeader("Content-Type", "application/json; charset=utf-8");
620
+ res.end(JSON.stringify(value));
621
+ }
622
+ }
623
+ function hash(value) { return createHash("sha256").update(value).digest(); }
624
+ function equalText(a, b) { return timingSafeEqual(hash(a), hash(b)); }
625
+ async function readJson(req) {
626
+ let body = "";
627
+ for await (const chunk of req) {
628
+ body += String(chunk);
629
+ if (Buffer.byteLength(body) > 8192)
630
+ throw new Error("Request too large.");
631
+ }
632
+ try {
633
+ return JSON.parse(body);
634
+ }
635
+ catch {
636
+ throw new Error("Invalid JSON request.");
637
+ }
638
+ }
639
+ //# sourceMappingURL=TerminalServer.js.map