@zicolasjac-ai/remote-readonly-ssh 1.4.1 → 1.4.3

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/README.md CHANGED
@@ -25,7 +25,7 @@ npx 会自动拉取最新版本、进入初始化向导,依次询问:
25
25
  1. 是否覆盖已有 `~/.rrs/config.json`(首次安装跳过此步)
26
26
  2. **认证组**:堡垒机主机 / 端口 / 用户名 / 认证模式(SSH 密钥或密码)/ 凭据;可配置多个认证组
27
27
  3. 目标主机白名单(每行 `ip=堡垒机搜索关键字`),并为每台主机指定归属的认证组(多台主机可共用同一组)
28
- 4. 是否把 SKILL.md 安装到 opencode / Claude Code / Cursor 等 AI 助手
28
+ 4. 是否把 SKILL.md 安装到 opencode / Claude Code / Cursor 等 AI 助手(安装命令的 postinstall 已自动分发,此步幂等可确认)
29
29
 
30
30
  向导结束后 `~/.rrs/config.json` 已就位、`~/.rrs/audit.log` 已建好、AI skill 已分发。直接试跑:
31
31
 
@@ -38,7 +38,7 @@ npx -y @zicolasjac-ai/remote-readonly-ssh@latest sys-info --json
38
38
  每次调用都 `npx` 较慢(需下载/缓存)。如果要把 `rrs` 作为日常命令长期使用:
39
39
 
40
40
  ```bash
41
- npm install -g @zicolasjac-ai/remote-readonly-ssh
41
+ npm install -g @zicolasjac-ai/remote-readonly-ssh # postinstall 自动分发 AI skill
42
42
  rrs init # TTY 下仍是交互向导;非交互环境只生成配置模板
43
43
  rrs sys-info --json # 直接用 rrs 前缀
44
44
  ```
@@ -68,7 +68,7 @@ rrs <tool> [arg] [--host <ip>] [--config <file>] [--json]
68
68
  | `dir <path>` | 列目录(同路径白名单) |
69
69
  | `bin-version <bin>` | 查二进制版本/编译参数(二进制白名单) |
70
70
  | `exec-only <cmd>` | 通用只读命令(前缀白名单 + 禁止模式) |
71
- | `download <远程> <本地>` | 下载单文件(SFTP,仅 direct 资产;shadow/.ssh 类路径防呆) |
71
+ | `download <远程> <本地>` | 下载单文件(SFTP:direct 直连或堡垒机 koko 网关;shadow/.ssh 类路径防呆) |
72
72
  | `upload <本地> <远程>` | 上传单文件(写操作,**默认禁用**:须 `file_transfer.upload.enabled=true` + 独立写路径白名单) |
73
73
 
74
74
  ## 配置
@@ -78,7 +78,7 @@ rrs <tool> [arg] [--host <ip>] [--config <file>] [--json]
78
78
  | 键 | 说明 |
79
79
  |-----|------|
80
80
  | `auth_profiles` | **认证组**(可多个):`{组名: {host, port, username, auth, direct?}}`。`auth.mode` 支持 `key`(`private_key_path` 必填,可选 `private_key_passphrase`)与 `password`(`password` 必填)。`direct: true` 表示**直连裸机**(跳过堡垒机菜单,适合无堡垒机的服务器;命令只读校验与堡垒机模式完全一致)。`list-tools` 只显示模式与主机,不显示任何凭据 |
81
- | `target_hosts` | 目标主机白名单 `{ip: {profile, keyword?}}`:`profile` 引用认证组名,`keyword` 为堡垒机资产搜索关键字(缺省用 IP)。**多台主机可共用同一认证组(N:1)** |
81
+ | `target_hosts` | 目标主机白名单 `{ip: {profile, keyword?, sftp_root?}}`:`profile` 引用认证组名,`keyword` 为堡垒机资产搜索关键字(缺省用 IP);`sftp_root` 可选——koko 资产树资产目录(如 `Default/业务分组/10.0.0.3`),文件传输在菜单资产模式下用于路径映射,未配置时自动探测(命中多个要求显式声明)。**多台主机可共用同一认证组(N:1)** |
82
82
  | `audit` | `enabled`(默认 true,关闭则不留痕)与 `include_output`(默认 false,true 时把命令输出摘要写入审计日志) |
83
83
  | `allowed_version_bins` | 允许执行 `-v/-V` 的二进制白名单 |
84
84
  | `allowed_command_prefixes` | 只读命令前缀白名单(cat/ls/grep/ps/ss/tail...) |
@@ -99,10 +99,14 @@ rrs <tool> [arg] [--host <ip>] [--config <file>] [--json]
99
99
 
100
100
  **硬约束**:
101
101
 
102
- - **仅支持 `direct: true` 直连认证组**。走堡垒机菜单的资产没有指向目标资产的 SFTP 通路,调用会被直接拒绝(这是防"误写到堡垒机文件系统"的保护,不是缺陷);如需传输,请为该主机增加一个 `direct: true` 的专属认证组。
103
- - **大小不设上限**(1GB 级为常规场景),多线路网络下观察 `--` 进度提示即可判断耗时。
102
+ - **双通道**:
103
+ - `direct: true` 直连认证组 → SFTP 通道即目标机**真实文件系统**;
104
+ - 普通堡垒机菜单资产 → 走 JumpServer **koko 的 SFTP 文件传输入口**(与 SSH 菜单同端口同一套凭据,无需进菜单)。语义路径自动映射进该资产在 koko 资产树中的目录(如 `Default/资产组/10.0.0.3`):默认**自动探测**(逐层 readdir 找名为资产 IP 的目录,命中多个则要求显式配置);特殊命名可在 `target_hosts` 条目加 `"sftp_root": "Default/资产组/10.0.0.3"` 显式声明。
105
+ - koko 模式暂不支持 `verify: "sha256"`(远端散列需直达 exec 通道),配置为 sha256 时明确报错。
106
+ - **大小不设上限**(1GB 级为常规场景;速度受链路带宽约束)。
104
107
  - 目标(两端)**已存在即拒绝**:`--overwrite` 从头重传;`--resume` 断点续传未完成半成品 `<目标>.rrs-part`(互斥,续传前校验两端字节数一致)。产物先写半成品、校验后原子改名,**见到正式文件即视为完整**。
105
- - 单文件传输;不支持目录递归、不支持容器内文件。
108
+ - 单文件传输;不支持目录递归、不支持 `docker exec` 容器内文件。
109
+ - 上传需 opt-in + 独立写路径白名单(语义路径层校验,koko 模式同样生效);上传/下载不受 `(语义路径) 之外的资产树越界`——映射后被强制限定在该资产目录内。
106
110
 
107
111
  **下载**(默认可用):
108
112
 
@@ -141,7 +145,7 @@ rrs upload "E:\deploy\nginx.conf" /opt/upload/nginx.conf --host 10.0.0.2 --json
141
145
 
142
146
  ## Agent Skills
143
147
 
144
- 本工具附带一份 `SKILL.md`,教 AI 助手何时用、怎么用 `rrs`。安装后用 `rrs setup skills` 一键分发到各 agent 的 skills 目录:
148
+ 本工具附带一份 `SKILL.md`,教 AI 助手何时用、怎么用 `rrs`。**`npm install` 时 postinstall 会自动分发**(agents 实体 + opencode/claude/qoder 软链接),无需手工执行;`rrs setup skills` 保留为手工重装 / 只装部分 agent 的方式:
145
149
 
146
150
  | `--agent` | 写入方式 | 路径 |
147
151
  |-----------|----------|------|
@@ -151,17 +155,16 @@ rrs upload "E:\deploy\nginx.conf" /opt/upload/nginx.conf --host 10.0.0.2 --json
151
155
  | `qoder` | 软链接 → agents | `~/.qoder/skills/remote-readonly-ssh/SKILL.md` |
152
156
  | `all`(默认) | 实体 + 上述 3 个软链接 | 一行命令装齐 |
153
157
 
154
- `rrs setup skills` 默认 `agent=all`(不询问,直接装),输出一行简明提示:
158
+ `rrs setup skills` 默认 `agent=all`(不询问,直接装;postinstall 自动分发亦采用此默认),输出一行简明提示:
155
159
 
156
160
  ```
157
161
  rrs skill 已安装: agents (实体), opencode/claude/qoder (软链接)
158
162
  ```
159
163
 
160
- > **Windows 注意**:普通用户缺少 `SeCreateSymbolicLinkPrivilege`,软链接创建会被 OS 拒绝;rrs 会自动 fallback 为复制,并提示"Windows 已 fallback 为复制(请开启开发者模式以启用真软链接)"。开启开发者模式后重新跑 `rrs setup skills` 即可使用真软链接。
164
+ > **Windows 注意**:普通用户缺少 `SeCreateSymbolicLinkPrivilege`,软链接创建会被 OS 拒绝;rrs 会自动 fallback 为复制,并提示"Windows 已 fallback 为复制(请开启开发者模式以启用真软链接)"。开启开发者模式后重新跑 `rrs setup skills`(或重装包)即可使用真软链接。
161
165
 
162
166
  ```bash
163
- rrs setup skills # 一行命令装齐(实体 + 3 个软链接)
164
- npx -y @zicolasjac-ai/remote-readonly-ssh@latest setup skills # 全局未装时用 npx
167
+ rrs setup skills # 手工重装(正常情况 npm install 已自动分发)
165
168
  rrs setup skills --agent opencode # 只装 opencode 链接(agents 实体也自动建)
166
169
  rrs setup skills --agent qoder # 只装 qoder 链接
167
170
  rrs setup skills --dry-run # 仅预览将要创建的目标
@@ -7,7 +7,28 @@ const path = require("path");
7
7
 
8
8
  const PACKAGE_ROOT = path.join(__dirname, "..");
9
9
 
10
+ /**
11
+ * npm 安装完成后自动把 SKILL.md 分发到各 AI 助手的 skills 目录(agents 实体 + 软链接),
12
+ * 使 "npm install -g @latest" 一步完成 CLI 与 AI skill 的同步升级;失败不阻塞安装。
13
+ * 幂等:重复安装/升级会重建实体与软链(Windows 无符号链接权限时自动退化为复制)。
14
+ */
15
+ function autoInstallSkills() {
16
+ try {
17
+ const results = setupSkill.install(PACKAGE_ROOT, { agent: "all" });
18
+ const canonical = results.find((r) => r.agent === "agents");
19
+ const links = results.filter((r) => r.agent !== "agents");
20
+ const anyFallback = links.some((r) => r.fallback);
21
+ const names = links.map((r) => r.agent).join("/");
22
+ const done = canonical && links.length ? `agents (实体), ${names} (软链接)` : canonical ? "agents (实体)" : names;
23
+ console.log(`rrs AI skill 已同步: ${done}${anyFallback ? " — Windows 已自动 fallback 为复制(开启开发者模式可获得真软链接)" : ""}`);
24
+ } catch (e) {
25
+ console.error(`[rrs postinstall] AI skill 自动分发失败(不影响 CLI 使用): ${e.message}`);
26
+ console.error("可稍后手动执行: rrs setup skills");
27
+ }
28
+ }
29
+
10
30
  (async () => {
31
+ autoInstallSkills();
11
32
  const tty = process.stdin.isTTY && process.stdout.isTTY;
12
33
  if (tty) {
13
34
  try {
@@ -19,9 +40,9 @@ const PACKAGE_ROOT = path.join(__dirname, "..");
19
40
  } else {
20
41
  console.log("");
21
42
  console.log("rrs 安装完成。下一步:");
22
- console.log(" rrs init # 交互式初始化 + 安装 AI skill(推荐)");
23
- console.log(" rrs setup skills # 仅安装 AI skill 到 opencode / claude 等");
43
+ console.log(" rrs init # 交互式初始化配置(首次安装必跑;向导不再询问 skill 安装,见上)");
44
+ console.log(" rrs setup skills --agent opencode # 如需只分发部分 AI 助手(默认已全量分发)");
24
45
  console.log(" rrs list-tools # 查看白名单与配置");
25
46
  console.log("");
26
47
  }
27
- })();
48
+ })();
package/bin/rrs.js CHANGED
@@ -11,7 +11,6 @@ const {
11
11
  const setupSkill = require("../src/setup");
12
12
  const initWizard = require("../src/init");
13
13
  const transfer = require("../src/transfer");
14
- const { resolveProfile } = require("../src/jumpshell");
15
14
 
16
15
  const PACKAGE_ROOT = path.join(__dirname, "..");
17
16
 
@@ -259,11 +258,6 @@ async function runTransfer(opts, conf) {
259
258
  ? `upload ${args.local || "(无)"} -> ${args.remote || "(无)"}`
260
259
  : `download ${args.remote || "(无)"} -> ${args.local || "(无)"}`];
261
260
  plan = transfer.prepareTransfer(conf, host, tool, args);
262
- const entry = (conf.target_hosts || {})[host] || {};
263
- const profile = resolveProfile(conf, host);
264
- if (profile.direct !== true) {
265
- throw new Error(`拒绝: 文件传输仅支持 direct: true 认证组;主机 [${host}] 当前认证组 [${entry.profile || "(未知)"}] 未启用直连。请在 auth_profiles 为该主机增加 direct: true 的专属认证组`);
266
- }
267
261
  } catch (e) {
268
262
  audit(conf, tool, host || "", cmds, false, e.message);
269
263
  emitResult(opts, { ok: false, tool, host: host || "", error: e.message, kind: "拒绝" });
@@ -283,16 +277,17 @@ async function runTransfer(opts, conf) {
283
277
 
284
278
  const speedBps = result.bytes / Math.max(0.001, result.elapsedMs / 1000);
285
279
  const resumeTag = result.resumed ? "(断点续传)" : "";
280
+ const viaTag = plan.mode === "koko" ? "(koko 资产树直传)" : "";
286
281
  const verifyTag = result.verify === "sha256" ? ",sha256 校验通过" : "";
287
- const text = `${isUpload ? "上传" : "下载"}完成: ${plan.local} ↔ ${plan.remote} | ${fmtBytes(result.bytes)} 耗时 ${fmtDur(result.elapsedMs)} 均 ${(speedBps / 1048576).toFixed(1)} MiB/s${resumeTag}${verifyTag}`;
282
+ const text = `${isUpload ? "上传" : "下载"}完成: ${plan.local} ↔ ${plan.remote} | ${fmtBytes(result.bytes)} 耗时 ${fmtDur(result.elapsedMs)} 均 ${(speedBps / 1048576).toFixed(1)} MiB/s${resumeTag}${viaTag}${verifyTag}`;
288
283
  audit(conf, tool, host, cmds, true,
289
- `bytes=${result.bytes} elapsed=${result.elapsedMs}ms resumed=${result.resumed} verify=${result.verify} overwrite=${plan.flags.overwrite}`);
284
+ `bytes=${result.bytes} elapsed=${result.elapsedMs}ms resumed=${result.resumed} verify=${result.verify} overwrite=${plan.flags.overwrite} via=${plan.mode}`);
290
285
  emitResult(opts, {
291
286
  ok: true, tool, host,
292
287
  local: plan.local, remote: plan.remote,
293
288
  bytes: result.bytes, resumed: result.resumed,
294
289
  elapsed_ms: result.elapsedMs, verify: result.verify,
295
- overwrite: plan.flags.overwrite,
290
+ overwrite: plan.flags.overwrite, via: plan.mode,
296
291
  output: text,
297
292
  });
298
293
  } catch (e) {
@@ -32,7 +32,8 @@
32
32
  },
33
33
  "target_hosts": {
34
34
  "10.0.0.1": { "profile": "profile_a", "keyword": "10.0.0" },
35
- "10.0.0.2": { "profile": "profile_a", "keyword": "10.0.0" }
35
+ "10.0.0.2": { "profile": "profile_a", "keyword": "10.0.0" },
36
+ "10.0.0.3": { "profile": "profile_b", "keyword": "10.0.0", "sftp_root": "Default/业务分组/10.0.0.3" }
36
37
  },
37
38
  "audit": {
38
39
  "enabled": true,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zicolasjac-ai/remote-readonly-ssh",
3
- "version": "1.4.1",
3
+ "version": "1.4.3",
4
4
  "description": "只读 SSH 运维 CLI 与 Agent Skill:通过 JumpServer 堡垒机白名单强制执行只读查询;认证组支持 SSH 私钥/密码两种模式与按 IP 分组;附 SKILL.md 让 opencode / Claude Code / Cursor 等 AI 助手自动发现并正确调用。",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: remote-readonly-ssh
3
- description: 通过 JumpServer 堡垒机对服务器做白名单强制的运维查询与单文件传输(sys-info / proc / ports / dir / read-file / bin-version / exec-only / download / upload)。命令默认只读、前缀白名单 + 禁止模式 + 路径白名单 + 主机白名单多层校验;下载自带敏感路径防呆,上传默认禁用需配置显式开启且仅支持 direct 直连资产。每次调用(含被拒绝的)都写入本地审计日志。AI Agent 需要查看服务器现状或与 direct 服务器互传单文件时调用此 skill。
3
+ description: 通过 JumpServer 堡垒机对服务器做白名单强制的运维查询与单文件传输(sys-info / proc / ports / dir / read-file / bin-version / exec-only / download / upload)。命令默认只读、前缀白名单 + 禁止模式 + 路径白名单 + 主机白名单多层校验;下载自带敏感路径防呆,上传默认禁用需配置显式开启;传输支持 direct 直连资产与堡垒机菜单资产(koko 资产树自动探测或 sftp_root 显式声明)。每次调用(含被拒绝的)都写入本地审计日志。AI Agent 需要查看服务器现状或与白名单服务器互传单文件时调用此 skill。
4
4
  license: MIT
5
5
  compatibility: opencode, claude-code, cursor, windsurf, openhands, qoder, codex
6
6
  metadata:
@@ -11,7 +11,7 @@ metadata:
11
11
 
12
12
  # rrs — Read-Only SSH Inspection & File Transfer Skill
13
13
 
14
- `rrs`(remote-read-only-ssh)是一个面向 AI Agent 的只读 SSH 运维工具。它通过 JumpServer 风格的堡垒机在白名单目标主机上执行**白名单强制**的只读查询,并支持与 `direct: true` 直连资产做单文件上传/下载(SFTP)。每次调用(含被拒绝的)都写入本地审计日志。
14
+ `rrs`(remote-read-only-ssh)是一个面向 AI Agent 的只读 SSH 运维工具。它通过 JumpServer 风格的堡垒机在白名单目标主机上执行**白名单强制**的只读查询,并支持对白名单主机做单文件上传/下载(SFTP):`direct: true` 直连资产走真实文件系统,普通菜单资产走 JumpServer koko 的 SFTP 文件传输入口(资产树自动探测或 `sftp_root` 显式声明)。每次调用(含被拒绝的)都写入本地审计日志。
15
15
 
16
16
  安全定位:**命令查询技术上零写能力**;文件下载原则上不写远端;**文件上传是唯一写远程状态的能力**——默认禁用,须在本机配置显式开启 + 写路径白名单。
17
17
 
@@ -34,11 +34,10 @@ metadata:
34
34
  ## Setup(用户一次性)
35
35
 
36
36
  ```bash
37
- npm install -g @zicolasjac-ai/remote-readonly-ssh
38
- rrs init # 生成配置模板 ~/.rrs/config.json
37
+ npm install -g @zicolasjac-ai/remote-readonly-ssh # postinstall 自动把 SKILL.md 分发到 opencode / claude / agents 等目录
38
+ rrs init # 生成配置模板 ~/.rrs/config.json(TTY 下为交互向导)
39
39
  # 编辑配置: auth_profiles(认证组,key/password 两种模式,可多个;direct:true 表示跳过堡垒机菜单直连裸机)
40
- # target_hosts(IP → 认证组+搜索关键字,多 IP 可共用一组)
41
- rrs setup skills # 把本 skill 安装到 opencode / claude / agents 等目录
40
+ # target_hosts(IP → 认证组+搜索关键字,多 IP 可共用一组;可选 sftp_root 声明 koko 资产树目录)
42
41
  ```
43
42
 
44
43
  ## Usage
@@ -114,7 +113,7 @@ rrs upload <本地绝对路径> <远程绝对路径> [--overwrite|--resume] [--h
114
113
  5. **容器命令只走受控路径**:若使用 `docker exec`,必须遵守 `container_exec.allowed_path_prefixes`;默认只允许 `/workspace/ds-dev` 这类明确前缀,`grep -r` 只允许递归配置中的日志目录,禁止 `bash/sh`、`tail -f`、`ls -R`、相对路径和越界路径。
115
114
  6. **修改 `~/.rrs/config.json` 前必须先征得用户确认**(`~/.rrs/` 下的所有文件均如此,含白名单、target_hosts、认证组、known_hosts)。被拒绝时不要直接动手改配置:先把需要的路径/主机/变更内容展示给用户,等用户明确同意后再编辑。配置文件属于用户资产,不在工具的只读授权范围内自行变更。
116
115
  7. **传输约束(upload/download)**:
117
- - 仅支持 `direct: true` 直连认证组;菜单模式资产一律拒绝(不要为传输把读文件当替代方案混传)
116
+ - 通道:`direct: true` 直连资产 = 真实文件系统;普通菜单资产 = **koko SFTP 网关**(连接后自动探测 koko 资产树定位该资产目录并映射语义路径;命名特殊时在 `target_hosts` 加 `"sftp_root": "模块/资产组/IP"` 显式声明;koko 模式不支持 `verify:"sha256"`,远端散列不可用)
118
117
  - `upload` 是**写操作**:默认禁用;只有用户明确要求传文件且配置已启用(`file_transfer.upload.enabled: true` + 写路径白名单)时才可用;启用与否、白名单变更属配置修改,须先征得用户同意(同规则 6)
119
118
  - `download` 无读白名单,但敏感路径防呆生效:内置(`/etc/shadow` 族、任意 `/.ssh/` 目录)+ `blocked_paths` 全局黑名单 + `file_transfer.download.blocked_paths` 追加;被拒即是禁止信号,**不要**换路径表达方式重试(可把需求转述给用户人工决策)
120
119
  - `upload` 在写白名单之上同样受防呆约束(`.ssh` / shadow / `blocked_paths` 族不可覆盖)
package/src/config.js CHANGED
@@ -99,6 +99,13 @@ function validateConfig(conf) {
99
99
  } else if (!profiles || !profiles[entry.profile]) {
100
100
  errors.push(`target_hosts[${ip}] 引用不存在的认证组 [${entry.profile}]`);
101
101
  }
102
+ if (entry.sftp_root !== undefined) {
103
+ if (typeof entry.sftp_root !== "string") {
104
+ errors.push(`target_hosts[${ip}] sftp_root 必须是字符串(koko 资产树目录,如 "Default/资产组/${ip}")`);
105
+ } else if (entry.sftp_root.split("/").some((seg) => !seg || seg === "." || seg === "..")) {
106
+ errors.push(`target_hosts[${ip}] sftp_root 含非法路径段(空段 / "." / ".." 不允许)`);
107
+ }
108
+ }
102
109
  }
103
110
  }
104
111
 
package/src/jumpshell.js CHANGED
@@ -241,22 +241,18 @@ class JumpShell {
241
241
  }
242
242
 
243
243
  /**
244
- * 打开 SFTP 子系统通道(文件传输专用)。仅支持 direct: true 直连认证组:
245
- * 堡垒机菜单模式下没有指向目标资产的 SFTP 通路(菜单会话只是 PTY),
246
- * 若堡垒机自身恰好提供 SFTP 子系统,路径语义也指向堡垒机而非目标资产,属错位写入,必须拒绝。
244
+ * 打开 SFTP 子系统通道(文件传输专用)。两种模式:
245
+ * - direct: true 直连认证组 → 通道即目标机真实文件系统
246
+ * - 堡垒机菜单认证组 → JumpServer koko 的 SFTP 文件传输入口(资产树虚拟根),
247
+ * 无需菜单导航;路径映射与资产定位由 transfer 层完成。
248
+ * 失败通常意味着堡垒机未开放文件传输或资产未授权 SFTP,报错给出可操作指引。
247
249
  */
248
250
  async openSftp() {
249
- if (this.direct !== true) {
250
- throw new Error(
251
- `文件传输仅支持 direct: true 认证组(当前主机 ${this.host} 未启用直连)。` +
252
- `请在 auth_profiles 中为该主机增加一个 direct: true 的专属认证组,并在 target_hosts 引用它`
253
- );
254
- }
255
251
  if (!this.conn) throw new Error("SSH 未连接");
256
252
  return new Promise((resolve, reject) => {
257
253
  this.conn.sftp((err, sftp) => {
258
254
  if (err) {
259
- reject(new Error(`SFTP 子系统打开失败: ${err.message}(目标机需运行 OpenSSH sftp-server;口令账号需有读写权限)`));
255
+ reject(new Error(`SFTP 子系统打开失败: ${err.message}(堡垒机需开放文件传输(koko)或走 direct 直连认证组;口令账号需有读写权限)`));
260
256
  } else {
261
257
  resolve(sftp);
262
258
  }
package/src/transfer.js CHANGED
@@ -167,14 +167,34 @@ function remoteSha256(shell, conf, p) {
167
167
 
168
168
  // ==== 前置校验(不经网络)====
169
169
 
170
+ // koko 资产树路径归一化(显式 sftp_root):去首尾 /,禁止空段/./.. 与控制字符;空值 → 交给自动探测
171
+ function normalizeSftpRoot(raw) {
172
+ if (typeof raw !== "string") return null;
173
+ const trimmed = raw.trim().replace(/^\/+|\/+$/g, "");
174
+ if (!trimmed) return null;
175
+ for (const seg of trimmed.split("/")) {
176
+ if (!seg || seg === "." || seg === "..") {
177
+ throw tErr(`配置错误: target_hosts.sftp_root 含非法路径段 "${seg}"(空段 / "." / ".." 不允许)[${raw}]`);
178
+ }
179
+ if (/[\x00-\x1f]/.test(seg)) {
180
+ throw tErr(`配置错误: target_hosts.sftp_root 含控制字符 [${raw}]`);
181
+ }
182
+ }
183
+ return trimmed;
184
+ }
185
+
170
186
  /**
171
187
  * 传输意图校验:开关/白名单/本地文件/远程路径/flag 互斥。
172
188
  * 任何失败抛"拒绝"类错误(调用方按退出码 2 处理并审计)。
173
- * 返回 {tool, flags, tc, local, remote, total}。
189
+ * 返回 {tool, flags, tc, mode: "direct"|"koko", sftpRoot, local, remote, total}。
174
190
  */
175
191
  function prepareTransfer(conf, host, tool, args = {}) {
176
192
  const flags = resolveTransferFlags(args);
177
193
  const tc = transferConf(conf);
194
+ // 连接模式判定:direct → 通道即目标机真实文件系统;菜单资产 → koko 资产树虚拟根
195
+ const entry = (conf.target_hosts || {})[host] || {};
196
+ const profileEntry = (conf.auth_profiles || {})[entry.profile] || {};
197
+ const isDirect = profileEntry.direct === true;
178
198
  if (tool === "upload") {
179
199
  if (!tc.upload.enabled) {
180
200
  throw tErr(
@@ -190,7 +210,10 @@ function prepareTransfer(conf, host, tool, args = {}) {
190
210
  }
191
211
  if (!st.isFile()) throw tErr(`拒绝: 本地路径不是文件 [${local}]`);
192
212
  const remote = validateRemotePath(args.remote, "upload", conf);
193
- return { tool, flags, tc, local, remote, total: st.size };
213
+ if (!isDirect && tc.verify === "sha256") {
214
+ throw tErr('拒绝: 菜单资产(koko 模式)暂不支持 verify:"sha256"(远端 sha256sum 需直达 exec 通道);请将 file_transfer.verify 设为 "size"');
215
+ }
216
+ return { tool, host, flags, tc, mode: isDirect ? "direct" : "koko", sftpRoot: isDirect ? null : normalizeSftpRoot(entry.sftp_root), local, remote, total: st.size };
194
217
  }
195
218
  if (tool === "download") {
196
219
  if (!args.remote) throw tErr("拒绝: 缺少远程文件路径(第一个参数)");
@@ -209,11 +232,48 @@ function prepareTransfer(conf, host, tool, args = {}) {
209
232
  if (partL && !flags.resume && !flags.overwrite) {
210
233
  throw tErr(`拒绝: 检测到未完成的传输 ${partPath(local)}。加 --resume 续传,或 --overwrite 删除后重传`);
211
234
  }
212
- return { tool, flags, tc, remote, local };
235
+ if (!isDirect && tc.verify === "sha256") {
236
+ throw tErr('拒绝: 菜单资产(koko 模式)暂不支持 verify:"sha256"(远端 sha256sum 需直达 exec 通道);请将 file_transfer.verify 设为 "size"');
237
+ }
238
+ return { tool, host, flags, tc, mode: isDirect ? "direct" : "koko", sftpRoot: isDirect ? null : normalizeSftpRoot(entry.sftp_root), remote, local };
213
239
  }
214
240
  throw tErr(`未知传输方向 [${tool}]`);
215
241
  }
216
242
 
243
+ // ==== koko 资产树定位(菜单资产模式专用)====
244
+
245
+ /**
246
+ * 在 koko 资产树虚拟根中定位目标资产的目录路径(自动探测,DFS 深度 ≤3 层)。
247
+ * 返回 "/Default/资产组/资产IP" 形态;命中多个 → 报错要求显式 sftp_root;未命中 → 报错引导配置。
248
+ */
249
+ function resolveKokoBase(sftp, host, explicitRoot) {
250
+ if (explicitRoot) return Promise.resolve(`/${explicitRoot}`);
251
+ const hits = [];
252
+ const walk = (dir, remainingDepth) => new Promise((resolveWalk) => {
253
+ sftp.readdir(dir, async (err, list) => {
254
+ if (err) return resolveWalk(); // 不可读子树静默跳过(收藏夹等)
255
+ const tasks = [];
256
+ for (const x of list) {
257
+ if (!x.longname.startsWith("d")) continue;
258
+ const full = `${dir.replace(/\/+$/, "")}/${x.filename}`;
259
+ if (x.filename === host) { hits.push(full); continue; }
260
+ if (remainingDepth > 0) tasks.push(walk(full, remainingDepth - 1));
261
+ }
262
+ if (tasks.length) await Promise.all(tasks);
263
+ resolveWalk();
264
+ });
265
+ });
266
+ return walk("/", 2).then(() => {
267
+ if (hits.length > 1) {
268
+ throw tErr(`拒绝: 资产 [${host}] 在 koko 资产树命中多个目录(${hits.join("、")});请在 target_hosts[${host}] 增加 "sftp_root" 显式声明资产目录`);
269
+ }
270
+ if (hits.length === 0) {
271
+ throw tErr(`拒绝: 自动探测未在 koko 资产树中找到资产目录 [${host}](已扫描根下两层)。请在 target_hosts[${host}] 增加 "sftp_root": "资产模块/资产组/${host}" 显式声明`);
272
+ }
273
+ return hits[0];
274
+ });
275
+ }
276
+
217
277
  // ==== 上传 ====
218
278
 
219
279
  async function uploadFile(shell, conf, plan, onProgress) {
@@ -226,9 +286,14 @@ async function uploadFile(shell, conf, plan, onProgress) {
226
286
  const total = st.size;
227
287
 
228
288
  const sftp = await shell.openSftp();
289
+ // direct 通道 → base=""(语义路径即真实路径);koko 通道 → base=资产树目录(语义路径映射进去)
290
+ const base = plan.mode === "direct" ? "" : await resolveKokoBase(sftp, plan.host, plan.sftpRoot);
291
+ const P = (semanticAbs) => base ? base + semanticAbs : semanticAbs;
292
+ const rRemote = P(remote);
293
+ const rPart = P(part);
229
294
 
230
295
  // 冲突裁决:正式目标 / 半成品 / flags
231
- const remoteInfo = await sftpStat(sftp, remote, "远程目标");
296
+ const remoteInfo = await sftpStat(sftp, rRemote, "远程目标");
232
297
  if (remoteInfo) {
233
298
  if (flags.resume) {
234
299
  throw tErr(`拒绝: --resume 只用于续传 ${part} 半成品;远程正式目标已存在 [${remote}](覆盖请用 --overwrite)`);
@@ -236,10 +301,10 @@ async function uploadFile(shell, conf, plan, onProgress) {
236
301
  if (!flags.overwrite) {
237
302
  throw tErr(`拒绝: 远程目标已存在 [${remote}]。加 --overwrite 重传,或 --resume 续传半成品`);
238
303
  }
239
- await sftpUnlink(sftp, remote, "远程正式目标");
304
+ await sftpUnlink(sftp, rRemote, "远程正式目标");
240
305
  }
241
306
 
242
- const partInfo = await sftpStat(sftp, part, "半成品");
307
+ const partInfo = await sftpStat(sftp, rPart, "半成品");
243
308
  let offset = 0;
244
309
  let resumed = false;
245
310
  if (partInfo) {
@@ -247,7 +312,7 @@ async function uploadFile(shell, conf, plan, onProgress) {
247
312
  throw tErr(`拒绝: 检测到未完成的传输 ${part}。加 --resume 续传,或 --overwrite 删除后重传`);
248
313
  }
249
314
  if (flags.overwrite) {
250
- await sftpUnlink(sftp, part, "半成品");
315
+ await sftpUnlink(sftp, rPart, "半成品");
251
316
  } else {
252
317
  const r = computeResumeOffset(partInfo.size, total);
253
318
  if (r.mode === "ready") {
@@ -256,15 +321,15 @@ async function uploadFile(shell, conf, plan, onProgress) {
256
321
  const localSha = await sha256File(local);
257
322
  const remoteSha = await remoteSha256(shell, conf, part);
258
323
  if (remoteSha !== localSha) {
259
- await sftpUnlink(sftp, part, "不一致的半成品");
324
+ await sftpUnlink(sftp, rPart, "不一致的半成品");
260
325
  throw tErr(`校验失败: 半成品 sha256 与本地源不一致,已删除 ${part}(请用 --overwrite 重传)`);
261
326
  }
262
327
  }
263
- await sftpRename(sftp, part, remote);
328
+ await sftpRename(sftp, rPart, rRemote);
264
329
  return { bytes: total, resumed: false, elapsedMs: Date.now() - startedAt, verify: tc.verify };
265
330
  }
266
331
  if (r.mode === "restart") {
267
- await sftpUnlink(sftp, part, "与源大小不符的半成品");
332
+ await sftpUnlink(sftp, rPart, "与源大小不符的半成品");
268
333
  } else {
269
334
  offset = r.offset;
270
335
  resumed = true;
@@ -281,14 +346,14 @@ async function uploadFile(shell, conf, plan, onProgress) {
281
346
  await (async () => {
282
347
  if (offset === 0 && total > 0) {
283
348
  try {
284
- await sftpFastPut(sftp, local, part, onProgress);
349
+ await sftpFastPut(sftp, local, rPart, onProgress);
285
350
  return;
286
351
  } catch (e) {
287
352
  // fastPut 失败(如非 OpenSSH sftp-server):回退 stream 管道,保可用性
288
353
  }
289
354
  }
290
355
  const rs = fs.createReadStream(local, { start: offset });
291
- const ws = sftp.createWriteStream(part, offset > 0 ? { flags: "r+", start: offset } : { flags: "w" });
356
+ const ws = sftp.createWriteStream(rPart, offset > 0 ? { flags: "r+", start: offset } : { flags: "w" });
292
357
  await pump(rs, ws, {
293
358
  sentBytes: () => offset + rs.bytesRead,
294
359
  onProgress: onProgress ? (sent) => onProgress(sent, total) : null,
@@ -300,13 +365,13 @@ async function uploadFile(shell, conf, plan, onProgress) {
300
365
  if (tc.verify === "sha256") {
301
366
  const remoteSha = await remoteSha256(shell, conf, part);
302
367
  if (remoteSha !== localSha) {
303
- await sftpUnlink(sftp, part, "校验失败的半成品");
368
+ await sftpUnlink(sftp, rPart, "校验失败的半成品");
304
369
  throw tErr(`校验失败: 上传内容 sha256 与本地源不一致(网络或源文件变动),已删除半成品`);
305
370
  }
306
371
  }
307
372
 
308
- await sftpRename(sftp, part, remote);
309
- return { bytes: total, resumed, elapsedMs: Date.now() - startedAt, verify: tc.verify };
373
+ await sftpRename(sftp, rPart, rRemote);
374
+ return { bytes: total, resumed, via: plan.mode, elapsedMs: Date.now() - startedAt, verify: tc.verify };
310
375
  }
311
376
 
312
377
  // ==== 下载 ====
@@ -322,8 +387,11 @@ async function downloadFile(shell, conf, plan, onProgress) {
322
387
  if (!fs.existsSync(dir)) throw tErr(`拒绝: 本地目标目录不存在 [${dir}]`);
323
388
 
324
389
  const sftp = await shell.openSftp();
390
+ const base = plan.mode === "direct" ? "" : await resolveKokoBase(sftp, plan.host, plan.sftpRoot);
391
+ const P = (semanticAbs) => base ? base + semanticAbs : semanticAbs;
392
+ const rRemote = P(remote);
325
393
 
326
- const remoteInfo = await sftpStat(sftp, remote, "远程文件");
394
+ const remoteInfo = await sftpStat(sftp, rRemote, "远程文件");
327
395
  if (!remoteInfo) throw tErr(`拒绝: 远程文件不存在或不可读 [${remote}]`);
328
396
  if (remoteInfo.isDirectory()) throw tErr(`拒绝: 远程目标是目录,传输仅支持单文件 [${remote}]`);
329
397
  const total = Number(remoteInfo.size || 0);
@@ -383,13 +451,13 @@ async function downloadFile(shell, conf, plan, onProgress) {
383
451
  await (async () => {
384
452
  if (offset === 0 && total > 0) {
385
453
  try {
386
- await sftpFastGet(sftp, remote, part, onProgress);
454
+ await sftpFastGet(sftp, rRemote, part, onProgress);
387
455
  return;
388
456
  } catch (e) {
389
457
  // fastGet 失败(如非 OpenSSH sftp-server):回退 stream 管道,保可用性
390
458
  }
391
459
  }
392
- const rs = sftp.createReadStream(remote, offset > 0 ? { start: offset } : { flags: "r" });
460
+ const rs = sftp.createReadStream(rRemote, offset > 0 ? { start: offset } : { flags: "r" });
393
461
  const ws = fs.createWriteStream(part, resumed ? { flags: "a" } : { flags: "w" });
394
462
  await pump(rs, ws, {
395
463
  sentBytes: () => offset + ws.bytesWritten,
@@ -408,7 +476,7 @@ async function downloadFile(shell, conf, plan, onProgress) {
408
476
  }
409
477
 
410
478
  fs.renameSync(part, local);
411
- return { bytes: total, resumed, elapsedMs: Date.now() - startedAt, verify: tc.verify };
479
+ return { bytes: total, resumed, via: plan.mode, elapsedMs: Date.now() - startedAt, verify: tc.verify };
412
480
  }
413
481
 
414
482
  module.exports = {
@@ -419,4 +487,6 @@ module.exports = {
419
487
  pump,
420
488
  remoteSha256,
421
489
  sha256File,
490
+ normalizeSftpRoot,
491
+ resolveKokoBase,
422
492
  };