aws-runtime-bridge 1.5.0 → 1.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/adapter/AdapterRegistry.d.ts +1 -1
- package/dist/adapter/AdapterRegistry.d.ts.map +1 -1
- package/dist/adapter/AdapterRegistry.js +0 -2
- package/dist/adapter/ClaudeSdkAdapter.d.ts +4 -0
- package/dist/adapter/ClaudeSdkAdapter.d.ts.map +1 -1
- package/dist/adapter/ClaudeSdkAdapter.js +11 -2
- package/dist/adapter/CodexSdkAdapter.js +1 -1
- package/dist/adapter/OpencodeSdkAdapter.js +2 -2
- package/dist/adapter/types.d.ts +10 -0
- package/dist/adapter/types.d.ts.map +1 -1
- package/dist/index.js +14 -43
- package/dist/middleware/auth.d.ts +5 -0
- package/dist/middleware/auth.d.ts.map +1 -1
- package/dist/middleware/auth.js +9 -1
- package/dist/routes/file-browser.d.ts.map +1 -1
- package/dist/routes/file-browser.js +21 -1
- package/dist/routes/file-browser.test.js +9 -0
- package/dist/routes/instance.d.ts +10 -0
- package/dist/routes/instance.d.ts.map +1 -1
- package/dist/routes/instance.js +93 -2
- package/dist/routes/instance.test.js +50 -0
- package/dist/routes/pty.d.ts +106 -0
- package/dist/routes/pty.d.ts.map +1 -0
- package/dist/routes/pty.js +526 -0
- package/dist/routes/pty.test.d.ts +2 -0
- package/dist/routes/pty.test.d.ts.map +1 -0
- package/dist/routes/pty.test.js +73 -0
- package/dist/routes/sessions.d.ts +1 -1
- package/dist/routes/sessions.d.ts.map +1 -1
- package/dist/routes/sessions.js +32 -213
- package/dist/routes/terminal.d.ts +32 -3
- package/dist/routes/terminal.d.ts.map +1 -1
- package/dist/routes/terminal.js +411 -243
- package/dist/routes/terminal.test.js +105 -29
- package/dist/services/agent-process-manager.d.ts +2 -2
- package/dist/services/agent-process-manager.d.ts.map +1 -1
- package/dist/services/agent-process-manager.js +3 -3
- package/dist/services/process-detector.d.ts +2 -4
- package/dist/services/process-detector.d.ts.map +1 -1
- package/dist/services/process-detector.js +9 -16
- package/dist/services/process-registry.d.ts +2 -2
- package/dist/services/process-registry.d.ts.map +1 -1
- package/dist/services/process-registry.js +1 -1
- package/dist/services/session-output.d.ts +15 -5
- package/dist/services/session-output.d.ts.map +1 -1
- package/dist/services/session-output.js +33 -3
- package/dist/services/session-output.test.js +43 -29
- package/dist/services/terminal-persistence.d.ts +9 -0
- package/dist/services/terminal-persistence.d.ts.map +1 -1
- package/dist/services/terminal-persistence.js +20 -0
- package/dist/services/tool-installer.d.ts +10 -0
- package/dist/services/tool-installer.d.ts.map +1 -1
- package/dist/services/tool-installer.js +126 -5
- package/dist/services/tool-installer.test.js +32 -1
- package/dist/services/workspace-files.d.ts +14 -0
- package/dist/services/workspace-files.d.ts.map +1 -1
- package/dist/services/workspace-files.js +52 -0
- package/dist/services/workspace-files.test.js +85 -1
- package/dist/types.d.ts +8 -4
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -1
|
@@ -0,0 +1,526 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import crypto from "node:crypto";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { Router as createRouter } from "express";
|
|
5
|
+
import * as pty from "node-pty";
|
|
6
|
+
import { v4 as uuidv4 } from "uuid";
|
|
7
|
+
import WebSocket, { WebSocketServer } from "ws";
|
|
8
|
+
import { validateToken, isRuntimeTokenValid } from "../middleware/auth.js";
|
|
9
|
+
import { createLogger } from "../utils/logger.js";
|
|
10
|
+
const log = createLogger("pty");
|
|
11
|
+
const DEFAULT_COLS = 80;
|
|
12
|
+
const DEFAULT_ROWS = 24;
|
|
13
|
+
const MAX_OUTPUT_BUFFER_CHARS = 200_000;
|
|
14
|
+
const DEFAULT_IDLE_TTL_MS = 30 * 60 * 1000;
|
|
15
|
+
const DEFAULT_EXITED_TTL_MS = 5 * 60 * 1000;
|
|
16
|
+
const DEFAULT_CONNECT_TOKEN_TTL_MS = 60 * 1000;
|
|
17
|
+
const DEFAULT_MAX_PTY_SESSIONS = 8;
|
|
18
|
+
export const ptySessions = new Map();
|
|
19
|
+
const ptyConnectTokens = new Map();
|
|
20
|
+
/**
|
|
21
|
+
* 解析 PTY 空闲清理时间。
|
|
22
|
+
* 主流程:读取环境变量 -> 非法值回退默认值 -> 用于 WebSocket 全断开后的延迟清理。
|
|
23
|
+
*/
|
|
24
|
+
export function resolvePtyIdleTtlMs(env = process.env) {
|
|
25
|
+
const raw = String(env.AWS_PTY_IDLE_TTL_MS || "").trim();
|
|
26
|
+
if (!raw) {
|
|
27
|
+
return DEFAULT_IDLE_TTL_MS;
|
|
28
|
+
}
|
|
29
|
+
const parsed = Number(raw);
|
|
30
|
+
if (!Number.isFinite(parsed) || parsed < 1_000) {
|
|
31
|
+
return DEFAULT_IDLE_TTL_MS;
|
|
32
|
+
}
|
|
33
|
+
return parsed;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* 选择默认 shell。
|
|
37
|
+
* 主流程:Windows 优先 pwsh/powershell/cmd;Unix 优先 SHELL/bash/sh,返回可执行文件名与参数。
|
|
38
|
+
*/
|
|
39
|
+
export function resolveDefaultShell(platform = process.platform, env = process.env) {
|
|
40
|
+
if (platform === "win32") {
|
|
41
|
+
const comSpec = String(env.ComSpec || env.COMSPEC || "").trim();
|
|
42
|
+
return { shell: comSpec || "powershell.exe", args: [] };
|
|
43
|
+
}
|
|
44
|
+
const configuredShell = String(env.SHELL || "").trim();
|
|
45
|
+
return { shell: configuredShell || "/bin/bash", args: [] };
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* 校验并解析 shell 选择。
|
|
49
|
+
* 主流程:auto 使用默认 shell;显式 shell 必须命中 allowlist,避免前端任意执行二进制。
|
|
50
|
+
*/
|
|
51
|
+
export function resolveRequestedShell(requestedShell, platform = process.platform, env = process.env) {
|
|
52
|
+
const normalized = String(requestedShell || "auto").trim();
|
|
53
|
+
if (!normalized || normalized === "auto") {
|
|
54
|
+
return resolveDefaultShell(platform, env);
|
|
55
|
+
}
|
|
56
|
+
const allowlist = new Set([
|
|
57
|
+
"bash",
|
|
58
|
+
"sh",
|
|
59
|
+
"zsh",
|
|
60
|
+
"fish",
|
|
61
|
+
"pwsh",
|
|
62
|
+
"pwsh.exe",
|
|
63
|
+
"powershell",
|
|
64
|
+
"powershell.exe",
|
|
65
|
+
"cmd",
|
|
66
|
+
"cmd.exe",
|
|
67
|
+
]);
|
|
68
|
+
const basename = path.basename(normalized).toLowerCase();
|
|
69
|
+
if (!allowlist.has(basename)) {
|
|
70
|
+
throw new Error(`shell is not allowed: ${normalized}`);
|
|
71
|
+
}
|
|
72
|
+
return { shell: normalized, args: [] };
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* 判断候选路径是否位于允许根目录中。
|
|
76
|
+
* 具体逻辑:使用 path.relative 处理 Windows/Unix 分隔符,拒绝向上穿越和跨盘路径。
|
|
77
|
+
*/
|
|
78
|
+
export function isPathInsideRoot(candidatePath, rootPath) {
|
|
79
|
+
const relative = path.relative(path.resolve(rootPath), path.resolve(candidatePath));
|
|
80
|
+
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
|
81
|
+
}
|
|
82
|
+
function resolveAllowedWorkspaceRoots(env = process.env) {
|
|
83
|
+
return String(env.AWS_PTY_ALLOWED_ROOTS || "")
|
|
84
|
+
.split(path.delimiter)
|
|
85
|
+
.map((item) => item.trim())
|
|
86
|
+
.filter(Boolean)
|
|
87
|
+
.map((item) => path.resolve(item));
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* 解析并校验 PTY 工作目录。
|
|
91
|
+
* 主流程:要求绝对目录存在;如果配置 AWS_PTY_ALLOWED_ROOTS,则必须位于允许根目录内。
|
|
92
|
+
*/
|
|
93
|
+
export function resolvePtyWorkspacePath(workspacePath, env = process.env) {
|
|
94
|
+
const raw = String(workspacePath || "").trim();
|
|
95
|
+
if (!raw) {
|
|
96
|
+
throw new Error("workspacePath is required");
|
|
97
|
+
}
|
|
98
|
+
if (!path.isAbsolute(raw)) {
|
|
99
|
+
throw new Error("workspacePath must be absolute");
|
|
100
|
+
}
|
|
101
|
+
const resolved = path.resolve(raw);
|
|
102
|
+
const stat = fs.statSync(resolved);
|
|
103
|
+
if (!stat.isDirectory()) {
|
|
104
|
+
throw new Error("workspacePath must be an existing directory");
|
|
105
|
+
}
|
|
106
|
+
const allowedRoots = resolveAllowedWorkspaceRoots(env);
|
|
107
|
+
if (allowedRoots.length > 0 && !allowedRoots.some((root) => isPathInsideRoot(resolved, root))) {
|
|
108
|
+
throw new Error("workspacePath is outside allowed roots");
|
|
109
|
+
}
|
|
110
|
+
return resolved;
|
|
111
|
+
}
|
|
112
|
+
function normalizeDimension(value, fallback) {
|
|
113
|
+
const parsed = Number(value);
|
|
114
|
+
if (!Number.isInteger(parsed) || parsed <= 0) {
|
|
115
|
+
return fallback;
|
|
116
|
+
}
|
|
117
|
+
return Math.min(parsed, 500);
|
|
118
|
+
}
|
|
119
|
+
function buildPtyEnv(baseEnv) {
|
|
120
|
+
return {
|
|
121
|
+
...baseEnv,
|
|
122
|
+
TERM: baseEnv.TERM || "xterm-256color",
|
|
123
|
+
COLORTERM: baseEnv.COLORTERM || "truecolor",
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
function appendOutputBuffer(session, data) {
|
|
127
|
+
session.outputBuffer = `${session.outputBuffer}${data}`;
|
|
128
|
+
if (session.outputBuffer.length > MAX_OUTPUT_BUFFER_CHARS) {
|
|
129
|
+
session.outputBuffer = session.outputBuffer.slice(-MAX_OUTPUT_BUFFER_CHARS);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
function toSummary(session) {
|
|
133
|
+
return {
|
|
134
|
+
id: session.id,
|
|
135
|
+
title: session.title,
|
|
136
|
+
workspacePath: session.workspacePath,
|
|
137
|
+
shell: session.shell,
|
|
138
|
+
cols: session.cols,
|
|
139
|
+
rows: session.rows,
|
|
140
|
+
status: session.status,
|
|
141
|
+
createdAt: session.createdAt,
|
|
142
|
+
lastActiveAt: session.lastActiveAt,
|
|
143
|
+
exitCode: session.exitCode,
|
|
144
|
+
signal: session.signal,
|
|
145
|
+
attachedClients: session.sockets.size,
|
|
146
|
+
ownerUserId: session.ownerUserId,
|
|
147
|
+
tenantId: session.tenantId,
|
|
148
|
+
projectName: session.projectName,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
function sendJson(socket, payload) {
|
|
152
|
+
if (socket.readyState === WebSocket.OPEN) {
|
|
153
|
+
socket.send(JSON.stringify(payload));
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
function broadcast(session, payload) {
|
|
157
|
+
for (const socket of session.sockets) {
|
|
158
|
+
sendJson(socket, payload);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
function scheduleIdleCleanup(session) {
|
|
162
|
+
if (session.idleTimer) {
|
|
163
|
+
clearTimeout(session.idleTimer);
|
|
164
|
+
}
|
|
165
|
+
session.idleTimer = setTimeout(() => {
|
|
166
|
+
if (session.sockets.size === 0) {
|
|
167
|
+
closePtySession(session.id, "idle-timeout");
|
|
168
|
+
}
|
|
169
|
+
}, resolvePtyIdleTtlMs());
|
|
170
|
+
}
|
|
171
|
+
function cancelIdleCleanup(session) {
|
|
172
|
+
if (session.idleTimer) {
|
|
173
|
+
clearTimeout(session.idleTimer);
|
|
174
|
+
session.idleTimer = null;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* 创建 PTY session。
|
|
179
|
+
* 主流程:校验 cwd/shell -> spawn 真实 PTY -> 注册 output/exit 事件 -> 返回元数据给控制面。
|
|
180
|
+
*/
|
|
181
|
+
export function createPtySession(input) {
|
|
182
|
+
if (ptySessions.size >= resolveMaxPtySessions()) {
|
|
183
|
+
throw new Error("maximum PTY sessions reached");
|
|
184
|
+
}
|
|
185
|
+
const workspacePath = resolvePtyWorkspacePath(input.workspacePath);
|
|
186
|
+
const shell = resolveRequestedShell(input.shell);
|
|
187
|
+
const cols = normalizeDimension(input.cols, DEFAULT_COLS);
|
|
188
|
+
const rows = normalizeDimension(input.rows, DEFAULT_ROWS);
|
|
189
|
+
const id = `pty_${uuidv4()}`;
|
|
190
|
+
const now = new Date().toISOString();
|
|
191
|
+
const title = String(input.title || "").trim() || `Terminal ${ptySessions.size + 1}`;
|
|
192
|
+
const ptyProcess = pty.spawn(shell.shell, shell.args, {
|
|
193
|
+
name: "xterm-256color",
|
|
194
|
+
cols,
|
|
195
|
+
rows,
|
|
196
|
+
cwd: workspacePath,
|
|
197
|
+
env: buildPtyEnv(process.env),
|
|
198
|
+
});
|
|
199
|
+
const session = {
|
|
200
|
+
id,
|
|
201
|
+
title,
|
|
202
|
+
workspacePath,
|
|
203
|
+
shell: shell.shell,
|
|
204
|
+
cols,
|
|
205
|
+
rows,
|
|
206
|
+
status: "running",
|
|
207
|
+
createdAt: now,
|
|
208
|
+
lastActiveAt: now,
|
|
209
|
+
attachedClients: 0,
|
|
210
|
+
ptyProcess,
|
|
211
|
+
sockets: new Set(),
|
|
212
|
+
outputBuffer: "",
|
|
213
|
+
idleTimer: null,
|
|
214
|
+
persistent: input.persistent === true,
|
|
215
|
+
ownerUserId: String(input.ownerUserId || "").trim() || undefined,
|
|
216
|
+
tenantId: String(input.tenantId || "").trim() || undefined,
|
|
217
|
+
projectName: String(input.projectName || "").trim() || undefined,
|
|
218
|
+
};
|
|
219
|
+
ptyProcess.onData((data) => {
|
|
220
|
+
session.lastActiveAt = new Date().toISOString();
|
|
221
|
+
appendOutputBuffer(session, data);
|
|
222
|
+
broadcast(session, { type: "output", data });
|
|
223
|
+
});
|
|
224
|
+
ptyProcess.onExit(({ exitCode, signal }) => {
|
|
225
|
+
session.status = "exited";
|
|
226
|
+
session.exitCode = exitCode;
|
|
227
|
+
session.signal = signal;
|
|
228
|
+
session.lastActiveAt = new Date().toISOString();
|
|
229
|
+
broadcast(session, { type: "exit", exitCode, signal });
|
|
230
|
+
if (session.sockets.size === 0 && !session.persistent) {
|
|
231
|
+
scheduleIdleCleanup(session);
|
|
232
|
+
}
|
|
233
|
+
else if (session.persistent) {
|
|
234
|
+
if (session.idleTimer) {
|
|
235
|
+
clearTimeout(session.idleTimer);
|
|
236
|
+
}
|
|
237
|
+
session.idleTimer = setTimeout(() => {
|
|
238
|
+
closePtySession(session.id, "exited-timeout");
|
|
239
|
+
}, resolvePtyExitedTtlMs());
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
ptySessions.set(id, session);
|
|
243
|
+
if (!session.persistent) {
|
|
244
|
+
scheduleIdleCleanup(session);
|
|
245
|
+
}
|
|
246
|
+
injectPtyPreCommand(session, input.preCommand);
|
|
247
|
+
log.info(`created pty session id=${id}, cwd=${workspacePath}, shell=${shell.shell}`);
|
|
248
|
+
return toSummary(session);
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* 注入 PTY 前置命令。
|
|
252
|
+
* 主流程:忽略空命令 -> 保持已有结尾换行 -> 否则追加一次回车,模拟用户输入执行。
|
|
253
|
+
*/
|
|
254
|
+
function injectPtyPreCommand(session, preCommand) {
|
|
255
|
+
const command = typeof preCommand === "string" ? preCommand : "";
|
|
256
|
+
if (!command.trim()) {
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
const payload = `${command.replace(/\n/g, "\r").replace(/\r+$/g, "")}\r`;
|
|
260
|
+
try {
|
|
261
|
+
session.ptyProcess.write(payload);
|
|
262
|
+
}
|
|
263
|
+
catch (error) {
|
|
264
|
+
const err = error;
|
|
265
|
+
log.warn(`failed to inject pty pre-command sessionId=${session.id}: ${err.message}`);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
function resolvePtyConnectTokenTtlMs(env = process.env) {
|
|
269
|
+
const raw = String(env.AWS_PTY_CONNECT_TOKEN_TTL_MS || "").trim();
|
|
270
|
+
if (!raw) {
|
|
271
|
+
return DEFAULT_CONNECT_TOKEN_TTL_MS;
|
|
272
|
+
}
|
|
273
|
+
const parsed = Number(raw);
|
|
274
|
+
if (!Number.isFinite(parsed) || parsed < 5_000 || parsed > 10 * 60 * 1000) {
|
|
275
|
+
return DEFAULT_CONNECT_TOKEN_TTL_MS;
|
|
276
|
+
}
|
|
277
|
+
return parsed;
|
|
278
|
+
}
|
|
279
|
+
export function resolvePtyExitedTtlMs(env = process.env) {
|
|
280
|
+
const raw = String(env.AWS_PTY_EXITED_TTL_MS || "").trim();
|
|
281
|
+
if (!raw) {
|
|
282
|
+
return DEFAULT_EXITED_TTL_MS;
|
|
283
|
+
}
|
|
284
|
+
const parsed = Number(raw);
|
|
285
|
+
if (!Number.isFinite(parsed) || parsed < 1_000) {
|
|
286
|
+
return DEFAULT_EXITED_TTL_MS;
|
|
287
|
+
}
|
|
288
|
+
return parsed;
|
|
289
|
+
}
|
|
290
|
+
export function resolveMaxPtySessions(env = process.env) {
|
|
291
|
+
const raw = String(env.AWS_PTY_MAX_SESSIONS || "").trim();
|
|
292
|
+
if (!raw) {
|
|
293
|
+
return DEFAULT_MAX_PTY_SESSIONS;
|
|
294
|
+
}
|
|
295
|
+
const parsed = Number(raw);
|
|
296
|
+
if (!Number.isInteger(parsed) || parsed < 1 || parsed > 100) {
|
|
297
|
+
return DEFAULT_MAX_PTY_SESSIONS;
|
|
298
|
+
}
|
|
299
|
+
return parsed;
|
|
300
|
+
}
|
|
301
|
+
function readCreatePtySessionInput(body) {
|
|
302
|
+
if (!body || typeof body !== "object") {
|
|
303
|
+
return { workspacePath: undefined };
|
|
304
|
+
}
|
|
305
|
+
const record = body;
|
|
306
|
+
return {
|
|
307
|
+
workspacePath: record.workspacePath,
|
|
308
|
+
title: record.title,
|
|
309
|
+
shell: record.shell,
|
|
310
|
+
cols: record.cols,
|
|
311
|
+
rows: record.rows,
|
|
312
|
+
persistent: record.persistent,
|
|
313
|
+
preCommand: record.preCommand,
|
|
314
|
+
ownerUserId: record.ownerUserId,
|
|
315
|
+
tenantId: record.tenantId,
|
|
316
|
+
projectName: record.projectName,
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
export function listPtySessions() {
|
|
320
|
+
return Array.from(ptySessions.values()).map(toSummary);
|
|
321
|
+
}
|
|
322
|
+
export function getPtySessionSummary(id) {
|
|
323
|
+
const session = ptySessions.get(id);
|
|
324
|
+
return session ? toSummary(session) : undefined;
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* 生成短期 PTY 连接令牌。
|
|
328
|
+
* 主流程:确认 session 存在 -> 生成随机 token -> 记录 session 绑定和过期时间。
|
|
329
|
+
*/
|
|
330
|
+
export function createPtyConnectToken(sessionId) {
|
|
331
|
+
if (!ptySessions.has(sessionId)) {
|
|
332
|
+
return undefined;
|
|
333
|
+
}
|
|
334
|
+
const token = `ptyws_${crypto.randomBytes(32).toString("base64url")}`;
|
|
335
|
+
const expiresAtMs = Date.now() + resolvePtyConnectTokenTtlMs();
|
|
336
|
+
ptyConnectTokens.set(token, { sessionId, expiresAt: expiresAtMs });
|
|
337
|
+
return { token, expiresAt: new Date(expiresAtMs).toISOString() };
|
|
338
|
+
}
|
|
339
|
+
function consumePtyConnectToken(sessionId, token) {
|
|
340
|
+
const entry = ptyConnectTokens.get(token);
|
|
341
|
+
if (!entry) {
|
|
342
|
+
return false;
|
|
343
|
+
}
|
|
344
|
+
if (entry.expiresAt < Date.now()) {
|
|
345
|
+
ptyConnectTokens.delete(token);
|
|
346
|
+
return false;
|
|
347
|
+
}
|
|
348
|
+
if (entry.sessionId !== sessionId) {
|
|
349
|
+
return false;
|
|
350
|
+
}
|
|
351
|
+
ptyConnectTokens.delete(token);
|
|
352
|
+
return true;
|
|
353
|
+
}
|
|
354
|
+
function deletePtyConnectTokensForSession(sessionId) {
|
|
355
|
+
for (const [token, entry] of ptyConnectTokens.entries()) {
|
|
356
|
+
if (entry.sessionId === sessionId) {
|
|
357
|
+
ptyConnectTokens.delete(token);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* 关闭 PTY session。
|
|
363
|
+
* 主流程:关闭连接 -> kill PTY -> 清理定时器和内存索引。
|
|
364
|
+
*/
|
|
365
|
+
export function closePtySession(id, reason = "closed") {
|
|
366
|
+
const session = ptySessions.get(id);
|
|
367
|
+
if (!session) {
|
|
368
|
+
return false;
|
|
369
|
+
}
|
|
370
|
+
cancelIdleCleanup(session);
|
|
371
|
+
deletePtyConnectTokensForSession(id);
|
|
372
|
+
broadcast(session, { type: "status", status: "closing", reason });
|
|
373
|
+
for (const socket of session.sockets) {
|
|
374
|
+
socket.close(1000, reason);
|
|
375
|
+
}
|
|
376
|
+
session.sockets.clear();
|
|
377
|
+
if (session.status === "running") {
|
|
378
|
+
session.ptyProcess.kill();
|
|
379
|
+
}
|
|
380
|
+
ptySessions.delete(id);
|
|
381
|
+
log.info(`closed pty session id=${id}, reason=${reason}`);
|
|
382
|
+
return true;
|
|
383
|
+
}
|
|
384
|
+
export function closeAllPtySessions(reason = "shutdown") {
|
|
385
|
+
for (const id of Array.from(ptySessions.keys())) {
|
|
386
|
+
closePtySession(id, reason);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
export const ptyRouter = createRouter();
|
|
390
|
+
ptyRouter.get("/sessions", validateToken, (_req, res) => {
|
|
391
|
+
res.json({ ok: true, sessions: listPtySessions() });
|
|
392
|
+
});
|
|
393
|
+
ptyRouter.post("/sessions", validateToken, (req, res) => {
|
|
394
|
+
try {
|
|
395
|
+
const session = createPtySession(readCreatePtySessionInput(req.body));
|
|
396
|
+
res.status(201).json({ ok: true, session });
|
|
397
|
+
}
|
|
398
|
+
catch (error) {
|
|
399
|
+
const err = error;
|
|
400
|
+
res.status(400).json({ error: err.message });
|
|
401
|
+
}
|
|
402
|
+
});
|
|
403
|
+
ptyRouter.get("/sessions/:sessionId", validateToken, (req, res) => {
|
|
404
|
+
const session = getPtySessionSummary(req.params.sessionId);
|
|
405
|
+
if (!session) {
|
|
406
|
+
res.status(404).json({ error: "pty session not found" });
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
res.json({ ok: true, session });
|
|
410
|
+
});
|
|
411
|
+
ptyRouter.delete("/sessions/:sessionId", validateToken, (req, res) => {
|
|
412
|
+
const closed = closePtySession(req.params.sessionId, "explicit-close");
|
|
413
|
+
if (!closed) {
|
|
414
|
+
res.status(404).json({ error: "pty session not found" });
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
res.json({ ok: true });
|
|
418
|
+
});
|
|
419
|
+
ptyRouter.post("/sessions/:sessionId/connect-token", validateToken, (req, res) => {
|
|
420
|
+
const token = createPtyConnectToken(req.params.sessionId);
|
|
421
|
+
if (!token) {
|
|
422
|
+
res.status(404).json({ error: "pty session not found" });
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
res.json({ ok: true, ...token });
|
|
426
|
+
});
|
|
427
|
+
function parseWebSocketMessage(raw) {
|
|
428
|
+
const text = raw.toString();
|
|
429
|
+
try {
|
|
430
|
+
return JSON.parse(text);
|
|
431
|
+
}
|
|
432
|
+
catch {
|
|
433
|
+
return { type: "input", data: text };
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
function handlePtySocket(session, socket) {
|
|
437
|
+
cancelIdleCleanup(session);
|
|
438
|
+
session.sockets.add(socket);
|
|
439
|
+
session.lastActiveAt = new Date().toISOString();
|
|
440
|
+
sendJson(socket, { type: "status", status: session.status, session: toSummary(session) });
|
|
441
|
+
if (session.outputBuffer) {
|
|
442
|
+
sendJson(socket, { type: "output", data: session.outputBuffer, replay: true });
|
|
443
|
+
}
|
|
444
|
+
socket.on("message", (raw) => {
|
|
445
|
+
const message = parseWebSocketMessage(raw);
|
|
446
|
+
if (!message || typeof message !== "object") {
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
const typed = message;
|
|
450
|
+
const type = String(typed.type || "");
|
|
451
|
+
session.lastActiveAt = new Date().toISOString();
|
|
452
|
+
if (type === "input") {
|
|
453
|
+
session.ptyProcess.write(String(typed.data || ""));
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
if (type === "resize") {
|
|
457
|
+
const cols = normalizeDimension(typed.cols, session.cols);
|
|
458
|
+
const rows = normalizeDimension(typed.rows, session.rows);
|
|
459
|
+
session.cols = cols;
|
|
460
|
+
session.rows = rows;
|
|
461
|
+
session.ptyProcess.resize(cols, rows);
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
if (type === "close") {
|
|
465
|
+
closePtySession(session.id, "client-close");
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
if (type === "ping") {
|
|
469
|
+
sendJson(socket, { type: "pong" });
|
|
470
|
+
}
|
|
471
|
+
});
|
|
472
|
+
socket.on("close", () => {
|
|
473
|
+
session.sockets.delete(socket);
|
|
474
|
+
session.lastActiveAt = new Date().toISOString();
|
|
475
|
+
if (session.sockets.size === 0 && session.status === "running" && !session.persistent) {
|
|
476
|
+
scheduleIdleCleanup(session);
|
|
477
|
+
}
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
function extractWsToken(request, requestUrl) {
|
|
481
|
+
const queryToken = requestUrl.searchParams.get("token") || "";
|
|
482
|
+
if (queryToken.trim()) {
|
|
483
|
+
return queryToken.trim();
|
|
484
|
+
}
|
|
485
|
+
const headerToken = request.headers["x-runtime-token"];
|
|
486
|
+
if (Array.isArray(headerToken)) {
|
|
487
|
+
return headerToken[0]?.trim() || "";
|
|
488
|
+
}
|
|
489
|
+
if (typeof headerToken === "string") {
|
|
490
|
+
return headerToken.trim();
|
|
491
|
+
}
|
|
492
|
+
const authorization = String(request.headers.authorization || "");
|
|
493
|
+
const bearerMatch = authorization.match(/^Bearer\s+(.+)$/i);
|
|
494
|
+
return bearerMatch?.[1]?.trim() || "";
|
|
495
|
+
}
|
|
496
|
+
/**
|
|
497
|
+
* 将 PTY WebSocket upgrade 挂到 HTTP server。
|
|
498
|
+
* 主流程:匹配 /pty/sessions/:id/stream -> token 校验 -> session 校验 -> 交给 noServer WSS。
|
|
499
|
+
*/
|
|
500
|
+
export function attachPtyWebSocketServer(server) {
|
|
501
|
+
const wss = new WebSocketServer({ noServer: true });
|
|
502
|
+
server.on("upgrade", (request, socket, head) => {
|
|
503
|
+
const host = request.headers.host || "localhost";
|
|
504
|
+
const requestUrl = new URL(request.url || "/", `http://${host}`);
|
|
505
|
+
const match = requestUrl.pathname.match(/^\/pty\/sessions\/([^/]+)\/stream$/);
|
|
506
|
+
if (!match) {
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
const token = extractWsToken(request, requestUrl);
|
|
510
|
+
const sessionId = decodeURIComponent(match[1] || "");
|
|
511
|
+
if (!isRuntimeTokenValid(token) && !consumePtyConnectToken(sessionId, token)) {
|
|
512
|
+
socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
|
|
513
|
+
socket.destroy();
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
const session = ptySessions.get(sessionId);
|
|
517
|
+
if (!session) {
|
|
518
|
+
socket.write("HTTP/1.1 404 Not Found\r\n\r\n");
|
|
519
|
+
socket.destroy();
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
wss.handleUpgrade(request, socket, head, (ws) => {
|
|
523
|
+
handlePtySocket(session, ws);
|
|
524
|
+
});
|
|
525
|
+
});
|
|
526
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"pty.test.d.ts","sourceRoot":"","sources":["../../src/routes/pty.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, resolve } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
const ptyWrite = vi.fn();
|
|
6
|
+
const ptyKill = vi.fn();
|
|
7
|
+
vi.mock('node-pty', () => ({
|
|
8
|
+
spawn: vi.fn(() => ({
|
|
9
|
+
write: ptyWrite,
|
|
10
|
+
kill: ptyKill,
|
|
11
|
+
onData: vi.fn(),
|
|
12
|
+
onExit: vi.fn(),
|
|
13
|
+
})),
|
|
14
|
+
}));
|
|
15
|
+
const currentDir = dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
afterEach(async () => {
|
|
17
|
+
const { closeAllPtySessions } = await import('./pty.js');
|
|
18
|
+
closeAllPtySessions('test-cleanup');
|
|
19
|
+
ptyWrite.mockClear();
|
|
20
|
+
ptyKill.mockClear();
|
|
21
|
+
});
|
|
22
|
+
describe('ordinary PTY persistence policy', () => {
|
|
23
|
+
it('keeps persistent dashboard PTY sessions alive across browser disconnects', () => {
|
|
24
|
+
const source = readFileSync(resolve(currentDir, './pty.ts'), 'utf-8');
|
|
25
|
+
expect(source).toContain('persistent: boolean');
|
|
26
|
+
expect(source).toContain('persistent?: unknown');
|
|
27
|
+
expect(source).toContain('persistent: input.persistent === true');
|
|
28
|
+
expect(source).toContain('ownerUserId: String(input.ownerUserId || "").trim() || undefined');
|
|
29
|
+
expect(source).toContain('tenantId: String(input.tenantId || "").trim() || undefined');
|
|
30
|
+
expect(source).toContain('projectName: String(input.projectName || "").trim() || undefined');
|
|
31
|
+
expect(source).toContain('if (!session.persistent) {');
|
|
32
|
+
expect(source).toContain('resolvePtyExitedTtlMs');
|
|
33
|
+
expect(source).toContain('closePtySession(session.id, "exited-timeout")');
|
|
34
|
+
expect(source).toContain('session.sockets.size === 0 && session.status === "running" && !session.persistent');
|
|
35
|
+
});
|
|
36
|
+
it('documents optional pre-command injection without exposing template IDs', () => {
|
|
37
|
+
const source = readFileSync(resolve(currentDir, './pty.ts'), 'utf-8');
|
|
38
|
+
expect(source).toContain('preCommand?: unknown');
|
|
39
|
+
expect(source).toContain('preCommand: record.preCommand');
|
|
40
|
+
expect(source).toContain('injectPtyPreCommand(session, input.preCommand)');
|
|
41
|
+
expect(source).toContain('command.replace(/\\n/g, "\\r")');
|
|
42
|
+
expect(source).toContain('session.ptyProcess.write(payload)');
|
|
43
|
+
expect(source).not.toContain('preCommandTemplateId');
|
|
44
|
+
});
|
|
45
|
+
it('writes multiline pre-commands as enter-separated PTY input', async () => {
|
|
46
|
+
const { createPtySession } = await import('./pty.js');
|
|
47
|
+
createPtySession({
|
|
48
|
+
workspacePath: process.cwd(),
|
|
49
|
+
preCommand: 'echo one\necho two',
|
|
50
|
+
persistent: true,
|
|
51
|
+
});
|
|
52
|
+
expect(ptyWrite).toHaveBeenCalledTimes(1);
|
|
53
|
+
expect(ptyWrite).toHaveBeenCalledWith('echo one\recho two\r');
|
|
54
|
+
});
|
|
55
|
+
it('normalizes a trailing LF to exactly one final PTY enter', async () => {
|
|
56
|
+
const { createPtySession } = await import('./pty.js');
|
|
57
|
+
createPtySession({
|
|
58
|
+
workspacePath: process.cwd(),
|
|
59
|
+
preCommand: 'echo one\n',
|
|
60
|
+
persistent: true,
|
|
61
|
+
});
|
|
62
|
+
expect(ptyWrite).toHaveBeenCalledTimes(1);
|
|
63
|
+
expect(ptyWrite).toHaveBeenCalledWith('echo one\r');
|
|
64
|
+
});
|
|
65
|
+
it('keeps no-template PTY creation from writing pre-command input', async () => {
|
|
66
|
+
const { createPtySession } = await import('./pty.js');
|
|
67
|
+
createPtySession({
|
|
68
|
+
workspacePath: process.cwd(),
|
|
69
|
+
persistent: true,
|
|
70
|
+
});
|
|
71
|
+
expect(ptyWrite).not.toHaveBeenCalled();
|
|
72
|
+
});
|
|
73
|
+
});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sessions.d.ts","sourceRoot":"","sources":["../../src/routes/sessions.ts"],"names":[],"mappings":"AAAA;;;;GAIG;
|
|
1
|
+
{"version":3,"file":"sessions.d.ts","sourceRoot":"","sources":["../../src/routes/sessions.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAqBH,eAAO,MAAM,cAAc,4CAAW,CAAC"}
|