dsh-mobile-plugin 0.5.17 → 0.5.18
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/lib/impl.js +6 -0
- package/lib/mobile/cli/acp.js +216 -0
- package/lib/mobile/cli/agents.js +217 -0
- package/lib/mobile/cli/blocks.js +228 -0
- package/lib/mobile/cli/config-prefs.js +172 -0
- package/lib/mobile/cli/icons.js +138 -0
- package/lib/mobile/cli/images.js +132 -0
- package/lib/mobile/cli/mobile.js +1520 -0
- package/lib/mobile/cli/model-prefs.js +77 -0
- package/lib/mobile/cli/model-visibility.js +92 -0
- package/lib/mobile/cli/registry.js +207 -0
- package/lib/mobile/cli/service.js +901 -0
- package/lib/mobile/cli/usage.js +133 -0
- package/lib/mobile/monitor.js +17 -6
- package/lib/mobile/narration.js +205 -0
- package/lib/mobile/phone/binding.js +247 -0
- package/lib/mobile/phone/index.js +63 -0
- package/lib/mobile/phone/routes.js +168 -0
- package/lib/mobile/phone/service.js +327 -0
- package/lib/mobile/phone/tools.js +471 -0
- package/lib/mobile/services.js +16 -0
- package/lib/mobile/session-send.js +1 -0
- package/lib/mobile/terminal.js +574 -0
- package/lib/routes/mobile-api.js +139 -3
- package/package.json +2 -1
|
@@ -0,0 +1,574 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 移动端远程终端:`/api/mobile/v1/terminal/*` 的 handler 与 WebSocket 流。
|
|
5
|
+
*
|
|
6
|
+
* 手机侧的新对话页(`app/src/components/TerminalScreen.tsx`)要在这台电脑的某个项目目录里
|
|
7
|
+
* 起一个本机已安装的 CLI(Codex / Claude Code 之类),并把它的 TUI 原样呈现在手机上。
|
|
8
|
+
* 那条链的两端就是这里与 App 里那个 xterm 页面:宿主起伪终端、把字节搬进搬出,App 只画。
|
|
9
|
+
*
|
|
10
|
+
* 与 Git 那组(`mobile/git.js`)同一条边界,但**边界本身换了一档**:Git 只收固定动词,
|
|
11
|
+
* 命令一个字都不透传;终端天然要透传键盘,等于把 shell 交出去。所以把它收在白名单上 ——
|
|
12
|
+
*
|
|
13
|
+
* 1. **可执行文件只能是 `AGENTS` 里那几个名字**,调用方给的是 id,不是命令、不是参数;
|
|
14
|
+
* 2. **工作目录只能来自这台设备已注册的工作区**(按 `workspaceId` 复核,不接受任意路径);
|
|
15
|
+
* 3. **没有「执行任意命令」这条路由**,也没有可以拼进 shell 的参数。
|
|
16
|
+
*
|
|
17
|
+
* 就这样,能跑的东西仍然是「用户自己装的那几个 CLI,在他自己的项目里」。手机能敲进去的
|
|
18
|
+
* 那些字属于 CLI 自身的交互,不是这条 API 给的命令口子。
|
|
19
|
+
*
|
|
20
|
+
* 伪终端用系统自带的 `python3` 开(`pty.fork`):插件不引 node-pty(原生模块,装的时候
|
|
21
|
+
* 要编译,跨平台出问题的概率远高于它带来的收益)。**不能用 `script`**:BSD 的 `script`
|
|
22
|
+
* 打开伪终端时要对它的 stdin 做 `tcgetattr`,而宿主进程的 stdin 是 socketpair,它会直接
|
|
23
|
+
* `Operation not supported on socket` 退出(宿主是 GUI 起的,压根没有终端)。
|
|
24
|
+
*
|
|
25
|
+
* 代价是**不能改窗口大小** —— 尺寸只在 fork 那一刻设一次,xterm 报上来的 resize 一律忽略。
|
|
26
|
+
* 所以页面上按起进程时定下的列数排版、靠调字号适配屏幕,CLI 的 TUI 布局才是稳的。
|
|
27
|
+
*
|
|
28
|
+
* 会话托管在宿主进程里:手机锁屏、切后台、换 Wi-Fi 都不该把 CLI 掐掉,重连时把最近
|
|
29
|
+
* 256KB 输出重放一遍。进程退出后会话留在表里一小会儿(页面要能读到退出码),
|
|
30
|
+
* 随后随宿主启动时的清理一起被扫掉。
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
const fs = require('node:fs');
|
|
34
|
+
const path = require('node:path');
|
|
35
|
+
const { spawn } = require('node:child_process');
|
|
36
|
+
const { WebSocketServer } = require('ws');
|
|
37
|
+
|
|
38
|
+
const { readJsonBody, sendJson } = require('../shared/http-util');
|
|
39
|
+
const { unwrap } = require('./envelope.js');
|
|
40
|
+
|
|
41
|
+
/** 错误码 → HTTP 状态;前端只认 code,message 只用于排障与兜底文案。 */
|
|
42
|
+
const STATUS_BY_CODE = {
|
|
43
|
+
BAD_REQUEST: 400,
|
|
44
|
+
FORBIDDEN: 403,
|
|
45
|
+
NOT_FOUND: 404,
|
|
46
|
+
UNSUPPORTED: 400,
|
|
47
|
+
AGENT_UNAVAILABLE: 404,
|
|
48
|
+
WORKSPACE_UNAVAILABLE: 502,
|
|
49
|
+
TOO_MANY_SESSIONS: 429,
|
|
50
|
+
SPAWN_FAILED: 500,
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* 认得的 CLI(白名单)。
|
|
55
|
+
*
|
|
56
|
+
* `bin` 是 PATH 上的可执行名;`label` 只在界面上用,认不出来的机器上不显示。这份表是这条
|
|
57
|
+
* API 能起什么进程的**全部**依据 —— 往里加一项就是扩一次能力面,别在别处开第二份。
|
|
58
|
+
*/
|
|
59
|
+
const AGENTS = [
|
|
60
|
+
{ id: 'codex', label: 'Codex CLI', bin: 'codex' },
|
|
61
|
+
{ id: 'claude', label: 'Claude Code', bin: 'claude' },
|
|
62
|
+
{ id: 'gemini', label: 'Gemini CLI', bin: 'gemini' },
|
|
63
|
+
{ id: 'opencode', label: 'OpenCode', bin: 'opencode' },
|
|
64
|
+
{ id: 'cursor-agent', label: 'Cursor Agent', bin: 'cursor-agent' },
|
|
65
|
+
{ id: 'aider', label: 'Aider', bin: 'aider' },
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
/** 同时在跑的会话上限:每一条都挂着一个真进程,手机上点多了不该把宿主的 fd 吃光。 */
|
|
69
|
+
const MAX_SESSIONS = 6;
|
|
70
|
+
|
|
71
|
+
/** 每个会话保留的重放缓冲(超出丢最旧的)。 */
|
|
72
|
+
const MAX_BUFFER_BYTES = 256 * 1024;
|
|
73
|
+
|
|
74
|
+
/** 帧首字节:数据与控件的区分。 */
|
|
75
|
+
const FRAME_DATA = 0;
|
|
76
|
+
const FRAME_CONTROL = 1;
|
|
77
|
+
|
|
78
|
+
/** 默认终端尺寸;App 页面按同一组数字排版(见文件头「不能改窗口大小」)。 */
|
|
79
|
+
const DEFAULT_COLS = 100;
|
|
80
|
+
const DEFAULT_ROWS = 30;
|
|
81
|
+
|
|
82
|
+
class TerminalRequestError extends Error {
|
|
83
|
+
constructor(code, message) {
|
|
84
|
+
super(message);
|
|
85
|
+
this.name = 'TerminalRequestError';
|
|
86
|
+
this.code = code;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function fail(code, message) {
|
|
91
|
+
return new TerminalRequestError(code, message);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function text(value) {
|
|
95
|
+
return typeof value === 'string' ? value.trim() : '';
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function query(req) {
|
|
99
|
+
return new URL(req.url || '/', 'http://x').searchParams;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function bodyJson(req) {
|
|
103
|
+
const body = await readJsonBody(req, 64 * 1024);
|
|
104
|
+
if (!body || typeof body !== 'object') throw fail('BAD_REQUEST', '需要 JSON 请求体');
|
|
105
|
+
return body;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* 沿 PATH 找可执行文件。
|
|
110
|
+
*
|
|
111
|
+
* 不 spawn 探测(`--version` 之类):那是拿「装没装」去换一次进程启动,而这个判断只要
|
|
112
|
+
* 文件在不在。Windows 的 PATH 上落的是 `.cmd` / `.exe`,按 PATHEXT 挨个试。
|
|
113
|
+
*/
|
|
114
|
+
function findExecutable(bin, env = process.env) {
|
|
115
|
+
const rawPath = env.PATH || env.Path || '';
|
|
116
|
+
const exts = process.platform === 'win32'
|
|
117
|
+
? (env.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean)
|
|
118
|
+
: [''];
|
|
119
|
+
for (const dir of rawPath.split(process.platform === 'win32' ? ';' : ':')) {
|
|
120
|
+
if (!dir) continue;
|
|
121
|
+
for (const ext of exts) {
|
|
122
|
+
const full = path.join(dir, bin + ext);
|
|
123
|
+
try {
|
|
124
|
+
const stat = fs.statSync(full);
|
|
125
|
+
if (!stat.isFile()) continue;
|
|
126
|
+
if (process.platform !== 'win32') fs.accessSync(full, fs.constants.X_OK);
|
|
127
|
+
return full;
|
|
128
|
+
} catch {
|
|
129
|
+
// 这一档没有,接着找。
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* 开伪终端的那一小段 Python(`pty.fork` + 双向搬运)。
|
|
138
|
+
*
|
|
139
|
+
* 为什么借 Python 而不是自己写:Node 没有开 pty 的 API,`script` 又要一个真终端(见文件头),
|
|
140
|
+
* 而 `pty.fork` 是最正统的那条路。这段代码只在子进程里跑,取的是「一个能开 pty 的现成工具」,
|
|
141
|
+
* 不引 brew / pip 的任何包。
|
|
142
|
+
*
|
|
143
|
+
* 干的事:fork 出 pty → 孩子设窗口尺寸后 `execvp` 目标 → 父进程在 master 与自己的
|
|
144
|
+
* stdin/stdout 之间来回搬字节 → 退出码原样交回去。stdin 关掉之后只搬输出(CLI 可能还要
|
|
145
|
+
* 收尾几行),不跟着退出。
|
|
146
|
+
*/
|
|
147
|
+
const PY_PTY_SCRIPT = `
|
|
148
|
+
import os, pty, select, struct, sys, fcntl, termios
|
|
149
|
+
|
|
150
|
+
def main():
|
|
151
|
+
a = sys.argv[1:]
|
|
152
|
+
cols = int(a[0]); rows = int(a[1]); argv = a[2:]
|
|
153
|
+
pid, master = pty.fork()
|
|
154
|
+
if pid == 0:
|
|
155
|
+
try:
|
|
156
|
+
fcntl.ioctl(0, termios.TIOCSWINSZ, struct.pack('HHHH', rows, cols, 0, 0))
|
|
157
|
+
except OSError:
|
|
158
|
+
pass
|
|
159
|
+
try:
|
|
160
|
+
os.execvp(argv[0], argv)
|
|
161
|
+
except OSError:
|
|
162
|
+
os._exit(127)
|
|
163
|
+
os._exit(127)
|
|
164
|
+
fin = sys.stdin.fileno()
|
|
165
|
+
fout = sys.stdout.fileno()
|
|
166
|
+
watching = [master, fin]
|
|
167
|
+
while True:
|
|
168
|
+
try:
|
|
169
|
+
ready, _, _ = select.select(watching, [], [])
|
|
170
|
+
except InterruptedError:
|
|
171
|
+
continue
|
|
172
|
+
if master in ready:
|
|
173
|
+
try:
|
|
174
|
+
data = os.read(master, 65536)
|
|
175
|
+
except OSError:
|
|
176
|
+
data = b''
|
|
177
|
+
if data:
|
|
178
|
+
try:
|
|
179
|
+
os.write(fout, data)
|
|
180
|
+
except OSError:
|
|
181
|
+
pass
|
|
182
|
+
else:
|
|
183
|
+
break
|
|
184
|
+
if fin in ready:
|
|
185
|
+
try:
|
|
186
|
+
data = os.read(fin, 65536)
|
|
187
|
+
except OSError:
|
|
188
|
+
data = b''
|
|
189
|
+
if data:
|
|
190
|
+
try:
|
|
191
|
+
os.write(master, data)
|
|
192
|
+
except OSError:
|
|
193
|
+
pass
|
|
194
|
+
else:
|
|
195
|
+
watching = [master]
|
|
196
|
+
_, status = os.waitpid(pid, 0)
|
|
197
|
+
try:
|
|
198
|
+
code = os.waitstatus_to_exitcode(status)
|
|
199
|
+
except ValueError:
|
|
200
|
+
code = 1
|
|
201
|
+
sys.exit(0 if code == 0 else 1)
|
|
202
|
+
|
|
203
|
+
main()
|
|
204
|
+
`;
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* 起一个带伪终端的子进程。
|
|
208
|
+
*
|
|
209
|
+
* 真正的 CLI 是 Python 那个进程的**孩子**(坐在 pty 从端上),所以我们这边的 stdin/stdout
|
|
210
|
+
* 是普通管道 —— 只要 Python 在,就不需要宿主自己有终端。
|
|
211
|
+
*
|
|
212
|
+
* Windows 没有 `pty` 模块、也没有同名解释器,所以这一档明确回 UNSUPPORTED。宁可说
|
|
213
|
+
* 「这台电脑暂时不支持」,也好过起一个没有终端语义的管道让 CLI 退化成一堆乱码。
|
|
214
|
+
*/
|
|
215
|
+
function spawnPty({ bin, cwd, cols, rows, env }) {
|
|
216
|
+
if (process.platform === 'win32') {
|
|
217
|
+
throw fail('UNSUPPORTED', 'Windows 上暂时还不支持远程终端');
|
|
218
|
+
}
|
|
219
|
+
const python = findExecutable('python3');
|
|
220
|
+
if (!python) {
|
|
221
|
+
throw fail('UNSUPPORTED', '这台电脑上没有 python3:远程终端要靠它开伪终端');
|
|
222
|
+
}
|
|
223
|
+
const child = spawn(python, ['-c', PY_PTY_SCRIPT, String(cols), String(rows), bin], {
|
|
224
|
+
cwd,
|
|
225
|
+
env: {
|
|
226
|
+
...env,
|
|
227
|
+
// CLI 读得到这两个就不用去问 ioctl;有的 TUI 只认它们。
|
|
228
|
+
COLUMNS: String(cols),
|
|
229
|
+
LINES: String(rows),
|
|
230
|
+
TERM: env.TERM || 'xterm-256color',
|
|
231
|
+
// 让它别为了「交互式登录」去读用户 shell 的 profile:那会把提示符和欢迎语混进 TUI。
|
|
232
|
+
SHELL: '/bin/sh',
|
|
233
|
+
},
|
|
234
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
235
|
+
});
|
|
236
|
+
return child;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** 会话表与它的所有出口。一个宿主进程一份(装配层建、装配层关)。 */
|
|
240
|
+
function createTerminalService({ authorized, onLog } = {}) {
|
|
241
|
+
/** sessionId → 会话。 */
|
|
242
|
+
const sessions = new Map();
|
|
243
|
+
let seq = 0;
|
|
244
|
+
let closed = false;
|
|
245
|
+
|
|
246
|
+
const wss = new WebSocketServer({
|
|
247
|
+
noServer: true,
|
|
248
|
+
perMessageDeflate: false,
|
|
249
|
+
maxPayload: 1024 * 1024,
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
const log = (message) => { try { onLog?.(message); } catch { /* 日志失败不该影响搬运 */ } };
|
|
253
|
+
|
|
254
|
+
function notify(session, frame) {
|
|
255
|
+
for (const ws of session.sockets) {
|
|
256
|
+
if (ws.readyState !== ws.OPEN) continue;
|
|
257
|
+
try { ws.send(frame, { binary: true }); } catch { /* 已经断了 */ }
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function controlFrame(payload) {
|
|
262
|
+
return Buffer.concat([Buffer.from([FRAME_CONTROL]), Buffer.from(JSON.stringify(payload), 'utf8')]);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** 输出按字节攒,超预算就把最旧的整段丢掉(重放窗口本来就是「最近这些」)。 */
|
|
266
|
+
function appendOutput(session, chunk) {
|
|
267
|
+
session.buffer.push(chunk);
|
|
268
|
+
session.buffered += chunk.length;
|
|
269
|
+
while (session.buffered > MAX_BUFFER_BYTES && session.buffer.length > 1) {
|
|
270
|
+
session.buffered -= session.buffer.shift().length;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function forget(sessionId) {
|
|
275
|
+
const session = sessions.get(sessionId);
|
|
276
|
+
if (!session) return;
|
|
277
|
+
sessions.delete(sessionId);
|
|
278
|
+
for (const ws of session.sockets) {
|
|
279
|
+
try { ws.close(); } catch { /* 已经关了 */ }
|
|
280
|
+
}
|
|
281
|
+
session.sockets.clear();
|
|
282
|
+
try { session.child.kill('SIGHUP'); } catch { /* 可能已经退出 */ }
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** GET terminal/agents:这台电脑上装了哪几个。 */
|
|
286
|
+
function handleAgents(_ctx, _req, res) {
|
|
287
|
+
const agents = AGENTS.map((agent) => {
|
|
288
|
+
const binPath = findExecutable(agent.bin);
|
|
289
|
+
return { id: agent.id, label: agent.label, available: Boolean(binPath) };
|
|
290
|
+
});
|
|
291
|
+
sendJson(res, 200, { ok: true, agents });
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* POST terminal/session { workspaceId, agentId, cols?, rows? }
|
|
296
|
+
*
|
|
297
|
+
* 工作目录从工作区清单里取:手机只给 `workspaceId`,路径由宿主自己填。于是「在哪个目录
|
|
298
|
+
* 起进程」这件事没有一条能让调用方指定任意路径的路,与 Git 那一组同一个口径。
|
|
299
|
+
*/
|
|
300
|
+
async function handleCreate(ctx, req, res) {
|
|
301
|
+
const body = await bodyJson(req);
|
|
302
|
+
if (closed) throw fail('UNSUPPORTED', '宿主正在关闭,稍后再试');
|
|
303
|
+
|
|
304
|
+
const workspaceId = text(body.workspaceId);
|
|
305
|
+
if (!workspaceId) throw fail('BAD_REQUEST', '需要 workspaceId');
|
|
306
|
+
|
|
307
|
+
const agentId = text(body.agentId);
|
|
308
|
+
const agent = AGENTS.find((entry) => entry.id === agentId);
|
|
309
|
+
if (!agent) throw fail('BAD_REQUEST', '不认识这个 CLI');
|
|
310
|
+
const binPath = findExecutable(agent.bin);
|
|
311
|
+
if (!binPath) throw fail('AGENT_UNAVAILABLE', `这台电脑上没有装 ${agent.label}`);
|
|
312
|
+
|
|
313
|
+
const listed = await ctx.apiProxy.workspace.list({ rpcId: 'mobile', method: 'workspace.list', payload: {} });
|
|
314
|
+
const { value, error } = unwrap(listed);
|
|
315
|
+
if (error) throw fail('WORKSPACE_UNAVAILABLE', '取不到工作区清单,稍后再试');
|
|
316
|
+
const items = Array.isArray(value) ? value : (value && Array.isArray(value.items) ? value.items : []);
|
|
317
|
+
const item = items.find((entry) => entry && entry.workspaceId === workspaceId);
|
|
318
|
+
if (!item) throw fail('FORBIDDEN', '这个工作区不在这台机器上');
|
|
319
|
+
const cwd = text(item.path);
|
|
320
|
+
if (!cwd) throw fail('FORBIDDEN', '这个工作区没有对应目录');
|
|
321
|
+
|
|
322
|
+
if (sessions.size >= MAX_SESSIONS) {
|
|
323
|
+
throw fail('TOO_MANY_SESSIONS', '同时在跑的终端太多了,先关掉几个');
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const cols = Number.isInteger(body.cols) && body.cols > 0 && body.cols <= 400 ? body.cols : DEFAULT_COLS;
|
|
327
|
+
const rows = Number.isInteger(body.rows) && body.rows > 0 && body.rows <= 200 ? body.rows : DEFAULT_ROWS;
|
|
328
|
+
|
|
329
|
+
const id = `term-${Date.now().toString(36)}-${(seq += 1).toString(36)}`;
|
|
330
|
+
let child;
|
|
331
|
+
try {
|
|
332
|
+
child = spawnPty({ bin: binPath, cwd, cols, rows, env: process.env });
|
|
333
|
+
} catch (spawnError) {
|
|
334
|
+
if (spawnError && typeof spawnError.code === 'string' && STATUS_BY_CODE[spawnError.code]) throw spawnError;
|
|
335
|
+
throw fail('SPAWN_FAILED', '起不来这个 CLI');
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const session = {
|
|
339
|
+
id,
|
|
340
|
+
workspaceId,
|
|
341
|
+
agentId: agent.id,
|
|
342
|
+
label: agent.label,
|
|
343
|
+
cwd,
|
|
344
|
+
cols,
|
|
345
|
+
rows,
|
|
346
|
+
child,
|
|
347
|
+
sockets: new Set(),
|
|
348
|
+
buffer: [],
|
|
349
|
+
buffered: 0,
|
|
350
|
+
exitCode: null,
|
|
351
|
+
startedAt: Date.now(),
|
|
352
|
+
};
|
|
353
|
+
sessions.set(id, session);
|
|
354
|
+
|
|
355
|
+
const pump = (chunk) => {
|
|
356
|
+
if (!chunk || chunk.length === 0) return;
|
|
357
|
+
appendOutput(session, chunk);
|
|
358
|
+
notify(session, Buffer.concat([Buffer.from([FRAME_DATA]), chunk]));
|
|
359
|
+
};
|
|
360
|
+
child.stdout.on('data', pump);
|
|
361
|
+
child.stderr.on('data', pump);
|
|
362
|
+
child.on('error', (error) => {
|
|
363
|
+
log(`terminal ${id}: ${error && error.message}`);
|
|
364
|
+
session.exitCode = session.exitCode === null ? 1 : session.exitCode;
|
|
365
|
+
notify(session, controlFrame({ t: 'exit', code: session.exitCode }));
|
|
366
|
+
});
|
|
367
|
+
child.on('exit', (code) => {
|
|
368
|
+
session.exitCode = typeof code === 'number' ? code : 0;
|
|
369
|
+
// 退出后台留 30 秒:页面要能读到退出码、把最后一屏留住,之后连表一起清掉。
|
|
370
|
+
setTimeout(() => { sessions.delete(id); }, 30000).unref?.();
|
|
371
|
+
notify(session, controlFrame({ t: 'exit', code: session.exitCode }));
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
sendJson(res, 200, {
|
|
375
|
+
ok: true,
|
|
376
|
+
session: { id, workspaceId, agentId: agent.id, label: agent.label, cwd, cols, rows },
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** POST terminal/close { sessionId }:主动收掉一个会话(页面返回、用户点「结束」)。 */
|
|
381
|
+
async function handleClose(_ctx, req, res) {
|
|
382
|
+
const body = await bodyJson(req);
|
|
383
|
+
const id = text(body.sessionId);
|
|
384
|
+
if (!id) throw fail('BAD_REQUEST', '需要 sessionId');
|
|
385
|
+
forget(id);
|
|
386
|
+
sendJson(res, 200, { ok: true });
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** GET terminal/sessions:宿主上还活着的会话(页面重进时用来接回上一条)。 */
|
|
390
|
+
function handleSessions(_ctx, _req, res) {
|
|
391
|
+
const list = [...sessions.values()].map((session) => ({
|
|
392
|
+
id: session.id,
|
|
393
|
+
workspaceId: session.workspaceId,
|
|
394
|
+
agentId: session.agentId,
|
|
395
|
+
label: session.label,
|
|
396
|
+
cwd: session.cwd,
|
|
397
|
+
cols: session.cols,
|
|
398
|
+
rows: session.rows,
|
|
399
|
+
running: session.exitCode === null,
|
|
400
|
+
startedAt: session.startedAt,
|
|
401
|
+
}));
|
|
402
|
+
sendJson(res, 200, { ok: true, sessions: list });
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* WebSocket 升级:`?session=<id>&token=<设备凭据>`。
|
|
407
|
+
*
|
|
408
|
+
* 凭据走查询串而不是头,只有一个原因:WebView 里的 `WebSocket` 构造函数带不了自定义头,
|
|
409
|
+
* 而这一页正是跑在 WebView 里的。中转与局域网代理都是纯字节/升级转发,不加查询串白名单,
|
|
410
|
+
* 所以这条与普通请求走同一条路。
|
|
411
|
+
*
|
|
412
|
+
* 和预览管道一样,拒绝要发生在 `handleUpgrade` 之前 —— 那时 socket 还是普通 HTTP。
|
|
413
|
+
*/
|
|
414
|
+
function handleUpgrade(req, socket, head) {
|
|
415
|
+
if (closed) return refuseUpgrade(socket, 503, 'Service Unavailable', '终端服务已关闭');
|
|
416
|
+
|
|
417
|
+
const params = query(req);
|
|
418
|
+
const id = text(params.get('session'));
|
|
419
|
+
if (!id) return refuseUpgrade(socket, 400, 'Bad Request', '缺少 session 参数');
|
|
420
|
+
// `cred` 是现在那一版 App 用的名字;`token` 留给还没升级的客户端。
|
|
421
|
+
const token = text(params.get('cred')) || text(params.get('token'));
|
|
422
|
+
|
|
423
|
+
let allowed = false;
|
|
424
|
+
try {
|
|
425
|
+
// 现在这一版 App 把凭据放在**请求头**里(RN 的 `WebSocket` 支持第三个参数,连接建在
|
|
426
|
+
// RN 侧、不在 WebView 里,见 App 的 `TerminalScreen`),所以先按真实请求判一次 ——
|
|
427
|
+
// 走中转时「转给哪台电脑」也靠这些头。查询串那一支留给没有头可用的情况(老客户端):
|
|
428
|
+
// 折成一次普通的鉴权判定,不动 `auth.js` 的口径。
|
|
429
|
+
allowed = Boolean(authorized && (authorized(req) || (token && authorized({
|
|
430
|
+
headers: { 'x-dsh-device-credential': token, authorization: `Bearer ${token}` },
|
|
431
|
+
}))));
|
|
432
|
+
} catch {
|
|
433
|
+
allowed = false;
|
|
434
|
+
}
|
|
435
|
+
if (!allowed) {
|
|
436
|
+
log(`terminal ${id}: 凭据无效,拒绝升级`);
|
|
437
|
+
return refuseUpgrade(socket, 401, 'Unauthorized', '移动端凭据无效');
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const session = sessions.get(id);
|
|
441
|
+
if (!session) {
|
|
442
|
+
log(`terminal ${id}: 会话不在表里,拒绝升级`);
|
|
443
|
+
return refuseUpgrade(socket, 404, 'Not Found', '这条终端已经不在了');
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
wss.handleUpgrade(req, socket, head, (ws) => attach(ws, session));
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function attach(ws, session) {
|
|
450
|
+
session.sockets.add(ws);
|
|
451
|
+
log(`terminal ${session.id}: 手机接上了(第 ${session.sockets.size} 条连接)`);
|
|
452
|
+
|
|
453
|
+
// 先补一帧「现在长什么样」,再把攒下来的输出整段重放:断线重连回来看到的仍是那一屏。
|
|
454
|
+
ws.send(controlFrame({
|
|
455
|
+
t: 'ready',
|
|
456
|
+
cols: session.cols,
|
|
457
|
+
rows: session.rows,
|
|
458
|
+
label: session.label,
|
|
459
|
+
cwd: session.cwd,
|
|
460
|
+
running: session.exitCode === null,
|
|
461
|
+
}), { binary: true });
|
|
462
|
+
if (session.buffered > 0) {
|
|
463
|
+
const replay = Buffer.concat(session.buffer);
|
|
464
|
+
try { ws.send(Buffer.concat([Buffer.from([FRAME_DATA]), replay]), { binary: true }); } catch { /* 已断 */ }
|
|
465
|
+
}
|
|
466
|
+
if (session.exitCode !== null) {
|
|
467
|
+
try { ws.send(controlFrame({ t: 'exit', code: session.exitCode }), { binary: true }); } catch { /* 已断 */ }
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
ws.on('message', (raw, isBinary) => {
|
|
471
|
+
let frame;
|
|
472
|
+
if (Buffer.isBuffer(raw)) frame = raw;
|
|
473
|
+
else if (Array.isArray(raw)) frame = Buffer.concat(raw);
|
|
474
|
+
else frame = Buffer.from(raw);
|
|
475
|
+
if (frame.length === 0) return;
|
|
476
|
+
const kind = frame[0];
|
|
477
|
+
const payload = frame.subarray(1);
|
|
478
|
+
if (kind === FRAME_DATA) {
|
|
479
|
+
if (session.exitCode !== null) return;
|
|
480
|
+
try { session.child.stdin.write(payload); } catch { /* 进程没了 */ }
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
if (kind === FRAME_CONTROL) {
|
|
484
|
+
let message = null;
|
|
485
|
+
try { message = JSON.parse(payload.toString('utf8')); } catch { message = null; }
|
|
486
|
+
// 目前只有一条控件,且是**不做**:script 起的伪终端改不了窗口大小,见文件头。
|
|
487
|
+
if (message && message.t === 'resize') return;
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
// 没打标签的帧(早先的客户端、或被别的东西写进来的):按原始输入处理,宁可多打字
|
|
491
|
+
// 也别让一次键盘输入石沉大海。
|
|
492
|
+
if (session.exitCode === null) {
|
|
493
|
+
try { session.child.stdin.write(frame); } catch { /* 进程没了 */ }
|
|
494
|
+
}
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
const detach = () => { session.sockets.delete(ws); };
|
|
498
|
+
ws.on('close', detach);
|
|
499
|
+
ws.on('error', detach);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/** 插件重载 / 宿主退出:会话一条都不留(它们的父进程这时也没了)。 */
|
|
503
|
+
function close() {
|
|
504
|
+
if (closed) return;
|
|
505
|
+
closed = true;
|
|
506
|
+
for (const id of [...sessions.keys()]) forget(id);
|
|
507
|
+
try { wss.close(); } catch { /* 已经关了 */ }
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
return {
|
|
511
|
+
handleAgents: wrap(handleAgents),
|
|
512
|
+
handleCreate: wrap(handleCreate),
|
|
513
|
+
handleClose: wrap(handleClose),
|
|
514
|
+
handleSessions: wrap(handleSessions),
|
|
515
|
+
handleUpgrade,
|
|
516
|
+
close,
|
|
517
|
+
// 守卫用:会话表只读快照(测试里核对「close 之后真的空了」)。
|
|
518
|
+
debugSessions: () => [...sessions.values()],
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/** 与 Git 那组同一个形状:错误码是契约,HTTP 状态只是顺带。 */
|
|
523
|
+
function wrap(fn) {
|
|
524
|
+
return async (ctx, req, res) => {
|
|
525
|
+
try {
|
|
526
|
+
await fn(ctx, req, res);
|
|
527
|
+
} catch (error) {
|
|
528
|
+
const code = error && typeof error.code === 'string' ? error.code : 'SPAWN_FAILED';
|
|
529
|
+
const status = STATUS_BY_CODE[code] || 500;
|
|
530
|
+
const message = error && typeof error.message === 'string' && error.message
|
|
531
|
+
? error.message
|
|
532
|
+
: '终端操作失败';
|
|
533
|
+
sendJson(res, status, { ok: false, error: { code, message } });
|
|
534
|
+
}
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/** 拒绝升级:这时 socket 还是一条普通 HTTP 连接,老实回一个状态码就行。 */
|
|
539
|
+
function refuseUpgrade(socket, status, reason, text_) {
|
|
540
|
+
if (!socket || socket.destroyed) return;
|
|
541
|
+
const body = Buffer.from(text_, 'utf8');
|
|
542
|
+
socket.write(
|
|
543
|
+
`HTTP/1.1 ${status} ${reason}\r\n`
|
|
544
|
+
+ 'Connection: close\r\n'
|
|
545
|
+
+ 'Content-Type: text/plain; charset=utf-8\r\n'
|
|
546
|
+
+ `Content-Length: ${body.length}\r\n\r\n`,
|
|
547
|
+
);
|
|
548
|
+
socket.write(body);
|
|
549
|
+
socket.end();
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* 装配层只调这一个(`impl.js` 有行数上限,装配代码不许长回来):建服务 + 挂清理。
|
|
554
|
+
*
|
|
555
|
+
* 清理必须挂在 `ctx.effect` 上而不是模块级:插件热替换之后旧实例没人能关,伪终端进程会
|
|
556
|
+
* 挂在那儿直到宿主退出。
|
|
557
|
+
*/
|
|
558
|
+
function mountTerminal(ctx, { authorized, name } = {}) {
|
|
559
|
+
const service = createTerminalService({
|
|
560
|
+
authorized,
|
|
561
|
+
onLog: (message) => ctx.logger?.info?.(`${name}: %s`, message),
|
|
562
|
+
})
|
|
563
|
+
ctx.effect(() => () => service.close(), `${name}: terminal sessions`)
|
|
564
|
+
return service
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
module.exports = {
|
|
568
|
+
mountTerminal,
|
|
569
|
+
createTerminalService,
|
|
570
|
+
findExecutable,
|
|
571
|
+
AGENTS,
|
|
572
|
+
DEFAULT_COLS,
|
|
573
|
+
DEFAULT_ROWS,
|
|
574
|
+
};
|