@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 +59 -0
- package/dist/services/ServiceManager.js +12 -7
- package/dist/services/tools/exec.js +80 -48
- package/package.json +1 -1
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
|
-
"
|
|
166
|
-
"
|
|
167
|
-
"
|
|
168
|
-
"$psi.
|
|
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
|
-
//
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
82
|
-
|
|
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 (
|
|
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
|
-
|
|
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(' ');
|