@xcanwin/manyoyo 7.0.25 → 7.1.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/lib/web/server.js CHANGED
@@ -42,6 +42,12 @@ const WEB_TERMINAL_DEFAULT_COLS = 120;
42
42
  const WEB_TERMINAL_DEFAULT_ROWS = 36;
43
43
  const WEB_TERMINAL_MIN_COLS = 40;
44
44
  const WEB_TERMINAL_MIN_ROWS = 12;
45
+ // 终端 WebSocket 空闲心跳:纯交互式终端可能长时间没有任何数据,
46
+ // 反向代理 / 移动网络的空闲超时(常见 60s)会悄悄掐断连接,这里定期发 ping
47
+ // 帧制造流量。判死放宽到连续 3 次没回 pong(约 90s),手机切后台导致连接被
48
+ // 系统短暂挂起时不至于被误杀——误杀意味着容器里的 shell 直接没了
49
+ const WEB_TERMINAL_PING_INTERVAL_MS = 30000;
50
+ const WEB_TERMINAL_MAX_MISSED_PONGS = 3;
45
51
  const WEB_AGENT_CONTEXT_MAX_MESSAGES = 24;
46
52
  const WEB_AGENT_CONTEXT_MAX_CHARS = 6000;
47
53
  const WEB_AGENT_CONTEXT_PER_MESSAGE_MAX_CHARS = 600;
@@ -96,36 +102,6 @@ const DEFAULT_WEB_CONFIG_TEMPLATE = `{
96
102
  }
97
103
  `;
98
104
 
99
- let XTERM_JS_FILE = null;
100
- let XTERM_CSS_FILE = null;
101
- let XTERM_ADDON_FIT_JS_FILE = null;
102
- let MARKED_MIN_JS_FILE = null;
103
- try {
104
- const xtermPackageDir = path.dirname(require.resolve('@xterm/xterm/package.json'));
105
- XTERM_JS_FILE = path.join(xtermPackageDir, 'lib', 'xterm.js');
106
- XTERM_CSS_FILE = path.join(xtermPackageDir, 'css', 'xterm.css');
107
- } catch (e) {
108
- XTERM_JS_FILE = null;
109
- XTERM_CSS_FILE = null;
110
- }
111
- try {
112
- const xtermAddonFitPackageDir = path.dirname(require.resolve('@xterm/addon-fit/package.json'));
113
- XTERM_ADDON_FIT_JS_FILE = path.join(xtermAddonFitPackageDir, 'lib', 'addon-fit.js');
114
- } catch (e) {
115
- XTERM_ADDON_FIT_JS_FILE = null;
116
- }
117
- try {
118
- const markedPackageDir = path.dirname(require.resolve('marked/package.json'));
119
- MARKED_MIN_JS_FILE = path.join(markedPackageDir, 'lib', 'marked.umd.js');
120
- } catch (e) {
121
- MARKED_MIN_JS_FILE = null;
122
- }
123
-
124
- const MIME_TYPES = {
125
- '.css': 'text/css; charset=utf-8',
126
- '.js': 'application/javascript; charset=utf-8',
127
- '.html': 'text/html; charset=utf-8'
128
- };
129
105
  const FILE_LANGUAGE_MAP = {
130
106
  '.cjs': 'javascript',
131
107
  '.css': 'css',
@@ -4183,49 +4159,6 @@ function resolveStaticAsset(name) {
4183
4159
  return fs.existsSync(fullPath) ? fullPath : null;
4184
4160
  }
4185
4161
 
4186
- function resolveVendorAsset(name) {
4187
- if (!isSafeStaticAssetName(name)) {
4188
- return null;
4189
- }
4190
- if (name === 'xterm.js') {
4191
- return XTERM_JS_FILE && fs.existsSync(XTERM_JS_FILE) ? XTERM_JS_FILE : null;
4192
- }
4193
- if (name === 'xterm.css') {
4194
- return XTERM_CSS_FILE && fs.existsSync(XTERM_CSS_FILE) ? XTERM_CSS_FILE : null;
4195
- }
4196
- if (name === 'xterm-addon-fit.js') {
4197
- return XTERM_ADDON_FIT_JS_FILE && fs.existsSync(XTERM_ADDON_FIT_JS_FILE) ? XTERM_ADDON_FIT_JS_FILE : null;
4198
- }
4199
- if (name === 'marked.min.js') {
4200
- return MARKED_MIN_JS_FILE && fs.existsSync(MARKED_MIN_JS_FILE) ? MARKED_MIN_JS_FILE : null;
4201
- }
4202
- return null;
4203
- }
4204
-
4205
- function sendFileAsset(res, filePath) {
4206
- if (!filePath || !fs.existsSync(filePath)) {
4207
- sendHtml(res, 404, '<h1>404 Not Found</h1>');
4208
- return;
4209
- }
4210
-
4211
- const ext = path.extname(filePath).toLowerCase();
4212
- const contentType = MIME_TYPES[ext] || 'application/octet-stream';
4213
- const content = fs.readFileSync(filePath);
4214
- res.writeHead(200, {
4215
- 'Content-Type': contentType,
4216
- 'Cache-Control': 'no-store'
4217
- });
4218
- res.end(content);
4219
- }
4220
-
4221
- function sendStaticAsset(res, assetName) {
4222
- sendFileAsset(res, resolveStaticAsset(assetName));
4223
- }
4224
-
4225
- function sendVendorAsset(res, assetName) {
4226
- sendFileAsset(res, resolveVendorAsset(assetName));
4227
- }
4228
-
4229
4162
  function loadTemplate(name) {
4230
4163
  const filePath = resolveStaticAsset(name);
4231
4164
  if (!filePath) {
@@ -4241,42 +4174,20 @@ function escapeHtmlText(value) {
4241
4174
  .replace(/>/g, '&gt;');
4242
4175
  }
4243
4176
 
4244
- // JSON.stringify 不转义 "</script>",且作为 String.replace 的第二个参数时 $&/$'/$` 等会被
4245
- // 当成特殊替换模式重新解释;用替换函数 + 转义 "<" 双重防护,避免 serveTitle 里的内容
4246
- // 提前闭合 <script> 标签或拼接进原始 HTML。
4247
- function jsonForInlineScript(value) {
4248
- return JSON.stringify(value).replace(/</g, '\\u003c');
4249
- }
4250
-
4251
4177
  function applyServeTitle(html, ctx) {
4252
4178
  if (typeof ctx.serveTitle !== 'string') {
4253
4179
  return html;
4254
4180
  }
4255
- // 正则匹配任意 <title> 内容而非硬编码 "MANYOYO Web",兼容 app.html/shadcn.html 各自的默认标题
4181
+ // 正则匹配任意 <title> 内容而非硬编码 "MANYOYO Web",前端换了默认标题也不用改这里
4256
4182
  return html.replace(/<title>[^<]*<\/title>/, () => `<title>${escapeHtmlText(ctx.serveTitle)}</title>`);
4257
4183
  }
4258
4184
 
4259
- function injectServeTitleFlag(html, ctx) {
4260
- if (typeof ctx.serveTitle !== 'string') {
4261
- return html;
4262
- }
4263
- // 告知 app.js 跳过按会话动态改写 document.title
4264
- return html.replace(
4265
- '</head>',
4266
- () => ` <script>window.__MANYOYO_SERVE_TITLE__ = ${jsonForInlineScript(ctx.serveTitle)};</script>\n</head>`
4267
- );
4268
- }
4269
-
4270
- function renderIndexHtml(ctx) {
4271
- return injectServeTitleFlag(applyServeTitle(loadTemplate('app.html'), ctx), ctx);
4272
- }
4273
-
4274
4185
  function renderShadcnHtml(ctx) {
4275
4186
  // shadcn.html 是 vite singlefile 打包产物,全部依赖内联成一个 <script>,
4276
- // 用 .replace('</head>', ...) 有极小概率命中某个库源码里字面量出现的
4277
- // "</head>",把注入脚本插进内联脚本中间导致整页解析错乱;所以不复用
4278
- // injectServeTitleFlag 那个 </head> 定位方式,改成跟 applyServeTitle 同款
4279
- // 更窄、更安全的 <title> 定位,插一个 meta 标记告知前端跳过动态改写 document.title
4187
+ // 用 .replace('</head>', ...) 注入标记有极小概率命中某个库源码里字面量出现的
4188
+ // "</head>",把注入内容插进内联脚本中间导致整页解析错乱;所以改成跟
4189
+ // applyServeTitle 同款更窄、更安全的 <title> 定位,插一个 meta 标记
4190
+ // 告知前端跳过动态改写 document.title
4280
4191
  let html = applyServeTitle(loadTemplate('shadcn.html'), ctx);
4281
4192
  if (typeof ctx.serveTitle === 'string') {
4282
4193
  html = html.replace(
@@ -4287,10 +4198,6 @@ function renderShadcnHtml(ctx) {
4287
4198
  return html;
4288
4199
  }
4289
4200
 
4290
- function renderLoginHtml(ctx) {
4291
- return applyServeTitle(loadTemplate('login.html'), ctx);
4292
- }
4293
-
4294
4201
  function toPositiveInt(value, fallback) {
4295
4202
  const parsed = Number.parseInt(value, 10);
4296
4203
  if (!Number.isFinite(parsed) || parsed <= 0) {
@@ -4406,11 +4313,32 @@ function bindTerminalWebSocket(ctx, state, ws, containerName, cols, rows) {
4406
4313
  rows
4407
4314
  });
4408
4315
 
4316
+ let missedPongs = 0;
4317
+ ws.on('pong', () => {
4318
+ missedPongs = 0;
4319
+ });
4320
+ const pingTimer = setInterval(() => {
4321
+ if (ws.readyState !== WebSocket.OPEN) {
4322
+ return;
4323
+ }
4324
+ if (missedPongs >= WEB_TERMINAL_MAX_MISSED_PONGS) {
4325
+ ws.terminate();
4326
+ return;
4327
+ }
4328
+ missedPongs += 1;
4329
+ try {
4330
+ ws.ping();
4331
+ } catch (e) {
4332
+ // ping 失败说明链路已经不可用,交给 close/error 事件收尾
4333
+ }
4334
+ }, WEB_TERMINAL_PING_INTERVAL_MS);
4335
+
4409
4336
  const cleanup = () => {
4410
4337
  if (session.closing) {
4411
4338
  return;
4412
4339
  }
4413
4340
  session.closing = true;
4341
+ clearInterval(pingTimer);
4414
4342
  state.terminalSessions.delete(sessionId);
4415
4343
  if (ptyProcess && !ptyProcess.killed) {
4416
4344
  ptyProcess.kill('SIGTERM');
@@ -4472,6 +4400,12 @@ function bindTerminalWebSocket(ctx, state, ws, containerName, cols, rows) {
4472
4400
  return;
4473
4401
  }
4474
4402
 
4403
+ if (payload.type === 'ping') {
4404
+ // 浏览器 JS 发不出 WebSocket ping 帧,前端改用应用层消息保活上行链路
4405
+ sendTerminalEvent(ws, 'pong');
4406
+ return;
4407
+ }
4408
+
4475
4409
  if (payload.type === 'close') {
4476
4410
  ws.close();
4477
4411
  }
@@ -4488,27 +4422,16 @@ async function handleWebAuthRoutes(req, res, pathname, ctx, state) {
4488
4422
  return true;
4489
4423
  }
4490
4424
 
4491
- // 旧版默认主题(现为 /legacy 兜底路由)的登录页
4492
- if (req.method === 'GET' && pathname === '/auth/login') {
4493
- sendHtml(res, 200, renderLoginHtml(ctx));
4494
- return true;
4495
- }
4496
-
4497
- // 默认主题(shadcn/ui 版)的登录页:与 / 、/shadcn 复用同一份构建产物,
4498
- // 由前端按 pathname 自行渲染登录表单,登录逻辑仍复用 /auth/login 接口
4425
+ // 登录页:与 / 、/shadcn 复用同一份构建产物,由前端按 pathname 自行渲染
4426
+ // 登录表单,登录逻辑走下面的 POST /auth/login 接口
4499
4427
  if (req.method === 'GET' && pathname === '/shadcn/auth/login') {
4500
4428
  sendHtml(res, 200, applyServeTitle(loadTemplate('shadcn.html'), ctx));
4501
4429
  return true;
4502
4430
  }
4503
4431
 
4504
- const authFrontendMatch = pathname.match(/^\/auth\/frontend\/([A-Za-z0-9._-]+)$/);
4505
- if (req.method === 'GET' && authFrontendMatch) {
4506
- const assetName = authFrontendMatch[1];
4507
- if (!(assetName === 'login.css' || assetName === 'login.js')) {
4508
- sendHtml(res, 404, '<h1>404 Not Found</h1>');
4509
- return true;
4510
- }
4511
- sendStaticAsset(res, assetName);
4432
+ // GET /auth/login 曾经是独立的登录页面,现在只留一个跳转,接住老书签
4433
+ if (req.method === 'GET' && pathname === '/auth/login') {
4434
+ sendRedirect(res, 302, '/shadcn/auth/login');
4512
4435
  return true;
4513
4436
  }
4514
4437
 
@@ -4553,25 +4476,14 @@ async function handleWebAuthRoutes(req, res, pathname, ctx, state) {
4553
4476
  return false;
4554
4477
  }
4555
4478
 
4556
- function sendWebUnauthorized(res, pathname, ctx) {
4479
+ function sendWebUnauthorized(res, pathname) {
4557
4480
  if (pathname.startsWith('/api/') || pathname.startsWith('/auth/')) {
4558
4481
  sendJson(res, 401, { error: 'UNAUTHORIZED' });
4559
4482
  return;
4560
4483
  }
4561
- if (pathname === '' || pathname === '/' || pathname === '/shadcn') {
4562
- sendRedirect(res, 302, '/shadcn/auth/login', { 'Set-Cookie': getWebAuthClearCookie() });
4563
- return;
4564
- }
4565
- if (pathname === '/legacy') {
4566
- sendRedirect(res, 302, '/auth/login', { 'Set-Cookie': getWebAuthClearCookie() });
4567
- return;
4568
- }
4569
- sendHtml(
4570
- res,
4571
- 401,
4572
- renderLoginHtml(ctx),
4573
- { 'Set-Cookie': getWebAuthClearCookie() }
4574
- );
4484
+ // 其余都是页面请求:一律打回登录页。登录页本身在认证网关之前就被
4485
+ // handleWebAuthRoutes 放行,不会在这里形成重定向循环
4486
+ sendRedirect(res, 302, '/shadcn/auth/login', { 'Set-Cookie': getWebAuthClearCookie() });
4575
4487
  }
4576
4488
 
4577
4489
  // 运行日志查看接口的支撑函数。日志目录以 serve 实际使用的 logger.path 为准,
@@ -5912,7 +5824,7 @@ async function startWebServer(options) {
5912
5824
 
5913
5825
  const authSession = getWebAuthSession(state, req);
5914
5826
  if (!authSession) {
5915
- sendWebUnauthorized(res, pathname, ctx);
5827
+ sendWebUnauthorized(res, pathname);
5916
5828
  return;
5917
5829
  }
5918
5830
 
@@ -5928,40 +5840,6 @@ async function startWebServer(options) {
5928
5840
  return;
5929
5841
  }
5930
5842
 
5931
- // 旧版默认主题:不再是默认路由,移到 /legacy 继续保留一段时间作为兜底
5932
- if (req.method === 'GET' && pathname === '/legacy') {
5933
- sendHtml(res, 200, renderIndexHtml(ctx));
5934
- return;
5935
- }
5936
-
5937
- const appFrontendMatch = pathname.match(/^\/app\/frontend\/([A-Za-z0-9._-]+)$/);
5938
- if (req.method === 'GET' && appFrontendMatch) {
5939
- const assetName = appFrontendMatch[1];
5940
- if (!(assetName === 'app.css'
5941
- || assetName === 'app.js'
5942
- || assetName === 'markdown.css'
5943
- || assetName === 'markdown-renderer.js'
5944
- || assetName === 'chat-behavior.js'
5945
- || assetName === 'file-browser.js'
5946
- || assetName === 'codemirror.bundle.js')) {
5947
- sendHtml(res, 404, '<h1>404 Not Found</h1>');
5948
- return;
5949
- }
5950
- sendStaticAsset(res, assetName);
5951
- return;
5952
- }
5953
-
5954
- const appVendorMatch = pathname.match(/^\/app\/vendor\/([A-Za-z0-9._-]+)$/);
5955
- if (req.method === 'GET' && appVendorMatch) {
5956
- const assetName = appVendorMatch[1];
5957
- if (!(assetName === 'xterm.css' || assetName === 'xterm.js' || assetName === 'xterm-addon-fit.js' || assetName === 'marked.min.js')) {
5958
- sendHtml(res, 404, '<h1>404 Not Found</h1>');
5959
- return;
5960
- }
5961
- sendVendorAsset(res, assetName);
5962
- return;
5963
- }
5964
-
5965
5843
  if (pathname === '/healthz') {
5966
5844
  sendJson(res, 200, { ok: true });
5967
5845
  return;
@@ -6061,6 +5939,12 @@ async function startWebServer(options) {
6061
5939
  url.searchParams.get('rows')
6062
5940
  );
6063
5941
 
5942
+ // 升级成 WebSocket 后这条连接是长连接,不能再受 HTTP 请求的空闲超时约束;
5943
+ // 同时打开 TCP keepalive,避免中间设备(NAT / 代理)回收看起来空闲的连接
5944
+ socket.setTimeout(0);
5945
+ socket.setNoDelay(true);
5946
+ socket.setKeepAlive(true, WEB_TERMINAL_PING_INTERVAL_MS);
5947
+
6064
5948
  ensureWebContainer(ctx, state, sessionRef.containerName)
6065
5949
  .then(() => {
6066
5950
  wsServer.handleUpgrade(req, socket, head, ws => {
@@ -6091,7 +5975,7 @@ async function startWebServer(options) {
6091
5975
  const { GREEN, CYAN, YELLOW, NC } = ctx.colors;
6092
5976
  const listenHost = formatUrlHost(ctx.serverHost);
6093
5977
  console.log(`${GREEN}✅ MANYOYO Web 服务已启动: http://${listenHost}:${listenPort}${NC}`);
6094
- console.log(`${CYAN}提示: 左侧是 manyoyo 容器会话列表,中间是活动/终端/配置/检查工作台,右侧显示当前会话上下文。${NC}`);
5978
+ console.log(`${CYAN}提示: 左侧是 manyoyo 容器与 AGENT 列表,右侧是聊天/终端/文件/详情/配置/检查工作台。${NC}`);
6095
5979
  if (ctx.serverHost === '0.0.0.0') {
6096
5980
  console.log(`${CYAN}提示: 当前监听全部网卡,请用本机局域网 IP 访问。${NC}`);
6097
5981
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xcanwin/manyoyo",
3
- "version": "7.0.25",
3
+ "version": "7.1.1",
4
4
  "imageVersion": "1.9.1-common",
5
5
  "playwrightCliVersion": "0.1.18",
6
6
  "description": "AI Agent CLI Security Sandbox for Docker and Podman",
@@ -31,12 +31,10 @@
31
31
  },
32
32
  "scripts": {
33
33
  "dev:release": "node scripts/dev-release.js",
34
- "build:web-editor": "node scripts/build-web-code-editor.js",
35
34
  "build:web-shadcn": "node scripts/build-web-shadcn.js",
36
35
  "dev:web-shadcn": "node scripts/dev-web-shadcn.js",
37
36
  "test:web-shadcn": "node scripts/test-web-shadcn.js",
38
- "prepack": "npm run build:web-editor && npm run build:web-shadcn",
39
- "prepare": "npm run build:web-editor",
37
+ "prepack": "npm run build:web-shadcn",
40
38
  "install-link": "npm link",
41
39
  "test": "jest --coverage && npm run test:web-shadcn",
42
40
  "test:unit": "jest test/ && npm run test:web-shadcn",
@@ -63,29 +61,12 @@
63
61
  ],
64
62
  "dependencies": {
65
63
  "@playwright/mcp": "0.0.79",
66
- "@xterm/addon-fit": "^0.11.0",
67
- "@xterm/xterm": "^6.0.0",
68
64
  "commander": "^15.0.0",
69
65
  "json5": "^2.2.3",
70
- "marked": "^18.0.9",
71
66
  "playwright": "1.62.1",
72
67
  "ws": "^8.21.3"
73
68
  },
74
69
  "devDependencies": {
75
- "@codemirror/commands": "^6.10.4",
76
- "@codemirror/lang-css": "^6.3.1",
77
- "@codemirror/lang-html": "^6.4.12",
78
- "@codemirror/lang-javascript": "^6.2.5",
79
- "@codemirror/lang-json": "^6.0.2",
80
- "@codemirror/lang-markdown": "^6.5.2",
81
- "@codemirror/lang-python": "^6.2.1",
82
- "@codemirror/lang-yaml": "^6.1.3",
83
- "@codemirror/language": "^6.12.4",
84
- "@codemirror/search": "^6.7.1",
85
- "@codemirror/state": "^6.7.1",
86
- "@codemirror/view": "^6.43.8",
87
- "codemirror": "^6.0.2",
88
- "esbuild": "^0.28.2",
89
70
  "jest": "^30.4.2",
90
71
  "vitepress": "^1.6.4"
91
72
  },