@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.
- package/CHANGELOG.md +26 -0
- package/README.md +27 -3
- package/README.zh.md +27 -3
- package/SECURITY.md +12 -0
- package/config.example.yaml +5 -0
- package/dist/cli/controlProtocol.js.map +1 -1
- package/dist/cli/serverStatus.js +1 -0
- package/dist/cli/serverStatus.js.map +1 -1
- package/dist/cli.js +6 -1
- package/dist/cli.js.map +1 -1
- package/dist/codex/CodexRuntime.js +202 -115
- package/dist/codex/CodexRuntime.js.map +1 -1
- package/dist/codex/ProviderSwitch.js +12 -13
- package/dist/codex/ProviderSwitch.js.map +1 -1
- package/dist/config/AgentExecutionDefaultsStore.js +4 -0
- package/dist/config/AgentExecutionDefaultsStore.js.map +1 -1
- package/dist/config/loadConfig.js +4 -0
- package/dist/config/loadConfig.js.map +1 -1
- package/dist/config/schema.js +3 -0
- package/dist/config/schema.js.map +1 -1
- package/dist/feishu/CardRenderer.js +66 -8
- package/dist/feishu/CardRenderer.js.map +1 -1
- package/dist/feishu/FeishuConnector.js +1 -0
- package/dist/feishu/FeishuConnector.js.map +1 -1
- package/dist/feishu/FeishuTurnPresenter.js +121 -17
- package/dist/feishu/FeishuTurnPresenter.js.map +1 -1
- package/dist/index.js +43 -6
- package/dist/index.js.map +1 -1
- package/dist/local-files/LocalFileViewerServer.js +51 -4
- package/dist/local-files/LocalFileViewerServer.js.map +1 -1
- package/dist/local-files/TurnPreviewPage.js +7 -2
- package/dist/local-files/TurnPreviewPage.js.map +1 -1
- package/dist/presentation/TurnStateReducer.js +41 -6
- package/dist/presentation/TurnStateReducer.js.map +1 -1
- package/dist/proxy/ProxySessionController.js +615 -214
- package/dist/proxy/ProxySessionController.js.map +1 -1
- package/dist/proxy/SessionOperationQueue.js +14 -0
- package/dist/proxy/SessionOperationQueue.js.map +1 -0
- package/dist/runtime/AcpRuntimeAdapter.js +4 -2
- package/dist/runtime/AcpRuntimeAdapter.js.map +1 -1
- package/dist/state/StateStore.js +22 -0
- package/dist/state/StateStore.js.map +1 -1
- package/dist/state/TurnPreviewJournal.js +19 -7
- package/dist/state/TurnPreviewJournal.js.map +1 -1
- package/dist/state/migrations.js +6 -0
- package/dist/state/migrations.js.map +1 -1
- package/dist/supervision/SafeRestartNotifier.js +1 -0
- package/dist/supervision/SafeRestartNotifier.js.map +1 -1
- package/dist/supervision/SafeRestartScheduler.js +19 -6
- package/dist/supervision/SafeRestartScheduler.js.map +1 -1
- package/dist/terminal/TerminalCards.js +13 -0
- package/dist/terminal/TerminalCards.js.map +1 -0
- package/dist/terminal/TerminalLinks.js +76 -0
- package/dist/terminal/TerminalLinks.js.map +1 -0
- package/dist/terminal/TerminalPage.js +212 -0
- package/dist/terminal/TerminalPage.js.map +1 -0
- package/dist/terminal/TerminalProcess.js +71 -0
- package/dist/terminal/TerminalProcess.js.map +1 -0
- package/dist/terminal/TerminalServer.js +639 -0
- package/dist/terminal/TerminalServer.js.map +1 -0
- package/docs/technical-reference.md +48 -0
- package/docs/technical-reference.zh.md +48 -0
- package/npm-shrinkwrap.json +55 -5
- package/package.json +11 -4
- package/skills/agent-bot/SKILL.md +9 -2
- package/src/cli/controlProtocol.ts +2 -1
- package/src/cli/serverStatus.ts +1 -0
- package/src/cli.ts +6 -1
- package/src/codex/CodexRuntime.ts +200 -123
- package/src/codex/ProviderSwitch.ts +11 -12
- package/src/config/AgentExecutionDefaultsStore.ts +2 -0
- package/src/config/loadConfig.ts +4 -0
- package/src/config/schema.ts +5 -1
- package/src/feishu/CardRenderer.ts +81 -8
- package/src/feishu/FeishuConnector.ts +1 -0
- package/src/feishu/FeishuTurnPresenter.ts +108 -16
- package/src/feishu/types.ts +1 -0
- package/src/index.ts +44 -7
- package/src/local-files/LocalFileViewerServer.ts +48 -4
- package/src/local-files/TurnPreviewPage.ts +7 -2
- package/src/presentation/TurnStateReducer.ts +46 -7
- package/src/presentation/turnViewTypes.ts +5 -0
- package/src/proxy/ProxySessionController.ts +625 -211
- package/src/proxy/SessionOperationQueue.ts +13 -0
- package/src/runtime/AcpRuntimeAdapter.ts +5 -3
- package/src/runtime/types.ts +8 -3
- package/src/state/StateStore.ts +38 -1
- package/src/state/TurnPreviewJournal.ts +19 -7
- package/src/state/migrations.ts +6 -0
- package/src/supervision/SafeRestartNotifier.ts +1 -0
- package/src/supervision/SafeRestartScheduler.ts +18 -6
- package/src/terminal/TerminalCards.ts +18 -0
- package/src/terminal/TerminalLinks.ts +61 -0
- package/src/terminal/TerminalPage.ts +213 -0
- package/src/terminal/TerminalProcess.ts +71 -0
- package/src/terminal/TerminalServer.ts +513 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import type { IPty } from "node-pty";
|
|
6
|
+
|
|
7
|
+
export type TerminalProcess = Pick<IPty, "write" | "resize" | "kill" | "onData" | "onExit">;
|
|
8
|
+
export type TerminalSpawner = (cwd: string, cols: number, rows: number) => Promise<TerminalProcess>;
|
|
9
|
+
|
|
10
|
+
export function terminalShell(
|
|
11
|
+
platform = process.platform, env = process.env, exists = fs.existsSync, loginShell = os.userInfo().shell,
|
|
12
|
+
): { file: string; args: string[] } {
|
|
13
|
+
if (platform === "win32") {
|
|
14
|
+
const directories = (env.PATH ?? env.Path ?? "").split(";").filter(Boolean);
|
|
15
|
+
const pwsh = directories.map((dir) => path.win32.join(dir, "pwsh.exe")).find(exists);
|
|
16
|
+
if (pwsh) return { file: pwsh, args: ["-NoLogo"] };
|
|
17
|
+
const windows = env.SystemRoot ?? "C:/Windows";
|
|
18
|
+
const powershell = path.win32.join(windows, "System32/WindowsPowerShell/v1.0/powershell.exe");
|
|
19
|
+
return exists(powershell) ? { file: powershell, args: ["-NoLogo"] }
|
|
20
|
+
: { file: path.win32.join(windows, "System32/cmd.exe"), args: [] };
|
|
21
|
+
}
|
|
22
|
+
const file = [env.SHELL, loginShell, platform === "darwin" ? "/bin/zsh" : "/bin/bash", "/bin/sh"]
|
|
23
|
+
.find((candidate): candidate is string => Boolean(candidate && path.posix.isAbsolute(candidate) && exists(candidate)));
|
|
24
|
+
if (!file) throw new Error("No interactive Shell is available.");
|
|
25
|
+
return { file, args: ["-i"] };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function terminalEnvironment(env = process.env): NodeJS.ProcessEnv {
|
|
29
|
+
return {
|
|
30
|
+
...Object.fromEntries(Object.entries(env).filter(([key]) =>
|
|
31
|
+
!/^(FEISHU_|AGENT_BOT(?:_|$)|NODE_OPTIONS$)/i.test(key))),
|
|
32
|
+
TERM: "xterm-256color", COLORTERM: "truecolor",
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function prepareTerminalHelper(
|
|
37
|
+
packageRoot: string, arch = process.arch, filesystem: Pick<typeof fs, "existsSync" | "statSync" | "chmodSync"> = fs,
|
|
38
|
+
): void {
|
|
39
|
+
// node-pty 1.1.0 ships macOS spawn-helper binaries without executable bits.
|
|
40
|
+
for (const directory of ["build/Release", "build/Debug", `prebuilds/darwin-${arch}`]) {
|
|
41
|
+
const helper = path.join(packageRoot, directory, "spawn-helper");
|
|
42
|
+
if (!filesystem.existsSync(helper)) continue;
|
|
43
|
+
const stat = filesystem.statSync(helper);
|
|
44
|
+
if (!stat.isFile()) throw new Error(`Invalid terminal helper: ${helper}`);
|
|
45
|
+
if ((stat.mode & 0o111) === 0o111) continue;
|
|
46
|
+
try { filesystem.chmodSync(helper, (stat.mode & 0o777) | 0o111); } catch (cause) {
|
|
47
|
+
throw new Error(`Terminal helper is not executable: ${helper}. Reinstall node-pty or restore its executable permission.`, { cause });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export const spawnTerminal: TerminalSpawner = async (cwd, cols, rows) => {
|
|
53
|
+
if (process.platform === "win32" && Number(os.release().split(".")[2] ?? 0) < 18309) {
|
|
54
|
+
throw new Error("网页终端需要支持 ConPTY 的 Windows 10 1903 / Windows Server 2022 或更新系统。");
|
|
55
|
+
}
|
|
56
|
+
if (!fs.statSync(cwd).isDirectory()) throw new Error("Terminal working directory is not a directory.");
|
|
57
|
+
let pty: typeof import("node-pty");
|
|
58
|
+
try { pty = await import("node-pty"); } catch {
|
|
59
|
+
throw new Error("交互终端组件 node-pty 不可用,请重新安装 AgentBot 的可选依赖(可能需要本机编译工具)。");
|
|
60
|
+
}
|
|
61
|
+
if (process.platform === "darwin") {
|
|
62
|
+
const require = createRequire(import.meta.url);
|
|
63
|
+
prepareTerminalHelper(path.dirname(require.resolve("node-pty/package.json")));
|
|
64
|
+
}
|
|
65
|
+
const shell = terminalShell();
|
|
66
|
+
return pty.spawn(shell.file, shell.args, {
|
|
67
|
+
cwd, cols, rows, name: "xterm-256color", env: terminalEnvironment(), useConpty: true,
|
|
68
|
+
// The bundled ConPTY closes via its input pipe, avoiding node-pty's console-list helper on kill.
|
|
69
|
+
useConptyDll: process.platform === "win32",
|
|
70
|
+
});
|
|
71
|
+
};
|
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
import { createHash, randomBytes, randomInt, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
4
|
+
import type { Duplex } from "node:stream";
|
|
5
|
+
import type { LocalFileViewerServer } from "../local-files/LocalFileViewerServer.js";
|
|
6
|
+
import { createRequire } from "node:module";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import type { Logger } from "pino";
|
|
9
|
+
import { WebSocket, WebSocketServer } from "ws";
|
|
10
|
+
import { z } from "zod";
|
|
11
|
+
import type { CardAction } from "../feishu/types.js";
|
|
12
|
+
import { spawnTerminal, type TerminalProcess, type TerminalSpawner } from "./TerminalProcess.js";
|
|
13
|
+
import { terminalPage, TERMINAL_CSS, TERMINAL_SCRIPT } from "./TerminalPage.js";
|
|
14
|
+
import { TerminalLinks } from "./TerminalLinks.js";
|
|
15
|
+
|
|
16
|
+
const require = createRequire(import.meta.url);
|
|
17
|
+
const AUTH_TTL = 5 * 60_000;
|
|
18
|
+
const IDLE_TTL = 30 * 60_000;
|
|
19
|
+
const DISCONNECT_TTL = 3 * 60_000;
|
|
20
|
+
const FOREGROUND_TTL = 90_000;
|
|
21
|
+
const MAX_TTL = 8 * 60 * 60_000;
|
|
22
|
+
const KEY = z.string().regex(/^[a-f0-9]{64}$/u);
|
|
23
|
+
const credentials = z.object({ requestId: z.string().regex(/^[a-f0-9]{32}$/u), browserKey: KEY });
|
|
24
|
+
const frame = z.discriminatedUnion("type", [
|
|
25
|
+
credentials.extend({ type: z.literal("auth") }),
|
|
26
|
+
z.object({ type: z.literal("input"), data: z.string().max(32_768) }),
|
|
27
|
+
z.object({ type: z.literal("resize"), cols: z.number().int().min(2).max(500), rows: z.number().int().min(2).max(200) }),
|
|
28
|
+
z.object({ type: z.literal("visibility"), foreground: z.boolean() }),
|
|
29
|
+
]);
|
|
30
|
+
|
|
31
|
+
export interface TerminalAuthorization {
|
|
32
|
+
requestId: string; approvalToken: string; code: string; cwd: string; expiresAt: number;
|
|
33
|
+
}
|
|
34
|
+
export interface TerminalCardView extends TerminalAuthorization {
|
|
35
|
+
contextKey: string; commandCardMessageId: string; shellStarted: boolean;
|
|
36
|
+
terminalId: string; reopenToken: string; reopenUrl: string; recovery: boolean; state: "pending" | "approved" | "running" | "closed";
|
|
37
|
+
connected: boolean; message?: string;
|
|
38
|
+
}
|
|
39
|
+
export interface TerminalServerOptions {
|
|
40
|
+
stateDirectory: string;
|
|
41
|
+
ownerId(): string | undefined;
|
|
42
|
+
canStart?(): boolean;
|
|
43
|
+
readJob(jobId: string): Promise<{ cwd: string; contextKey: string; cardMessageId?: string }>;
|
|
44
|
+
requestApproval(authorization: TerminalCardView): Promise<string | undefined>;
|
|
45
|
+
updateCard?(messageId: string, view: TerminalCardView): Promise<void>;
|
|
46
|
+
logger: Pick<Logger, "info" | "warn">;
|
|
47
|
+
spawn?: TerminalSpawner;
|
|
48
|
+
now?: () => number;
|
|
49
|
+
}
|
|
50
|
+
interface TerminalSession extends TerminalAuthorization {
|
|
51
|
+
reopenToken: string;
|
|
52
|
+
contextKey: string; commandCardMessageId: string;
|
|
53
|
+
ownerId: string; keyHash: Buffer; createdAt: number; lastActivity: number;
|
|
54
|
+
activeAccessId: string;
|
|
55
|
+
state: "pending" | "approved" | "running" | "closed";
|
|
56
|
+
message?: string; cardId?: string; pty?: TerminalProcess; socket?: WebSocket;
|
|
57
|
+
starting?: Promise<void>; output: string;
|
|
58
|
+
attached?: boolean;
|
|
59
|
+
disconnectedAt?: number;
|
|
60
|
+
disconnectTimer?: NodeJS.Timeout;
|
|
61
|
+
foregroundUntil?: number;
|
|
62
|
+
approvalObserved?: boolean;
|
|
63
|
+
}
|
|
64
|
+
interface TerminalRecovery extends TerminalAuthorization {
|
|
65
|
+
targetId: string; ownerId: string; keyHash: Buffer; cardId?: string;
|
|
66
|
+
state: "pending" | "approved" | "closed"; message?: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export class TerminalServer {
|
|
70
|
+
private unmount?: () => void;
|
|
71
|
+
private readonly sockets = new WebSocketServer({ noServer: true, maxPayload: 64 * 1024, perMessageDeflate: false });
|
|
72
|
+
private readonly sessions = new Map<string, TerminalSession>();
|
|
73
|
+
private readonly recoveries = new Map<string, TerminalRecovery>();
|
|
74
|
+
private cardUpdates = Promise.resolve();
|
|
75
|
+
private readonly requests: number[] = [];
|
|
76
|
+
private timer?: NodeJS.Timeout;
|
|
77
|
+
private links?: TerminalLinks;
|
|
78
|
+
private base?: URL;
|
|
79
|
+
private closed = false;
|
|
80
|
+
private lastUpgradeWarning = -Infinity;
|
|
81
|
+
private readonly now: () => number;
|
|
82
|
+
|
|
83
|
+
constructor(private readonly options: TerminalServerOptions) { this.now = options.now ?? Date.now; }
|
|
84
|
+
|
|
85
|
+
async start(viewer: LocalFileViewerServer): Promise<string> {
|
|
86
|
+
if (this.closed) throw new Error("Terminal server is closed.");
|
|
87
|
+
if (this.base) return this.base.toString();
|
|
88
|
+
fs.mkdirSync(this.options.stateDirectory, { recursive: true });
|
|
89
|
+
const secretPath = path.join(this.options.stateDirectory, "secret");
|
|
90
|
+
if (!fs.existsSync(secretPath)) fs.writeFileSync(secretPath, randomBytes(32), { flag: "wx", mode: 0o600 });
|
|
91
|
+
const secret = fs.readFileSync(secretPath);
|
|
92
|
+
if (secret.length !== 32) throw new Error("Invalid terminal link secret.");
|
|
93
|
+
this.links = new TerminalLinks(path.join(this.options.stateDirectory, "open-links"), secret);
|
|
94
|
+
const mounted = viewer.mountService("terminal", {
|
|
95
|
+
handleRequest: (req, res) => {
|
|
96
|
+
void this.handleRequest(req, res).catch((error: unknown) => {
|
|
97
|
+
this.options.logger.warn({ error: error instanceof Error ? error.message : String(error) }, "Terminal HTTP request failed.");
|
|
98
|
+
if (res.headersSent) res.destroy();
|
|
99
|
+
else this.json(res, 400, { error: error instanceof Error ? error.message : "Terminal request failed." });
|
|
100
|
+
});
|
|
101
|
+
},
|
|
102
|
+
handleUpgrade: (req, socket, head) => this.handleUpgrade(req, socket, head),
|
|
103
|
+
close: () => this.close(),
|
|
104
|
+
});
|
|
105
|
+
this.base = new URL(mounted.publicBaseUrl);
|
|
106
|
+
this.unmount = mounted.unmount;
|
|
107
|
+
this.timer = setInterval(() => this.sweep(), 30_000); this.timer.unref();
|
|
108
|
+
return this.base.toString();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
private handleUpgrade(req: IncomingMessage, socket: Duplex, head: Buffer): void {
|
|
112
|
+
if (this.closed || !this.validOrigin(req) || req.url !== `${this.basePath}/socket` || this.sockets.clients.size >= 20) {
|
|
113
|
+
if (this.now() - this.lastUpgradeWarning >= 10_000) {
|
|
114
|
+
this.lastUpgradeWarning = this.now();
|
|
115
|
+
this.options.logger.warn({ stage: "upgrade", hostMatches: req.headers.host === this.base?.host,
|
|
116
|
+
originMatches: req.headers.origin === this.base?.origin }, "Terminal WebSocket upgrade rejected.");
|
|
117
|
+
}
|
|
118
|
+
socket.end("HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n"); return;
|
|
119
|
+
}
|
|
120
|
+
this.sockets.handleUpgrade(req, socket, head, (ws) => this.connect(ws));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
createUrl(jobId: string): string | undefined {
|
|
124
|
+
if (this.closed || !this.base || !this.links || !this.options.ownerId() || !/^[a-f0-9-]{36}$/u.test(jobId)) return undefined;
|
|
125
|
+
try { return `${this.base.origin}${this.basePath}/${this.links.create(jobId)}`; }
|
|
126
|
+
catch (error) {
|
|
127
|
+
this.options.logger.warn({ code: (error as NodeJS.ErrnoException).code }, "Failed to create terminal short link.");
|
|
128
|
+
return undefined;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
get activeCount(): number {
|
|
133
|
+
return [...this.sessions.values()].filter((s) => s.pty || s.starting).length;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async resolveApproval(action: CardAction): Promise<string> {
|
|
137
|
+
this.sweep();
|
|
138
|
+
const id = String(action.value.requestId ?? "");
|
|
139
|
+
const recovery = this.recoveries.get(id);
|
|
140
|
+
const authorization = recovery ?? this.sessions.get(id);
|
|
141
|
+
if (!authorization || action.userId !== this.options.ownerId() || action.userId !== authorization.ownerId
|
|
142
|
+
|| !authorization.cardId || action.messageId !== authorization.cardId
|
|
143
|
+
|| !equalText(authorization.approvalToken, String(action.value.token ?? ""))) throw new Error("终端授权无效或已过期。");
|
|
144
|
+
const session = this.sessions.get(recovery?.targetId ?? id);
|
|
145
|
+
if (!session) throw new Error("终端已结束或服务已重启,请从命令卡片创建新终端。");
|
|
146
|
+
if (action.value.decision === "refresh") {
|
|
147
|
+
this.publishCards(session); await this.cardUpdates; return "已刷新终端管理卡。";
|
|
148
|
+
}
|
|
149
|
+
if (action.value.decision === "close") {
|
|
150
|
+
if (recovery && recovery.state !== "approved") this.closeRecovery(recovery, "恢复连接请求已取消");
|
|
151
|
+
else this.disposeSession(session, "所有者已在飞书关闭终端");
|
|
152
|
+
this.publishCards(session); await this.cardUpdates;
|
|
153
|
+
return recovery?.state === "closed" && session.state !== "closed" ? "恢复连接请求已取消,原终端不受影响。" : "终端已关闭。";
|
|
154
|
+
}
|
|
155
|
+
if (authorization.state !== "pending") throw new Error("该终端授权已处理或过期,请查看原会话中的终端管理卡。");
|
|
156
|
+
if (action.value.decision === "allow") {
|
|
157
|
+
if (recovery) {
|
|
158
|
+
if (!this.canReopen(session)) throw new Error("原终端已结束,不能恢复。");
|
|
159
|
+
if (session.state === "approved") {
|
|
160
|
+
this.assertCanStart();
|
|
161
|
+
session.expiresAt = this.now() + AUTH_TTL;
|
|
162
|
+
}
|
|
163
|
+
for (const other of this.recoveries.values()) {
|
|
164
|
+
if (other.targetId === session.requestId && other !== recovery && other.state !== "closed") this.closeRecovery(other, "已由另一网页恢复连接");
|
|
165
|
+
}
|
|
166
|
+
recovery.state = "approved"; session.activeAccessId = recovery.requestId;
|
|
167
|
+
session.socket?.close(1000, "Reconnected from another page"); session.socket = undefined;
|
|
168
|
+
this.markDisconnected(session);
|
|
169
|
+
} else session.state = "approved";
|
|
170
|
+
this.publishCards(session); await this.cardUpdates;
|
|
171
|
+
this.options.logger.info({ requestId: session.requestId }, "Terminal authorized by owner.");
|
|
172
|
+
return session.state === "approved"
|
|
173
|
+
? "已授权,请返回网页连接;若原网页已关闭,点击本卡片的“打开终端”。"
|
|
174
|
+
: "已授权,请返回网页;也可点击本卡片的“重新打开终端”或“关闭终端”。";
|
|
175
|
+
}
|
|
176
|
+
if (action.value.decision !== "deny") throw new Error("无效的终端授权操作。");
|
|
177
|
+
if (recovery) this.closeRecovery(recovery, "恢复连接已拒绝,原终端不受影响");
|
|
178
|
+
else this.disposeSession(session, "终端授权已拒绝");
|
|
179
|
+
this.publishCards(session); await this.cardUpdates;
|
|
180
|
+
return "已拒绝终端连接。";
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async close(): Promise<void> {
|
|
184
|
+
if (this.closed) return;
|
|
185
|
+
this.closed = true; clearInterval(this.timer);
|
|
186
|
+
this.unmount?.(); this.unmount = undefined;
|
|
187
|
+
for (const s of this.sessions.values()) this.disposeSession(s, "AgentBot 正在关闭,终端已结束");
|
|
188
|
+
for (const ws of this.sockets.clients) ws.terminate();
|
|
189
|
+
await Promise.all([...this.sessions.values()].map((s) => s.starting?.catch(() => undefined)));
|
|
190
|
+
await this.cardUpdates;
|
|
191
|
+
this.sockets.close();
|
|
192
|
+
this.base = undefined;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
private get basePath(): string { return this.base?.pathname.replace(/[/]$/u, "") ?? ""; }
|
|
196
|
+
private reopenUrl(session: TerminalSession): string {
|
|
197
|
+
return `${this.base!.origin}${this.basePath}/${session.reopenToken}`;
|
|
198
|
+
}
|
|
199
|
+
private canReopen(session: TerminalSession): boolean {
|
|
200
|
+
return session.state === "approved" || (session.state === "running" && Boolean(session.pty));
|
|
201
|
+
}
|
|
202
|
+
private markDisconnected(session: TerminalSession): void {
|
|
203
|
+
if (session.state !== "running" || session.socket || session.disconnectedAt !== undefined) return;
|
|
204
|
+
this.leaveForeground(session);
|
|
205
|
+
session.disconnectedAt = this.now();
|
|
206
|
+
const expire = () => {
|
|
207
|
+
if (session.state !== "running" || session.socket || session.disconnectedAt === undefined) return;
|
|
208
|
+
const remaining = DISCONNECT_TTL - (this.now() - session.disconnectedAt);
|
|
209
|
+
if (remaining <= 0) this.disposeSession(session, "网页已断开 3 分钟,Shell 已自动关闭");
|
|
210
|
+
else { session.disconnectTimer = setTimeout(expire, remaining); session.disconnectTimer.unref(); }
|
|
211
|
+
};
|
|
212
|
+
session.disconnectTimer = setTimeout(expire, DISCONNECT_TTL); session.disconnectTimer.unref();
|
|
213
|
+
}
|
|
214
|
+
private clearDisconnectTimer(session: TerminalSession): void {
|
|
215
|
+
clearTimeout(session.disconnectTimer); session.disconnectTimer = undefined; session.disconnectedAt = undefined;
|
|
216
|
+
}
|
|
217
|
+
private leaveForeground(session: TerminalSession): void {
|
|
218
|
+
if (session.foregroundUntil !== undefined) {
|
|
219
|
+
session.lastActivity = Math.max(session.lastActivity, Math.min(this.now(), session.foregroundUntil));
|
|
220
|
+
session.foregroundUntil = undefined;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
private resumeId(launch: string): string | undefined {
|
|
224
|
+
if (!launch.startsWith(`${this.basePath}/`)) return undefined;
|
|
225
|
+
const token = /^[/]([A-Za-z0-9_-]{8})$/u.exec(launch.slice(this.basePath.length));
|
|
226
|
+
return token ? [...this.sessions.values()].find((session) => session.reopenToken === token[1])?.requestId : undefined;
|
|
227
|
+
}
|
|
228
|
+
private cardView(session: TerminalSession, recovery?: TerminalRecovery): TerminalCardView {
|
|
229
|
+
const auth = recovery ?? session;
|
|
230
|
+
const state = recovery && recovery.state !== "approved" ? recovery.state : session.state;
|
|
231
|
+
return { requestId: auth.requestId, approvalToken: auth.approvalToken, code: auth.code, cwd: session.cwd,
|
|
232
|
+
contextKey: session.contextKey, commandCardMessageId: session.commandCardMessageId, shellStarted: Boolean(session.pty),
|
|
233
|
+
expiresAt: auth.expiresAt, terminalId: session.requestId, reopenToken: session.reopenToken, reopenUrl: this.reopenUrl(session), recovery: Boolean(recovery), state,
|
|
234
|
+
connected: session.socket?.readyState === WebSocket.OPEN, message: recovery?.message ?? session.message };
|
|
235
|
+
}
|
|
236
|
+
private publishCards(session: TerminalSession): void {
|
|
237
|
+
if (!this.options.updateCard || !this.base) return;
|
|
238
|
+
for (const auth of [session, ...[...this.recoveries.values()].filter((r) => r.targetId === session.requestId)]) {
|
|
239
|
+
const cardId = auth.cardId; if (!cardId) continue;
|
|
240
|
+
const view = this.cardView(session, "targetId" in auth ? auth : undefined);
|
|
241
|
+
this.cardUpdates = this.cardUpdates.then(() => this.options.updateCard!(cardId, view)).catch(() => {
|
|
242
|
+
this.options.logger.warn({ requestId: auth.requestId }, "Failed to update terminal management card.");
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
private closeRecovery(recovery: TerminalRecovery, message: string): void {
|
|
247
|
+
recovery.state = "closed"; recovery.message = message;
|
|
248
|
+
}
|
|
249
|
+
private validOrigin(req: IncomingMessage): boolean {
|
|
250
|
+
return Boolean(this.base && req.headers.host === this.base.host && req.headers.origin === this.base.origin);
|
|
251
|
+
}
|
|
252
|
+
private launchJob(launch: string): string | undefined {
|
|
253
|
+
if (!launch.startsWith(`${this.basePath}/`)) return undefined;
|
|
254
|
+
const token = /^[/]([A-Za-z0-9_-]{8})$/u.exec(launch.slice(this.basePath.length));
|
|
255
|
+
return token ? this.links?.resolve(token[1]!) : undefined;
|
|
256
|
+
}
|
|
257
|
+
private access(value: unknown): { authorization: TerminalSession | TerminalRecovery; session: TerminalSession } {
|
|
258
|
+
const parsed = credentials.parse(value);
|
|
259
|
+
this.sweep();
|
|
260
|
+
const recovery = this.recoveries.get(parsed.requestId);
|
|
261
|
+
const authorization = recovery ?? this.sessions.get(parsed.requestId);
|
|
262
|
+
const session = this.sessions.get(recovery?.targetId ?? parsed.requestId);
|
|
263
|
+
if (!authorization || !session || !timingSafeEqual(authorization.keyHash, hash(parsed.browserKey))
|
|
264
|
+
|| (!recovery && session.activeAccessId !== parsed.requestId)) throw new Error("终端凭证无效或已由其他网页恢复,请从原会话的终端管理卡重新打开。");
|
|
265
|
+
return { authorization, session };
|
|
266
|
+
}
|
|
267
|
+
private authenticated(value: unknown): TerminalSession {
|
|
268
|
+
const { authorization, session } = this.access(value);
|
|
269
|
+
if (authorization.state === "pending" || authorization.state === "closed" || session.activeAccessId !== authorization.requestId) {
|
|
270
|
+
throw new Error("终端尚未授权、已结束或已由其他网页恢复。");
|
|
271
|
+
}
|
|
272
|
+
return session;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
private async handleRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
276
|
+
if (!this.base || req.headers.host !== this.base.host) { this.json(res, 403, { error: "Invalid Host." }); return; }
|
|
277
|
+
const url = new URL(req.url ?? "/", this.base);
|
|
278
|
+
if (url.search || !url.pathname.startsWith(`${this.basePath}/`)) { this.json(res, 404, { error: "Not found." }); return; }
|
|
279
|
+
const route = url.pathname.slice(this.basePath.length);
|
|
280
|
+
this.sweep();
|
|
281
|
+
if (req.method === "GET" || req.method === "HEAD") {
|
|
282
|
+
let body: string | undefined; let type = "text/javascript; charset=utf-8";
|
|
283
|
+
if (route === "/assets/terminal.js") body = TERMINAL_SCRIPT;
|
|
284
|
+
else if (route === "/assets/terminal.css") { body = TERMINAL_CSS; type = "text/css"; }
|
|
285
|
+
else if (route === "/assets/xterm.js") body = fs.readFileSync(require.resolve("@xterm/xterm"), "utf8");
|
|
286
|
+
else if (route === "/assets/fit.js") body = fs.readFileSync(require.resolve("@xterm/addon-fit"), "utf8");
|
|
287
|
+
else if (route === "/assets/xterm.css") {
|
|
288
|
+
body = fs.readFileSync(path.join(path.dirname(require.resolve("@xterm/xterm")), "../css/xterm.css"), "utf8"); type = "text/css";
|
|
289
|
+
} else {
|
|
290
|
+
const resumeId = this.resumeId(url.pathname);
|
|
291
|
+
if (this.launchJob(url.pathname) || resumeId) { body = terminalPage(this.basePath, Boolean(resumeId)); type = "text/html; charset=utf-8"; }
|
|
292
|
+
}
|
|
293
|
+
this.headers(res); res.statusCode = body === undefined ? 404 : 200; res.setHeader("Content-Type", type);
|
|
294
|
+
res.end(req.method === "HEAD" ? undefined : body ?? "Not found."); return;
|
|
295
|
+
}
|
|
296
|
+
if (req.method !== "POST" || !this.validOrigin(req) || req.headers["content-type"] !== "application/json") {
|
|
297
|
+
this.json(res, 403, { error: "Origin or request method rejected." }); return;
|
|
298
|
+
}
|
|
299
|
+
const data = await readJson(req);
|
|
300
|
+
if (route === "/api/request") {
|
|
301
|
+
const parsed = z.object({ launch: z.string().max(300), browserKey: KEY }).parse(data);
|
|
302
|
+
const resumeId = this.resumeId(parsed.launch);
|
|
303
|
+
const jobId = this.launchJob(parsed.launch);
|
|
304
|
+
const ownerId = this.options.ownerId();
|
|
305
|
+
if ((!jobId && !resumeId) || !ownerId) throw new Error("终端链接无效,或尚未配置飞书所有者。");
|
|
306
|
+
const existing = resumeId ? this.sessions.get(resumeId) : undefined;
|
|
307
|
+
if (resumeId && (!existing || !this.canReopen(existing) || existing.ownerId !== ownerId)) {
|
|
308
|
+
throw new Error("原终端尚未授权、已结束或服务已重启;请从原命令卡片创建新终端。");
|
|
309
|
+
}
|
|
310
|
+
if (!existing || existing.state === "approved") this.assertCanStart();
|
|
311
|
+
const now = this.now();
|
|
312
|
+
while (this.requests[0] !== undefined && this.requests[0] < now - 3600_000) this.requests.shift();
|
|
313
|
+
if (this.requests.length >= 20 || (this.requests.length > 0 && now - this.requests.at(-1)! < 10_000)
|
|
314
|
+
|| (existing ? [...this.recoveries.values()].filter((r) => r.state === "pending").length >= 3
|
|
315
|
+
: [...this.sessions.values()].filter((s) => s.state !== "closed").length >= 3)) {
|
|
316
|
+
this.json(res, 429, { error: "终端请求过于频繁或已达 3 个会话上限,请稍后重试。" }); return;
|
|
317
|
+
}
|
|
318
|
+
this.requests.push(now);
|
|
319
|
+
if (existing) {
|
|
320
|
+
const recovery: TerminalRecovery = { requestId: randomBytes(16).toString("hex"), targetId: existing.requestId, ownerId,
|
|
321
|
+
keyHash: hash(parsed.browserKey), approvalToken: randomBytes(32).toString("hex"), code: String(randomInt(100000, 1000000)),
|
|
322
|
+
cwd: existing.cwd, expiresAt: now + AUTH_TTL, state: "pending" };
|
|
323
|
+
this.recoveries.set(recovery.requestId, recovery);
|
|
324
|
+
try {
|
|
325
|
+
recovery.cardId = await this.options.requestApproval(this.cardView(existing, recovery));
|
|
326
|
+
if (!recovery.cardId) throw new Error("无法发送飞书授权卡片。");
|
|
327
|
+
} catch (error) { this.closeRecovery(recovery, "恢复授权通知失败"); throw error; }
|
|
328
|
+
if (existing.state !== "running" || recovery.state !== "pending") this.publishCards(existing);
|
|
329
|
+
this.json(res, 200, { requestId: recovery.requestId, code: recovery.code }); return;
|
|
330
|
+
}
|
|
331
|
+
const job = await this.options.readJob(jobId!);
|
|
332
|
+
this.assertCanStart();
|
|
333
|
+
if (!job.cardMessageId || !job.contextKey?.trim()) throw new Error("命令卡片或原始会话信息尚未就绪。");
|
|
334
|
+
const requestId = randomBytes(16).toString("hex");
|
|
335
|
+
const session: TerminalSession = { requestId, reopenToken: this.links!.createRecovery(), activeAccessId: requestId, ownerId, cwd: job.cwd, keyHash: hash(parsed.browserKey),
|
|
336
|
+
contextKey: job.contextKey, commandCardMessageId: job.cardMessageId,
|
|
337
|
+
approvalToken: randomBytes(32).toString("hex"), code: String(randomInt(100000, 1000000)),
|
|
338
|
+
createdAt: now, lastActivity: now, expiresAt: now + AUTH_TTL, state: "pending", output: "" };
|
|
339
|
+
this.sessions.set(requestId, session);
|
|
340
|
+
try {
|
|
341
|
+
session.cardId = await this.options.requestApproval(this.cardView(session));
|
|
342
|
+
if (!session.cardId) throw new Error("无法发送飞书授权卡片。");
|
|
343
|
+
if (session.state !== "pending") this.publishCards(session);
|
|
344
|
+
} catch (error) { this.disposeSession(session, "授权通知失败"); throw error; }
|
|
345
|
+
this.json(res, 200, { requestId, code: session.code }); return;
|
|
346
|
+
}
|
|
347
|
+
if (route !== "/api/status" && route !== "/api/close") { this.json(res, 404, { error: "Not found." }); return; }
|
|
348
|
+
const { authorization, session } = this.access(data);
|
|
349
|
+
if ("targetId" in authorization && authorization.state !== "approved") {
|
|
350
|
+
if (route === "/api/close") { this.closeRecovery(authorization, "恢复连接请求已取消"); this.publishCards(session); }
|
|
351
|
+
this.json(res, 200, { state: authorization.state, message: authorization.message }); return;
|
|
352
|
+
}
|
|
353
|
+
if (route === "/api/status" && session.state === "approved" && !session.approvalObserved) {
|
|
354
|
+
session.approvalObserved = true;
|
|
355
|
+
this.options.logger.info({ requestId: session.requestId }, "Terminal approval observed by browser.");
|
|
356
|
+
}
|
|
357
|
+
if (route === "/api/close") this.disposeSession(session, "终端已关闭");
|
|
358
|
+
this.json(res, 200, { state: session.state, message: session.message });
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
private connect(ws: WebSocket): void {
|
|
362
|
+
let session: TerminalSession | undefined; let authenticating = false; let accessId: string | undefined;
|
|
363
|
+
const timeout = setTimeout(() => {
|
|
364
|
+
this.options.logger.warn({ stage: "auth_timeout" }, "Terminal WebSocket authentication timed out.");
|
|
365
|
+
this.send(ws, { type: "error", stage: "auth", message: "终端连接鉴权超时,请重新连接。" });
|
|
366
|
+
ws.close(1008, "Authentication required");
|
|
367
|
+
}, 5000); timeout.unref();
|
|
368
|
+
ws.on("error", () => ws.terminate());
|
|
369
|
+
ws.on("close", () => {
|
|
370
|
+
clearTimeout(timeout);
|
|
371
|
+
if (session?.socket === ws) { session.socket = undefined; this.markDisconnected(session); this.publishCards(session); }
|
|
372
|
+
});
|
|
373
|
+
ws.on("message", (data, binary) => {
|
|
374
|
+
let stage = "protocol";
|
|
375
|
+
let requestId: string | undefined;
|
|
376
|
+
void (async () => {
|
|
377
|
+
if (binary) throw new Error("Binary input rejected.");
|
|
378
|
+
let parsed: unknown;
|
|
379
|
+
try { parsed = JSON.parse(data.toString()) as unknown; } catch { throw new Error("Invalid terminal JSON frame."); }
|
|
380
|
+
const value = frame.parse(parsed);
|
|
381
|
+
if (!session) {
|
|
382
|
+
stage = "auth";
|
|
383
|
+
if (authenticating || value.type !== "auth") throw new Error("Authentication required.");
|
|
384
|
+
authenticating = true; const target = this.authenticated(value);
|
|
385
|
+
if (target.state !== "approved" && target.state !== "running") throw new Error("终端尚未授权或已结束。");
|
|
386
|
+
requestId = target.requestId;
|
|
387
|
+
clearTimeout(timeout);
|
|
388
|
+
this.options.logger.info({ requestId }, "Terminal WebSocket authenticated.");
|
|
389
|
+
stage = "spawn";
|
|
390
|
+
await this.ensureProcess(target);
|
|
391
|
+
if (ws.readyState !== WebSocket.OPEN) return;
|
|
392
|
+
if (target.activeAccessId !== value.requestId) throw new Error("终端已由其他网页恢复。");
|
|
393
|
+
if (target.state !== "running") throw new Error(target.message ?? "终端已结束。");
|
|
394
|
+
target.socket?.close(1000, "Reconnected from another page");
|
|
395
|
+
session = target; session.socket = ws; accessId = value.requestId; clearTimeout(timeout);
|
|
396
|
+
this.clearDisconnectTimer(session);
|
|
397
|
+
session.lastActivity = this.now();
|
|
398
|
+
session.foregroundUntil = undefined;
|
|
399
|
+
// Only the first attachment may answer buffered Shell startup queries.
|
|
400
|
+
this.send(ws, { type: "ready", cwd: session.cwd, output: session.output, replay: session.attached === true });
|
|
401
|
+
session.attached = true;
|
|
402
|
+
this.publishCards(session);
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
if (session.state !== "running" || session.socket !== ws || session.activeAccessId !== accessId) throw new Error("Terminal is closed.");
|
|
406
|
+
if (value.type === "input") { session.lastActivity = this.now(); session.pty?.write(value.data); }
|
|
407
|
+
else if (value.type === "resize") session.pty?.resize(value.cols, value.rows);
|
|
408
|
+
else if (value.type === "visibility") {
|
|
409
|
+
if (value.foreground) session.foregroundUntil = this.now() + FOREGROUND_TTL;
|
|
410
|
+
else this.leaveForeground(session);
|
|
411
|
+
}
|
|
412
|
+
else throw new Error("Already authenticated.");
|
|
413
|
+
})().catch((error: unknown) => {
|
|
414
|
+
clearTimeout(timeout);
|
|
415
|
+
this.options.logger.warn({ requestId, stage }, "Terminal WebSocket request failed.");
|
|
416
|
+
this.send(ws, { type: "error", stage, message: error instanceof Error ? error.message : "Terminal error" });
|
|
417
|
+
ws.close(1008, "Terminal request rejected");
|
|
418
|
+
});
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
private async ensureProcess(session: TerminalSession): Promise<void> {
|
|
423
|
+
if (session.pty) return;
|
|
424
|
+
if (session.starting) return session.starting;
|
|
425
|
+
this.assertCanStart();
|
|
426
|
+
const starting = (async () => {
|
|
427
|
+
const pty = await (this.options.spawn ?? spawnTerminal)(session.cwd, 100, 30);
|
|
428
|
+
if (this.closed || this.options.canStart?.() === false || session.state === "closed"
|
|
429
|
+
|| session.ownerId !== this.options.ownerId() || this.now() >= session.expiresAt) {
|
|
430
|
+
pty.kill(); this.disposeSession(session, "服务正在重启或授权已失效"); return;
|
|
431
|
+
}
|
|
432
|
+
session.pty = pty; session.state = "running"; session.lastActivity = this.now();
|
|
433
|
+
this.markDisconnected(session);
|
|
434
|
+
pty.onData((data) => {
|
|
435
|
+
if (session.state !== "running") return;
|
|
436
|
+
session.lastActivity = this.now();
|
|
437
|
+
session.output = (session.output + data).slice(-256 * 1024);
|
|
438
|
+
if (session.socket) this.send(session.socket, { type: "data", data });
|
|
439
|
+
});
|
|
440
|
+
pty.onExit(({ exitCode }) => { session.pty = undefined; this.disposeSession(session, `Shell 已退出(${exitCode})`); });
|
|
441
|
+
this.options.logger.info({ requestId: session.requestId }, "Interactive terminal started.");
|
|
442
|
+
})();
|
|
443
|
+
session.starting = starting;
|
|
444
|
+
try { await starting; } catch (error) { this.disposeSession(session, "终端启动失败"); throw error; }
|
|
445
|
+
finally { session.starting = undefined; }
|
|
446
|
+
}
|
|
447
|
+
private assertCanStart(): void {
|
|
448
|
+
if (this.closed || this.options.canStart?.() === false) throw new Error("AgentBot 正在等待重启或关闭,暂时不能创建新终端。");
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
private sweep(): void {
|
|
452
|
+
const now = this.now();
|
|
453
|
+
for (const recovery of this.recoveries.values()) {
|
|
454
|
+
const target = this.sessions.get(recovery.targetId);
|
|
455
|
+
if (recovery.state === "pending" && now >= recovery.expiresAt) {
|
|
456
|
+
this.closeRecovery(recovery, "恢复授权已过期,原终端不受影响");
|
|
457
|
+
if (target) this.publishCards(target);
|
|
458
|
+
}
|
|
459
|
+
if (!target || (recovery.state === "closed" && now >= recovery.expiresAt + AUTH_TTL)) this.recoveries.delete(recovery.requestId);
|
|
460
|
+
}
|
|
461
|
+
for (const s of this.sessions.values()) {
|
|
462
|
+
if (s.ownerId !== this.options.ownerId() || now - s.createdAt >= MAX_TTL
|
|
463
|
+
|| (s.state !== "running" && s.state !== "closed" && now >= s.expiresAt)) {
|
|
464
|
+
this.disposeSession(s, "终端已超时或授权已失效,请重新连接");
|
|
465
|
+
}
|
|
466
|
+
if (s.state === "running" && s.disconnectedAt !== undefined && now - s.disconnectedAt >= DISCONNECT_TTL) {
|
|
467
|
+
this.disposeSession(s, "网页已断开 3 分钟,Shell 已自动关闭");
|
|
468
|
+
}
|
|
469
|
+
if (s.state === "running" && now - Math.max(s.lastActivity, s.foregroundUntil ?? 0) >= IDLE_TTL) {
|
|
470
|
+
this.disposeSession(s, "连续 30 分钟无输入或输出,Shell 已自动关闭");
|
|
471
|
+
}
|
|
472
|
+
if (s.state === "closed" && !s.starting && now - s.lastActivity >= AUTH_TTL) this.sessions.delete(s.requestId);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
private disposeSession(s: TerminalSession, message: string): void {
|
|
476
|
+
if (s.state === "closed") return;
|
|
477
|
+
this.clearDisconnectTimer(s);
|
|
478
|
+
s.state = "closed"; s.message = message; s.lastActivity = this.now();
|
|
479
|
+
const pty = s.pty; s.pty = undefined;
|
|
480
|
+
if (s.socket) { this.send(s.socket, { type: "exit", message }); s.socket.close(); s.socket = undefined; }
|
|
481
|
+
try { pty?.kill(); } catch (error) { this.options.logger.warn({ error, requestId: s.requestId }, "Failed to close terminal process."); }
|
|
482
|
+
s.output = "";
|
|
483
|
+
for (const recovery of this.recoveries.values()) {
|
|
484
|
+
if (recovery.targetId === s.requestId) this.closeRecovery(recovery, message);
|
|
485
|
+
}
|
|
486
|
+
this.publishCards(s);
|
|
487
|
+
}
|
|
488
|
+
private send(ws: WebSocket, value: unknown): void {
|
|
489
|
+
if (ws.readyState !== WebSocket.OPEN) return;
|
|
490
|
+
if (ws.bufferedAmount > 1024 * 1024) { ws.close(1013, "Slow consumer"); return; }
|
|
491
|
+
ws.send(JSON.stringify(value));
|
|
492
|
+
}
|
|
493
|
+
private headers(res: ServerResponse): void {
|
|
494
|
+
res.setHeader("Cache-Control", "no-store"); res.setHeader("Referrer-Policy", "no-referrer");
|
|
495
|
+
res.setHeader("X-Content-Type-Options", "nosniff"); res.setHeader("X-Frame-Options", "DENY");
|
|
496
|
+
const socketOrigin = this.base?.origin.replace(/^http/u, "ws") ?? "";
|
|
497
|
+
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'`);
|
|
498
|
+
}
|
|
499
|
+
private json(res: ServerResponse, status: number, value: unknown): void {
|
|
500
|
+
this.headers(res); res.statusCode = status; res.setHeader("Content-Type", "application/json; charset=utf-8"); res.end(JSON.stringify(value));
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function hash(value: string): Buffer { return createHash("sha256").update(value).digest(); }
|
|
505
|
+
function equalText(a: string, b: string): boolean { return timingSafeEqual(hash(a), hash(b)); }
|
|
506
|
+
async function readJson(req: IncomingMessage): Promise<unknown> {
|
|
507
|
+
let body = "";
|
|
508
|
+
for await (const chunk of req) {
|
|
509
|
+
body += String(chunk); if (Buffer.byteLength(body) > 8192) throw new Error("Request too large.");
|
|
510
|
+
}
|
|
511
|
+
try { return JSON.parse(body) as unknown; }
|
|
512
|
+
catch { throw new Error("Invalid JSON request."); }
|
|
513
|
+
}
|