根本原因
以前的编辑器会把工作文件上传到目标文件的父目录,而在 /etc 或 /var 下,SSH 账户通常没有该目录的写入权限。SFTP 重命名既不提供经授权的 sudo 路径,也没有基准冲突检查、安全元数据验证或可靠的覆盖语义。其错误处理程序会删除临时副本,即使在无法确定操作是否完成时,也会移除可用于恢复的材料。
会话状态以前只显示连接状态。没有任何 ICMP 或经身份验证的 SSH 响应测量为终端指示器提供数据。
保存协议
- 一个固定的 SSH 辅助程序使用基于描述符的相对操作,并对每个路径组件使用
O_NOFOLLOW,打开一个普通的单链接文件。它会记录内容、SHA-256 哈希、设备/inode、UID、GID、完整的权限位、纳秒级的修改/变更时间以及所有可读的扩展属性。Core 会验证哈希,并以严格的 UTF-8 解码,同时保留 BOM。每个文档都会获得一个不透明的editId,其作用域限定于对应的插件、终端会话和服务器身份。 - 一个非特权的准备操作通过
pwd.getpwuid(os.getuid())解析已认证账户的主目录,以0700模式创建$HOME/ch-j-sm/,并创建一个私有的、独占的恢复日志。不安全的主目录、作为符号链接的工作区或由其他账户拥有的工作区都会被拒绝。 - Core 通过 SFTP 上传一个隐藏的、随机 128 位的
.tmp工作文件,使用独占创建(wx)、0600模式以及六十秒的通道/上传超时。日志包含目标、原始基准和预期的工作文件哈希;不包含任何凭据。 - 一次 SSH exec 完成整个最终处理,可以选择通过现有的 sudo 执行器运行。辅助程序会安全地打开私有工作副本、计算其哈希并将其刷新到磁盘。它会锁定原始 inode,检查其基准,在目标旁创建一个独占的目标文件,写入全部内容,应用并验证元数据,再次检查原始文件,然后刷新替换文件。
- 在同一目录中使用
os.replace以原子方式替换目标。父目录会被刷新,并验证最终路径、哈希、inode 和元数据。在替换完成之前,原始路径始终可用。工作文件永远不会跨文件系统移动,也永远不会在最终处理过程中被删除。 - 在 Core 收到经过验证的成功结果后,会由一个单独的清理步骤再次检查最终内容和安全元数据,然后才删除工作文件和日志。即使清理失败,也会报告已确认的保存,并附带
recoveryAvailable: true。
上传失败、sudo 失败、冲突、元数据错误或替换失败时,会保留原始文件以及所有已暂存的材料。未完成的上传会被如实标识,无法作为完整的已编辑文档恢复。SSH 通信中断、缺少退出状态、超时以及意外的最终处理结果都会被报告为未知,绝不会被报告为已保存。对于结果不确定的文档,Core 会阻止再次保存;不会自动重试替换。
元数据、权限与安全边界
辅助程序会保留并验证 UID、GID 以及所有 POSIX 权限位,包括可执行、setuid、setgid 和 sticky 位。它会逐字节复现 Linux 扩展属性,包括 POSIX ACL、capabilities 和 SELinux 标签。所有权和文件内容会先于安全属性设置,因为写入/chown 可能会清除 capabilities 或特殊权限位。原始文件中不存在、但目标文件继承而来的属性会被移除。修改时间反映新的写入,不会从原始文件复制。
无法读取或无法表示的属性会中止操作;元数据不会被悄悄丢弃。IMA/EVM 签名会被明确拒绝,因为复制与内容/inode 绑定的完整性签名会使其失效。目标路径或主目录路径中的符号链接、硬链接文件以及非普通文件都会被拒绝。不使用任何原地覆盖或较弱的 SFTP 覆盖回退方式。
普通的可写保存以 SSH 账户身份运行。受保护的保存需要显式指定 { sudo: true };对于无需密码的 sudo,sudo 密码是可选的。密码经过验证后只通过通道的 stdin 传递,绝不会出现在命令、日志、工作文件、记录器或远程错误中。root SSH 会话使用现有的直接 root 执行器。授权失败时,原始文件和工作副本保持不变。所有以 root 身份执行的保存(直接 root SSH 或 sudo)还会拒绝由其他账户拥有或可写、或带有访问 ACL 的目标目录/上级目录,而不会信任由其他账户控制的特权路径名。
固定的辅助程序以 python3 -I -B 运行:它会忽略用户的 Python 路径、工作目录和用户 site-packages,并且不写入字节码。路径和 JSON 参数在 Core 中进行 shell 转义。插件永远不会获得任意的 SSH exec 或 SFTP 句柄。现有的沙盒、CSP、清单权限、主机密钥验证和更新验证仍然有效。
Plugin API 与剩余的 Monaco 集成
单独分发的 Monaco/File Manager UI 不在此检出中。其 UI 集成、sudo 提示、未保存缓冲区的处理和翻译无法在这里实现或测试。内置的插件源代码是 Hash & Checksum 插件。没有虚构任何替代的 File Manager 源代码。
现有方法仍然可用。新增的方法/选项由沙盒化的 preload 和经过权限检查的运行时提供:
const opened = await chjPlugin.files.readText(sessionId, path);
// For a protected read, explicitly pass { sudo: true, sudoPassword }.
const result = await chjPlugin.files.saveText(sessionId, path, editor.getValue(), {
editId: opened.editId,
sudo: true, // Include only after the user authorizes elevation.
sudoPassword // Omit for passwordless sudo; never persist this value.
});
if (result.ok && result.value.status === "saved") {
// Mark this exact submitted editor version as saved. Later edits stay dirty.
} else {
// Keep the Monaco model dirty. Translate result.error.code/status.
// result.error.recoveryId/recoveryPath identify retained working material.
}
await chjPlugin.files.closeText(opened.editId);
const recovery = await chjPlugin.files.listRecovery(sessionId);
const copy = await chjPlugin.files.readRecovery(sessionId, recoveryId);
// Review copy.status and copy.text; open a fresh document before another save.
await chjPlugin.files.cleanupRecovery(sessionId, recoveryId);
// Cleanup rejects anything whose target is not currently confirmed.
当检查目标需要提升权限时,恢复方法可接受一个可选的最终参数 { sudo: true, sudoPassword }。即使通过 sudo 执行,它们也会保留已认证用户的工作区。日志在应用重启和 SSH 重新连接后依然存在;恢复永远不会自动重复最终处理。在显式保存恢复出的缓冲区之前,请先将其与重新打开的目标进行比对。
files.read 保护读取、检查和句柄释放。files.write 保护保存和恢复清理。对于现有插件,writeText 在失败时仍会抛出拒绝;saveText 则返回结构化的 { ok, value/error } 封装,因为 Electron 会丢弃被拒绝的 Error 对象上的自定义属性。旧式的三参数 writeText 可以使用单个已打开文档的基准。同一路径上存在多个活动文档时需要 editId;含糊的旧式调用会安全地失败,而不会选用另一个文档的基准。插件应释放不再使用的句柄。
外部 UI 必须立即显示保存状态,在失败时保持缓冲区为未保存状态,仅在出现权限/身份验证失败后才提供 sudo,在不覆盖的情况下显示冲突,并在连接中断或结果未知后提供恢复。它必须以捷克语、德语和英语翻译这些状态。建议的状态文本:
| 状态 | 捷克语 | 德语 | 英语 |
|---|---|---|---|
| 正在保存 | Ukládání… | Wird gespeichert… | Saving… |
| 已确认 | Uloženo | Gespeichert | Saved |
| 失败 | Uložení selhalo | Speichern fehlgeschlagen | Save failed |
| 需要 sudo | Vyžadováno oprávnění sudo | sudo-Autorisierung erforderlich | Sudo authorization required |
| 冲突 | Původní soubor byl změněn | Originaldatei wurde geändert | Original file changed |
| 连接已断开 | Spojení bylo přerušeno | Verbindung unterbrochen | Connection lost |
| 未知 | Výsledek uložení není znám | Speicherergebnis unbekannt | Save result unknown |
| 恢复 | Obnova je dostupná | Wiederherstellung verfügbar | Recovery available |
延迟的定义与生命周期
Ping 是向 SSH 所用的已解析 IP 发送 ICMP echo 请求、并由客户端实际收到回复所报告的延迟。Core 以固定的参数数组异步调用平台的 ping 可执行文件,不使用 shell,也不要求以管理员身份启动。Linux、macOS(包括 ping6)和 Windows 的参数分别选择。ICMP 被阻止、缺少可执行文件、超时、输出无效,或者 Windows 只给出上限值的回复(例如 <1ms)时,都会产生“不可用”的值,而不是编造一个数字。
SSH RTT 是通过现有的已认证 SSH 客户端执行固定的 true exec 请求所耗费的单调 performance.now() 时长。它包括通道建立、服务器处理和响应传递;它既不是纯粹的网络 RTT,也不是 SSH 登录时长。已翻译的指示器提示会解释这一区别。
终端一连接,监测就会开始。各项探测并发且异步地运行,请求/进程超时为五秒。下一次采样安排在上一次采样完成后十二秒进行,以避免重叠。断开连接会中止 ICMP、清除计时器、取消待处理的 SSH 操作并清空数值。记录身份检查可防止旧连接在重新连接后继续发布数据。ICMP 失败永远不会改变 SSH 连接状态。
延迟数据包含在现有的 state 事件 / ssh:state IPC 载荷中:
latency: { pingMs: 24.1, sshRttMs: 31.25, measuredAt: "…" }
不可用的数值为 null。紧凑的终端指示器使用毫秒、本地化的“不可用”文本以及捷克语/德语/英语标签。断开连接时它会隐藏。不需要额外的渲染器执行能力或 IPC 请求。
验证与运行限制
- 在
app/中运行:npm ci,然后运行npm test。 - 最终本地结果:179 个测试;163 个通过,0 个失败,16 个跳过。所有跳过的都是此 macOS 主机上的 Linux 文件系统用例。
npm ci、Python 语法编译和git diff --check均已成功完成。 - 测试覆盖 Core 的保存/恢复、sudo 协议与密码遮盖、对恶意路径的真实 POSIX shell 转义、冲突、上传、清理失败、不确定的结果、并发文档、IPC 权限以及延迟生命周期。
- Linux 文件系统测试会运行随附的 Python 入口点,并使用私有的测试账户主目录查找:所有权/模式/xattr、POSIX ACL、符号链接/硬链接、FIFO、上传和元数据失败、替换失败、目标竞争、替换后结果未知以及恢复。跨文件系统暂存在可用时会使用
/dev/shm和另一个文件系统。这些测试在非 Linux 主机上会被跳过;缺少 ACL/独立文件系统设施时会被明确跳过。 - 新增的 Linux GitHub Actions 作业会运行完整的测试套件,并以 root 身份重复文件系统用例。该工作流尚未从此工作树触发运行。
- 本地验证在 macOS 上进行。Python 源代码语法使用 Command Line Tools 自带的 Python 可执行文件直接检查。一次真实的 macOS 回环 ICMP 冒烟检查返回了
0.071 ms。本地没有测试任何实时远程 SSH 服务器、Windows 运行时、Linux 运行时、SELinux 部署或外部 Monaco UI。 - 远程文本编辑现在需要 Linux 以及支持基于描述符的相对文件系统/xattr 操作的 Python 3。缺少解释器时会明确报错。此 Core 有意拒绝不安全的元数据、链接和目录配置。
- 锁定只是建议性的:在最后一次基准检查与原子重命名之间的狭窄时间窗口内,不配合的进程仍可能产生竞争。Linux 没有提供通用的原子“内容哈希比较并替换”操作。检查会在该时间窗口之前发现更改;保留的工作副本则支持之后的恢复。
- 恢复数据会一直保留在远程账户中,直至完成经确认的清理。未完成的、存在冲突的以及无法检查的日志永远不会被悄悄丢弃。恢复文件不会自动过期,也没有强制覆盖选项。
npm ci报告了 17 项依赖审计发现(9 项中等,8 项高危)。此次更改不会改变依赖项或应用的更新信任模型。
用户要求在现有工作树(当前为 feature/hash-checksum-plugin)中保留更改;没有切换分支,也没有进行无关的清理。
此任务中更改的文件
src/main/files/remoteFileService.jssrc/main/files/remoteEditor.py(新增)src/main/sessions/sessionManager.jssrc/main/sessions/latencyMonitor.js(新增)src/main/plugins/pluginRuntime.jssrc/preload/pluginPreload.jssrc/renderer/app.jssrc/renderer/index.htmlsrc/renderer/styles.csssrc/renderer/i18n.jstest/remoteFileService.test.jstest/sessionManager.test.jstest/pluginRuntime.test.jstest/remoteEditor.test.js(新增)test/remoteEditorProtocol.test.js(新增)test/remoteEditorLinux.test.js(新增)test/fixtures/remoteEditorLinux.py(新增)test/latencyMonitor.test.js(新增)README.mddocs/remote-editor-latency.md(本文件)../.github/workflows/remote-editor-tests.yml(新增)