@zicolasjac-ai/remote-readonly-ssh 1.4.1 → 1.4.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/README.md CHANGED
@@ -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
 
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.2",
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
 
@@ -114,7 +114,7 @@ rrs upload <本地绝对路径> <远程绝对路径> [--overwrite|--resume] [--h
114
114
  5. **容器命令只走受控路径**:若使用 `docker exec`,必须遵守 `container_exec.allowed_path_prefixes`;默认只允许 `/workspace/ds-dev` 这类明确前缀,`grep -r` 只允许递归配置中的日志目录,禁止 `bash/sh`、`tail -f`、`ls -R`、相对路径和越界路径。
115
115
  6. **修改 `~/.rrs/config.json` 前必须先征得用户确认**(`~/.rrs/` 下的所有文件均如此,含白名单、target_hosts、认证组、known_hosts)。被拒绝时不要直接动手改配置:先把需要的路径/主机/变更内容展示给用户,等用户明确同意后再编辑。配置文件属于用户资产,不在工具的只读授权范围内自行变更。
116
116
  7. **传输约束(upload/download)**:
117
- - 仅支持 `direct: true` 直连认证组;菜单模式资产一律拒绝(不要为传输把读文件当替代方案混传)
117
+ - 通道:`direct: true` 直连资产 = 真实文件系统;普通菜单资产 = **koko SFTP 网关**(连接后自动探测 koko 资产树定位该资产目录并映射语义路径;命名特殊时在 `target_hosts` 加 `"sftp_root": "模块/资产组/IP"` 显式声明;koko 模式不支持 `verify:"sha256"`,远端散列不可用)
118
118
  - `upload` 是**写操作**:默认禁用;只有用户明确要求传文件且配置已启用(`file_transfer.upload.enabled: true` + 写路径白名单)时才可用;启用与否、白名单变更属配置修改,须先征得用户同意(同规则 6)
119
119
  - `download` 无读白名单,但敏感路径防呆生效:内置(`/etc/shadow` 族、任意 `/.ssh/` 目录)+ `blocked_paths` 全局黑名单 + `file_transfer.download.blocked_paths` 追加;被拒即是禁止信号,**不要**换路径表达方式重试(可把需求转述给用户人工决策)
120
120
  - `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
  };