@myassis/gateway 1.0.50 → 1.0.52

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/dist/main.js CHANGED
@@ -33,6 +33,65 @@ const logger = (0, shared_1.getLogger)('index');
33
33
  // ─── CLI 模式 ─────────────────────────────────────────
34
34
  // gateway install | start | stop | uninstall | status | update
35
35
  const cliCommand = process.argv[2];
36
+ // ─── 崩溃防护 & stdout/stderr 重定向 ─────────────────────────────────────
37
+ // 打包为 exe 由 PowerShell 启动时,子进程会继承父 PS 的 stdout/stderr 句柄;
38
+ // PowerShell 立即退出后这些句柄失效,任何 console.log 都会触发 EPIPE/EBADF,
39
+ // Node 若无捕获会直接崩溃 → 表现为「gateway 运行一段时间就停止了」。
40
+ // 因此在业务代码启动前把 stdout/stderr 重定向到日志文件,并挂全局兜底。
41
+ if (!cliCommand && process.platform === 'win32') {
42
+ try {
43
+ // 使用 require 而非 import,避免打包器把 fs/path 提前解析导致副作用
44
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
45
+ const fs = require('fs');
46
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
47
+ const pathMod = require('path');
48
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
49
+ const osMod = require('os');
50
+ const logDir = pathMod.join(process.env.LOCALAPPDATA || osMod.tmpdir(), '我的助手', 'logs');
51
+ fs.mkdirSync(logDir, { recursive: true });
52
+ const logFile = pathMod.join(logDir, 'gateway.log');
53
+ // 简单滚动:>10MB 备份为 .old
54
+ try {
55
+ const st = fs.statSync(logFile);
56
+ if (st.size > 10 * 1024 * 1024) {
57
+ const oldFile = logFile + '.old';
58
+ try {
59
+ fs.unlinkSync(oldFile);
60
+ }
61
+ catch { /* ignore */ }
62
+ fs.renameSync(logFile, oldFile);
63
+ }
64
+ }
65
+ catch { /* 文件不存在,忽略 */ }
66
+ const out = fs.createWriteStream(logFile, { flags: 'a' });
67
+ out.on('error', () => { });
68
+ const writeFn = (chunk, enc, cb) => {
69
+ try {
70
+ return out.write(chunk, enc, cb);
71
+ }
72
+ catch {
73
+ return true;
74
+ }
75
+ };
76
+ process.stdout.write = writeFn;
77
+ process.stderr.write = writeFn;
78
+ // 全局兜底:任何未捕获异常都记录,不让进程静默退出
79
+ process.on('uncaughtException', (err) => {
80
+ try {
81
+ out.write(`[${new Date().toISOString()}] uncaughtException: ${err.stack || err.message}\r\n`);
82
+ }
83
+ catch { /* ignore */ }
84
+ });
85
+ process.on('unhandledRejection', (reason) => {
86
+ try {
87
+ out.write(`[${new Date().toISOString()}] unhandledRejection: ${reason?.stack || reason}\r\n`);
88
+ }
89
+ catch { /* ignore */ }
90
+ });
91
+ out.write(`\r\n[${new Date().toISOString()}] ===== gateway 启动 pid=${process.pid} =====\r\n`);
92
+ }
93
+ catch { /* 初始化失败不影响启动 */ }
94
+ }
36
95
  if (cliCommand) {
37
96
  (async () => {
38
97
  try {
@@ -162,10 +162,10 @@ function writeGatewayLauncherScript(exePath, scriptPath, workDir) {
162
162
  "$psi = New-Object System.Diagnostics.ProcessStartInfo",
163
163
  "$psi.FileName = $exe",
164
164
  "$psi.WorkingDirectory = $workDir",
165
- "$psi.UseShellExecute = $false",
166
- "$psi.CreateNoWindow = $true",
167
- "# 注意:不重定向 stdout/stderr,避免匿名管道 4KB 缓冲区打满后阻塞 gateway 的事件循环",
168
- "$psi.Environment['NODE_ENV'] = 'production'",
165
+ "# 关键:UseShellExecute=$true ShellExecute API,子进程不继承父 PS 的 stdout/stderr/stdin 句柄",
166
+ "# 防止 PowerShell 退出后句柄失效导致 gateway 写 stdout 时 EPIPE 崩溃(运行一段时间后自杀)",
167
+ "$psi.UseShellExecute = $true",
168
+ "$psi.WindowStyle = 'Hidden'",
169
169
  "$proc = [System.Diagnostics.Process]::Start($psi)",
170
170
  "if ($proc) {",
171
171
  " $proc.Id.ToString() | Out-File -FilePath $pidFile -Encoding UTF8",
@@ -440,14 +440,19 @@ async function ensureAutoStartHealthy() {
440
440
  const lower = currentValue.toLowerCase();
441
441
  const hasPowershell = lower.includes('powershell.exe');
442
442
  const pointsToNewPath = lower.includes(GATEWAY_LAUNCHER_FILE.toLowerCase());
443
- // 脚本内容健康:不能含 RedirectStandardOutput(旧版本会因管道阻塞卡死)
443
+ // 脚本内容健康:不能含 RedirectStandardOutput/WaitForExit(旧版本管道阻塞 bug),
444
+ // 也不能是 UseShellExecute=$false(会继承父 PS 句柄,PS 退出后 gateway 写 stdout 崩溃)
444
445
  let scriptHealthy = false;
445
446
  try {
446
447
  if (fs_1.default.existsSync(GATEWAY_LAUNCHER_FILE)) {
447
448
  const scriptContent = fs_1.default.readFileSync(GATEWAY_LAUNCHER_FILE, 'utf8');
448
- // 老版含 RedirectStandardOutput(管道阻塞 bug)或 WaitForExit(陪跑浪费内存)→ 判定不健康
449
+ // 老版包含以下任一特征就重写:
450
+ // 1) RedirectStandardOutput → 管道阻塞旧 bug
451
+ // 2) WaitForExit → PS 陆跑浪费内存
452
+ // 3) UseShellExecute = $false → 句柄继承导致进程崩溃(本次修复)
449
453
  scriptHealthy = !scriptContent.includes('RedirectStandardOutput')
450
- && !scriptContent.includes('WaitForExit');
454
+ && !scriptContent.includes('WaitForExit')
455
+ && !scriptContent.includes('UseShellExecute = $false');
451
456
  }
452
457
  }
453
458
  catch { /* ignore */ }
@@ -6,8 +6,6 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.execTool = exports.cleanupExpiredApprovals = exports.getAndRemovePendingApproval = exports.addPendingApproval = exports.clearSessionCwd = exports.setSessionCwd = exports.getSessionCwd = void 0;
7
7
  const child_process_1 = require("child_process");
8
8
  const util_1 = require("util");
9
- const path_1 = __importDefault(require("path"));
10
- const os_1 = __importDefault(require("os"));
11
9
  const shared_1 = require("@myassis/shared");
12
10
  const crypto_1 = __importDefault(require("crypto"));
13
11
  const logger = (0, shared_1.getLogger)('exec');
@@ -17,39 +15,6 @@ const pendingApprovals = new Map();
17
15
  // 会话级工作目录状态:sessionId -> cwd
18
16
  // 让 cd 命令能跨多次 exec 调用持久化工作目录
19
17
  const sessionCwdMap = new Map();
20
- /** 解析 cd 参数,返回目标绝对路径。支持 ~、相对路径、绝对路径 */
21
- function resolveCdTarget(cdArg, currentCwd) {
22
- const arg = cdArg.trim();
23
- if (!arg)
24
- return os_1.default.homedir(); // cd 无参数 → 回家目录
25
- if (arg === '~' || arg.startsWith('~/')) {
26
- return path_1.default.join(os_1.default.homedir(), arg.slice(1));
27
- }
28
- // Windows 盘符路径或 Unix 绝对路径
29
- if (path_1.default.isAbsolute(arg))
30
- return path_1.default.resolve(arg);
31
- // 相对路径:基于当前 cwd 解析
32
- return path_1.default.resolve(currentCwd, arg);
33
- }
34
- /** 检查命令是否以 cd 开头,若是则提取目标路径(支持 cd /d D:\path 这种 Windows 写法)*/
35
- function parseCdCommand(command) {
36
- const trimmed = command.trim();
37
- // 匹配:cd arg 或 cd /d arg(Windows)或 cd "path with spaces"
38
- const match = trimmed.match(/^cd(?:\s+\/d)?\s+(.+)$/is);
39
- if (!match) {
40
- // 裸 cd(无参数)→ 返回 home
41
- if (/^cd\s*$/i.test(trimmed))
42
- return '';
43
- return null;
44
- }
45
- // 去掉末尾可能存在的 && 其他命令,只取 cd 的目标
46
- let target = match[1].trim();
47
- // 去掉引号
48
- target = target.replace(/^['"]|['"]$/g, '');
49
- // 如果 target 里包含 && 或 ; ,只取第一部分
50
- const cut = target.split(/&&|\|/)[0].trim();
51
- return cut || '';
52
- }
53
18
  /** 获取某个 session 的当前工作目录 */
54
19
  function getSessionCwd(sessionId) {
55
20
  return sessionCwdMap.get(sessionId);
@@ -72,32 +37,99 @@ function generateApprovalToken() {
72
37
  function tokenizeCommand(command) {
73
38
  return command.match(/"[^"]*"|'[^']*'|&&|\|\||[;&|\r\n]|\S+/g) ?? [];
74
39
  }
75
- function buildCommandSafetyView(command) {
76
- const textCommands = new Set(['echo', 'findstr', 'grep']);
77
- const separators = new Set(['&&', '||', '&', '|', ';', '\r', '\n']);
40
+ /** 去掉外层成对引号 */
41
+ function stripOuterQuotes(token) {
42
+ if (token.length >= 2) {
43
+ const first = token[0];
44
+ const last = token[token.length - 1];
45
+ if ((first === '"' || first === "'") && first === last) {
46
+ return token.slice(1, -1);
47
+ }
48
+ }
49
+ return token;
50
+ }
51
+ function getCommandName(token) {
52
+ const normalized = stripOuterQuotes(token)
53
+ .replace(/\\/g, '/')
54
+ .toLowerCase();
55
+ return (normalized.split('/').pop() ?? normalized)
56
+ .replace(/\.(exe|cmd|bat|ps1|sh)$/i, '');
57
+ }
58
+ // 参数被视为「文本/脚本」而非「可执行动词」的命令:
59
+ // - 文本处理类(参数通常是正则/字符串):grep/egrep/fgrep/findstr/sed/awk/printf/echo
60
+ // - 其它高频误报:head/tail/cat 后的文件名一般不含危险词,这里不特殊处理
61
+ const TEXT_COMMANDS = new Set([
62
+ 'echo', 'printf',
63
+ 'findstr',
64
+ 'grep', 'egrep', 'fgrep',
65
+ 'sed', 'awk', 'gawk',
66
+ ]);
67
+ // Shell 执行器:这些命令后面若跟 -c/-lc/-Command 等参数,
68
+ // 紧随其后的字符串是「内嵌脚本」,需要递归解析
69
+ const SHELL_EXECUTORS = new Set([
70
+ 'bash', 'sh', 'zsh', 'dash', 'ksh',
71
+ 'pwsh', 'powershell',
72
+ 'cmd',
73
+ 'wsl', // wsl -- bash -c "..." 之类
74
+ 'ssh', // ssh host "remote-cmd" 情况
75
+ ]);
76
+ // -c / -lc / --command / /c / /k / -Command 等:其下一个 token 是脚本
77
+ const SCRIPT_ARG_FLAGS = new Set([
78
+ '-c', '-lc', '-ic', '-lic',
79
+ '--command',
80
+ '/c', '/k',
81
+ '-command',
82
+ ]);
83
+ const SEPARATORS = new Set(['&&', '||', '&', '|', ';', '\r', '\n']);
84
+ /**
85
+ * 构建「安全审计视图」:
86
+ * - 文本类命令(grep/echo/…)的参数被剥离,避免正则里的 reboot/shutdown 触发误报
87
+ * - Shell 执行器(bash -c "...")的内嵌脚本被递归展开,让内部真实命令仍能被审计
88
+ */
89
+ function buildCommandSafetyView(command, depth = 0) {
90
+ if (depth > 4)
91
+ return ''; // 防递归爆炸
78
92
  const tokens = tokenizeCommand(command);
79
93
  const view = [];
80
94
  let skipTextArgs = false;
81
- const getCommandName = (token) => {
82
- const normalized = token
83
- .replace(/^["']|["']$/g, '')
84
- .replace(/\\/g, '/')
85
- .toLowerCase();
86
- return (normalized.split('/').pop() ?? normalized)
87
- .replace(/\.(exe|cmd|bat|ps1|sh)$/i, '');
88
- };
95
+ let expectScriptArg = false; // 上一个 token -c / -lc 等,本 token 是脚本字符串
96
+ let inShellExecutor = false; // 当前命令词是 bash/sh/pwsh 之类
89
97
  for (const token of tokens) {
90
- if (separators.has(token)) {
98
+ if (SEPARATORS.has(token)) {
91
99
  view.push(token);
92
100
  skipTextArgs = false;
101
+ expectScriptArg = false;
102
+ inShellExecutor = false;
93
103
  continue;
94
104
  }
105
+ // 情况 A:这个 token 是 shell 执行器的脚本参数 —— 递归展开
106
+ if (expectScriptArg) {
107
+ const inner = stripOuterQuotes(token);
108
+ const innerView = buildCommandSafetyView(inner, depth + 1);
109
+ if (innerView)
110
+ view.push(innerView);
111
+ expectScriptArg = false;
112
+ skipTextArgs = false;
113
+ inShellExecutor = false;
114
+ continue;
115
+ }
116
+ // 情况 B:文本类命令的参数 —— 直接丢弃
95
117
  if (skipTextArgs) {
96
118
  continue;
97
119
  }
98
120
  view.push(token);
99
- if (textCommands.has(getCommandName(token))) {
121
+ const cmdName = getCommandName(token);
122
+ // 记录命令词 → 决定后续策略
123
+ if (TEXT_COMMANDS.has(cmdName)) {
100
124
  skipTextArgs = true;
125
+ inShellExecutor = false;
126
+ }
127
+ else if (SHELL_EXECUTORS.has(cmdName)) {
128
+ inShellExecutor = true;
129
+ }
130
+ else if (inShellExecutor && SCRIPT_ARG_FLAGS.has(token.toLowerCase())) {
131
+ // bash -c / pwsh -Command 后面的字符串是脚本
132
+ expectScriptArg = true;
101
133
  }
102
134
  }
103
135
  return view.join(' ');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myassis/gateway",
3
- "version": "1.0.50",
3
+ "version": "1.0.52",
4
4
  "description": "我的助手 Gateway Service - 本地 AI 网关服务,支持认证、WebSocket 实时通信和任务调度",
5
5
  "main": "dist/index.js",
6
6
  "bin": {