@xiaoqiong0v0/opencode-ssh-tool 0.7.0 → 0.7.2

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/precheck.js CHANGED
@@ -1,7 +1,12 @@
1
1
  // 发送前语法预检(term_exec / web run-exec 共用):
2
2
  // 只解析、绝不执行;命中语法错误即拦下并返回文案,模块自身异常一律放行。
3
+ // - 本地会话:走 parse-check(本机/容器的校验器:pwsh 解析侧车 / `bash -n`、`zsh -n`)。
4
+ // - SSH 远程:走会话提供的 `precheckSyntax`(独立 exec channel:bash/zsh 跑 `-n`,
5
+ // pwsh 启动解析侧车;不占 PTY);两者互不复用,远程通道异常/超时一律 fail-open。
6
+ // CMD 没有安全的 parse-only 通道,明确跳过(只查 shell 语法,不查 Python/Node 等程序代码)。
3
7
  // 单独成文件是为了让 index(工具入口)与 agent(web 执行入口)共用,避免 index↔agent 循环依赖。
4
8
  import { stripCommandTail } from "./base-session.js";
9
+ import { REMOTE_PRECHECK_TIMEOUT_MS } from "./constants.js";
5
10
  import { validateCommand } from "./parse-check.js";
6
11
  import log from "./log.js";
7
12
  import { getToolLang, tr } from "./i18n.js";
@@ -9,6 +14,8 @@ import { getToolLang, tr } from "./i18n.js";
9
14
  const SYNTAX_REJECT_MAX_ERRORS = 5;
10
15
  /** 拦截消息里出错片段的展示长度上限(超长截断,保留可定位性) */
11
16
  const SYNTAX_ISSUE_TEXT_MAX = 60;
17
+ /** 远程预检提取出错片段的长度上限(字符):避免把超长命令整行塞进拦截文案 */
18
+ const REMOTE_SYNTAX_FRAGMENT_MAX = 200;
12
19
  /**
13
20
  * 适配器名 → parse-check 的 shell 标识(shell-adapter 的 `name` 与 classifyShell 兼容)。
14
21
  * 未列出的适配器(cmd 等)不校验、直接放行;bash/zsh 各自走 `bash -n`/`zsh -n`,
@@ -72,20 +79,36 @@ export function formatSyntaxRejection(command, errors, lang) {
72
79
  const more = errors.length > shown.length ? `${sep}${fillTemplate(tr("precheck_more", lang), { n: errors.length })}` : "";
73
80
  return `${tr("precheck_failed", lang)}${lang === "zh" ? ":" : ": "}${body}${more}`;
74
81
  }
82
+ /**
83
+ * 远程预检的出错片段:优先取命令中出错行的原文,取不到再退回解析器输出首行。
84
+ * 结果压成单行并限制长度,避免把整条超长命令回显进拦截文案。
85
+ * @param command 待发送的命令文本
86
+ * @param line 解析出的错误行号(1 起;0/非法时只看 fallback)
87
+ * @param fallback 解析器 stderr(用于取首行)
88
+ * @returns 单行片段(可能为空串)
89
+ */
90
+ export function pickSyntaxErrorText(command, line, fallback) {
91
+ const fromCommand = line > 0 ? (command.split(/\r?\n/)[line - 1] ?? "") : "";
92
+ const source = fromCommand.trim() ? fromCommand : (fallback.split(/\r?\n/)[0] ?? "");
93
+ const flat = source.replace(/\s+/g, " ").trim();
94
+ return flat.length > REMOTE_SYNTAX_FRAGMENT_MAX ? `${flat.slice(0, REMOTE_SYNTAX_FRAGMENT_MAX)}…` : flat;
95
+ }
75
96
  /**
76
97
  * 发送前语法预检(只解析、绝不执行):命中语法错误即拦下并返回文案,放行返回 null。
77
- * - 只对本地会话生效:SSH 远程需会话内往返且涉及 `$?` 污染,本步不接。
78
- * - shell 映射:pwsh/bash/zsh → 对应校验器;cmd 等 → 跳过。
98
+ * - 本地会话:parse-check(pwsh/bash/zsh;cmd 等跳过)。
99
+ * - SSH 远程:仅当会话提供 `precheckSyntax` 时调用(SshSession 对 bash/zsh/pwsh 启用,
100
+ * CMD 无安全 parse-only 通道故跳过),与本地 parse-check 完全分离;未提供/抛异常/超时一律 fail-open。
79
101
  * - 校验模块异常一律放行,绝不因预检故障阻塞命令。
80
- * @param session 已解析的会话(需提供 shellName/isLocal)
102
+ * @param session 已解析的会话(需提供 shellName/isLocal,远程另需 precheckSyntax)
81
103
  * @param command 待发送的命令文本
82
- * @param validate 校验函数(默认 validateCommand;仅供单测注入异常实现)
104
+ * @param validate 本地校验函数(默认 validateCommand;仅供单测注入异常实现)
83
105
  * @param lang 文案语言(默认取当前工具语言)
106
+ * @param remoteTimeoutMs 远程预检等待上限(毫秒;默认 REMOTE_PRECHECK_TIMEOUT_MS,仅供单测缩短)
84
107
  * @returns null=放行;非 null=拦截文案(调用方直接返回,不写终端)
85
108
  */
86
- export async function precheckBeforeExec(session, command, validate = validateCommand, lang = getToolLang()) {
109
+ export async function precheckBeforeExec(session, command, validate = validateCommand, lang = getToolLang(), remoteTimeoutMs = REMOTE_PRECHECK_TIMEOUT_MS) {
87
110
  if (!session.isLocal)
88
- return null;
111
+ return precheckRemote(session, command, lang, remoteTimeoutMs);
89
112
  const shell = CHECK_SHELL_BY_ADAPTER[session.shellName ?? ""];
90
113
  if (!shell)
91
114
  return null; // cmd 等不支持的 shell:跳过校验
@@ -102,4 +125,56 @@ export async function precheckBeforeExec(session, command, validate = validateCo
102
125
  return null; // 预检故障绝不阻塞命令
103
126
  }
104
127
  }
128
+ /**
129
+ * 远程会话的发送前预检:调用会话自带的 `precheckSyntax`(独立 exec channel)。
130
+ * 未实现、调用异常、超时、返回结构异常一律放行(fail-open),绝不误拦或阻塞主命令。
131
+ * @param session 已解析的远程会话
132
+ * @param command 待发送的命令文本
133
+ * @param lang 文案语言
134
+ * @param timeoutMs 等待上限(毫秒)
135
+ * @returns null=放行;非 null=拦截文案
136
+ */
137
+ async function precheckRemote(session, command, lang, timeoutMs) {
138
+ const precheckSyntax = session.precheckSyntax;
139
+ if (typeof precheckSyntax !== "function")
140
+ return null;
141
+ try {
142
+ // 与本地一致:校验实际会发送的命令体(剥掉尾注释/尾运算符,避免把合法命令误判)
143
+ const target = stripCommandTail(command, session.shellName ?? "");
144
+ if (!target)
145
+ return null;
146
+ const result = await withTimeout(precheckSyntax.call(session, target), timeoutMs);
147
+ if (!result || result.ok !== false)
148
+ return null;
149
+ const errors = Array.isArray(result.errors) ? result.errors : [];
150
+ if (errors.length === 0)
151
+ return null;
152
+ return formatSyntaxRejection(target, errors, lang);
153
+ }
154
+ catch (e) {
155
+ log.error("远程发送前语法校验异常,放行命令", e instanceof Error ? e : String(e));
156
+ return null; // 预检故障绝不阻塞命令
157
+ }
158
+ }
159
+ /**
160
+ * 给远程预检加超时:到点返回 null(调用方按 fail-open 处理)。
161
+ * 原 Promise 后续的 rejection 也在此处消费,避免未捕获拒绝。
162
+ * @param task 远程预检 Promise
163
+ * @param timeoutMs 等待上限(毫秒,最小 1)
164
+ * @returns 预检结果;超时为 null
165
+ */
166
+ function withTimeout(task, timeoutMs) {
167
+ return new Promise((resolve) => {
168
+ let settled = false;
169
+ const finish = (value) => {
170
+ if (settled)
171
+ return;
172
+ settled = true;
173
+ clearTimeout(timer);
174
+ resolve(value);
175
+ };
176
+ const timer = setTimeout(() => finish(null), Math.max(1, timeoutMs));
177
+ task.then((value) => finish(value), () => finish(null));
178
+ });
179
+ }
105
180
  //# sourceMappingURL=precheck.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"precheck.js","sourceRoot":"","sources":["../src/precheck.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"precheck.js","sourceRoot":"","sources":["../src/precheck.ts"],"names":[],"mappings":"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"}
package/dist/session.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { type DownloadOptions, type TransferResult, type UploadOptions } from "./transfer.js";
2
2
  import type { ExecResult } from "./base-session.js";
3
3
  import { BaseSession } from "./base-session.js";
4
+ import { type ParseCheckResult } from "./parse-check.js";
4
5
  export interface SessionStatus {
5
6
  connected: boolean;
6
7
  busy: boolean;
@@ -54,6 +55,17 @@ export declare class SshSession extends BaseSession {
54
55
  * @returns 传输结果(路径/实际字节/耗时,不含内容)
55
56
  */
56
57
  downloadFile(opts: Omit<DownloadOptions, "sftp">): Promise<TransferResult>;
58
+ /**
59
+ * SSH 远程发送前语法预检(bash/zsh/pwsh):独立 exec channel,只解析不执行。
60
+ * - bash/zsh:跑 `<shell> -n`,命令经 stdin 写入;
61
+ * - pwsh:启动 PowerShell 解析侧车(与本地 parse-check 同一脚本/协议),命令以 base64 写入 stdin;
62
+ * - 不占 PTY、不写 history、不翻转 busy、不修改 `_connected`;
63
+ * - 其他 shell(sh/dash/cmd/未知)或未连接时直接放行;cmd 无安全 parse-only 通道,明确跳过;
64
+ * - 通道异常、超时、拿不到响应、非语法失败一律 `{ok:true}`(绝不误拦)。
65
+ * @param command 待校验的命令文本(调用方已剥掉尾注释/尾运算符)
66
+ * @returns 通过或无法判定为 `{ok:true}`;确认语法错误为 `{ok:false,errors}`(含行号/片段)
67
+ */
68
+ precheckSyntax(command: string): Promise<ParseCheckResult>;
57
69
  /**
58
70
  * 串行化文件传输:前一个任务成功或失败后都继续执行下一个(失败不阻塞队列)。
59
71
  * @param job 传输任务
@@ -74,6 +86,44 @@ export declare class SshSession extends BaseSession {
74
86
  * @returns 任务结果
75
87
  */
76
88
  private _withSftp;
89
+ /**
90
+ * 用独立 SSH exec channel 跑 `<shell> -n`:命令文本经 stdin 写入(末尾补换行)后 EOF。
91
+ * 只解析不执行;通道结束/错误/超时都只结算本次预检,不触碰 PTY、busy、history 或连接状态。
92
+ * @param client 当前 SSH 客户端(调用方已确认非 null 且已连接)
93
+ * @param shell bash 或 zsh
94
+ * @param command 待校验命令文本
95
+ * @returns 确认语法错误 `{ok:false,errors}`;其余情况一律 `{ok:true}`(fail-open)
96
+ */
97
+ private _execSyntaxCheck;
98
+ /**
99
+ * 用独立 SSH exec channel 启动 PowerShell 解析侧车(只解析不执行):
100
+ * 复用本地 parse-check 的侧车脚本/响应协议,命令以 base64(UTF-8) 写入 stdin,
101
+ * 随后写 `!QUIT` 让侧车自行退出;只读 stdout 的 `ERRCOUNT=` 行,不触碰 PTY/连接状态。
102
+ * 命令超长或编码失败直接放行(fail-open)。
103
+ * @param client 当前 SSH 客户端(调用方已确认非 null 且已连接)
104
+ * @param command 待校验命令文本
105
+ * @returns 确认语法错误 `{ok:false,errors}`;其余情况一律 `{ok:true}`
106
+ */
107
+ private _execPwshSyntaxCheck;
108
+ /**
109
+ * 按候选顺序(pwsh → powershell → powershell.exe)启动侧车,共享总超时预算。
110
+ * 只有"候选不可用"(找不到解释器/启动失败)才换下一个;拿到协议结果或通道异常/超时即结算,
111
+ * 除确认语法错误外一律返回 `{ok:true}`。
112
+ * @param client 当前 SSH 客户端
113
+ * @param payload base64(UTF-8 命令文本)
114
+ * @returns 预检结果(fail-open)
115
+ */
116
+ private _runPwshCandidates;
117
+ /**
118
+ * 单个候选解释器的一次预检:打开独立 exec channel、写入请求与 `!QUIT`、收集 stdout/stderr。
119
+ * 结算时清定时器/销毁通道;通道错误与"命令不存在"都只影响本次预检。
120
+ * @param client 当前 SSH 客户端
121
+ * @param bin 候选解释器名(pwsh / powershell / powershell.exe)
122
+ * @param payload base64(UTF-8 命令文本)
123
+ * @param timeoutMs 本次候选的剩余预算(毫秒)
124
+ * @returns 候选结果(result/unavailable/error/timeout)
125
+ */
126
+ private _runPwshCandidate;
77
127
  getStatus(): SessionStatus;
78
128
  }
79
129
  /** runSshTransfer 参数 */
@@ -1 +1 @@
1
- {"version":3,"file":"session.d.ts","sourceRoot":"","sources":["../src/session.ts"],"names":[],"mappings":"AAiBA,OAAO,EAAkE,KAAK,eAAe,EAAE,KAAK,cAAc,EAAE,KAAK,aAAa,EAAE,MAAM,eAAe,CAAA;AAC7J,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAA;AACnD,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAA;AAE/C,MAAM,WAAW,aAAa;IAC5B,SAAS,EAAE,OAAO,CAAA;IAClB,IAAI,EAAE,OAAO,CAAA;IACb,OAAO,EAAE,MAAM,CAAA;IACf,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,WAAW,CAAC,EAAE,MAAM,CAAA;CACrB;AAED,MAAM,WAAW,aAAa;IAC5B,EAAE,EAAE,OAAO,CAAA;IACX,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,KAAK,CAAC,EAAE,MAAM,CAAA;CACf;AAED,qBAAa,UAAW,SAAQ,WAAW;IACzC,OAAO,CAAC,OAAO,CAAsB;IACrC,OAAO,CAAC,OAAO,CAA4C;IAC3D,OAAO,CAAC,KAAK,CAAK;IAClB,OAAO,CAAC,KAAK,CAAK;IAClB,OAAO,CAAC,KAAK,CAAK;IAClB;;;OAGG;IACH,OAAO,CAAC,cAAc,CAAsC;IAE5D,SAAS,KAAK,YAAY,IAAI,OAAO,CAAC,UAAU,CAAC,CAAgC;IAEjF,SAAS,CAAC,MAAM,IAAI,OAAO,CAE1B;IAED,SAAS,CAAC,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAEnC;IAED,SAAS,CAAC,eAAe,IAAI,IAAI,CAIhC;IAEK,OAAO,CAAC,IAAI,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,aAAa,CAAC,CAiH5G;IAED;;;;;OAKG;IACG,UAAU,CAAC,IAAI,EAAE,IAAI,CAAC,aAAa,EAAE,MAAM,CAAC,GAAG,OAAO,CAAC,cAAc,CAAC,CAQ3E;IAED;;;;;OAKG;IACG,YAAY,CAAC,IAAI,EAAE,IAAI,CAAC,eAAe,EAAE,MAAM,CAAC,GAAG,OAAO,CAAC,cAAc,CAAC,CAQ/E;IAED;;;;OAIG;IACH,OAAO,CAAC,gBAAgB;IAMxB;;;;;;;;;;;;OAYG;IACH,OAAO,CAAC,SAAS;IAiIjB,SAAS,IAAI,aAAa,CAYzB;CACF;AAED,wBAAwB;AACxB,MAAM,WAAW,oBAAoB,CAAC,CAAC;IACrC,yCAAyC;IACzC,OAAO,EAAE,OAAO,CAAA;IAChB,wCAAwC;IACxC,GAAG,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAA;IACxB,iBAAiB;IACjB,GAAG,EAAE,CAAC,OAAO,EAAE,UAAU,KAAK,OAAO,CAAC,CAAC,CAAC,CAAA;CACzC;AAED;;;;;;;;GAQG;AACH,wBAAsB,cAAc,CAAC,CAAC,EAAE,MAAM,EAAE,oBAAoB,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,CAUnF"}
1
+ {"version":3,"file":"session.d.ts","sourceRoot":"","sources":["../src/session.ts"],"names":[],"mappings":"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"}
package/dist/session.js CHANGED
@@ -2,11 +2,61 @@
2
2
  // 完成标记法:每条命令后追加一条标记命令,完成后输出 <SSH_DONE:nonce:seq:退出码>(不依赖 PROMPT_COMMAND/PS1 钩子)
3
3
  import { Client } from "ssh2";
4
4
  import log, { isDebugEnabled, ssh2Trace } from "./log.js";
5
- import { INJECT_TIMEOUT_MS, PTY_COLS, PTY_ROWS, READY_TIMEOUT_MS, SSH_KEEPALIVE_COUNT_MAX, SSH_KEEPALIVE_INTERVAL_MS, TRANSFER_JOB_SETTLE_TIMEOUT_MS, TRANSFER_TIMEOUT_MS, } from "./constants.js";
5
+ import { INJECT_TIMEOUT_MS, PTY_COLS, PTY_ROWS, READY_TIMEOUT_MS, REMOTE_PRECHECK_COMMAND_MAX, REMOTE_PRECHECK_OUTPUT_MAX, REMOTE_PRECHECK_TIMEOUT_MS, SSH_KEEPALIVE_COUNT_MAX, SSH_KEEPALIVE_INTERVAL_MS, TRANSFER_JOB_SETTLE_TIMEOUT_MS, TRANSFER_TIMEOUT_MS, } from "./constants.js";
6
6
  import { tr } from "./i18n.js";
7
7
  import { resolveAuth, resolvePassword } from "./ssh-auth.js";
8
8
  import { assertTransferCapable, downloadFile, uploadFile, TransferError } from "./transfer.js";
9
9
  import { BaseSession } from "./base-session.js";
10
+ import { encodePwshSidecarPayload, looksLikePosixSyntaxError, parsePosixSyntaxLine, parsePwshSidecarResponse, PS_SIDECAR_ENCODED_COMMAND, } from "./parse-check.js";
11
+ import { pickSyntaxErrorText } from "./precheck.js";
12
+ /** 远程预检放行结果(共享单例) */
13
+ const PRECHECK_PASS = { ok: true };
14
+ /** 远程 pwsh 侧车候选解释器(按序尝试;任一不可用/超时即 fail-open) */
15
+ const REMOTE_PWSH_CANDIDATES = ["pwsh", "powershell", "powershell.exe"];
16
+ /** 侧车启动参数(脚本经 UTF-16LE base64 传入,纯 ASCII,规避远端代码页/引号问题) */
17
+ const PS_SIDECAR_ARGV = `-NoProfile -NonInteractive -NoLogo -EncodedCommand ${PS_SIDECAR_ENCODED_COMMAND}`;
18
+ /** 侧车协议响应行前缀 */
19
+ const PWSH_RESPONSE_PREFIX = "ERRCOUNT=";
20
+ const PWSH_ERROR_PREFIX = "ERROR=";
21
+ /**
22
+ * 从侧车 stdout 取出协议响应行(容忍 CRLF、UTF-8 BOM 与前置 READY 行)。
23
+ * @param stdout 侧车标准输出
24
+ * @returns `ERRCOUNT=...` / `ERROR=...` 行;无响应返回 null
25
+ */
26
+ function findPwshResponseLine(stdout) {
27
+ for (const raw of stdout.split(/\r?\n/)) {
28
+ const line = raw.replace(/^\uFEFF/, "");
29
+ if (line.startsWith(PWSH_RESPONSE_PREFIX) || line.startsWith(PWSH_ERROR_PREFIX))
30
+ return line;
31
+ }
32
+ return null;
33
+ }
34
+ /** 远端"解释器不存在/无法启动"的典型文案(仅在拿不到协议响应时用于决定是否换候选) */
35
+ const PWSH_UNAVAILABLE_RE = /command not found|not found|not recognized|不是内部或外部命令|无法将.*识别|未找到命令|找不到命令/i;
36
+ /**
37
+ * 追加输出并保留尾部上限,避免异常输出撑爆内存。
38
+ * @param current 已收集文本
39
+ * @param chunk 新到片段
40
+ * @returns 截尾后的文本(上限 REMOTE_PRECHECK_OUTPUT_MAX)
41
+ */
42
+ function appendBounded(current, chunk) {
43
+ const next = current + chunk;
44
+ return next.length > REMOTE_PRECHECK_OUTPUT_MAX ? next.slice(-REMOTE_PRECHECK_OUTPUT_MAX) : next;
45
+ }
46
+ /**
47
+ * 候选解释器是否明显"没跑起来"(不存在/启动失败)。
48
+ * 只在没有协议响应时判定:非语法失败必须放行,绝不误拦。
49
+ * @param exitCode 远端退出码(null=未收到)
50
+ * @param stderr 远端标准错误输出
51
+ * @returns 可换下一候选时为 true
52
+ */
53
+ function isPwshCandidateUnavailable(exitCode, stderr) {
54
+ if (exitCode === null)
55
+ return true;
56
+ if (exitCode === 0)
57
+ return false;
58
+ return exitCode === 127 || exitCode === 9009 || PWSH_UNAVAILABLE_RE.test(stderr);
59
+ }
10
60
  export class SshSession extends BaseSession {
11
61
  _client = null;
12
62
  _stream = null;
@@ -167,6 +217,26 @@ export class SshSession extends BaseSession {
167
217
  const timeoutMs = opts.timeoutMs ?? TRANSFER_TIMEOUT_MS;
168
218
  return this._enqueueTransfer(() => this._withSftp(timeoutMs, (sftp, remainingMs, abortSignal) => downloadFile({ ...opts, sftp, timeoutMs: remainingMs, abortSignal })));
169
219
  }
220
+ /**
221
+ * SSH 远程发送前语法预检(bash/zsh/pwsh):独立 exec channel,只解析不执行。
222
+ * - bash/zsh:跑 `<shell> -n`,命令经 stdin 写入;
223
+ * - pwsh:启动 PowerShell 解析侧车(与本地 parse-check 同一脚本/协议),命令以 base64 写入 stdin;
224
+ * - 不占 PTY、不写 history、不翻转 busy、不修改 `_connected`;
225
+ * - 其他 shell(sh/dash/cmd/未知)或未连接时直接放行;cmd 无安全 parse-only 通道,明确跳过;
226
+ * - 通道异常、超时、拿不到响应、非语法失败一律 `{ok:true}`(绝不误拦)。
227
+ * @param command 待校验的命令文本(调用方已剥掉尾注释/尾运算符)
228
+ * @returns 通过或无法判定为 `{ok:true}`;确认语法错误为 `{ok:false,errors}`(含行号/片段)
229
+ */
230
+ async precheckSyntax(command) {
231
+ const shell = this._adapter?.name;
232
+ if (shell !== "bash" && shell !== "zsh" && shell !== "pwsh")
233
+ return PRECHECK_PASS;
234
+ if (!command || !this._connected || this._client === null)
235
+ return PRECHECK_PASS;
236
+ if (shell === "pwsh")
237
+ return this._execPwshSyntaxCheck(this._client, command);
238
+ return this._execSyntaxCheck(this._client, shell, command);
239
+ }
170
240
  /**
171
241
  * 串行化文件传输:前一个任务成功或失败后都继续执行下一个(失败不阻塞队列)。
172
242
  * @param job 传输任务
@@ -332,6 +402,260 @@ export class SshSession extends BaseSession {
332
402
  }
333
403
  });
334
404
  }
405
+ /**
406
+ * 用独立 SSH exec channel 跑 `<shell> -n`:命令文本经 stdin 写入(末尾补换行)后 EOF。
407
+ * 只解析不执行;通道结束/错误/超时都只结算本次预检,不触碰 PTY、busy、history 或连接状态。
408
+ * @param client 当前 SSH 客户端(调用方已确认非 null 且已连接)
409
+ * @param shell bash 或 zsh
410
+ * @param command 待校验命令文本
411
+ * @returns 确认语法错误 `{ok:false,errors}`;其余情况一律 `{ok:true}`(fail-open)
412
+ */
413
+ _execSyntaxCheck(client, shell, command) {
414
+ return new Promise((resolve) => {
415
+ let settled = false;
416
+ /** 远端退出码;未收到 exit 事件时为 null(按"无法判定"放行) */
417
+ let exitCode = null;
418
+ let stderr = "";
419
+ let stdout = "";
420
+ let stream = null;
421
+ let timer = null;
422
+ /** 单次结算:清定时器;abort=true 时销毁预检通道(超时/错误路径) */
423
+ const settle = (result, abort = false) => {
424
+ if (settled)
425
+ return;
426
+ settled = true;
427
+ if (timer !== null) {
428
+ clearTimeout(timer);
429
+ timer = null;
430
+ }
431
+ if (abort && stream !== null) {
432
+ try {
433
+ stream.destroy();
434
+ }
435
+ catch {
436
+ /* 通道可能已关闭 */
437
+ }
438
+ }
439
+ resolve(result);
440
+ };
441
+ /** 通道关闭:只有"非 0 退出码 + stderr 确实像语法错误"才拦截,其余放行 */
442
+ const finish = () => {
443
+ if (settled)
444
+ return;
445
+ if (exitCode === null || exitCode === 0 || !looksLikePosixSyntaxError(stderr)) {
446
+ settle(PRECHECK_PASS);
447
+ return;
448
+ }
449
+ const line = parsePosixSyntaxLine(stderr);
450
+ const detail = stderr.trim() ? stderr : stdout;
451
+ settle({
452
+ ok: false,
453
+ errors: [{ errorId: `${shell}-syntax`, line, col: 1, text: pickSyntaxErrorText(command, line, detail) }],
454
+ });
455
+ };
456
+ timer = setTimeout(() => {
457
+ log.info(`远程语法预检超时,放行命令 (session ${this.sessionID}, term ${this.name})`);
458
+ settle(PRECHECK_PASS, true);
459
+ }, REMOTE_PRECHECK_TIMEOUT_MS);
460
+ try {
461
+ client.exec(`${shell} -n`, { pty: false }, (err, channel) => {
462
+ // 超时/错误之后才建好的通道:直接销毁,避免泄漏
463
+ if (settled) {
464
+ try {
465
+ channel?.destroy();
466
+ }
467
+ catch {
468
+ /* 已关闭 */
469
+ }
470
+ return;
471
+ }
472
+ if (err || !channel) {
473
+ log.info(`远程语法预检通道不可用,放行命令 (session ${this.sessionID}, term ${this.name})`);
474
+ settle(PRECHECK_PASS);
475
+ return;
476
+ }
477
+ stream = channel;
478
+ channel.on("error", (e) => {
479
+ if (settled)
480
+ return;
481
+ log.info(`远程语法预检通道错误,放行命令 (session ${this.sessionID}, term ${this.name}): ${e.message}`);
482
+ settle(PRECHECK_PASS, true);
483
+ });
484
+ channel.stderr.on("error", () => settle(PRECHECK_PASS, true));
485
+ channel.stderr.setEncoding("utf8");
486
+ channel.stderr.on("data", (chunk) => { stderr = appendBounded(stderr, chunk); });
487
+ channel.setEncoding("utf8");
488
+ channel.on("data", (chunk) => { stdout = appendBounded(stdout, chunk); });
489
+ // 只认 exit 的退出码;close 用于确保 stdout/stderr 已消费完毕再结算
490
+ channel.on("exit", (code) => {
491
+ exitCode = typeof code === "number" ? code : null;
492
+ });
493
+ channel.on("close", finish);
494
+ try {
495
+ channel.end(command.endsWith("\n") ? command : `${command}\n`);
496
+ }
497
+ catch {
498
+ settle(PRECHECK_PASS, true);
499
+ }
500
+ });
501
+ }
502
+ catch (e) {
503
+ log.error(`远程发送前语法预检异常,放行命令 (session ${this.sessionID}, term ${this.name})`, e instanceof Error ? e : String(e));
504
+ settle(PRECHECK_PASS);
505
+ }
506
+ });
507
+ }
508
+ /**
509
+ * 用独立 SSH exec channel 启动 PowerShell 解析侧车(只解析不执行):
510
+ * 复用本地 parse-check 的侧车脚本/响应协议,命令以 base64(UTF-8) 写入 stdin,
511
+ * 随后写 `!QUIT` 让侧车自行退出;只读 stdout 的 `ERRCOUNT=` 行,不触碰 PTY/连接状态。
512
+ * 命令超长或编码失败直接放行(fail-open)。
513
+ * @param client 当前 SSH 客户端(调用方已确认非 null 且已连接)
514
+ * @param command 待校验命令文本
515
+ * @returns 确认语法错误 `{ok:false,errors}`;其余情况一律 `{ok:true}`
516
+ */
517
+ _execPwshSyntaxCheck(client, command) {
518
+ if (command.length > REMOTE_PRECHECK_COMMAND_MAX) {
519
+ log.info(`远程 pwsh 语法预检:命令超长,放行命令 (session ${this.sessionID}, term ${this.name})`);
520
+ return Promise.resolve(PRECHECK_PASS);
521
+ }
522
+ let payload;
523
+ try {
524
+ payload = encodePwshSidecarPayload(command);
525
+ }
526
+ catch (e) {
527
+ log.error(`远程 pwsh 语法预检编码失败,放行命令 (session ${this.sessionID}, term ${this.name})`, e instanceof Error ? e : String(e));
528
+ return Promise.resolve(PRECHECK_PASS);
529
+ }
530
+ return this._runPwshCandidates(client, payload);
531
+ }
532
+ /**
533
+ * 按候选顺序(pwsh → powershell → powershell.exe)启动侧车,共享总超时预算。
534
+ * 只有"候选不可用"(找不到解释器/启动失败)才换下一个;拿到协议结果或通道异常/超时即结算,
535
+ * 除确认语法错误外一律返回 `{ok:true}`。
536
+ * @param client 当前 SSH 客户端
537
+ * @param payload base64(UTF-8 命令文本)
538
+ * @returns 预检结果(fail-open)
539
+ */
540
+ async _runPwshCandidates(client, payload) {
541
+ const deadlineAt = Date.now() + REMOTE_PRECHECK_TIMEOUT_MS;
542
+ for (const bin of REMOTE_PWSH_CANDIDATES) {
543
+ const remaining = deadlineAt - Date.now();
544
+ if (remaining <= 0)
545
+ break;
546
+ const outcome = await this._runPwshCandidate(client, bin, payload, remaining);
547
+ if (outcome.kind === "result") {
548
+ return outcome.errors.length === 0 ? PRECHECK_PASS : { ok: false, errors: outcome.errors };
549
+ }
550
+ if (outcome.kind === "unavailable") {
551
+ log.info(`远程 pwsh 语法预检候选不可用(${bin}),尝试下一候选 (session ${this.sessionID}, term ${this.name})`);
552
+ continue;
553
+ }
554
+ // 侧车内部异常 / 超时:放行,不再重试(超时已耗尽预算)
555
+ log.info(`远程 pwsh 语法预检未获结果(${bin}:${outcome.kind}),放行命令 (session ${this.sessionID}, term ${this.name})`);
556
+ return PRECHECK_PASS;
557
+ }
558
+ log.info(`远程 pwsh 语法预检无可用候选,放行命令 (session ${this.sessionID}, term ${this.name})`);
559
+ return PRECHECK_PASS;
560
+ }
561
+ /**
562
+ * 单个候选解释器的一次预检:打开独立 exec channel、写入请求与 `!QUIT`、收集 stdout/stderr。
563
+ * 结算时清定时器/销毁通道;通道错误与"命令不存在"都只影响本次预检。
564
+ * @param client 当前 SSH 客户端
565
+ * @param bin 候选解释器名(pwsh / powershell / powershell.exe)
566
+ * @param payload base64(UTF-8 命令文本)
567
+ * @param timeoutMs 本次候选的剩余预算(毫秒)
568
+ * @returns 候选结果(result/unavailable/error/timeout)
569
+ */
570
+ _runPwshCandidate(client, bin, payload, timeoutMs) {
571
+ return new Promise((resolve) => {
572
+ let settled = false;
573
+ /** 远端退出码;未收到 exit 事件时为 null(按"候选不可用"处理,交给下一个候选) */
574
+ let exitCode = null;
575
+ let stdout = "";
576
+ let stderr = "";
577
+ let stream = null;
578
+ let timer = null;
579
+ /** 单次结算:清定时器;destroy=true 时销毁预检通道(超时/错误路径) */
580
+ const settle = (outcome, destroy = false) => {
581
+ if (settled)
582
+ return;
583
+ settled = true;
584
+ if (timer !== null) {
585
+ clearTimeout(timer);
586
+ timer = null;
587
+ }
588
+ if (destroy && stream !== null) {
589
+ try {
590
+ stream.destroy();
591
+ }
592
+ catch {
593
+ /* 通道可能已关闭 */
594
+ }
595
+ }
596
+ stream = null;
597
+ resolve(outcome);
598
+ };
599
+ /** 通道关闭:先找协议响应行;无响应时按退出码/错误文案判断是否换下一候选 */
600
+ const finishFromOutput = () => {
601
+ if (settled)
602
+ return;
603
+ const line = findPwshResponseLine(stdout);
604
+ if (line !== null) {
605
+ if (line.startsWith(PWSH_ERROR_PREFIX)) {
606
+ settle({ kind: "error" });
607
+ return;
608
+ }
609
+ const errors = parsePwshSidecarResponse(line);
610
+ settle(errors === null ? { kind: "error" } : { kind: "result", errors });
611
+ return;
612
+ }
613
+ settle(isPwshCandidateUnavailable(exitCode, stderr) ? { kind: "unavailable" } : { kind: "error" });
614
+ };
615
+ timer = setTimeout(() => settle({ kind: "timeout" }, true), Math.max(1, timeoutMs));
616
+ try {
617
+ client.exec(`${bin} ${PS_SIDECAR_ARGV}`, { pty: false }, (err, channel) => {
618
+ // 超时/错误之后才建好的通道:直接销毁,避免泄漏
619
+ if (settled) {
620
+ try {
621
+ channel?.destroy();
622
+ }
623
+ catch {
624
+ /* 已关闭 */
625
+ }
626
+ return;
627
+ }
628
+ if (err || !channel) {
629
+ settle({ kind: "unavailable" });
630
+ return;
631
+ }
632
+ stream = channel;
633
+ // 通道级错误只影响本候选(可能只是"命令不存在"导致写失败):交给下一个候选
634
+ channel.on("error", () => settle({ kind: "unavailable" }, true));
635
+ channel.stderr.on("error", () => settle({ kind: "unavailable" }, true));
636
+ channel.stderr.setEncoding("utf8");
637
+ channel.stderr.on("data", (chunk) => { stderr = appendBounded(stderr, chunk); });
638
+ channel.setEncoding("utf8");
639
+ channel.on("data", (chunk) => { stdout = appendBounded(stdout, chunk); });
640
+ channel.on("exit", (code) => {
641
+ exitCode = typeof code === "number" ? code : null;
642
+ });
643
+ channel.on("close", finishFromOutput);
644
+ try {
645
+ // 一行请求 + !QUIT:侧车解析后输出 ERRCOUNT 行并自行退出
646
+ channel.end(`${payload}\n!QUIT\n`);
647
+ }
648
+ catch {
649
+ settle({ kind: "unavailable" }, true);
650
+ }
651
+ });
652
+ }
653
+ catch (e) {
654
+ log.error(`远程 pwsh 语法预检通道异常,放行命令 (session ${this.sessionID}, term ${this.name})`, e instanceof Error ? e : String(e));
655
+ settle({ kind: "unavailable" });
656
+ }
657
+ });
658
+ }
335
659
  getStatus() {
336
660
  return {
337
661
  connected: this._connected,