浏览代码

实现密码库受控表单和 SSH/SFTP 适配器,验证 Bot 授权执行与撤权链路

Samien 1 月之前
父节点
当前提交
cf0c6f0c46

+ 4 - 0
THIRD_PARTY_NOTICES.md

@@ -18,3 +18,7 @@ Avatar silhouette paths and original two-eye expressions from zhulin025/LaoA-Gro
 ## libsodium.js credential vault cryptography
 
 `libsodium-wrappers-sumo` 0.8.4 and its locked `libsodium-sumo` 0.8.4 dependency provide Argon2id and XChaCha20-Poly1305 for the manually unlocked credential vault. Copyright 2015-2026 Ahmad Ben Mrad, Frank Denis and Ryan Lester. ISC license: see `licenses/libsodium.js/LICENSE`. Source: https://github.com/jedisct1/libsodium.js.
+
+## ssh2 credential execution
+
+`ssh2` 1.17.0 provides SSH public-key authentication, host key verification and SFTP for the protected credential executor. Copyright Brian White. MIT license: see `licenses/ssh2/LICENSE`. Source: https://github.com/mscdex/ssh2. Installation lifecycle scripts are not required for the pure JavaScript fallback.

+ 19 - 0
deploy/credential-vault/README.md

@@ -0,0 +1,19 @@
+# 密码库正式适配器:人工部署审阅材料
+
+这些文件仅是可审阅模板;本轮没有安装、执行或启用服务,也没有创建用户、控制 token、真实密码库或修改网络规则。JSON 中 UID/PID/时间均为无效示例,批准默认 false。不要直接复制成已批准配置。
+
+首先选择同一执行节点上的可信控制身份:Web/BFF 与 broker 使用同一非 root 控制 UID,Hermes、其任意 shell 和通用浏览器使用另一个非 root UID。当前 Unix socket 实现没有安全的跨节点控制通道,不能把 socket 改成公开 TCP 或通过 Runner 传输主密码。控制身份下不得提供给模型任意本机执行/调试/资料读取能力;若目前 Web 内运行这样的 host tools,须在实际部署前隔离它们,不能仅另起一个同 UID broker。服务器/Runner 的 `credential-ref-v1` 必须匹配;Runner 不持有秘密。
+
+需管理员另行批准和实施:
+
+1. 用实际 UID/PID 替换配置值,审核服务身份、组/ACL、父目录、备份目录、交换区/core dump/调试策略。Hermes 不能 root、sudo、调试控制进程或读取管理登录会话;必须验证其 shell 无法读取 vault/config/token/browser 资料或连接 socket。控制文件 0600、目录 0700;token 由管理员生成高熵随机值,仅用于 IPC,不是 DEK。不要放入仓库、环境日志或 Hermes 工作区。
+2. 安装 Node >=24、锁定的生产依赖和 Chromium(由管理员批准来源/版本),审核 Linux unit 的真实路径/浏览器 sandbox。不能以 `--no-sandbox` 或宽化私有权限解除启动问题。unit 不是 OS 隔离证明;实际平台需要等价的可信沙箱/防调试控制。模板不启动或修改 Hermes。
+3. 建立并验证网络策略:broker/browser 的出口只允许批准目标地址/端口与受信 DNS,禁止 loopback/内网管理面、云 metadata、通用 CDP/调试端口及非批准 SSH。网站外部资源/重定向须逐项审批,首次实现仅接受同 origin 的指定成功页。Chromium 请求路由不能控制所有后台/WebRTC 网络;网络过滤必须在 OS/容器层生效。DNS/CDN/IPv6 变化需明确审核,不把域名字符串当网络防火墙。
+4. 验证浏览器每次授权独立 pipe/context/profile,Hermes 无法访问其 PID、DOM、Cookie、请求、截图/CDP。Web 专用管理页必须经可信 HTTPS,不能让 Bot 控制的浏览器录入主密码。管理 Web、broker 和其依赖属于可信计算边界;批准文件只能由可信管理员维护。
+5. 完成上述实际攻击检查后,人工填写 `approved:true`、毫秒 `approvedAt/expiresAt`(最长31天)、broker/Hermes UID 和实际 Hermes PID;所有四项 controls 必须已经实测。代码会复查私有文件、时效和 `ps` 返回的实际 Hermes UID,失效会锁库/撤权;它不会证明四项控制本身。Hermes 重启 PID 变化后须重新审核,不自动恢复批准。
+6. BFF 配置 `HERMES_YAOYAO_VAULT_SOCKET`、`HERMES_YAOYAO_VAULT_TOKEN_FILE`、`HERMES_YAOYAO_VAULT_HERMES_UID`,前台启动命令为 `node dist-server/server/credentialVault/index.js --config /私有路径/vault.json`。这是后续部署操作,本轮未执行。只有独立部署批准才能启用 unit 或改线上配置。
+7. 用户亲自在「Bot模式 → 设置 → 密码管理」创建、解锁、录入凭据/使用计划,并逐任务授权。首次真实账号登录还需相应批准。先用专门的 dummy 测试账号验证锁定/登出/撤权/取消/断线/重连、host key 拒绝、Cookie 清理和备份恢复,不把同进程测试算作隔离验收。
+
+网站是标准表单认证验证,不支持任意网站登录后的通用浏览动作。正常完成可访问配置的 GET 退出页;取消或没有退出页时远端会话按网站规则处理,必要时人工撤销。SSH 仅固定无参数绝对程序,返回退出码;非零码表示程序已完成但失败。SFTP read 只返回有界字节数/摘要,write 创建固定正文的新文件,不覆盖。SHA256 摘要也属于目标数据的派生信息,只有授权任务可得到。中断后的远端命令/部分文件可能已生效;回执未知时需人工核对,不重试、删除或覆盖。
+
+支持证据:`tests/server/credentialVaultBotFlow.test.ts` 经 WorkspaceRuntime 工具 HTTP 目录、调用上下文、专用授权 API、私有 Unix IPC、正式 Playwright/ssh2 adapter 访问本地 dummy HTTPS/SSH/SFTP 服务。Hermes/模型是 stub,隔离 gate 是明确的测试替身;没有跨 OS 身份、真实外部账号、真实 Hermes 自主推理或部署验收。

+ 15 - 0
deploy/credential-vault/isolation-approval.json.example

@@ -0,0 +1,15 @@
+{
+  "version": 1,
+  "approved": false,
+  "brokerUid": 12346,
+  "hermesUid": 12345,
+  "hermesPid": 12345,
+  "approvedAt": 0,
+  "expiresAt": 0,
+  "controls": [
+    "separate-identity",
+    "hermes-no-control-access",
+    "browser-private-no-cdp",
+    "restricted-egress"
+  ]
+}

+ 11 - 0
deploy/credential-vault/vault.json.example

@@ -0,0 +1,11 @@
+{
+  "home": "/var/lib/yaoyao-control/vault",
+  "socket": "/run/yaoyao-control/vault.sock",
+  "tokenFile": "/etc/yaoyao-control/control.token",
+  "hermesUid": 12345,
+  "execution": {
+    "approvalFile": "/etc/yaoyao-control/isolation-approval.json",
+    "hermesPid": 12345,
+    "home": "/var/lib/yaoyao-control/executor"
+  }
+}

+ 32 - 0
deploy/credential-vault/yaoyao-vault.service.example

@@ -0,0 +1,32 @@
+# Review-only Linux template. No installer runs this unit. Adjust all paths,
+# identities, browser sandbox and network policy after an independent review.
+[Unit]
+Description=Yaoyao manually unlocked credential broker
+After=network.target
+
+[Service]
+Type=simple
+User=yaoyao-control
+Group=yaoyao-control
+WorkingDirectory=/opt/yaoyao
+ExecStart=/opt/node24/bin/node /opt/yaoyao/dist-server/server/credentialVault/index.js --config /etc/yaoyao-control/vault.json
+UMask=0077
+NoNewPrivileges=yes
+PrivateTmp=yes
+ProtectSystem=strict
+ProtectHome=yes
+CapabilityBoundingSet=
+AmbientCapabilities=
+Environment=PATH=/opt/node24/bin:/usr/bin:/bin
+Environment=PLAYWRIGHT_BROWSERS_PATH=/opt/yaoyao-browsers
+LimitCORE=0
+ReadWritePaths=/var/lib/yaoyao-control /run/yaoyao-control
+# The UID probe needs process-list metadata for Hermes. Standard different-UID
+# memory/ptrace denial must remain enforced; review host Yama/LSM settings.
+# Chromium and ps must work under the reviewed sandbox. Do not disable browser
+# sandbox or expose private IPC/public CDP just to make the unit start.
+# This unit alone does NOT restrict egress or isolate a same-UID Hermes process.
+Restart=no
+
+[Install]
+WantedBy=multi-user.target

+ 12 - 6
docs/credential-vault.md

@@ -14,7 +14,11 @@
 
 同 OS 用户的 Hermes 任意 shell 可以读取同用户文件或访问进程/IPC,因此单纯子进程、0600 权限、secretref 或加密 DB 不构成隔离。真实部署前需人工批准独立 OS 身份/可信沙箱、私有 IPC、控制面访问权限以及浏览器进程/资料目录边界。Web 管理面也不能与拥有任意本机 shell 的 Hermes 共用可信身份。
 
-首期正式浏览器与 SSH executor **未启用**。即使库已解锁也不能退回通用 fill、host_shell、环境变量或临时密钥文件;没有“模拟登录成功”的生产实现。网站登录最终需要独占阶段、精确 HTTPS origin/frame/form 目标校验,冻结模型 DOM/截图/请求/Cookie 访问;MFA/验证码由用户处理。SSH 需固定 host key,密钥认证优先,禁 agent forwarding 与任意 ProxyCommand;文件传输采用 SFTP,明文 FTP 不支持。
+正式适配器现已实现,默认仍关闭;只有经过人工审核的隔离部署、私有批准文件、实际 Hermes 进程身份检查均有效时,CLI 才声明 `protected-adapters`。批准文件是管理员对 OS/IPC/CDP/网络边界的信任声明,不是代码自动证明。未配置、过期、身份变化、撤销时锁库并中止授权,不能退回通用 fill、host_shell、环境变量或临时密钥文件。配置不会创建 OS 身份或安装服务。配置与审阅步骤见 [部署模板](../deploy/credential-vault/README.md)。
+
+网站仅支持用户在专用 UI 配置的主页面标准 HTML POST 表单:固定 HTTPS origin、登录/提交/成功页路径、form id、输入 name 和成功标记,允许成功跳转也必须预先指定。每个授权使用独立无持久资料的 Playwright Chromium/context,经私有 pipe 控制,无公开 CDP、截图、DOM/请求/Cookie 工具;受保护阶段不交给 Bot 通用浏览器。跨 origin、iframe、popup、SSO、MFA、验证码、XHR 登录或不匹配表单转人工接管,不会猜测或通用 fill/JS 降级。销毁本地 Cookie/context;可配置同 origin 固定 GET 退出路径,在正常完成时执行。未配置退出或中途取消时不能保证远端会话撤销,需站点自身过期/人工核对。登录成功回执只是认证证明,本期没有提供登录后任意网站浏览/业务操作;不能将受保护 Cookie 导出给通用工具。浏览器路由检查不能代替 OS 网络策略(例如 WebRTC/后台网络),也不能代替独立身份/禁止调试。
+
+SSH/SFTP 使用固定 `ssh2` 1.17.0,公钥认证及用户确认的 SHA256 host key,未知 host key 拒绝且不会尝试密码认证。SSH 只执行预先配置的绝对可执行文件,无参数/插值/PTY/环境/Agent forwarding/代理/端口转发,返回退出码,不返回 stdout/stderr。服务器协议内部执行方式由远端 SSH 服务决定,这不等于任意远端程序的沙箱。SFTP 只读取指定普通文件并返回有界字节数/校验,或把专用 UI 输入且加密的固定正文以 0600/exclusive-create 写到批准的绝对新路径;不覆盖已存在文件,不允许 Bot 提供正文/路径。限制为读至多 1 MiB、写至多 64 KiB,旧空正文编辑保留,不回显。没有将任意服务器上的实时文件/符号链接竞态转化为强隔离;远端目录仍必须由可信账号控制。执行断开/取消会关闭短连接,远端命令可能已执行、写入可能已部分落盘,不能自动重试或自动删除结果。明文 FTP 不支持。
 
 开发仅使用临时目录中的 dummy 凭据和本机测试夹具。真实秘密录入、真实登录、OS 用户/权限/守护进程安装及生产切换都不属于本轮本地实现操作。
 
@@ -28,23 +32,25 @@ Web 的「Bot 模式 → 设置 → 密码管理」提供创建、手动解锁 5
 
 Runner 只声明 `credential-ref-v1`,**没有** `credential-executor-v1`。授权绑定 owner、本次登录、Bot、活动 work ID、Runner ID/instance/epoch、精确 origin 或 SSH 主机/端口/host key、操作、凭据版本和到期时间。停止/取消/删除/权限变更/重连/锁定/轮换/条目改动使旧授权失效,迟到的解锁或 grant 响应不能恢复权限。旧 Runner 明确拒绝;离线/锁定时 Bot 在任务的 waiting 状态等待,最长 120 秒后返回未执行的等待结果。任务取消会释放等待并撤销授权。
 
-Bot 工具只有 `credential_refs` 与 `credential_request`,输入严格为引用及操作,模型看不到用户名/密码/私钥/主密码/控制 token。refs 返回当前任务的允许目标;收到合法短授权后,生产 CLI 返回 `manual_takeover_required / vault_executor_not_enabled`。CLI 没有启用执行器的配置开关。测试构造器注入 `FixtureWebsiteLoginExecutor` 时声明 `dummy-fixture`,它只接受预先指定的 `https://127.0.0.1:端口`,验证临时测试 CA,向固定 `/login` 提交库中 dummy 账号密码,取得 Cookie 后访问 `/protected` 证明认证。禁止重定向、公共主机、全局代理和任意路径/表单;秘密和 Cookie 都不会返回模型。此适配器从真实 WorkspaceRuntime 工具目录/调用上下文、用户授权 API、私有 IPC 完成认证,重复工具 call ID 只收到原脱敏回执,不再登录。这是工作的本地 dummy 登录适配器,尚非通用网站浏览器适配器;SSH/SFTP 目前只有存储、目标和授权校验,没有执行实现。不能将它作为 OS 隔离证据。
+Bot 工具只有 `credential_refs` 与 `credential_request`,输入严格为引用及操作,模型看不到用户名/密码/私钥/主密码/控制 token,也不能选择新的路径、正文、命令或表单。refs 返回当前任务的目标和公开的固定使用计划,不含 SFTP 正文。专用 UI 授权后,正式适配器在 broker 内实际使用秘密,返回严格白名单回执;空/额外字段/操作不匹配的正式回执均拒绝。重复工具 call ID 只收到原脱敏回执,不重复执行。缺使用计划或不支持的站点会明确返回人工接管,执行后未确认返回 `vault_operation_uncertain`,不能假称成功或转回等待后自动重试。私有 IPC 断连中止当前租约;Runner 在回执抵达前重连也返回未知结果。旧 `FixtureWebsiteLoginExecutor` 仍只是最初的专用测试夹具,生产入口不加载它。
+
+真实 WorkspaceRuntime 工具目录/调用上下文、专用用户授权 API、私有 IPC 和正式 adapter 已通过本地 HTTPS 标准表单、SSH 公钥固定命令、SFTP 读和写协议验收。Hermes 网关和模型是 stub,broker/工具 HTTP/浏览器/SSH 协议均执行实际代码。这不是不同 OS 身份攻击验收。
 
 ## 部署前需人工完成的边界(本轮未执行)
 
 1. 选择 Hermes 所在的同一执行节点,确认 Hermes 非 root 身份,并为 Web 管理面与 vault 分配不同于 Hermes 的非 root 可信服务身份。首期为同主机 Unix IPC;远程 Web → vault 网络通道尚未实现,不能将 Unix socket 暴露为 TCP 或经通用 Runner 转发主密码。
-2. 人工审核 broker、Web 管理面、配置、socket 父目录、控制 token、库目录的 ownership、ACL、进程调试/core dump、备份访问及管理浏览器隔离。代码检查私有权限和配置声明的 Hermes UID,但声明数字本身不是隔离证据;Hermes 的 shell 不得读取控制 token、管理面登录会话或访问私有 IPC。两者为同用户时直接拒绝,不自动改 OS 设置。
+2. 人工审核 broker、Web 管理面、配置、socket 父目录、控制 token、库目录的 ownership、ACL、进程调试/core dump、备份访问及管理浏览器隔离。代码检查私有权限、批准时效及 Hermes PID 的实际 UID,但这些检查不是 OS 边界的完整证据;Hermes 的 shell 不得读取控制 token、管理面登录会话或访问私有 IPC。两者为同用户时直接拒绝,不自动改 OS 设置。
 3. 在该可信控制身份下人工准备 0700 私有目录,以及 0600 配置和高熵控制 token 文件;token 仅用于 IPC 认证,绝不是静态解密密钥。配置结构 `{ "home": "/私有库目录", "socket": "/私有连接目录/vault.sock", "tokenFile": "/私有连接目录/control.token", "hermesUid": 12345 }` 中的 UID 是文档示例,必须填实测 UID,且不能与控制服务身份相同。不要把这些文件放入仓库或 Hermes 工作目录。
 4. 经后续部署批准后,使用 Node >=24、生产依赖和构建输出,人工前台执行 `node dist-server/server/credentialVault/index.js --config /私有路径/vault.json`。单实例控制避免两个 broker 同时写库;启动会检查而不会创建 OS 身份/安装守护进程。Web 配置 `HERMES_YAOYAO_VAULT_SOCKET`、`HERMES_YAOYAO_VAULT_TOKEN_FILE`、`HERMES_YAOYAO_VAULT_HERMES_UID`,它们只有路径/UID。未配置时 UI 明确离线;锁定或离线不自动降级。
 5. 用户亲自在专用 UI 创建并解锁。上线前还需验证真实 OS/IPC 攻击边界、HTTPS 管理通道及备份恢复,不把本地同进程 dummy 测试作为隔离验收。本轮没有实际创建服务身份、控制凭据、生产密码库或录入真实秘密。
 
-未来受保护执行器需要独立安全实施与验收:限定操作及单次提交,执行前/后检查授权,撤销中止;结果只返回脱敏状态,执行断线或取消结果不明确时禁止自动重试。浏览器填充阶段必须独占并隔离 DOM、请求、Cookie、截图、CDP 与同 UID shell;登录后的站点操作也须走受限动作/脱敏返回,不能向模型开放可读 Cookie 的通用浏览器。SSH/SFTP 要独立短连接及已确认的 host key,不能允许 shell 自选命令、任意跳板/代理、端口转发、agent forwarding、密钥导出或明文 FTP。
+受保护执行器的部署仍需要独立安全验收:限定操作及单次提交,执行前/后检查授权,撤销中止;结果只返回脱敏状态,执行断线或取消结果不明确时禁止自动重试。浏览器填充阶段必须独占并隔离 DOM、请求、Cookie、截图、CDP 与同 UID shell;登录后的站点操作也须走受限动作/脱敏返回,不能向模型开放可读 Cookie 的通用浏览器。SSH/SFTP 要独立短连接及已确认的 host key,不能允许 shell 自选命令、任意跳板/代理、端口转发、agent forwarding、密钥导出或明文 FTP。
 
 ## 本地验证
 
 `tests/server/credentialVaultStore.test.ts`:密文与默认锁定、AAD 篡改、fresh nonce/修订、备份和轮换写入中断。`credentialVaultBroker.test.ts`:作用域、默认拒绝、单次消费、锁定/登出/撤权/取消/重连/修改/删除/到期的在途中止、脱敏审计及 symlink 拒绝。`credentialVaultCoordinator.test.ts`:dummy Unix IPC、迟到解锁/grant、会话与任务/Runner 失效、等待取消、HTTPS/旧 Runner拒绝。`credentialVaultRoutes.test.ts` 使用真实 Web/LocalAuth 但上游为 stub,检查认证/Origin/CSRF/登出联动。`tests/client/credentialVaultPanel.test.ts`:专用输入、无 localStorage、无明文回显、编辑空值保留、关闭/隐藏时取消并锁定。Runner 现有本机夹具验证能力声明。
 
-这些测试不连接真实网站或 SSH;没有真实账号、跨 OS 身份穿透测试、移动端专用原生密码库 UI、真实适配器、端到端生产部署或真机验收。现有移动端与独立原生管理器不在首期范围。
+测试连接临时本地 HTTPS/SSH/SFTP 服务,只使用 dummy 账号;没有真实账号、跨 OS 身份穿透测试、移动端专用原生密码库 UI、端到端生产部署或真机验收。现有移动端与独立原生管理器不在首期范围。
 
 ## 首期验收记录(2026-09-30)
 
@@ -54,4 +60,4 @@ Bot 工具只有 `credential_refs` 与 `credential_request`,输入严格为引
 
 日志/JSON 在本机 `/tmp/yaoyao-vault-final-vitest.log`、`/tmp/yaoyao-vault-final-vitest.json`、`/tmp/yaoyao-vault-final-typecheck.log`、`/tmp/yaoyao-vault-final-build.log`、`/tmp/yaoyao-vault-final-runner-build.log`;首次全量证据为 `/tmp/yaoyao-vault-full-vitest.json`,隔离复验为 `/tmp/yaoyao-vault-isolated-failures.log`。构建输出为 `dist/`、`dist-server/server/credentialVault/` 和 `.runner-build/runner.mjs`,只进行了构建检查,没有打新签名安装包、推送、部署、重启生产或安装到日常设备;工作区并行附件修改另行负责,未混入密码库提交。
 
-未验证的真实边界:不同 OS 身份间的隔离与攻击验证、部署后的 HTTPS 用户管理通道、通用网站浏览器登录/登录后权限控制、SSH/SFTP 实际连接、移动原生 UI、真实 Hermes/模型自主调用和生产数据恢复。上游 Hermes 模型/网关为测试夹具;Web/WorkspaceRuntime、工具 HTTP 路由、vault IPC、用户授权与本地 HTTPS 认证是实际代码执行。整个开发只接触 dummy 数据,未扫描 Keychain/.env/既有秘密库或录入真实秘密。必须先取得实际 OS/IPC 部署批准并验收,再另行实现和批准真实受保护执行器;当前不能安全启用真实网站/SSH 登录。
+未验证的真实边界:不同 OS 身份间的隔离与攻击验证、部署后的 HTTPS 用户管理通道、复杂网站/登录后业务操作、真实外部 SSH/SFTP 目标、移动原生 UI、真实 Hermes/模型自主调用和生产数据恢复。上游 Hermes 模型/网关为测试夹具;Web/WorkspaceRuntime、工具 HTTP 路由、vault IPC、用户授权与本地 HTTPS 认证是实际代码执行。整个开发只接触 dummy 数据,未扫描 Keychain/.env/既有秘密库或录入真实秘密。正式 adapter 的本地协议实现已完成;实际 OS/IPC/CDP/网络部署尚未批准/实施/验收,当前环境不启用真实凭据登录。

+ 19 - 0
licenses/ssh2/LICENSE

@@ -0,0 +1,19 @@
+Copyright Brian White. All rights reserved.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to
+deal in the Software without restriction, including without limitation the
+rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+sell copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+IN THE SOFTWARE.

+ 100 - 0
package-lock.json

@@ -44,6 +44,7 @@
         "qrcode": "^1.5.4",
         "read-excel-file": "^7.0.3",
         "sass": "^1.99.0",
+        "ssh2": "1.17.0",
         "typescript": "~6.0.2",
         "vite": "^8.0.4",
         "vue": "^3.5.32",
@@ -58,6 +59,7 @@
       "devDependencies": {
         "@playwright/test": "^1.60.0",
         "@types/jsdom": "^21.1.7",
+        "@types/ssh2": "1.15.5",
         "@types/supertest": "^6.0.3",
         "@vitest/coverage-v8": "^3.2.4",
         "@vue/test-utils": "^2.4.6",
@@ -2979,6 +2981,33 @@
         "@types/node": "*"
       }
     },
+    "node_modules/@types/ssh2": {
+      "version": "1.15.5",
+      "resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-1.15.5.tgz",
+      "integrity": "sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/node": "^18.11.18"
+      }
+    },
+    "node_modules/@types/ssh2/node_modules/@types/node": {
+      "version": "18.19.130",
+      "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz",
+      "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "undici-types": "~5.26.4"
+      }
+    },
+    "node_modules/@types/ssh2/node_modules/undici-types": {
+      "version": "5.26.5",
+      "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
+      "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
+      "dev": true,
+      "license": "MIT"
+    },
     "node_modules/@types/superagent": {
       "version": "8.1.11",
       "resolved": "https://registry.npmmirror.com/@types/superagent/-/superagent-8.1.11.tgz",
@@ -3768,6 +3797,15 @@
       "dev": true,
       "license": "MIT"
     },
+    "node_modules/asn1": {
+      "version": "0.2.6",
+      "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz",
+      "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==",
+      "license": "MIT",
+      "dependencies": {
+        "safer-buffer": "~2.1.0"
+      }
+    },
     "node_modules/asn1js": {
       "version": "3.0.10",
       "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz",
@@ -3890,6 +3928,15 @@
       ],
       "license": "MIT"
     },
+    "node_modules/bcrypt-pbkdf": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz",
+      "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==",
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "tweetnacl": "^0.14.3"
+      }
+    },
     "node_modules/bidi-js": {
       "version": "1.0.3",
       "resolved": "https://registry.npmmirror.com/bidi-js/-/bidi-js-1.0.3.tgz",
@@ -3941,6 +3988,15 @@
       "dev": true,
       "license": "MIT"
     },
+    "node_modules/buildcheck": {
+      "version": "0.0.7",
+      "resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.7.tgz",
+      "integrity": "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==",
+      "optional": true,
+      "engines": {
+        "node": ">=10.0.0"
+      }
+    },
     "node_modules/builder-util": {
       "version": "26.15.3",
       "resolved": "https://registry.npmjs.org/builder-util/-/builder-util-26.15.3.tgz",
@@ -4529,6 +4585,20 @@
       "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
       "license": "MIT"
     },
+    "node_modules/cpu-features": {
+      "version": "0.0.10",
+      "resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz",
+      "integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==",
+      "hasInstallScript": true,
+      "optional": true,
+      "dependencies": {
+        "buildcheck": "~0.0.6",
+        "nan": "^2.19.0"
+      },
+      "engines": {
+        "node": ">=10.0.0"
+      }
+    },
     "node_modules/cross-dirname": {
       "version": "0.1.0",
       "resolved": "https://registry.npmjs.org/cross-dirname/-/cross-dirname-0.1.0.tgz",
@@ -7574,6 +7644,13 @@
         "vue": "^3.0.0"
       }
     },
+    "node_modules/nan": {
+      "version": "2.29.0",
+      "resolved": "https://registry.npmjs.org/nan/-/nan-2.29.0.tgz",
+      "integrity": "sha512-GlGk3HIvitbvs+LT3g6XUP1kpirKNvmDFwF/bmo6XNWSb/eYEs/O4bfgIEIXCZ+lIOTS5xNwDvSGMw6FJdAhtA==",
+      "license": "MIT",
+      "optional": true
+    },
     "node_modules/nanoid": {
       "version": "3.3.18",
       "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.18.tgz",
@@ -9076,6 +9153,23 @@
       "license": "BSD-3-Clause",
       "optional": true
     },
+    "node_modules/ssh2": {
+      "version": "1.17.0",
+      "resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.17.0.tgz",
+      "integrity": "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==",
+      "hasInstallScript": true,
+      "dependencies": {
+        "asn1": "^0.2.6",
+        "bcrypt-pbkdf": "^1.0.2"
+      },
+      "engines": {
+        "node": ">=10.16.0"
+      },
+      "optionalDependencies": {
+        "cpu-features": "~0.0.10",
+        "nan": "^2.23.0"
+      }
+    },
     "node_modules/stackback": {
       "version": "0.0.2",
       "resolved": "https://registry.npmmirror.com/stackback/-/stackback-0.0.2.tgz",
@@ -9676,6 +9770,12 @@
         "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
       }
     },
+    "node_modules/tweetnacl": {
+      "version": "0.14.5",
+      "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz",
+      "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==",
+      "license": "Unlicense"
+    },
     "node_modules/type-fest": {
       "version": "0.13.1",
       "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz",

+ 2 - 0
package.json

@@ -79,6 +79,7 @@
     "qrcode": "^1.5.4",
     "read-excel-file": "^7.0.3",
     "sass": "^1.99.0",
+    "ssh2": "1.17.0",
     "typescript": "~6.0.2",
     "vite": "^8.0.4",
     "vue": "^3.5.32",
@@ -90,6 +91,7 @@
   "devDependencies": {
     "@playwright/test": "^1.60.0",
     "@types/jsdom": "^21.1.7",
+    "@types/ssh2": "1.15.5",
     "@types/supertest": "^6.0.3",
     "@vitest/coverage-v8": "^3.2.4",
     "@vue/test-utils": "^2.4.6",

+ 30 - 4
src/client/components/app/CredentialVaultPanel.vue

@@ -12,9 +12,11 @@ const root='/api/app/vault', state=ref<View>(), busy=ref(false), error=ref(''),
 const master=ref(''), confirmation=ref(''), replacement=ref(''), secret=ref(''), passphrase=ref('')
 const name=ref(''), username=ref(''), kind=ref<'website'|'ssh'>('website'), origin=ref(''), host=ref(''), port=ref(22), hostKey=ref('')
 const editing=ref<CredentialSummary>(), showEditor=ref(false), taskId=ref(''), credentialId=ref(''), operation=ref<CredentialOperation>('website.login')
+const configureUse=ref(false),useKind=ref<'ssh.exec'|'sftp.read'|'sftp.write'>('ssh.exec'),command=ref('/usr/bin/uptime'),remotePath=ref(''),contents=ref(''),maxBytes=ref(65536),clearPassphrase=ref(false)
+const loginPath=ref('/login'),submitPath=ref('/login'),successPath=ref('/account'),logoutPath=ref(''),formId=ref('login'),usernameName=ref('username'),passwordName=ref('password'),successSelector=ref('#login-success')
 let timer:ReturnType<typeof setInterval>|undefined,alive=true
 const inflight=new Set<AbortController>()
-function clearSecrets(){master.value='';confirmation.value='';replacement.value='';secret.value='';passphrase.value=''}
+function clearSecrets(){master.value='';confirmation.value='';replacement.value='';secret.value='';passphrase.value='';contents.value='';clearPassphrase.value=false}
 async function request<T>(path:string,method:'GET'|'POST'|'PUT'|'DELETE'='GET',body?:Record<string,JsonValue>){
   const c=new AbortController();inflight.add(c)
   try{return await apiRequest<T>(root+path,{method,...(body?{body}:{}),signal:c.signal,timeoutMs:20000})}
@@ -34,19 +36,26 @@ function edit(entry?:CredentialSummary){
   clearSecrets();editing.value=entry;name.value=entry?.name??'';username.value=entry?.username??'';kind.value=entry?.target.kind??'website'
   origin.value=entry?.target.kind==='website'?entry.target.origin:'';host.value=entry?.target.kind==='ssh'?entry.target.host:''
   port.value=entry?.target.kind==='ssh'?entry.target.port:22;hostKey.value=entry?.target.kind==='ssh'?entry.target.hostKey:''
+  configureUse.value=!!entry?.usage;useKind.value=entry?.usage&&entry.usage.kind!=='website.form'?entry.usage.kind:'ssh.exec'
+  command.value=entry?.usage?.kind==='ssh.exec'?entry.usage.command:'/usr/bin/uptime';remotePath.value=entry?.usage&&(entry.usage.kind==='sftp.read'||entry.usage.kind==='sftp.write')?entry.usage.remotePath:''
+  maxBytes.value=entry?.usage?.kind==='sftp.read'?entry.usage.maxBytes:65536
+  const p=entry?.usage?.kind==='website.form'?entry.usage:undefined
+  loginPath.value=p?.loginPath??'/login';submitPath.value=p?.submitPath??'/login';successPath.value=p?.successPath??'/account';logoutPath.value=p?.logoutPath??'';formId.value=p?.formId??'login';usernameName.value=p?.usernameName??'username';passwordName.value=p?.passwordName??'password';successSelector.value=p?.successSelector??'#login-success'
   showEditor.value=true
 }
 async function save(){
   const target:Record<string,JsonValue>=kind.value==='website'?{kind:'website',origin:origin.value}:{kind:'ssh',host:host.value,port:port.value,hostKey:hostKey.value}
-  const body={name:name.value,username:username.value,target,...(secret.value?{secret:secret.value}:{}),...(passphrase.value?{passphrase:passphrase.value}:{}),...(editing.value?{revision:editing.value.revision}:{})}
+  const usage:Record<string,JsonValue>|undefined=!configureUse.value?undefined:kind.value==='website'?{kind:'website.form',loginPath:loginPath.value,submitPath:submitPath.value,successPath:successPath.value,...(logoutPath.value?{logoutPath:logoutPath.value}:{}),formId:formId.value,usernameName:usernameName.value,passwordName:passwordName.value,successSelector:successSelector.value}:useKind.value==='ssh.exec'?{kind:'ssh.exec',command:command.value}:useKind.value==='sftp.read'?{kind:'sftp.read',remotePath:remotePath.value,maxBytes:maxBytes.value}:{kind:'sftp.write',remotePath:remotePath.value,...(contents.value||!editing.value||editing.value.usage?.kind!=='sftp.write'?{contents:contents.value}:{})}
+  const body={name:name.value,username:username.value,target,...(usage?{usage}:{}),...(secret.value?{secret:secret.value}:{}),...(clearPassphrase.value?{passphrase:''}:passphrase.value?{passphrase:passphrase.value}:{}),...(editing.value?{revision:editing.value.revision}:{})}
   await action(async()=>{await request(editing.value?'/entries/'+editing.value.id:'/entries',editing.value?'PUT':'POST',body);showEditor.value=false},'凭据已加密保存,密码与私钥不回显')
 }
 async function remove(entry:CredentialSummary){await action(async()=>{await request('/entries/'+entry.id,'DELETE')},'凭据已删除,相关任务授权已撤销')}
 async function grant(){
   const task=state.value?.tasks.find(t=>t.workId===taskId.value);if(!task)return
-  await action(async()=>{await request('/leases','POST',{credentialRef:credentialId.value,agentId:task.agentId,workId:task.workId,operation:operation.value,seconds:60})},'已授予此任务 60 秒引用权限;正式登录执行器尚未启用')
+  await action(async()=>{await request('/leases','POST',{credentialRef:credentialId.value,agentId:task.agentId,workId:task.workId,operation:operation.value,seconds:60})},'已授予此任务 60 秒;只允许已配置的操作,隔离未批准时不会执行')
 }
 function selectRequest(value:View['requests'][number]){taskId.value=value.workId;credentialId.value=value.credentialRef;operation.value=value.operation}
+function describeUse(entry?:CredentialSummary){const p=entry?.usage;if(!p)return '尚未配置,将转人工接管';if(p.kind==='website.form')return '网站表单 '+p.loginPath+' → '+p.submitPath+' → '+p.successPath+(p.logoutPath?' → 退出 '+p.logoutPath:'(远端会话按站点规则到期)');if(p.kind==='ssh.exec')return 'SSH '+p.command+',固定无参数';return p.kind==='sftp.read'?'SFTP 读取校验 '+p.remotePath+',最多 '+p.maxBytes+' 字节':'SFTP 创建新文件 '+p.remotePath+','+p.bytes+' 字节'}
 async function backup(){
   await action(async()=>{
     const data=await request<{archive:string}>('/backup','POST',{});if(!alive)return
@@ -81,7 +90,7 @@ onBeforeUnmount(()=>{
 <template>
   <section class="credential-vault" :aria-busy="busy">
     <p>秘密只在专用界面录入,加密保存在 Hermes 执行节点;不发送给 Bot。密码库默认锁定,重启或到期需重新手动解锁。</p>
-    <p role="status">正式网站与 SSH 登录尚未启用。隔离执行器验收前,Bot 只能请求授权并由你人工接管。</p>
+    <p role="status">{{state?.execution==='protected-adapters'?'受控执行器已取得人工部署批准:仅支持配置的标准表单、固定 SSH 命令和指定 SFTP 文件;仍须逐任务授权。':'受控网站与 SSH/SFTP 执行尚未启用。隔离部署批准前,由你人工接管。'}}</p>
     <p v-if="error" role="alert">{{error}}</p><p v-if="notice" role="status">{{notice}}</p>
     <button type="button" :disabled="busy" @click="action(load,'状态已刷新')">刷新状态</button>
     <p v-if="!state?.online">执行节点密码库离线或尚未配置。需要管理员先批准独立服务身份和私有连接;此处不会自动安装或启动服务。</p>
@@ -109,6 +118,22 @@ onBeforeUnmount(()=>{
           <template v-else><label>SSH 主机<input v-model="host" required :disabled="busy" /></label><label>端口<input v-model.number="port" type="number" min="1" max="65535" required :disabled="busy" /></label><label>已人工确认的 host key 指纹<input v-model="hostKey" placeholder="SHA256:…" required :disabled="busy" /></label></template>
           <label>{{kind==='website'?'密码':'私钥'}}<input v-if="kind==='website'" v-model="secret" type="password" autocomplete="new-password" :required="!editing" maxlength="32768" :disabled="busy" /><textarea v-else v-model="secret" autocomplete="off" :required="!editing" maxlength="32768" :disabled="busy" /><small v-if="editing">留空保留原值;没有秘密读取或回显接口。</small></label>
           <label v-if="kind==='ssh'">私钥口令(可选)<input v-model="passphrase" type="password" autocomplete="new-password" :disabled="busy" /></label>
+          <label v-if="kind==='ssh'&&editing"><input v-model="clearPassphrase" type="checkbox" :disabled="busy" />清除原私钥口令(留空输入默认保留)</label>
+          <label><input v-model="configureUse" type="checkbox" :disabled="busy||!!editing?.usage" />配置允许 Bot 执行的固定操作</label>
+          <template v-if="configureUse&&kind==='website'">
+            <p>仅支持主页面的标准 POST 表单。允许跳转仅限此 origin 的成功页;iframe、跨站 SSO、MFA 或验证码转人工。</p>
+            <label>登录页路径<input v-model="loginPath" required :disabled="busy" /></label><label>表单提交路径<input v-model="submitPath" required :disabled="busy" /></label><label>登录成功页路径<input v-model="successPath" required :disabled="busy" /></label>
+            <label>退出页路径(可选、仅批准的 GET)<input v-model="logoutPath" :disabled="busy" /></label><p>操作后销毁本地 Cookie;未配置退出页时,远端会话仍按站点规则到期。取消时不追加退出请求。</p>
+            <label>表单 HTML id<input v-model="formId" required :disabled="busy" /></label><label>用户名 input name<input v-model="usernameName" required :disabled="busy" /></label><label>密码 input name<input v-model="passwordName" required :disabled="busy" /></label><label>成功标记(#id 或 [data-…])<input v-model="successSelector" required :disabled="busy" /></label>
+          </template>
+          <template v-if="configureUse&&kind==='ssh'">
+            <label>允许操作<select v-model="useKind" :disabled="busy"><option value="ssh.exec">固定 SSH 可执行文件</option><option value="sftp.read">SFTP 指定文件读取校验</option><option value="sftp.write">SFTP 创建指定新文件</option></select></label>
+            <label v-if="useKind==='ssh.exec'">批准的绝对可执行路径(无参数、无 shell 拼接)<input v-model="command" required :disabled="busy" /></label>
+            <label v-else>批准的远端绝对路径<input v-model="remotePath" required :disabled="busy" /></label>
+            <label v-if="useKind==='sftp.read'">最大读取字节数<input v-model.number="maxBytes" type="number" min="1" max="1048576" required :disabled="busy" /></label>
+            <label v-if="useKind==='sftp.write'">固定写入正文(加密保存;最多 64 KiB)<textarea v-model="contents" maxlength="65536" autocomplete="off" :disabled="busy" /><small v-if="editing?.usage?.kind==='sftp.write'">留空保留原正文,不回显。写入仅创建新文件,不覆盖;中断需人工核对。</small></label>
+            <p>仅密钥认证,固定 host key;不转发 Agent/端口。Bot 只收到退出码或字节数和校验,收不到输出正文、密钥或文件正文。</p>
+          </template>
           <div class="actions"><button :disabled="busy">加密保存</button><button type="button" :disabled="busy" @click="showEditor=false;clearSecrets()">取消</button></div>
         </form>
         <form @submit.prevent="grant">
@@ -120,6 +145,7 @@ onBeforeUnmount(()=>{
           <label>凭据<select v-model="credentialId" required :disabled="busy"><option disabled value="">请选择</option><option v-for="e in state.entries" :key="e.id" :value="e.id">{{e.name}}</option></select></label>
           <label>活动任务<select v-model="taskId" required :disabled="busy"><option disabled value="">请选择</option><option v-for="t in state.tasks" :key="t.workId" :value="t.workId">{{t.name}} · {{t.workId.slice(0,8)}}</option></select></label>
           <label>操作<select v-model="operation" :disabled="busy"><option value="website.login">网站登录</option><option value="ssh.exec">SSH 执行</option><option value="sftp.read">SFTP 读取</option><option value="sftp.write">SFTP 写入</option></select></label>
+          <p>批准的使用计划:{{describeUse(state.entries.find(e=>e.id===credentialId))}}</p>
           <button :disabled="busy||!state.tasks.length||!state.entries.length">授予本任务 60 秒引用权限</button>
         </form>
         <ul><li v-for="lease in state.leases" :key="lease.id">{{lease.workId.slice(0,8)}} · {{lease.operation}} · {{new Date(lease.expiresAt).toLocaleTimeString()}}<button type="button" :disabled="busy" @click="action(async()=>{await request('/leases/'+lease.id,'DELETE')},'任务授权已撤销')">撤销</button></li></ul>

+ 29 - 15
src/server/credentialVault/broker.ts

@@ -4,26 +4,24 @@ import { join } from 'node:path'
 import { z } from 'zod'
 import { HttpError } from '../errors.js'
 import { CredentialVaultStore } from './store.js'
-import { parse, leaseInput, type LeaseInput, type VaultEntry } from './schema.js'
+import { parse, leaseInput, type LeaseInput } from './schema.js'
+import type {CredentialExecutor,CredentialExecutionReceipt} from './executor.js'
 import type { CredentialLeaseSummary, CredentialVaultStatus } from '../../shared/credentialVault.js'
 interface Lease extends CredentialLeaseSummary {
   owner: string; session: string; version: number; epoch: number; runnerInstance: string; runnerEpoch: string
   target: LeaseInput['target']; consumed: boolean; controller: AbortController
 }
-/** Intentionally no production adapters. Only tests inject this interface, with
- * dummy values. CLI configuration cannot enable an executor or claim isolation. */
-export interface CredentialExecutorFixture {
-  execute(entry: Readonly<VaultEntry>, input: LeaseInput, signal: AbortSignal): Promise<void>
-}
+export type CredentialExecutorFixture = CredentialExecutor
 export class CredentialVaultBroker {
   private sessions = new Map<string, string>()
   private epochs = new Map<string, number>()
   private leases = new Map<string, Lease>()
   private controlEpoch = ''
   private timer: ReturnType<typeof setInterval>
-  constructor(readonly store: CredentialVaultStore, private now = Date.now, private fixture?: CredentialExecutorFixture) {
+  constructor(readonly store: CredentialVaultStore, private now = Date.now, private executor?: CredentialExecutor) {
     store.onLock = owner => this.revokeOwner(owner)
     this.timer = setInterval(() => {
+      try { this.executor?.assertAvailable?.() } catch { this.disconnect() }
       for (const owner of this.sessions.keys()) {
         try { store.state(owner) }
         catch { store.lock(owner); this.sessions.delete(owner); this.revokeOwner(owner) }
@@ -49,16 +47,21 @@ export class CredentialVaultBroker {
   hello(epoch: string) {
     parse(z.string().uuid(), epoch)
     if (epoch !== this.controlEpoch) { this.store.lockAll(); this.revokeAll(); this.sessions.clear(); this.controlEpoch = epoch }
-    return { protocol: 1, execution: this.fixture ? 'dummy-fixture' : 'disabled' }
+    return { protocol: 1, execution: this.execution().execution }
+  }
+  private execution():Pick<CredentialVaultStatus,'execution'|'reason'>{
+    if(!this.executor)return {execution:'disabled',reason:'executor_not_enabled'}
+    try{this.executor.assertAvailable?.();return {execution:this.executor.mode??'dummy-fixture',reason:this.executor.mode==='protected-adapters'?'operator_approved':'dummy_fixture_only'}}
+    catch{this.disconnect();return {execution:'disabled',reason:'isolation_required'}}
   }
   private authorize(owner: string, session: string) {
     if (this.sessions.get(owner) !== session || !this.store.state(owner).unlocked)
       throw new HttpError(423, '请在本次登录的密码管理界面手动解锁', 'vault_locked')
   }
   status(owner: string, session: string): CredentialVaultStatus {
-    const state = this.store.state(owner), own = this.sessions.get(owner) === session && state.unlocked
+    const execution=this.execution(),state = this.store.state(owner), own = this.sessions.get(owner) === session && state.unlocked
     return { protocol: 1, online: true, initialized: state.initialized, unlocked: own,
-      ...(own ? { unlockExpiresAt: state.unlockExpiresAt } : {}), execution: this.fixture ? 'dummy-fixture' : 'disabled', reason: this.fixture ? 'dummy_fixture_only' : 'executor_not_enabled',
+      ...(own ? { unlockExpiresAt: state.unlockExpiresAt } : {}), ...execution,
       entries: own ? this.store.list(owner) : [], leases: own ? [...this.leases.values()].filter(l => l.owner === owner).map(({ id, credentialRef, agentId, workId, runnerId, operation, expiresAt }) =>
         ({ id, credentialRef, agentId, workId, runnerId, operation, expiresAt })) : [] }
   }
@@ -103,6 +106,7 @@ export class CredentialVaultBroker {
       const entry = this.store.list(owner).find(e => e.id === b.credentialRef)
       if (!entry || JSON.stringify(entry.target) !== JSON.stringify(b.target) || (entry.target.kind === 'website') !== (b.operation === 'website.login'))
         throw new HttpError(403, '凭据目标或操作不匹配', 'vault_target_forbidden')
+      if(entry.usage&&(entry.usage.kind==='website.form'?'website.login':entry.usage.kind)!==b.operation)throw new HttpError(403,'操作不属于用户批准的凭据使用计划','vault_usage_forbidden')
       if ([...this.leases.values()].filter(l => l.owner === owner).length >= 64) throw new HttpError(429, '任务授权数量已达到上限', 'vault_lease_limit')
       const { seconds: _seconds, target: _target, runnerInstance: _instance, runnerEpoch: _epoch, ...publicInput } = b
       const result: CredentialLeaseSummary = { ...publicInput, id: randomUUID(), expiresAt: Math.min(this.now() + b.seconds * 1000, this.store.state(owner).unlockExpiresAt!) }
@@ -130,17 +134,27 @@ export class CredentialVaultBroker {
       if (entry?.revision !== l.version) throw new HttpError(410, '凭据版本已变化', 'vault_lease_revoked')
     }
     check()
-    if (!this.fixture) throw new HttpError(409, '受保护的浏览器与 SSH 执行器尚未启用,请人工接管', 'vault_executor_not_enabled')
+    if (!this.executor) throw new HttpError(409, '受保护的浏览器与 SSH 执行器尚未启用,请人工接管', 'vault_executor_not_enabled')
+    this.executor.assertAvailable?.()
     if (l!.consumed) throw new HttpError(409, '此授权已提交,不能重复执行', 'vault_operation_uncertain')
     l!.consumed = true
     this.audit(owner, 'execute-started', id)
     try {
-      await this.store.withSecret(owner, l!.credentialRef, entry => this.fixture!.execute(entry, b, l!.controller.signal))
-      check(); this.audit(owner, 'execute-complete', id)
-      return { status: 'complete', operation: b.operation } // No executor output, URL, exception or secret.
-    } catch {
+      const result=await this.store.withSecret(owner, l!.credentialRef, entry => this.executor!.execute(entry, b, l!.controller.signal))
+      check()
+      if(result===undefined&&this.executor.mode==='protected-adapters')throw new Error('protected executor receipt missing')
+      const receipt:CredentialExecutionReceipt=result===undefined?{status:'complete',operation:b.operation}:parse(z.union([
+        z.object({status:z.literal('complete'),operation:leaseInput.shape.operation,exitCode:z.number().int().optional(),bytes:z.number().int().nonnegative().optional(),sha256:z.string().regex(/^[a-f0-9]{64}$/).optional()}).strict(),
+        z.object({status:z.literal('manual_takeover_required'),reason:z.enum(['unsupported_form_flow','usage_policy_required']),submitted:z.boolean()}).strict(),
+      ]),result)
+      if(receipt.status==='complete'&&receipt.operation!==b.operation)throw new Error('receipt scope changed')
+      this.audit(owner, receipt.status==='complete'?'execute-complete':'execute-manual', id)
+      return receipt // No raw executor output, URL, exception, Cookie or secret.
+    } catch(error) {
       this.audit(owner, 'execute-ended', id, 'not-confirmed')
+      if(error instanceof HttpError&&['vault_host_key_rejected','vault_usage_forbidden','vault_isolation_required'].includes(error.code))throw error
       throw new HttpError(409, '操作结果未确认,授权不再允许重试,请人工核对', 'vault_operation_uncertain')
     } finally { this.revoke(id) }
   }
+  connectionClosed(owner:string,session:string,id:string){const l=this.leases.get(id);if(l?.owner===owner&&l.session===session)this.revoke(id)}
 }

+ 9 - 7
src/server/credentialVault/coordinator.ts

@@ -9,16 +9,16 @@ import type { LocalAuthStore } from '../localAuth.js'
 import type { RunnerHub } from '../runnerHub.js'
 import type { WorkspaceAgent, WorkspaceRun } from '../../shared/workspace.js'
 import type { Work } from '../workspaceScheduler.js'
-import type { CredentialLeaseSummary, CredentialVaultStatus, CredentialTarget } from '../../shared/credentialVault.js'
+import type { CredentialLeaseSummary, CredentialVaultStatus, CredentialTarget,CredentialUseSummary } from '../../shared/credentialVault.js'
 import { CredentialVaultClient } from './transport.js'
 import { parse, leaseInput } from './schema.js'
 
 interface Session { owner: string; key: string; cookie: string; version: number | undefined }
-interface Grant { summary: CredentialLeaseSummary; target: CredentialTarget; session: Session; binding: ReturnType<RunnerHub['credentialBinding']> }
+interface Grant { summary: CredentialLeaseSummary; target: CredentialTarget; usage?:CredentialUseSummary; session: Session; binding: ReturnType<RunnerHub['credentialBinding']> }
 const toolSchema = { type: 'object', properties: { credentialRef: { type: 'string' }, operation: { enum: ['website.login', 'ssh.exec', 'sftp.read', 'sftp.write'] } }, required: ['credentialRef', 'operation'], additionalProperties: false }
 export const CREDENTIAL_TOOLS = [
   { id: 'credential_refs', name: 'credential_refs', description: '列出用户授予当前 Bot/任务的凭据引用与允许目标,不包含账号密码或私钥。', inputSchema: { type: 'object', properties: {}, additionalProperties: false } },
-  { id: 'credential_request', name: 'credential_request', description: '请求当前任务的凭据使用授权。锁定/离线时等待用户在密码管理界面操作;当前正式网站/SSH executor 未启用,只能人工接管。不得向用户索取或通过其他工具读取密码,不得降级 fill/shell。', inputSchema: toolSchema },
+  { id: 'credential_request', name: 'credential_request', description: '请求并使用当前任务的短凭据授权,只执行用户配置的标准网站表单、固定 SSH 命令或指定 SFTP 操作。锁定/离线时等待用户解锁与授权,隔离未批准或复杂网站转人工接管。不得索取或读取密码,不得降级 fill/shell;未知结果不能自动重试。', inputSchema: toolSchema },
 ]
 /** The Web control plane never receives a read-secret response. Deployment must
  * keep this process/IPC identity separate from Hermes' arbitrary shell identity. */
@@ -127,7 +127,7 @@ export class CredentialVaultCoordinator {
       try {
         this.task(s.owner, body.agentId, body.workId)
         if (JSON.stringify(this.binding(s.owner, body.agentId)) !== JSON.stringify(binding)) throw new Error()
-        this.grants.set(summary.id, { summary, target:entry.target, session: s, binding }); ctx.body = summary
+        this.grants.set(summary.id, { summary, target:entry.target,usage:entry.usage, session: s, binding }); ctx.body = summary
       } catch {
         await this.client.call(s.owner, s.key, 'revoke', { id: summary.id }).catch(() => {})
         throw new HttpError(410, '任务或执行节点已变化,授权未保留', 'vault_task_revoked')
@@ -156,7 +156,7 @@ export class CredentialVaultCoordinator {
         if (g.session.owner !== owner || g.summary.agentId !== agentId || g.summary.workId !== workId || g.summary.expiresAt <= Date.now()
           || !this.valid(g.session) || JSON.stringify(g.binding) !== JSON.stringify(binding)) continue
         const state: CredentialVaultStatus = await this.call(g.session, 'status'); check()
-        if (state.unlocked && state.leases.some(l => l.id === g.summary.id)) entries.push({ credentialRef:g.summary.credentialRef, operation:g.summary.operation, allowedTarget:g.target })
+        if (state.unlocked && state.leases.some(l => l.id === g.summary.id)) entries.push({ credentialRef:g.summary.credentialRef, operation:g.summary.operation, allowedTarget:g.target,...(g.usage?{allowedUse:g.usage}:{}) })
       }
       return entries
     }
@@ -184,10 +184,12 @@ export class CredentialVaultCoordinator {
                 const state: CredentialVaultStatus = await this.call(g.session, 'status'); check()
                 if (state.unlocked && state.leases.some(l => l.id === g.summary.id)) {
                   if(JSON.stringify(this.binding(owner,agentId))!==JSON.stringify(binding))throw new HttpError(410,'节点重连后需要新授权','vault_connection_revoked')
-                  if (state.execution === 'dummy-fixture') {
+                  if (state.execution === 'dummy-fixture'||state.execution==='protected-adapters') {
                     try {
                       const result = await this.call(g.session,'execute',{id:g.summary.id,input:{...b,agentId,workId,...binding,target:g.target,seconds:60}})
-                      check();return result
+                      check()
+                      if(JSON.stringify(this.binding(owner,agentId))!==JSON.stringify(binding))throw new HttpError(409,'执行端在回执确认前变化,结果未知;不能自动重试','vault_operation_uncertain')
+                      return result
                     } finally { this.grants.delete(g.summary.id) }
                   }
                   return { status: 'manual_takeover_required', reason: 'vault_executor_not_enabled', message: '正式网站/SSH 受保护执行器尚未启用;请人工接管,不能使用通用 fill 或 shell 替代。' }

+ 11 - 0
src/server/credentialVault/executor.ts

@@ -0,0 +1,11 @@
+import type { VaultEntry, LeaseInput } from './schema.js'
+export type CredentialExecutionReceipt =
+  | {status:'complete';operation:LeaseInput['operation'];exitCode?:number;bytes?:number;sha256?:string}
+  | {status:'manual_takeover_required';reason:'unsupported_form_flow'|'usage_policy_required';submitted:boolean}
+export interface CredentialExecutor {
+  readonly mode?:'dummy-fixture'|'protected-adapters'
+  assertAvailable?():void
+  execute(entry:Readonly<VaultEntry>,input:LeaseInput,signal:AbortSignal):Promise<CredentialExecutionReceipt|void>
+}
+/** Supplied by the trusted boot path, never by the model or an RPC body. */
+export interface IsolationGate { assert():void }

+ 6 - 3
src/server/credentialVault/index.ts

@@ -9,21 +9,24 @@ import { CredentialVaultBroker } from './broker.js'
 import { serveCredentialVault } from './transport.js'
 import { acquireServiceInstance } from '../serviceInstance.js'
 import { readPrivateUtf8 } from './privateFiles.js'
+import {OperatorIsolationGate} from './isolation.js'
+import {ProtectedCredentialExecutor} from './protectedExecutor.js'
 try {
   const index = process.argv.indexOf('--config'), path = index < 0 ? undefined : process.argv[index + 1]
   if (!path) throw new Error('用法:node credentialVault/index.js --config /私有路径/vault.json')
   const config = parse(z.object({ home: z.string().refine(isAbsolute), socket: z.string().refine(isAbsolute), tokenFile: z.string().refine(isAbsolute),
-    hermesUid: z.number().int().positive() }).strict(), JSON.parse(readPrivateUtf8(path,8192)))
+    hermesUid: z.number().int().positive(),execution:z.object({approvalFile:z.string().refine(isAbsolute),hermesPid:z.number().int().positive(),home:z.string().refine(isAbsolute)}).strict().optional() }).strict(), JSON.parse(readPrivateUtf8(path,8192)))
   if (!process.getuid || process.getuid() === 0 || process.getuid() === config.hermesUid) throw new Error('密码库需要独立非 root OS 身份,不能与 Hermes 同用户运行')
   const controlToken = readPrivateUtf8(config.tokenFile,4096).trim()
   const store = await CredentialVaultStore.create(config.home)
   process.umask(0o077)
   const instance = acquireServiceInstance(store.root, 'credential-vault-v1')
-  const broker = new CredentialVaultBroker(store) // No production executor, no enable-isolation flag.
+  const executor=config.execution?new ProtectedCredentialExecutor(config.execution.home,new OperatorIsolationGate(config.execution.approvalFile,config.hermesUid,config.execution.hermesPid)):undefined
+  const broker = new CredentialVaultBroker(store,Date.now,executor)
   const server = serveCredentialVault(broker, controlToken)
   process.once('exit', () => { broker.close(); instance.release() })
   server.listen(config.socket, () => {
-    try { chmodSync(config.socket, 0o600); process.stdout.write('密码库私有服务已启动,默认锁定,真实登录执行未启用\n') }
+    try { chmodSync(config.socket, 0o600); process.stdout.write('密码库私有服务已启动,默认锁定;执行能力以专用管理界面状态为准\n') }
     catch { broker.close(); server.close(); process.exitCode = 1 }
   })
   const stop = () => { broker.close(); server.close(() => { instance.release() }); server.closeAllConnections() }

+ 23 - 0
src/server/credentialVault/isolation.ts

@@ -0,0 +1,23 @@
+import {execFileSync} from 'node:child_process'
+import {z} from 'zod'
+import {readPrivateUtf8} from './privateFiles.js'
+import {parse} from './schema.js'
+import {HttpError} from '../errors.js'
+import type {IsolationGate} from './executor.js'
+const controls=['separate-identity','hermes-no-control-access','browser-private-no-cdp','restricted-egress'] as const
+const approval=z.object({version:z.literal(1),approved:z.literal(true),brokerUid:z.number().int().positive(),hermesUid:z.number().int().positive(),hermesPid:z.number().int().positive(),
+  approvedAt:z.number().int().positive(),expiresAt:z.number().int().positive(),controls:z.array(z.enum(controls)).length(4)}).strict()
+const processUid=(pid:number)=>Number(execFileSync('/bin/ps',['-o','uid=','-p',String(pid)],{encoding:'utf8',timeout:2000,env:{PATH:'/usr/bin:/bin'}}).trim())
+/** Manual operator approval is a deployment trust contract, NOT proof of OS,
+ * IPC/CDP/firewall isolation. Also checks the observable process identity.
+ * CLI uses the real probe; tests inject a dummy probe without inspecting users. */
+export class OperatorIsolationGate implements IsolationGate {
+  constructor(private path:string,private hermesUid:number,private hermesPid:number,private probe=processUid,private now=Date.now){}
+  assert(){
+    try{
+      const a=parse(approval,JSON.parse(readPrivateUtf8(this.path,8192))),uid=process.getuid?.()
+      if(!uid||uid!==a.brokerUid||uid===a.hermesUid||a.hermesUid!==this.hermesUid||a.hermesPid!==this.hermesPid||this.probe(a.hermesPid)!==a.hermesUid
+        ||a.approvedAt>this.now()||a.expiresAt<=this.now()||a.expiresAt-a.approvedAt>31*86400000||new Set(a.controls).size!==controls.length)throw new Error()
+    }catch{throw new HttpError(503,'执行隔离尚未获得有效人工批准或身份已变化','vault_isolation_required')}
+  }
+}

+ 17 - 0
src/server/credentialVault/protectedExecutor.ts

@@ -0,0 +1,17 @@
+import {mkdirSync,lstatSync} from 'node:fs'
+import type {CredentialExecutor,IsolationGate} from './executor.js'
+import type {VaultEntry,LeaseInput} from './schema.js'
+import {WebsiteFormExecutor} from './websiteExecutor.js'
+import {SshSftpExecutor} from './sshExecutor.js'
+export class ProtectedCredentialExecutor implements CredentialExecutor {
+  readonly mode='protected-adapters' as const
+  private website:WebsiteFormExecutor
+  private ssh:SshSftpExecutor
+  constructor(home:string,private gate:IsolationGate,testCA?:Buffer){
+    gate.assert();mkdirSync(home,{recursive:true,mode:0o700});const stat=lstatSync(home)
+    if(!stat.isDirectory()||stat.isSymbolicLink()||(stat.mode&0o077)||stat.uid!==process.getuid?.())throw new Error('private executor home required')
+    this.website=new WebsiteFormExecutor(home,gate,testCA);this.ssh=new SshSftpExecutor(gate)
+  }
+  assertAvailable(){this.gate.assert()}
+  execute(entry:Readonly<VaultEntry>,input:LeaseInput,signal:AbortSignal){this.gate.assert();return entry.target.kind==='website'?this.website.execute(entry,input,signal):this.ssh.execute(entry,input,signal)}
+}

+ 11 - 2
src/server/credentialVault/schema.ts

@@ -10,6 +10,15 @@ export const website = z.object({ kind: z.literal('website'), origin: z.string()
 export const ssh = z.object({ kind: z.literal('ssh'), host: z.string().min(1).max(253).regex(/^[A-Za-z0-9.:[\]_-]+$/).transform(v => v.toLowerCase()),
   port: z.number().int().min(1).max(65535), hostKey: z.string().regex(/^SHA256:[A-Za-z0-9+/]{43}=?$/) }).strict()
 export const target = z.discriminatedUnion('kind', [website, ssh])
+const path = z.string().min(2).max(1024).refine(v=>v.startsWith('/')&&!v.includes('\\')&&!/[\u0000-\u001f\u007f?#]/.test(v)&&v.split('/').every((s,i)=>i===0||!!s&&s!=='.'&&s!=='..'))
+const name = z.string().min(1).max(100).regex(/^[A-Za-z][A-Za-z0-9_.:-]*$/)
+const form = z.object({kind:z.literal('website.form'),loginPath:path,submitPath:path,successPath:path,logoutPath:path.optional(),formId:name,usernameName:name,passwordName:name,
+  successSelector:z.string().min(1).max(256).regex(/^(#[A-Za-z][A-Za-z0-9_-]*|\[data-[a-z0-9-]+(?:="[A-Za-z0-9_-]+")?\])$/)}).strict()
+const command = z.object({kind:z.literal('ssh.exec'),command:z.string().max(512).regex(/^\/[A-Za-z0-9_/-]+$/).refine(v=>!v.includes('//')&&!v.endsWith('/'))}).strict()
+const read = z.object({kind:z.literal('sftp.read'),remotePath:path,maxBytes:z.number().int().min(1).max(1048576).default(65536)}).strict()
+const write = z.object({kind:z.literal('sftp.write'),remotePath:path,contents:z.string().max(65536)}).strict()
+export const usage = z.discriminatedUnion('kind',[form,command,read,write])
+const updatedUsage = z.discriminatedUnion('kind',[form,command,read,write.extend({contents:z.string().max(65536).optional()})])
 export const leaseInput = z.object({
   credentialRef: z.string().uuid(), agentId: z.string().uuid(), workId: z.string().uuid(),
   runnerId: z.string().uuid(), runnerInstance: z.string().uuid(), runnerEpoch: z.string().min(1).max(128),
@@ -18,8 +27,8 @@ export const leaseInput = z.object({
 }).strict()
 export type LeaseInput = z.infer<typeof leaseInput>
 export const entryInput = z.object({ name: z.string().trim().min(1).max(120), username: z.string().min(1).max(256),
-  target, secret: z.string().min(1).max(32768), passphrase: z.string().max(4096).optional() }).strict()
-export const updateInput = entryInput.extend({ secret: entryInput.shape.secret.optional(), revision: z.number().int().positive() })
+  target, usage:usage.optional(), secret: z.string().min(1).max(32768), passphrase: z.string().max(4096).optional() }).strict()
+export const updateInput = entryInput.extend({ secret: entryInput.shape.secret.optional(), usage:updatedUsage.optional(), revision: z.number().int().positive() })
 export const storedEntry = entryInput.extend({ id: z.string().uuid(), revision: z.number().int().positive(), updatedAt: z.number().int().nonnegative() })
 export type VaultEntry = z.infer<typeof storedEntry>
 export function parse<T>(schema: z.ZodType<T>, value: unknown): T {

+ 83 - 0
src/server/credentialVault/sshExecutor.ts

@@ -0,0 +1,83 @@
+import {Client,type SFTPWrapper} from 'ssh2'
+import {createHash,timingSafeEqual} from 'node:crypto'
+import type {IsolationGate,CredentialExecutionReceipt} from './executor.js'
+import type {VaultEntry,LeaseInput} from './schema.js'
+import {HttpError} from '../errors.js'
+const call=<T>(start:(done:(error:Error|undefined|null,value:T)=>void)=>void)=>new Promise<T>((resolve,reject)=>start((error,value)=>error?reject(error):resolve(value)))
+/** Key-only, pinned-host SSH. No shell session, command interpolation, PTY,
+ * environment, agent forwarding, ProxyCommand, jump host or TCP forwarding. */
+export class SshSftpExecutor {
+  constructor(private gate:IsolationGate){}
+  async execute(entry:Readonly<VaultEntry>,input:LeaseInput,externalSignal:AbortSignal):Promise<CredentialExecutionReceipt>{
+    this.gate.assert();externalSignal.throwIfAborted()
+    const plan=entry.usage
+    if(entry.target.kind!=='ssh'||input.target.kind!=='ssh'||JSON.stringify(entry.target)!==JSON.stringify(input.target)||!plan||plan.kind==='website.form'||plan.kind!==input.operation)
+      return {status:'manual_takeover_required',reason:'usage_policy_required',submitted:false}
+    const host=entry.target,expected=Buffer.from(host.hostKey.slice(7),'base64')
+    if(expected.length!==32)throw new HttpError(403,'SSH host key 指纹无效','vault_host_key_rejected')
+    const signal=AbortSignal.any([externalSignal,AbortSignal.timeout(15000)]),conn=new Client()
+    let hostRejected=false,finished=false,fail!:(error:unknown)=>void,ready!:()=>void
+    const failure=new Promise<never>((_r,reject)=>{fail=reject}),connected=new Promise<void>(resolve=>{ready=resolve})
+    // connect() can synchronously reject a malformed key before work is created.
+    // Keep close/error rejection observed on that path as well.
+    void failure.catch(()=>{})
+    const invoke=<T>(start:(done:(error:Error|undefined|null,value:T)=>void)=>void)=>Promise.race([call(start),failure])
+    const abort=()=>{fail(new Error('operation cancelled'));conn.destroy()}
+    signal.addEventListener('abort',abort,{once:true})
+    conn.on('error',()=>fail(hostRejected?new HttpError(403,'SSH host key 与人工批准指纹不符','vault_host_key_rejected'):new Error('SSH connection unavailable')))
+    conn.on('close',()=>{if(!finished)fail(new Error('SSH receipt unavailable'))})
+    conn.once('ready',ready)
+    const check=()=>{signal.throwIfAborted();this.gate.assert()}
+    try{
+      conn.connect({host:host.host.replace(/^\[|\]$/g,''),port:host.port,username:entry.username,privateKey:entry.secret,...(entry.passphrase?{passphrase:entry.passphrase}:{}),
+        authHandler:['publickey'],tryKeyboard:false,agentForward:false,readyTimeout:5000,keepaliveInterval:0,
+        algorithms:{serverHostKey:['ssh-ed25519','rsa-sha2-512','rsa-sha2-256','ecdsa-sha2-nistp256']},
+        hostVerifier:(key:Buffer)=>{const digest=createHash('sha256').update(key).digest();const match=timingSafeEqual(digest,expected);hostRejected=!match;return match}})
+      const work=(async()=>{
+        await Promise.race([connected,failure]);check()
+        if(plan.kind==='ssh.exec'){
+          // Exactly the user-approved absolute executable; no arguments or
+          // strings assembled from model input are ever sent to the server.
+          const stream=await invoke<import('ssh2').ClientChannel>(done=>conn.exec(plan.command,{pty:false},done))
+          check();let output=0
+          const discard=(data:Buffer)=>{output+=data.length;if(output>65536)abort()}
+          stream.on('data',discard);stream.stderr.on('data',discard);stream.on('error',()=>fail(new Error('SSH command interrupted')))
+          const code=await new Promise<number>((resolve,reject)=>{stream.once('close',(value:number|undefined)=>Number.isInteger(value)?resolve(value!):reject(new Error('SSH exit receipt unavailable')));stream.end()})
+          check();return {status:'complete',operation:'ssh.exec',exitCode:code} as CredentialExecutionReceipt
+        }
+        const sftp=await invoke<SFTPWrapper>(done=>conn.sftp(done));sftp.on('error',()=>fail(new Error('SFTP interrupted')))
+        check()
+        if(plan.kind==='sftp.read'){
+          const canonical=await invoke<string>(done=>sftp.realpath(plan.remotePath,done));check()
+          if(canonical!==plan.remotePath)throw new HttpError(403,'SFTP 读取目标不是批准的准确路径','vault_usage_forbidden')
+          const handle=await invoke<Buffer>(done=>sftp.open(plan.remotePath,'r',done));check()
+          try{
+            const stat=await invoke<import('ssh2').Stats>(done=>sftp.fstat(handle,done));check()
+            if(!stat.isFile()||stat.size>plan.maxBytes)throw new HttpError(403,'SFTP 读取文件超出授权类型或大小','vault_usage_forbidden')
+            const digest=createHash('sha256'),buffer=Buffer.alloc(16384);let bytes=0
+            try{
+              while(true){check();const count=await invoke<number>(done=>sftp.read(handle,buffer,0,Math.min(buffer.length,plan.maxBytes+1-bytes),bytes,(error,count)=>done(error,count)));check();if(!count)break;bytes+=count;if(bytes>plan.maxBytes)throw new Error('SFTP size limit');digest.update(buffer.subarray(0,count))}
+              return {status:'complete',operation:'sftp.read',bytes,sha256:digest.digest('hex')} as CredentialExecutionReceipt
+            }finally{buffer.fill(0)}
+          }finally{await invoke<void>(done=>sftp.close(handle,error=>done(error,undefined))).catch(()=>{})}
+        }
+        const slash=plan.remotePath.lastIndexOf('/'),parent=plan.remotePath.slice(0,slash)||'/'
+        const canonical=await invoke<string>(done=>sftp.realpath(parent,done));check()
+        if(canonical!==parent)throw new HttpError(403,'SFTP 写入目录不是批准的准确路径','vault_usage_forbidden')
+        if(Buffer.byteLength(plan.contents,'utf8')>65536)throw new HttpError(403,'SFTP 写入超过授权大小','vault_usage_forbidden')
+        const data=Buffer.from(plan.contents,'utf8');let handle:Buffer|undefined
+        try{
+          // Exclusive creation: never overwrite or follow an existing final link.
+          handle=await invoke<Buffer>(done=>sftp.open(plan.remotePath,'wx',{mode:0o600},done));check()
+          for(let offset=0;offset<data.length;offset+=16384){check();await invoke<void>(done=>sftp.write(handle!,data,offset,Math.min(16384,data.length-offset),offset,error=>done(error,undefined)));check()}
+          await invoke<void>(done=>sftp.close(handle!,error=>done(error,undefined)));check()
+          handle=undefined
+          return {status:'complete',operation:'sftp.write',bytes:data.length,sha256:createHash('sha256').update(data).digest('hex')} as CredentialExecutionReceipt
+        }finally{data.fill(0);if(handle)await invoke<void>(done=>sftp.close(handle!,error=>done(error,undefined))).catch(()=>{})} // Interrupted writes may leave a partial file: never retry or overwrite automatically.
+      })()
+      const result=await Promise.race([work,failure]);finished=true;return result
+    }finally{
+      signal.removeEventListener('abort',abort);conn.destroy()
+    }
+  }
+}

+ 3 - 2
src/server/credentialVault/store.ts

@@ -42,7 +42,7 @@ function derive(password: string, e: Envelope) {
   try { return sodium.crypto_pwhash(32, data, decode(e.kdf.salt, 16), e.kdf.ops, e.kdf.memory, sodium.crypto_pwhash_ALG_ARGON2ID13) }
   finally { wipe(data) }
 }
-const summary = ({ secret: _secret, passphrase: _passphrase, ...entry }: VaultEntry): CredentialSummary => entry
+const summary = ({ secret: _secret, passphrase: _passphrase, usage, ...entry }: VaultEntry): CredentialSummary => ({...entry,...(usage?{usage:usage.kind==='sftp.write'?{kind:usage.kind,remotePath:usage.remotePath,bytes:Buffer.byteLength(usage.contents)}:usage}:{})})
 
 /** This belongs in an independently isolated broker. No file/OS-account key is created.
  * JS strings/GC and WASM are not a secure enclave; same-UID process access must be
@@ -169,7 +169,8 @@ export class CredentialVaultStore {
     const s = this.active(owner), input = parse(updateInput, value), old = s.entries.find(v => v.id === id)
     if (!old) throw new HttpError(404, '凭据不存在', 'vault_entry_missing')
     if (old.revision !== input.revision) throw new HttpError(409, '凭据已变化,请刷新', 'vault_revision_conflict')
-    const next = { ...old, ...input, secret: input.secret ?? old.secret, revision: old.revision + 1, updatedAt: this.now() }
+    const changedUsage=input.usage?.kind==='sftp.write'?{...input.usage,contents:input.usage.contents??(old.usage?.kind==='sftp.write'?old.usage.contents:undefined)}:input.usage??old.usage
+    const next = parse(storedEntry,{ ...old, ...input,usage:changedUsage, secret: input.secret ?? old.secret, revision: old.revision + 1, updatedAt: this.now() })
     this.commit(owner, s.entries.map(v => v.id === id ? next : v)); this.onLock(owner)
     return summary(next)
   }

+ 3 - 2
src/server/credentialVault/transport.ts

@@ -22,7 +22,7 @@ export function serveCredentialVault(broker: CredentialVaultBroker, token: strin
     const body = parse(z.object({ epoch: z.string().uuid(), owner: z.string().min(1).max(256), session: z.string().regex(/^[a-f0-9]{64}$/),
       command: z.string().max(64), value: z.unknown().optional() }).strict(), JSON.parse(Buffer.concat(chunks).toString('utf8')))
     const result = body.command === 'hello' ? broker.hello(body.epoch) : body.command === 'execute'
-      ? await (() => { const v=parse(z.object({id:z.string().uuid(),input:z.unknown()}).strict(),body.value); return broker.execute(body.epoch,body.owner,body.session,v.id,v.input as any) })()
+      ? await (() => { const v=parse(z.object({id:z.string().uuid(),input:z.unknown()}).strict(),body.value); res.once('close',()=>{if(!res.writableEnded)broker.connectionClosed(body.owner,body.session,v.id)}); return broker.execute(body.epoch,body.owner,body.session,v.id,v.input as any) })()
       : broker.dispatch(body.epoch, body.owner, body.session, body.command, body.value)
     res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify({ result }))
   })().catch(error => {
@@ -67,7 +67,7 @@ export class CredentialVaultClient {
           catch { reject(new HttpError(503, '密码库响应无效', 'vault_offline')) }
         })
       })
-      req.setTimeout(15000, () => req.destroy())
+      req.setTimeout(command==='execute'?40000:15000, () => req.destroy())
       req.on('error', () => reject(new HttpError(503, '密码库连接中断', 'vault_offline')))
       req.end(JSON.stringify({ epoch: this.epoch, owner, session, command, value }))
     })
@@ -80,6 +80,7 @@ export class CredentialVaultClient {
     } catch (e) {
       if (e instanceof HttpError && (e.code === 'vault_offline' || e.code === 'vault_control_changed')) {
         this.hello = undefined; this.epoch = randomUUID(); this.onDisconnect()
+        if(command==='execute')throw new HttpError(409,'执行连接中断,结果不确定,禁止自动重试','vault_operation_uncertain')
       }
       throw e
     }

+ 71 - 0
src/server/credentialVault/websiteExecutor.ts

@@ -0,0 +1,71 @@
+import {chromium,type Browser,type BrowserContext,type Page} from 'playwright'
+import {resolve} from 'node:path'
+import type {IsolationGate,CredentialExecutionReceipt} from './executor.js'
+import type {VaultEntry,LeaseInput} from './schema.js'
+/** Standard HTML POST form only. One private browser/context per lease; no
+ * persistent profile, public CDP port, fill/JS API, screenshots or page output. */
+export class WebsiteFormExecutor {
+  constructor(private home:string,private gate:IsolationGate,private testCA?:Buffer){}
+  async execute(entry:Readonly<VaultEntry>,input:LeaseInput,signal:AbortSignal):Promise<CredentialExecutionReceipt>{
+    this.gate.assert();signal.throwIfAborted()
+    const plan=entry.usage
+    if(entry.target.kind!=='website'||input.target.kind!=='website'||input.operation!=='website.login'||entry.target.origin!==input.target.origin||plan?.kind!=='website.form')
+      return {status:'manual_takeover_required',reason:'usage_policy_required',submitted:false}
+    // Test-only custom CA is not a TLS bypass: Chromium explicitly trusts this
+    // certificate's SPKI. It is restricted to loopback and never set by CLI.
+    const base=new URL(entry.target.origin)
+    if(this.testCA&&base.hostname!=='127.0.0.1')throw new Error('fixture CA is loopback only')
+    let browser:Browser|undefined,context:BrowserContext|undefined,page:Page|undefined,unsafe=false,submitted=false
+    const abort=()=>{void context?.close().catch(()=>{});void browser?.close().catch(()=>{})}
+    signal.addEventListener('abort',abort,{once:true})
+    try{
+      const args=['--force-webrtc-ip-handling-policy=disable_non_proxied_udp','--disable-background-networking']
+      if(this.testCA){const {X509Certificate,createHash}=await import('node:crypto');args.push('--ignore-certificate-errors-spki-list='+createHash('sha256').update(new X509Certificate(this.testCA).publicKey.export({type:'spki',format:'der'})).digest('base64'))}
+      browser=await chromium.launch({headless:true,chromiumSandbox:true,timeout:10000,args,env:{PATH:process.env.PATH??'/usr/bin:/bin',HOME:resolve(this.home),TMPDIR:resolve(this.home),LANG:'en_US.UTF-8'}})
+      signal.throwIfAborted();this.gate.assert()
+      context=await browser.newContext({acceptDownloads:false,serviceWorkers:'block',permissions:[]})
+      context.setDefaultTimeout(5000);context.setDefaultNavigationTimeout(5000)
+      await context.routeWebSocket('**/*',route=>route.close())
+      page=await context.newPage()
+      context.on('page',other=>{if(other!==page){unsafe=true;void other.close().catch(()=>{})}})
+      page.on('dialog',dialog=>{unsafe=true;void dialog.dismiss().catch(()=>{})})
+      page.on('frameattached',()=>{unsafe=true})
+      await context.route('**/*',async route=>{
+        const request=route.request(),url=new URL(request.url())
+        const main=request.isNavigationRequest()&&request.frame()===page!.mainFrame()
+        const approvedNavigation=main&&[plan.loginPath,plan.submitPath,plan.successPath,...(plan.logoutPath?[plan.logoutPath]:[])].includes(url.pathname)&&!url.search&&!url.hash
+        const resource=!request.isNavigationRequest()&&request.method()==='GET'&&['script','stylesheet','image','font'].includes(request.resourceType())
+        const allowed=url.origin===base.origin&&(approvedNavigation||resource)&&(!request.redirectedFrom()||request.method()==='GET'&&url.pathname===plan.successPath)
+          &&(request.method()==='GET'||main&&request.method()==='POST'&&url.pathname===plan.submitPath)&&!signal.aborted
+        if(!allowed){unsafe=true;await route.abort();return}
+        await route.continue()
+      })
+      await page.goto(new URL(plan.loginPath,base).href,{waitUntil:'domcontentloaded'})
+      const form=page.locator(`form[id="${plan.formId}"]`),user=form.locator(`input[name="${plan.usernameName}"]`),password=form.locator(`input[name="${plan.passwordName}"]`),submit=form.locator('button[type="submit"],input[type="submit"]')
+      if(unsafe||page.frames().length!==1||await form.count()!==1||await user.count()!==1||await password.count()!==1||await submit.count()!==1||plan.usernameName===plan.passwordName)
+        return {status:'manual_takeover_required',reason:'unsupported_form_flow',submitted:false}
+      const metadata=await form.evaluate(el=>{const f=el as HTMLFormElement;return {method:f.method,action:f.action,enctype:f.enctype}})
+      const action=new URL(metadata.action)
+      if(metadata.method.toLowerCase()!=='post'||metadata.enctype!=='application/x-www-form-urlencoded'||action.origin!==base.origin||action.pathname!==plan.submitPath||action.search||action.hash||await password.getAttribute('type')!=='password')
+        return {status:'manual_takeover_required',reason:'unsupported_form_flow',submitted:false}
+      this.gate.assert();signal.throwIfAborted()
+      await user.fill(entry.username);await password.fill(entry.secret)
+      if(unsafe) return {status:'manual_takeover_required',reason:'unsupported_form_flow',submitted:false}
+      submitted=true
+      await Promise.all([page.waitForURL(url=>url.origin===base.origin&&url.pathname===plan.successPath&&!url.search&&!url.hash,{waitUntil:'domcontentloaded'}),submit.click()])
+      signal.throwIfAborted();this.gate.assert()
+      if(unsafe||page.frames().length!==1||await page.locator(plan.successSelector).count()!==1||!await page.locator(plan.successSelector).isVisible())
+        return {status:'manual_takeover_required',reason:'unsupported_form_flow',submitted:true}
+      if(plan.logoutPath){const response=await page.goto(new URL(plan.logoutPath,base).href,{waitUntil:'domcontentloaded'});if(!response||response.status()>=300||unsafe)return {status:'manual_takeover_required',reason:'unsupported_form_flow',submitted:true}}
+      // The authenticated context is destroyed below. Cookies/DOM/network output
+      // are never handed to generic tools or included in the model receipt.
+      return {status:'complete',operation:'website.login'}
+    }catch{
+      signal.throwIfAborted()
+      return {status:'manual_takeover_required',reason:'unsupported_form_flow',submitted}
+    }finally{
+      signal.removeEventListener('abort',abort)
+      await context?.clearCookies().catch(()=>{});await context?.close().catch(()=>{});await browser?.close().catch(()=>{})
+    }
+  }
+}

+ 7 - 1
src/shared/credentialVault.ts

@@ -4,9 +4,15 @@ export type CredentialTarget =
   | { kind: 'website'; origin: string }
   | { kind: 'ssh'; host: string; port: number; hostKey: string }
 export type CredentialOperation = 'website.login' | 'ssh.exec' | 'sftp.read' | 'sftp.write'
+export type CredentialUseSummary =
+  | { kind:'website.form';loginPath:string;submitPath:string;successPath:string;logoutPath?:string;formId:string;usernameName:string;passwordName:string;successSelector:string }
+  | { kind:'ssh.exec';command:string }
+  | { kind:'sftp.read';remotePath:string;maxBytes:number }
+  | { kind:'sftp.write';remotePath:string;bytes:number }
 export interface CredentialSummary {
   id: string; name: string; username: string; target: CredentialTarget
   revision: number; updatedAt: number
+  usage?: CredentialUseSummary
 }
 export interface CredentialLeaseSummary {
   id: string; credentialRef: string; agentId: string; workId: string
@@ -15,6 +21,6 @@ export interface CredentialLeaseSummary {
 export interface CredentialVaultStatus {
   protocol: 1; online: boolean; initialized: boolean; unlocked: boolean
   unlockExpiresAt?: number
-  execution: 'disabled' | 'dummy-fixture'; reason: 'executor_not_enabled' | 'vault_offline' | 'dummy_fixture_only'
+  execution: 'disabled' | 'dummy-fixture' | 'protected-adapters'; reason: 'executor_not_enabled' | 'vault_offline' | 'dummy_fixture_only' | 'operator_approved' | 'isolation_required'
   entries: CredentialSummary[]; leases: CredentialLeaseSummary[]
 }

+ 15 - 0
tests/client/credentialVaultPanel.test.ts

@@ -52,3 +52,18 @@ it('hiding the page locks even while unlock is still pending',async()=>{
   expect(call[1].signal.aborted).toBe(true);expect(api).toHaveBeenCalledWith('/api/app/vault/lock',expect.objectContaining({body:{}}))
   expect(w.get<HTMLInputElement>('input[type=password]').element.value).toBe('');pending.resolve({});await flushPromises()
 })
+it('configures an encrypted fixed SFTP body in the dedicated UI, clears it after save and never reads it back',async()=>{
+  state.unlocked=true;state.execution='protected-adapters';const w=await setup()
+  await w.findAll('button').find(b=>b.text()==='添加凭据')!.trigger('click')
+  const editor=w.findAll('form')[0]!
+  await editor.get('select').setValue('ssh');await editor.get('input[type=checkbox]').setValue(true)
+  await editor.findAll('select')[1]!.setValue('sftp.write')
+  const label=(text:string)=>editor.findAll('label').find(l=>l.text().startsWith(text))!
+  await label('名称').get('input').setValue('dummy transfer');await label('用户名').get('input').setValue('dummy')
+  await label('SSH 主机').get('input').setValue('127.0.0.1');await label('已人工确认').get('input').setValue('SHA256:'+'A'.repeat(43))
+  await label('私钥').get('textarea').setValue('DUMMY-private-key');await label('批准的远端').get('input').setValue('/approved/new.txt')
+  await label('固定写入正文').get('textarea').setValue('DUMMY-private-write-body')
+  await editor.trigger('submit');await flushPromises()
+  expect(api).toHaveBeenCalledWith('/api/app/vault/entries',expect.objectContaining({method:'POST',body:expect.objectContaining({usage:{kind:'sftp.write',remotePath:'/approved/new.txt',contents:'DUMMY-private-write-body'}})}))
+  expect(w.find('textarea').exists()).toBe(false);expect(w.text()).not.toContain('DUMMY-private-write-body')
+})

+ 75 - 0
tests/fixtures/credentialProtocols.ts

@@ -0,0 +1,75 @@
+// Local, ephemeral dummy protocols only; never contact an external account.
+import {createServer} from 'node:https'
+import {once} from 'node:events'
+import {execFileSync} from 'node:child_process'
+import {readFileSync,writeFileSync} from 'node:fs'
+import {join} from 'node:path'
+import {createHash,randomBytes} from 'node:crypto'
+import {Server,utils,type Connection} from 'ssh2'
+export async function websiteFixture(home:string){
+  const key=join(home,'dummy-key.pem'),cert=join(home,'dummy-cert.pem'),cnf=join(home,'dummy-openssl.cnf')
+  writeFileSync(cnf,'[req]\ndistinguished_name=dn\nx509_extensions=ext\nprompt=no\n[dn]\nCN=127.0.0.1\n[ext]\nsubjectAltName=IP:127.0.0.1\nbasicConstraints=critical,CA:TRUE\n')
+  execFileSync('openssl',['req','-x509','-newkey','rsa:2048','-nodes','-days','1','-keyout',key,'-out',cert,'-config',cnf],{stdio:'ignore'})
+  const username='dummy-site-user',secret='DUMMY-local-only-password',cookie=randomBytes(32).toString('hex')
+  const submitted=Promise.withResolvers<void>()
+  const state={logins:0,proofs:0,logouts:0,loginViews:0,formAction:'/login',iframe:false,redirect:'/account',delay:false,requests:[] as string[]}
+  const server=createServer({key:readFileSync(key),cert:readFileSync(cert)},async(req,res)=>{
+    state.requests.push(req.method+' '+req.url)
+    if(req.url==='/login'&&req.method==='GET'){state.loginViews++;res.setHeader('Content-Type','text/html');res.end(`<form id="login" method="post" action="${state.formAction}"><input name="username"><input type="password" name="password"><button type="submit">Login</button></form>${state.iframe?'<iframe src="/frame"></iframe>':''}`);return}
+    if(req.url==='/login'&&req.method==='POST'){
+      let body='';for await(const c of req)body+=c
+      const form=new URLSearchParams(body)
+      if(form.get('username')!==username||form.get('password')!==secret){res.writeHead(401);res.end();return}
+      state.logins++;submitted.resolve();if(state.delay)return
+      res.setHeader('Set-Cookie',`fixture_auth=${cookie}; HttpOnly; Secure; SameSite=Strict`);res.writeHead(303,{Location:state.redirect});res.end();return
+    }
+    if(req.url==='/account'&&req.headers.cookie===`fixture_auth=${cookie}`){state.proofs++;res.setHeader('Content-Type','text/html');res.end('<div id="login-success">Dummy authenticated</div>');return}
+    if(req.url==='/logout'&&req.headers.cookie===`fixture_auth=${cookie}`){state.logouts++;res.setHeader('Set-Cookie','fixture_auth=; Max-Age=0; HttpOnly; Secure; SameSite=Strict');res.end('Logged out');return}
+    res.writeHead(401);res.end()
+  })
+  server.listen(0,'127.0.0.1');await once(server,'listening')
+  return {state,submitted:submitted.promise,username,secret,cookie,cert:readFileSync(cert),origin:'https://127.0.0.1:'+(server.address() as any).port,
+    usage:{kind:'website.form' as const,loginPath:'/login',submitPath:'/login',successPath:'/account',logoutPath:'/logout',formId:'login',usernameName:'username',passwordName:'password',successSelector:'#login-success'},
+    close:async()=>{server.closeAllConnections();await new Promise<void>(r=>server.close(()=>r()))}}
+}
+export async function sshFixture(){
+  const host=utils.generateKeyPairSync('ed25519'),key=utils.generateKeyPairSync('ed25519'),parsed=utils.parseKey(key.public) as utils.ParsedKey
+  const fingerprint='SHA256:'+createHash('sha256').update((utils.parseKey(host.private) as utils.ParsedKey).getPublicSSH()).digest('base64').replace(/=+$/,'')
+  const clients=new Set<Connection>(),files=new Map<string,Buffer>([['/approved/input.txt',Buffer.from('DUMMY-private-file-contents')]])
+  const executed=Promise.withResolvers<void>(),written=Promise.withResolvers<void>()
+  const state={auths:0,commands:[] as string[],opens:[] as {path:string,flags:number,mode:number}[],writes:0,delayWrite:false,delayExec:false,readRedirect:false}
+  const server=new Server({hostKeys:[host.private]},client=>{
+    clients.add(client);client.on('error',()=>{});client.on('close',()=>clients.delete(client))
+    client.on('authentication',ctx=>{
+      if(ctx.method!=='publickey'||ctx.username!=='dummy-ssh-user'||!ctx.key.data.equals(parsed.getPublicSSH())||ctx.signature&&parsed.verify(ctx.blob!,ctx.signature)!==true){ctx.reject();return}
+      state.auths++;ctx.accept()
+    }).on('ready',()=>client.on('session',accept=>{
+      const session=accept()
+      session.on('exec',(accept,_reject,info)=>{state.commands.push(info.command);const stream=accept();executed.resolve();if(state.delayExec)return;stream.write('DUMMY-private-command-output');stream.stderr.write('DUMMY-private-stderr');stream.exit(0);stream.end()})
+      session.on('sftp',accept=>{
+        const s=accept(),handles=new Map<string,string>();let id=0
+        s.on('error',()=>{})
+        s.on('REALPATH',(req,path)=>s.name(req,[{filename:state.readRedirect&&path==='/approved/input.txt'?'/outside/private.txt':path,longname:path,attrs:{}}]))
+        s.on('OPEN',(req,path,flags,attrs)=>{
+          state.opens.push({path,flags,mode:attrs.mode})
+          if(!path.startsWith('/approved/')||(flags&32)&&files.has(path)){s.status(req,4);return}
+          if(flags&8)files.set(path,Buffer.alloc(0))
+          if(!files.has(path)){s.status(req,2);return}
+          const handle=Buffer.from(String(++id));handles.set(handle.toString(),path);s.handle(req,handle)
+        })
+        s.on('FSTAT',(req,handle)=>{const data=files.get(handles.get(handle.toString())??'');if(!data){s.status(req,4);return}s.attrs(req,{mode:0o100600,size:data.length,uid:1,gid:1,atime:1,mtime:1})})
+        s.on('READ',(req,handle,offset,len)=>{const data=files.get(handles.get(handle.toString())??'');if(!data){s.status(req,4);return}if(offset>=data.length){s.status(req,1);return}s.data(req,data.subarray(offset,offset+len))})
+        s.on('WRITE',(req,handle,offset,data)=>{
+          const path=handles.get(handle.toString());if(!path){s.status(req,4);return}
+          state.writes++;const old=files.get(path)!,next=Buffer.alloc(Math.max(old.length,offset+data.length));old.copy(next);data.copy(next,offset);files.set(path,next)
+          written.resolve()
+          if(!state.delayWrite)s.status(req,0)
+        })
+        s.on('CLOSE',(req,handle)=>{handles.delete(handle.toString());s.status(req,0)})
+      })
+    }))
+  })
+  server.listen(0,'127.0.0.1');await once(server,'listening')
+  return {state,files,executed:executed.promise,written:written.promise,username:'dummy-ssh-user',secret:key.private,target:{kind:'ssh' as const,host:'127.0.0.1',port:(server.address() as any).port,hostKey:fingerprint},
+    close:async()=>{for(const client of clients)client.end();await new Promise<void>(r=>server.close(()=>r()))}}
+}

+ 91 - 0
tests/server/credentialVaultAdapters.test.ts

@@ -0,0 +1,91 @@
+// @vitest-environment node
+import {afterEach,expect,it,vi} from 'vitest'
+import {mkdtempSync,rmSync,writeFileSync,chmodSync} from 'node:fs'
+import {tmpdir} from 'node:os'
+import {join} from 'node:path'
+import {randomUUID,createHash} from 'node:crypto'
+import {websiteFixture,sshFixture} from '../fixtures/credentialProtocols'
+import {ProtectedCredentialExecutor} from '../../src/server/credentialVault/protectedExecutor'
+import {OperatorIsolationGate} from '../../src/server/credentialVault/isolation'
+import {parse,entryInput,type VaultEntry,type LeaseInput} from '../../src/server/credentialVault/schema'
+const homes:string[]=[],cleanup:(()=>Promise<void>)[]=[]
+const gate={assert(){}} // Dummy wiring validates protocols, never OS isolation.
+function home(){const h=mkdtempSync(join(tmpdir(),'yaoyao-vault-adapters-'));homes.push(h);return h}
+function entry(value:unknown):VaultEntry{return {...parse(entryInput,value),id:randomUUID(),revision:1,updatedAt:Date.now()}}
+function input(e:VaultEntry,operation:LeaseInput['operation']):LeaseInput{return {credentialRef:e.id,agentId:randomUUID(),workId:randomUUID(),runnerId:randomUUID(),runnerInstance:randomUUID(),runnerEpoch:'dummy',target:e.target,operation,seconds:60}}
+afterEach(async()=>{for(const close of cleanup.splice(0))await close();for(const h of homes.splice(0))rmSync(h,{recursive:true,force:true})})
+it('a protected standard form authenticates, verifies the marker, logs out and returns no secret/session/browser output',async()=>{
+  const h=home(),site=await websiteFixture(h);cleanup.push(site.close)
+  const e=entry({name:'dummy',username:site.username,secret:site.secret,target:{kind:'website',origin:site.origin},usage:site.usage})
+  const executor=new ProtectedCredentialExecutor(join(h,'browser'),gate,site.cert)
+  expect(await executor.execute(e,input(e,'website.login'),new AbortController().signal)).toEqual({status:'complete',operation:'website.login'})
+  expect(site.state).toMatchObject({logins:1,proofs:1,logouts:1})
+  // Fresh context cannot reuse the previous session.
+  expect(await executor.execute(e,input(e,'website.login'),new AbortController().signal)).toEqual({status:'complete',operation:'website.login'})
+  expect(site.state).toMatchObject({logins:2,proofs:2,logouts:2})
+},20000)
+it.each(['action','iframe','redirect'] as const)('rejects unapproved website %s and never exports a session',async cause=>{
+  const h=home(),site=await websiteFixture(h);cleanup.push(site.close)
+  if(cause==='action')site.state.formAction='https://127.0.0.1:1/steal'
+  if(cause==='iframe')site.state.iframe=true
+  if(cause==='redirect')site.state.redirect='https://127.0.0.1:1/steal'
+  const e=entry({name:'dummy',username:site.username,secret:site.secret,target:{kind:'website',origin:site.origin},usage:site.usage})
+  const result=await new ProtectedCredentialExecutor(join(h,'browser'),gate,site.cert).execute(e,input(e,'website.login'),new AbortController().signal)
+  expect(result).toMatchObject({status:'manual_takeover_required',reason:'unsupported_form_flow'})
+  expect(site.state.logins).toBe(cause==='redirect'?1:0);expect(site.state.proofs).toBe(0)
+  expect(JSON.stringify(result)).not.toContain(site.secret)
+},15000)
+it('cancelling a submitted website login closes its private context and leaves an unknown result without retry/logout',async()=>{
+  const h=home(),site=await websiteFixture(h);cleanup.push(site.close);site.state.delay=true
+  const e=entry({name:'dummy',username:site.username,secret:site.secret,target:{kind:'website',origin:site.origin},usage:site.usage}),controller=new AbortController()
+  const run=new ProtectedCredentialExecutor(join(h,'browser'),gate,site.cert).execute(e,input(e,'website.login'),controller.signal),result=expect(run).rejects.toThrow()
+  await vi.waitFor(()=>expect(site.state.logins).toBe(1));controller.abort();await result
+  expect(site.state.logins).toBe(1);expect(site.state.logouts).toBe(0)
+},15000)
+it('uses pinned public-key SSH, a fixed executable and bounded exact SFTP paths; receipts omit stdout and file contents',async()=>{
+  const h=home(),ssh=await sshFixture();cleanup.push(ssh.close);const executor=new ProtectedCredentialExecutor(join(h,'executor'),gate)
+  const base={name:'dummy',username:ssh.username,secret:ssh.secret,target:ssh.target}
+  const command=entry({...base,usage:{kind:'ssh.exec',command:'/usr/bin/uptime'}})
+  expect(await executor.execute(command,input(command,'ssh.exec'),new AbortController().signal)).toEqual({status:'complete',operation:'ssh.exec',exitCode:0})
+  expect(ssh.state.commands).toEqual(['/usr/bin/uptime'])
+  const read=entry({...base,usage:{kind:'sftp.read',remotePath:'/approved/input.txt',maxBytes:64}}),original=ssh.files.get('/approved/input.txt')!
+  expect(await executor.execute(read,input(read,'sftp.read'),new AbortController().signal)).toEqual({status:'complete',operation:'sftp.read',bytes:original.length,sha256:createHash('sha256').update(original).digest('hex')})
+  const write=entry({...base,usage:{kind:'sftp.write',remotePath:'/approved/output.txt',contents:'DUMMY-fixed-encrypted-transfer-body'}})
+  const receipt=await executor.execute(write,input(write,'sftp.write'),new AbortController().signal)
+  expect(receipt).toMatchObject({status:'complete',operation:'sftp.write',bytes:Buffer.byteLength('DUMMY-fixed-encrypted-transfer-body')});expect(JSON.stringify(receipt)).not.toContain('DUMMY')
+  expect(ssh.files.get('/approved/output.txt')?.toString()).toBe('DUMMY-fixed-encrypted-transfer-body')
+  expect(ssh.state.opens.at(-1)).toMatchObject({flags:58,mode:0o600})
+  await expect(executor.execute(write,input(write,'sftp.write'),new AbortController().signal)).rejects.toThrow() // Never overwrite.
+},15000)
+it('rejects an unknown SSH host key before authentication, symlink targets and oversized files',async()=>{
+  const h=home(),ssh=await sshFixture();cleanup.push(ssh.close);const executor=new ProtectedCredentialExecutor(join(h,'executor'),gate)
+  const base={name:'dummy',username:ssh.username,secret:ssh.secret,target:ssh.target},e=entry({...base,target:{...ssh.target,hostKey:'SHA256:'+'A'.repeat(43)},usage:{kind:'ssh.exec',command:'/usr/bin/uptime'}})
+  await expect(executor.execute(e,input(e,'ssh.exec'),new AbortController().signal)).rejects.toMatchObject({code:'vault_host_key_rejected'});expect(ssh.state.auths).toBe(0)
+  const read=entry({...base,usage:{kind:'sftp.read',remotePath:'/approved/input.txt',maxBytes:1}})
+  await expect(executor.execute(read,input(read,'sftp.read'),new AbortController().signal)).rejects.toMatchObject({code:'vault_usage_forbidden'})
+  ssh.state.readRedirect=true
+  await expect(executor.execute(read,input(read,'sftp.read'),new AbortController().signal)).rejects.toMatchObject({code:'vault_usage_forbidden'})
+  expect(ssh.state.opens).toHaveLength(1)
+},15000)
+it('a malformed private key produces a rejected operation without unhandled connection errors',async()=>{
+  const h=home(),ssh=await sshFixture();cleanup.push(ssh.close)
+  const e=entry({name:'dummy',username:ssh.username,secret:'DUMMY-not-a-private-key',target:ssh.target,usage:{kind:'ssh.exec',command:'/usr/bin/uptime'}})
+  await expect(new ProtectedCredentialExecutor(join(h,'executor'),gate).execute(e,input(e,'ssh.exec'),new AbortController().signal)).rejects.toThrow()
+  expect(ssh.state.auths).toBe(0)
+})
+it('cancelling SFTP during a lost write acknowledgement terminates promptly and leaves the partial result for manual inspection',async()=>{
+  const h=home(),ssh=await sshFixture();cleanup.push(ssh.close);ssh.state.delayWrite=true
+  const e=entry({name:'dummy',username:ssh.username,secret:ssh.secret,target:ssh.target,usage:{kind:'sftp.write',remotePath:'/approved/partial.txt',contents:'DUMMY-only'}}),controller=new AbortController()
+  const run=new ProtectedCredentialExecutor(join(h,'executor'),gate).execute(e,input(e,'sftp.write'),controller.signal),result=expect(run).rejects.toThrow()
+  await vi.waitFor(()=>expect(ssh.state.writes).toBe(1));controller.abort();await result
+  expect(ssh.files.get('/approved/partial.txt')?.toString()).toBe('DUMMY-only');expect(ssh.state.writes).toBe(1)
+},10000)
+it('requires private, unexpired operator approval and the actual distinct Hermes process identity; revocation cannot downgrade',()=>{
+  const h=home(),path=join(h,'approval.json'),uid=process.getuid!(),now=Date.now()
+  const approval={version:1,approved:true,brokerUid:uid,hermesUid:uid+1,hermesPid:123,approvedAt:now,expiresAt:now+60000,controls:['separate-identity','hermes-no-control-access','browser-private-no-cdp','restricted-egress']}
+  const save=(v:unknown)=>writeFileSync(path,JSON.stringify(v),{mode:0o600}),check=()=>new OperatorIsolationGate(path,uid+1,123,()=>uid+1,()=>now).assert()
+  expect(check).toThrowError(expect.objectContaining({code:'vault_isolation_required'}));save(approval);expect(check).not.toThrow()
+  expect(()=>new OperatorIsolationGate(path,uid+1,123,()=>uid,()=>now).assert()).toThrow()
+  save({...approval,expiresAt:now});expect(check).toThrow();save(approval);chmodSync(path,0o644);expect(check).toThrow()
+  chmodSync(path,0o600);save({...approval,approved:false});expect(check).toThrow()
+})

+ 24 - 33
tests/server/credentialVaultBotFlow.test.ts

@@ -2,13 +2,11 @@
 import { expect, it, vi } from 'vitest'
 import Koa from 'koa'
 import request from 'supertest'
-import { createServer as createHttpsServer } from 'node:https'
 import { mkdtempSync, rmSync, writeFileSync, readFileSync, chmodSync } from 'node:fs'
-import { execFileSync } from 'node:child_process'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { once } from 'node:events'
-import { randomUUID, randomBytes } from 'node:crypto'
+import { randomUUID, randomBytes, createHash } from 'node:crypto'
 import { WebSocketServer } from 'ws'
 import { WorkspaceStore } from '../../src/server/workspaceStore'
 import { WorkspaceRuntime } from '../../src/server/workspaceRuntime'
@@ -21,30 +19,22 @@ import { CredentialVaultStore } from '../../src/server/credentialVault/store'
 import { CredentialVaultBroker } from '../../src/server/credentialVault/broker'
 import { CredentialVaultClient, serveCredentialVault } from '../../src/server/credentialVault/transport'
 import { CredentialVaultCoordinator } from '../../src/server/credentialVault/coordinator'
-import { FixtureWebsiteLoginExecutor } from '../../src/server/credentialVault/fixtureWebsiteExecutor'
+import { ProtectedCredentialExecutor } from '../../src/server/credentialVault/protectedExecutor'
+import { websiteFixture, sshFixture } from '../fixtures/credentialProtocols'
 import type { RunnerHub } from '../../src/server/runnerHub'
 import type { WorkspaceRun } from '../../src/shared/workspace'
 
-it('a Bot turn uses its real tool catalog and task approval to authenticate a dummy HTTPS session, returning only a sanitized receipt',async()=>{
+it.each(['website.login','ssh.exec','sftp.read','sftp.write','website.login.cancel','ssh.exec.cancel','sftp.write.cancel'] as const)('a Bot turn uses its real tool catalog, task approval and protected adapter for local dummy %s, returning only a sanitized receipt',async scenario=>{
+  const cancelled=scenario.endsWith('.cancel'),operation=scenario.replace(/\.cancel$/,'') as 'website.login'|'ssh.exec'|'sftp.read'|'sftp.write'
   const home=mkdtempSync(join(tmpdir(),'yaoyao-vault-bot-flow-'))
-  const key=join(home,'dummy-key.pem'),cert=join(home,'dummy-cert.pem'),cnf=join(home,'dummy-openssl.cnf')
-  writeFileSync(cnf,'[req]\ndistinguished_name=dn\nx509_extensions=ext\nprompt=no\n[dn]\nCN=127.0.0.1\n[ext]\nsubjectAltName=IP:127.0.0.1\nbasicConstraints=critical,CA:TRUE\n')
-  // Ephemeral test TLS only. No real certificate store or identity is changed.
-  execFileSync('openssl',['req','-x509','-newkey','rsa:2048','-nodes','-days','1','-keyout',key,'-out',cert,'-config',cnf],{stdio:'ignore'})
-  const secret='DUMMY-ONLY-password-for-local-fixture',username='dummy-site-user',master='DUMMY-master-password-only',sessionCookie=randomBytes(32).toString('hex')
-  let logins=0,proofs=0
-  const website=createHttpsServer({key:readFileSync(key),cert:readFileSync(cert)},async(req,res)=>{
-    if(req.url==='/login'&&req.method==='POST'){
-      let body='';for await(const chunk of req)body+=chunk
-      const form=new URLSearchParams(body)
-      if(form.get('username')!==username||form.get('password')!==secret){res.writeHead(401);res.end();return}
-      logins++;res.setHeader('Set-Cookie','fixture_auth='+sessionCookie+'; HttpOnly; Secure; SameSite=Strict');res.end('dummy-login-accepted');return
-    }
-    if(req.url==='/protected'&&req.headers.cookie==='fixture_auth='+sessionCookie){proofs++;res.end('dummy-authenticated');return}
-    res.writeHead(401);res.end()
-  })
-  website.listen(0,'127.0.0.1');await once(website,'listening')
-  const origin='https://127.0.0.1:'+(website.address() as any).port,store=new WorkspaceStore(home),uploads=new UploadStore(home),auth=new LocalAuthStore(home)
+  const website=await websiteFixture(home),ssh=await sshFixture()
+  if(cancelled){website.state.delay=true;ssh.state.delayExec=operation==='ssh.exec';ssh.state.delayWrite=true}
+  const isWebsite=operation==='website.login',master='DUMMY-master-password-only',secret=isWebsite?website.secret:ssh.secret,username=isWebsite?website.username:ssh.username,sessionCookie=website.cookie
+  const usage=operation==='website.login'?website.usage:operation==='ssh.exec'?{kind:'ssh.exec',command:'/usr/bin/uptime'}:operation==='sftp.read'?{kind:'sftp.read',remotePath:'/approved/input.txt',maxBytes:64}:{kind:'sftp.write',remotePath:'/approved/output.txt',contents:'DUMMY-fixed-transfer-body'}
+  const credentialTarget=isWebsite?{kind:'website',origin:website.origin}:ssh.target
+  const expected=operation==='website.login'?{status:'complete',operation}:operation==='ssh.exec'?{status:'complete',operation,exitCode:0}:operation==='sftp.read'?{status:'complete',operation,bytes:ssh.files.get('/approved/input.txt')!.length,sha256:createHash('sha256').update(ssh.files.get('/approved/input.txt')!).digest('hex')}:{status:'complete',operation,bytes:Buffer.byteLength('DUMMY-fixed-transfer-body'),sha256:createHash('sha256').update('DUMMY-fixed-transfer-body').digest('hex')}
+  const effectCount=()=>isWebsite?website.state.logins:operation==='ssh.exec'?ssh.state.commands.length:ssh.state.opens.length
+  const store=new WorkspaceStore(home),uploads=new UploadStore(home),auth=new LocalAuthStore(home)
   const loginHeaders:Record<string,any>={},login={state:{},response:{headers:loginHeaders},get:()=>'',set:(k:string,v:any)=>{loginHeaders[k.toLowerCase()]=v}} as any
   const user=auth.setupAdmin(login,'dummy-admin','DUMMY-local-account-password'),cookies=String(loginHeaders['set-cookie']).split(';')[0]!,owner=user.id
   saveHostTools(home,{managedBrowser:false,vm:false,serverComputer:false,scriptMachine:false,cloud:false})
@@ -59,7 +49,7 @@ it('a Bot turn uses its real tool catalog and task approval to authenticate a du
   const nodes=new WorkspaceNodes(store,loadServerConfig({HERMES_YAOYAO_HOME:home}),target),runtime=new WorkspaceRuntime(store,nodes,uploads,owner=>auth.isUserActive(owner),owner=>auth.pushAuthorizationVersion(owner)??0)
   const socket=join(home,'dummy-vault.sock'),tokenFile=join(home,'dummy-control-token'),token=randomBytes(32).toString('base64url')
   writeFileSync(tokenFile,token,{mode:0o600})
-  const vault=await CredentialVaultStore.create(join(home,'vault')),broker=new CredentialVaultBroker(vault,Date.now,new FixtureWebsiteLoginExecutor(origin,readFileSync(cert))),ipc=serveCredentialVault(broker,token)
+  const vault=await CredentialVaultStore.create(join(home,'vault')),broker=new CredentialVaultBroker(vault,Date.now,new ProtectedCredentialExecutor(join(home,'private-executor'),{assert(){}},website.cert)),ipc=serveCredentialVault(broker,token)
   ipc.listen(socket);await once(ipc,'listening');chmodSync(socket,0o600)
   const client=new CredentialVaultClient({socket,tokenFile,hermesUid:process.getuid!()+1}),binding={runnerId:randomUUID(),runnerInstance:randomUUID(),runnerEpoch:'dummy-binding-epoch'}
   // This same-process dummy wiring exercises protocol and turn routing, not OS isolation.
@@ -84,15 +74,16 @@ it('a Bot turn uses its real tool catalog and task approval to authenticate a du
             const named=(name:string)=>catalog.tools.find((t:any)=>t.name===name)!.id
             expect(named('credential_request')).toBeTruthy();expect(named('credential_refs')).toBeTruthy()
             expect((await tool('/tools/call',{toolId:named('credential_refs'),arguments:{},callId:'before-approval'})).structuredContent.references).toEqual([])
-            const call={toolId:named('credential_request'),arguments:{credentialRef,operation:'website.login'},callId:'single-dummy-login'}
+            const call={toolId:named('credential_request'),arguments:{credentialRef,operation},callId:'single-dummy-login'}
             const pending=tool('/tools/call',call)
             let view:any
             await vi.waitFor(async()=>{view=(await ui('')).body;expect(view.requests).toHaveLength(1)})
-            expect(logins).toBe(0)
+            expect(effectCount()).toBe(0)
             const waiting=store.require<any>(owner,'turn',view.requests[0].workId);expect(waiting.status).toBe('waiting')
-            await ui('/leases',{credentialRef,agentId:view.requests[0].agentId,workId:view.requests[0].workId,operation:'website.login',seconds:60}).expect(200)
-            const result=await pending;expect(result.structuredContent).toEqual({status:'complete',operation:'website.login'});results.push(result)
-            expect(await tool('/tools/call',call)).toEqual(result);expect(logins).toBe(1) // Same call ID cannot log in twice.
+            await ui('/leases',{credentialRef,agentId:view.requests[0].agentId,workId:view.requests[0].workId,operation,seconds:60}).expect(200)
+            if(cancelled){await (isWebsite?website.submitted:operation==='ssh.exec'?ssh.executed:ssh.written);expect(effectCount()).toBe(1);await ui('/lock',{}).expect(200)}
+            const result=await pending;if(cancelled){expect(result.isError).toBe(true);expect(JSON.stringify(result)).toContain('vault_operation_uncertain')}else expect(result.structuredContent).toEqual(expected);results.push(result)
+            expect(await tool('/tools/call',call)).toEqual(result);expect(effectCount()).toBe(1) // Same call ID cannot log in twice.
           }catch(e){integrationError=e}
           ws.send(JSON.stringify({method:'event',params:{type:'message.complete',session_id:f.params.session_id,payload:{text:'本地测试登录完成,凭据未展示。',status:'complete'}}}))
         })()
@@ -101,20 +92,20 @@ it('a Bot turn uses its real tool catalog and task approval to authenticate a du
   })
   try{
     await ui('/initialize',{password:master}).expect(200);await ui('/unlock',{password:master}).expect(200)
-    const entry=await ui('/entries',{name:'dummy-local-site',username,target:{kind:'website',origin},secret}).expect(200);credentialRef=entry.body.id
+    const entry=await ui('/entries',{name:'dummy-local-site',username,target:credentialTarget,usage,secret}).expect(200);credentialRef=entry.body.id
     const bot=store.createAgent(owner,{name:'dummy-login-Bot',profile:'default'}),conversation=store.list<any>(owner,'conversation').find(c=>c.kind==='direct'&&c.memberIds[0]===bot.id)!
     const run=runtime.send(owner,conversation.id,{requestId:randomUUID(),content:'请使用我授予当前任务的凭据引用完成本地测试登录。'})
     await vi.waitFor(()=>expect(['complete','failed']).toContain(store.require<WorkspaceRun>(owner,'run',run.id).status),{timeout:8000})
     expect(integrationError).toBeUndefined();expect(store.require<WorkspaceRun>(owner,'run',run.id).status).toBe('complete')
-    expect(logins).toBe(1);expect(proofs).toBe(1);expect(results).toHaveLength(1);expect(catalogs).toHaveLength(1)
+    expect(effectCount()).toBe(1);if(isWebsite){expect(website.state.proofs).toBe(cancelled?0:1);expect(website.state.logouts).toBe(cancelled?0:1)};expect(results).toHaveLength(1);expect(catalogs).toHaveLength(1)
     for(const blob of [JSON.stringify(results),JSON.stringify(catalogs),JSON.stringify(prompts),JSON.stringify(store.messages(owner,conversation.id)),readFileSync(join(home,'vault','audit.jsonl'),'utf8')]){
-      for(const privateValue of [secret,master,username,sessionCookie,token])expect(blob).not.toContain(privateValue)
+      for(const privateValue of [secret,master,username,sessionCookie,token,'DUMMY-private-command-output','DUMMY-private-stderr','DUMMY-private-file-contents','DUMMY-fixed-transfer-body'])expect(blob).not.toContain(privateValue)
     }
     expect((await ui('')).body.leases).toHaveLength(0)
   }finally{
     runtime.close();coordinator.close();await new Promise(resolve=>setTimeout(resolve,10))
     for(const ws of hermes.clients)ws.terminate();await new Promise<void>(resolve=>hermes.close(()=>resolve()))
-    await new Promise<void>(resolve=>ipc.close(()=>resolve()));website.closeAllConnections();await new Promise<void>(resolve=>website.close(()=>resolve()))
+    await new Promise<void>(resolve=>ipc.close(()=>resolve()));await website.close();await ssh.close()
     nodes.close();uploads.close();store.close();rmSync(home,{recursive:true,force:true})
   }
 },15000)

+ 14 - 0
tests/server/credentialVaultBroker.test.ts

@@ -77,3 +77,17 @@ it('refuses symlink audit sinks and leaves their destination unchanged',async()=
   writeFileSync(destination,'unchanged');rmSync(path);symlinkSync(destination,path)
   expect(()=>f.dispatch('backup')).toThrow();expect(readFileSync(destination,'utf8')).toBe('unchanged')
 })
+it.each([undefined,{status:'complete',operation:'website.login',secret},{status:'complete',operation:'ssh.exec'}])('never treats missing, raw or scope-mismatched protected receipts as success',async receipt=>{
+  const f=await fixture({mode:'protected-adapters',execute:async()=>receipt as any}),lease=f.grant()
+  await expect(f.execute(lease.id)).rejects.toMatchObject({code:'vault_operation_uncertain'})
+  expect(f.dispatch('status').leases).toHaveLength(0)
+  expect(readFileSync(join(f.home,'audit.jsonl'),'utf8')).not.toContain(secret)
+})
+it('revocation of deployment approval locks and cancels the current operation instead of downgrading its executor',async()=>{
+  let approved=true,signal:AbortSignal|undefined
+  const started=Promise.withResolvers<void>(),finish=Promise.withResolvers<void>()
+  const f=await fixture({mode:'protected-adapters',assertAvailable(){if(!approved)throw new Error('revoked')},execute:async(_e,_i,s)=>{signal=s;started.resolve();await finish.promise;return {status:'complete',operation:'website.login'}}}),lease=f.grant()
+  const pending=f.execute(lease.id),assertion=expect(pending).rejects.toMatchObject({code:'vault_operation_uncertain'});await started.promise
+  approved=false;expect(f.dispatch('status')).toMatchObject({unlocked:false,execution:'disabled',reason:'isolation_required',leases:[]})
+  expect(signal?.aborted).toBe(true);finish.resolve();await assertion
+})

+ 22 - 4
tests/server/credentialVaultCoordinator.test.ts

@@ -8,6 +8,7 @@ import { randomUUID } from 'node:crypto'
 import { once } from 'node:events'
 import { CredentialVaultStore } from '../../src/server/credentialVault/store'
 import { CredentialVaultBroker } from '../../src/server/credentialVault/broker'
+import type {CredentialExecutor} from '../../src/server/credentialVault/executor'
 import { CredentialVaultClient, serveCredentialVault } from '../../src/server/credentialVault/transport'
 import { CredentialVaultCoordinator } from '../../src/server/credentialVault/coordinator'
 import { LocalAuthStore } from '../../src/server/localAuth'
@@ -22,9 +23,9 @@ function context(cookie='',path='',method='GET',body:unknown={}):Koa.Context{
   return {state:{},get:(key:string)=>key.toLowerCase()==='cookie'?cookie:'',set:(key:string,value:any)=>{headers[key.toLowerCase()]=value},response:{headers},path,method,request:{body},params:{},secure:false,req:{socket:{remoteAddress:'127.0.0.1'}}} as unknown as Koa.Context
 }
 function cookie(ctx:Koa.Context){const value=ctx.response.headers['set-cookie'];return String(Array.isArray(value)?value.at(-1):value).split(';')[0]!}
-async function fixture(ipc=false){
+async function fixture(ipc=false,executor?:CredentialExecutor){
   const home=mkdtempSync(join(tmpdir(),'yaoyao-vault-coordinator-')),auth=new LocalAuthStore(home),login=context(),user=auth.setupAdmin(login,'dummy-user','dummy-login-password'),cookies=cookie(login)
-  const vault=await CredentialVaultStore.create(join(home,'vault')),broker=new CredentialVaultBroker(vault)
+  const vault=await CredentialVaultStore.create(join(home,'vault')),broker=new CredentialVaultBroker(vault,Date.now,executor)
   let client:CredentialVaultClient,server:Server|undefined
   if(ipc){
     const socket=join(home,'dummy.sock'),tokenFile=join(home,'dummy-control'),token='DUMMY-CONTROL-TOKEN-NOT-PRODUCTION-00000'
@@ -34,7 +35,7 @@ async function fixture(ipc=false){
   }else{
     const epoch=randomUUID();broker.hello(epoch)
     client=new CredentialVaultClient()
-    vi.spyOn(client,'call').mockImplementation(async(o,s,c,v)=>broker.dispatch(epoch,o,s,c,v))
+    vi.spyOn(client,'call').mockImplementation(async(o,s,c,v)=>c==='execute'?broker.execute(epoch,o,s,(v as any).id,(v as any).input):broker.dispatch(epoch,o,s,c,v))
   }
   const agent={id:randomUUID(),name:'dummy-Bot',nodeId:'local',profile:'default',archived:false},run={id:randomUUID(),stopRequested:false},work={id:randomUUID(),agentId:agent.id,runId:run.id,status:'running',cancelRequested:false}
   const objects:any={agent,run,turn:work},store={require:(o:string,kind:string,id:string)=>{if(o!==user.id||objects[kind]?.id!==id)throw new HttpError(404,'fixture missing','not_found');return objects[kind]},list:(o:string,kind:string)=>o===user.id&&objects[kind]?[objects[kind]]:[]} as unknown as WorkspaceStore
@@ -47,7 +48,7 @@ async function fixture(ipc=false){
   await unlock();const entry=await request('/entries','POST',{name:'dummy-site',username:'dummy-user',target:{kind:'website',origin:'https://example.test'},secret})
   const grant=()=>request('/leases','POST',{credentialRef:entry.id,agentId:agent.id,workId:work.id,operation:'website.login',seconds:60})
   const turn=(signal=new AbortController().signal,waiting=vi.fn())=>coordinator.openTurn(user.id,agent.id,work.id,signal,()=>{},waiting)
-  return {home,auth,user,cookies,broker,client,coordinator,request,unlock,status,entry,agent,run,work,grant,turn,changeBinding:()=>{binding={...binding,runnerInstance:randomUUID()}},hub}
+  return {home,auth,user,cookies,broker,client,coordinator,request,unlock,status,entry,agent,run,work,grant,turn,changeBinding:()=>{binding={...binding,runnerInstance:randomUUID()}},hub,server}
 }
 afterEach(async()=>{for(const cleanup of cleanups.splice(0))await cleanup();vi.restoreAllMocks()})
 it('uses private IPC for encrypted CRUD and short grants but returns manual takeover instead of a login',async()=>{
@@ -116,3 +117,20 @@ it('parallel requests retain separate approvals and close cancels both waiting c
   t.close();await Promise.all(assertions);expect(waiting).toHaveBeenLastCalledWith(false);expect(waiting.mock.calls.filter(([v])=>!v)).toHaveLength(1)
   expect((await f.status()).requests).toHaveLength(0)
 })
+it('a Runner change after execution but before its receipt reaches the Bot leaves an unknown result and cannot replay',async()=>{
+  const execute=vi.fn(async()=>({status:'complete' as const,operation:'website.login' as const})),f=await fixture(false,{mode:'protected-adapters',execute});await f.grant()
+  const original=f.client.call.bind(f.client),reached=Promise.withResolvers<void>(),release=Promise.withResolvers<void>()
+  vi.spyOn(f.client,'call').mockImplementation(async(o,s,c,v)=>{const result=await original(o,s,c,v);if(c==='execute'){reached.resolve();await release.promise}return result})
+  const t=f.turn(),pending=t.call('credential_request',{credentialRef:f.entry.id,operation:'website.login'}),assertion=expect(pending).rejects.toMatchObject({code:'vault_operation_uncertain'})
+  await reached.promise;f.changeBinding();release.resolve();await assertion
+  expect(execute).toHaveBeenCalledOnce();expect((await f.status()).leases).toHaveLength(0);t.close()
+})
+it('losing the private execution connection aborts the lease and returns unknown once instead of waiting/retrying',async()=>{
+  const reached=Promise.withResolvers<void>();let signal:AbortSignal|undefined
+  const execute=vi.fn(async(_e,_i,s:AbortSignal)=>{signal=s;reached.resolve();await new Promise<void>((_r,reject)=>s.addEventListener('abort',()=>reject(new Error('DUMMY-private-error')),{once:true}));return {status:'complete' as const,operation:'website.login' as const}})
+  const f=await fixture(true,{mode:'protected-adapters',execute});await f.grant()
+  const t=f.turn(),pending=t.call('credential_request',{credentialRef:f.entry.id,operation:'website.login'}),assertion=expect(pending).rejects.toMatchObject({code:'vault_operation_uncertain'})
+  await reached.promise;f.server!.closeAllConnections();await assertion
+  await vi.waitFor(()=>expect(signal?.aborted).toBe(true));expect(execute).toHaveBeenCalledOnce()
+  expect((await f.status()).leases).toHaveLength(0);t.close()
+})

+ 12 - 0
tests/server/credentialVaultStore.test.ts

@@ -69,3 +69,15 @@ it('locks on a fixed deadline and preserves the old decryptable file if rotation
   now += 30000; expect(guarded.state(owner).unlocked).toBe(false)
   fail = false; guarded.unlock(owner, password); expect(guarded.list(owner)).toHaveLength(1)
 })
+it('encrypts fixed SFTP contents, never returns them, retains a blank edit and revokes policy changes',async()=>{
+  const {store,home}=await fixture();store.initialize(owner,password);store.unlock(owner,password)
+  const ssh={name:'dummy',username:'dummy',secret:'DUMMY-key',target:{kind:'ssh',host:'127.0.0.1',port:22,hostKey:'SHA256:'+'A'.repeat(43)}}
+  const body='DUMMY-fixed-private-file-body',e=store.add(owner,{...ssh,usage:{kind:'sftp.write',remotePath:'/approved/new.txt',contents:body}})
+  expect(e.usage).toEqual({kind:'sftp.write',remotePath:'/approved/new.txt',bytes:Buffer.byteLength(body)})
+  expect(JSON.stringify(store.list(owner))).not.toContain(body);expect(store.backup(owner)).not.toContain(body)
+  let revoked=0;store.onLock=()=>revoked++
+  const edit=store.update(owner,e.id,{...ssh,secret:undefined,revision:1,usage:{kind:'sftp.write',remotePath:'/approved/other.txt'}})
+  expect(edit.usage).toMatchObject({bytes:Buffer.byteLength(body)});expect(store.withSecret(owner,e.id,x=>x.usage)).toMatchObject({contents:body});expect(revoked).toBe(1)
+  for(const value of [{kind:'ssh.exec',command:'/bin/sh -c id'},{kind:'sftp.read',remotePath:'/approved/../outside',maxBytes:10}])expect(()=>store.update(owner,e.id,{...ssh,revision:2,usage:value})).toThrowError(expect.objectContaining({code:'vault_invalid_request'}))
+  expect(readFileSync(join(home,readdirSync(home).find(v=>v.endsWith('.vault'))!),'utf8')).not.toContain(body)
+})