Explorar o código

接通本机密码库的 Bot 任务授权与受控执行

Samien hai 1 mes
pai
achega
d594cf62e1

+ 25 - 10
docs/credential-vault.md

@@ -1,8 +1,15 @@
 # Bot 内置密码库(首期)
 
-默认安装提供夭夭服务器本机密码保险箱:登录后在「Bot 模式 → 设置 → 密码管理」创建主密码、手动解锁,即可添加网站密码、SSH 登录密码和私钥,无需先安装独立密码库服务。每个账号独立存储,密文位于夭夭数据目录的 `credential-vault/`;单实例锁防止其他 broker 同时写入。默认锁定,重启不自动解锁。它不向 Bot 提供秘密读取、任务租约或自动登录执行。
+默认安装提供夭夭服务器本机密码保险箱:登录后在「Bot 模式 → 设置 → 密码管理」创建主密码、手动解锁,即可添加网站密码、SSH 登录密码和私钥,无需先安装独立密码库服务。每个账号独立存储,密文位于夭夭数据目录的 `credential-vault/`;单实例锁防止其他 broker 同时写入。默认锁定,重启不自动解锁。用户配置固定使用计划并授权当前任务后,Bot 可以通过凭据引用执行受控操作;模型没有读取秘密的接口。
 
-配置 `HERMES_YAOYAO_VAULT_SOCKET` / `HERMES_YAOYAO_VAULT_TOKEN_FILE` 后,仍使用原有独立密码库服务;显式配置的服务不可用时保持离线,不切换到新的本机保险箱。独立服务可以部署在 Hermes 执行节点,并在隔离部署验收后启用受保护执行器。密码库不是 Hermes 的环境变量或工具配置。
+默认本机模式直接使用服务器上的受控执行器,不需要额外的 socket、服务身份或部署批准文件。配置 `HERMES_YAOYAO_VAULT_SOCKET` / `HERMES_YAOYAO_VAULT_TOKEN_FILE` 后,仍使用原有独立密码库服务;显式配置的服务不可用时保持离线,不切换到新的本机保险箱。独立服务可以部署在 Hermes 执行节点,并在隔离部署验收后启用受保护执行器。密码库不是 Hermes 的环境变量或工具配置。
+
+## 本机 Bot 使用流程
+
+1. 在密码管理中解锁密码库,添加或编辑凭据,勾选「配置允许 Bot 执行的固定操作」。配置标准网站表单、SSH 固定可执行文件,或 SFTP 固定路径与操作,然后保存。已有条目可以编辑配置,无需重新建库。
+2. 启动 Bot 任务。`credential_refs` 只列出已配置使用计划的凭据名称、引用、目标和公开计划;`authorized` 表示当前任务是否已经获准。Bot 提交 `credential_request` 后等待解锁与任务授权,不会因发现引用而自动执行。
+3. 在密码管理中选择待授权请求,核对 Bot、任务、凭据和固定操作,签发 60 秒单次授权。操作在夭夭服务器执行;直接连接 Hermes 的任务不需要另装 Runner。使用已注册 Runner 的任务仍在其断线、重连或权限变化时使原授权失效。
+4. 模型仅收到登录状态、SSH 退出码或 SFTP 字节数与摘要,不收到用户名、密码、私钥、命令输出、文件正文或 Cookie。锁定、到期、登出、取消任务、修改条目和关闭服务器都会撤销授权。标准网页登录回执只证明登录成功,尚不提供登录后的通用浏览或业务操作。
 
 ## 密码学与存储
 
@@ -14,11 +21,11 @@
 
 ## 执行边界
 
-本机保险箱复用 Web/BFF 内的加密存储与会话校验,不属于独立 OS 身份隔离。主密码和解锁后的材料会进入可信服务器进程内存;同 UID 的任意 shell、调试器或被控制的服务器进程仍可能访问它们。它提供密文落盘和管理 API 的账号/会话隔离,不能据此声称具备抵御同 UID 攻击的隔离保证。需要 Bot 自动使用真实凭据时,应配置并验收下述独立服务;本机模式不会签发任务租约,也不会执行网站、SSH 或 SFTP 操作。
+本机保险箱复用 Web/BFF 内的加密存储与会话校验,不属于独立 OS 身份隔离。主密码和解锁后的材料会进入可信服务器进程内存;同 UID 的任意 shell、调试器或被控制的服务器进程仍可能访问它们。它提供密文落盘、管理 API 的账号/会话隔离,以及固定计划与单次任务授权的受控执行;其信任边界包含服务器和同 UID 进程,不能据此声称具备抵御同 UID 攻击的隔离保证。需要该隔离保证时,应配置并验收下述独立服务。
 
-同 OS 用户的 Hermes 任意 shell 可以读取同用户文件或访问进程/IPC,因此单纯子进程、0600 权限、secretref 或加密 DB 不构成隔离。真实部署前需人工批准独立 OS 身份/可信沙箱、私有 IPC、控制面访问权限以及浏览器进程/资料目录边界。Web 管理面也不能与拥有任意本机 shell 的 Hermes 共用可信身份。
+同 OS 用户的 Hermes 任意 shell 可以读取同用户文件或访问进程/IPC,因此单纯子进程、0600 权限、secretref 或加密 DB 不构成隔离。独立隔离服务上线前需人工批准独立 OS 身份/可信沙箱、私有 IPC、控制面访问权限以及浏览器进程/资料目录边界。在该隔离部署中,Web 管理面也不能与拥有任意本机 shell 的 Hermes 共用可信身份。
 
-正式适配器现已实现,默认仍关闭;只有经过人工审核的隔离部署、私有批准文件、实际 Hermes 进程身份检查均有效时,CLI 才声明 `protected-adapters`。批准文件是管理员对 OS/IPC/CDP/网络边界的信任声明,不是代码自动证明。未配置、过期、身份变化、撤销时锁库并中止授权,不能退回通用 fill、host_shell、环境变量或临时密钥文件。配置不会创建 OS 身份或安装服务。配置与审阅步骤见 [部署模板](../deploy/credential-vault/README.md)。
+本机执行状态声明为 `local-adapters` / `local_controlled`,不会声明通过了独立身份隔离审核。独立隔离服务的正式适配器默认关闭;只有经过人工审核的隔离部署、私有批准文件、实际 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/后台网络),也不能代替独立身份/禁止调试。
 
@@ -34,18 +41,18 @@ Web 的「Bot 模式 → 设置 → 密码管理」提供创建、手动解锁 5
 
 私有协议为 `credential-vault/v1`(HTTP JSON,经 Unix socket,禁止 Origin),没有 read-secret 命令;`execute` 仅接受单次短授权与固定范围,正式 CLI 未注入执行器时明确拒绝。每次 Web 控制进程重新握手都会锁定全部库并撤销旧授权。BFF 重启不会恢复授权或重放操作。UI/API `GET /api/app/vault` 返回状态、元数据、活动任务和待授权请求;其下 POST `initialize/unlock/lock/rotate/backup/restore`,POST `entries/leases`,PUT/DELETE `entries/:id`,DELETE `leases/:id` 均沿用当前登录、精确 Origin、CSRF 和 owner 校验;管理入口仅接受 HTTPS 或本机回环 HTTP。不依赖全局「允许不安全局域网」选项。
 
-Runner 只声明 `credential-ref-v1`,**没有** `credential-executor-v1`。授权绑定 owner、本次登录、Bot、活动 work ID、Runner ID/instance/epoch、精确 origin 或 SSH 主机/端口/host key、操作、凭据版本和到期时间。停止/取消/删除/权限变更/重连/锁定/轮换/条目改动使旧授权失效,迟到的解锁或 grant 响应不能恢复权限。旧 Runner 明确拒绝;离线/锁定时 Bot 在任务的 waiting 状态等待,最长 120 秒后返回未执行的等待结果。任务取消会释放等待并撤销授权。
+Runner 只声明 `credential-ref-v1`,**没有** `credential-executor-v1`,也不接收秘密。本机授权绑定 owner、本次登录、Bot、活动 work ID、服务器实例、来源节点和 profile;已注册 Runner 的 instance/epoch 同时纳入绑定。独立节点执行模式沿用 Runner ID/instance/epoch 和引用能力检查。两种模式均绑定精确 origin 或 SSH 主机/端口/host key、操作、凭据版本和到期时间。停止/取消/删除/权限变更/重连/锁定/轮换/条目改动使旧授权失效,迟到的解锁或 grant 响应不能恢复权限。独立节点模式的旧 Runner 明确拒绝;离线/锁定时 Bot 在任务的 waiting 状态等待,最长 120 秒后返回未执行的等待结果。任务取消会释放等待并撤销授权。
 
-Bot 工具只有 `credential_refs` 与 `credential_request`,输入严格为引用及操作,模型看不到用户名/密码/私钥/主密码/控制 token,也不能选择新的路径、正文、命令或表单。refs 返回当前任务的目标和公开的固定使用计划,不含 SFTP 正文。专用 UI 授权后,正式适配器在 broker 内实际使用秘密,返回严格白名单回执;空/额外字段/操作不匹配的正式回执均拒绝。重复工具 call ID 只收到原脱敏回执,不重复执行。缺使用计划或不支持的站点会明确返回人工接管,执行后未确认返回 `vault_operation_uncertain`,不能假称成功或转回等待后自动重试。私有 IPC 断连中止当前租约;Runner 在回执抵达前重连也返回未知结果。旧 `FixtureWebsiteLoginExecutor` 仍只是最初的专用测试夹具,生产入口不加载它。
+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 身份攻击验收。
+真实 WorkspaceRuntime 工具目录/调用上下文、专用用户授权 API 和正式 adapter 已通过本地 HTTPS 标准表单、SSH 公钥及密码认证固定命令、SFTP 读和写协议验收。同组 Bot 测试分别验证独立服务的私有 IPC,以及无外部服务、无注册 Runner 的本机服务器执行。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 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。未配置外部服务时使用仅存储的本机保险箱;配置后连接失败保持离线,不自动降级。
+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。未配置外部服务时使用支持受控任务执行的本机保险箱;配置后连接失败保持离线,不自动降级。
 5. 用户亲自在专用 UI 创建并解锁。上线前还需验证真实 OS/IPC 攻击边界、HTTPS 管理通道及备份恢复,不把本地同进程 dummy 测试作为隔离验收。本轮没有实际创建服务身份、控制凭据、生产密码库或录入真实秘密。
 
 受保护执行器的部署仍需要独立安全验收:限定操作及单次提交,执行前/后检查授权,撤销中止;结果只返回脱敏状态,执行断线或取消结果不明确时禁止自动重试。浏览器填充阶段必须独占并隔离 DOM、请求、Cookie、截图、CDP 与同 UID shell;登录后的站点操作也须走受限动作/脱敏返回,不能向模型开放可读 Cookie 的通用浏览器。SSH/SFTP 要独立短连接及已确认的 host key,不能允许 shell 自选命令、任意跳板/代理、端口转发、agent forwarding、密钥导出或明文 FTP。
@@ -56,6 +63,14 @@ Bot 工具只有 `credential_refs` 与 `credential_request`,输入严格为引
 
 测试连接临时本地 HTTPS/SSH/SFTP 服务,只使用 dummy 账号;没有真实账号、跨 OS 身份穿透测试、移动端专用原生密码库 UI、端到端生产部署或真机验收。现有移动端与独立原生管理器不在首期范围。
 
+## 本机 Bot 使用验收(2026-10-02)
+
+默认本机库已接通 Bot 的工具目录、已配置凭据发现、待授权请求、专用 UI 单任务授权和服务器受控执行。沿用现有加密格式及账号数据,无需迁移或额外服务;未配置使用计划的旧条目需在编辑页勾选并保存固定操作。独立服务仍保持原隔离门禁,显式配置的服务不可用时不回退本机库。
+
+非 live 全量回归 `npm test -- --exclude 'tests/live/**' --maxWorkers=2` 为 **2275 passed / 7 skipped / 0 failed**。其中 Bot 流程 22 项分别覆盖独立服务与本机模式,从真实 WorkspaceRuntime 工具发现和用户授权,到 HTTPS 标准表单、SSH 密码/私钥认证、SFTP 读写、执行中撤权及调用去重;本机模式没有注册 Runner。测试确认模型回执、工具目录、会话消息和审计不含秘密、用户名、Cookie、命令输出或文件正文。
+
+类型检查、Web/服务端构建通过;专用管理 UI 测试 13 项通过。Chromium 验证默认建库、保存与编辑 SSH 密码和固定使用计划、任务授权入口、锁库与错误主密码提示,桌面及 375px 手机布局通过。只构建和验证源码,未更新签名安装包、安装日常设备或部署生产。证据为 `/tmp/yaoyao-local-vault-bot-regression.log`、`/tmp/yaoyao-local-vault-bot-build.log`、`/tmp/yaoyao-local-vault-bot-browser.log`。
+
 ## 首期验收记录(2026-09-30)
 
 存储底座提交 `9092831`;UI、私有 broker/IPC、任务短授权、Runner 引用能力和工作的本地 dummy 网站登录工具链提交 `771c72c`。中间 `d1650ef` 是并行的客户端上下文统计工作,不属于密码库修改。版本保持 v0.4.77,私有密码库协议 v1,没有自行升版。

+ 15 - 12
src/client/components/app/CredentialVaultPanel.vue

@@ -15,6 +15,7 @@ const editing=ref<CredentialSummary>(), showEditor=ref(false), taskId=ref(''), c
 const sshAuth=ref<'privateKey'|'password'>('privateKey')
 const authenticationChanged=computed(()=>editing.value?.target.kind==='ssh'&&kind.value==='ssh'&&sshAuth.value!==(editing.value.target.auth??'privateKey'))
 const secretRequired=computed(()=>!editing.value||authenticationChanged.value)
+const selectedCredential=computed(()=>state.value?.entries.find(entry=>entry.id===credentialId.value))
 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
@@ -22,7 +23,7 @@ const inflight=new Set<AbortController>()
 function clearSecrets(){master.value='';confirmation.value='';replacement.value='';secret.value='';passphrase.value='';contents.value='';clearPassphrase.value=false}
 function failure(cause:unknown){
   const code=cause&&typeof cause==='object'&&'code' in cause?String(cause.code):''
-  return ({vault_offline:'密码保险箱连接不可用,请刷新状态或检查数据目录权限。',vault_locked:'密码保险箱已锁定,请重新手动解锁后添加凭据。',vault_unlock_failed:'主密码错误或密码库已损坏,请核对主密码。',vault_invalid_request:'填写内容不符合要求:主密码至少 12 位;网站须为不含路径的 HTTPS 地址;SSH 须提供 SHA256 主机指纹。',vault_auth_secret_required:'更换 SSH 认证方式后,请重新填写密码或私钥。',vault_executor_not_enabled:'本机保险箱可以保存凭据;Bot 自动使用需要连接独立密码库服务。'} as Record<string,string>)[code]??'操作未完成,请检查连接和主密码;秘密不会回显。'
+  return ({vault_offline:'密码保险箱连接不可用,请刷新状态或检查数据目录权限。',vault_locked:'密码保险箱已锁定,请重新手动解锁后添加凭据。',vault_unlock_failed:'主密码错误或密码库已损坏,请核对主密码。',vault_invalid_request:'填写内容不符合要求:主密码至少 12 位;网站须为不含路径的 HTTPS 地址;SSH 须提供 SHA256 主机指纹。',vault_auth_secret_required:'更换 SSH 认证方式后,请重新填写密码或私钥。',vault_executor_not_enabled:'凭据执行器暂时不可用,请刷新状态或检查服务。'} as Record<string,string>)[code]??'操作未完成,请检查连接和主密码;秘密不会回显。'
 }
 async function request<T>(path:string,method:'GET'|'POST'|'PUT'|'DELETE'='GET',body?:Record<string,JsonValue>){
   const c=new AbortController();inflight.add(c)
@@ -37,7 +38,7 @@ async function action(work:()=>Promise<void>,message:string){
   finally{clearSecrets();busy.value=false}
 }
 async function create(){if(master.value!==confirmation.value){error.value='两次主密码不一致';return}await action(async()=>{await request('/initialize','POST',{password:master.value})},'密码库已创建,仍保持锁定')}
-async function unlock(){await action(async()=>{await request('/unlock','POST',{password:master.value,seconds:300})},state.value?.storageMode==='local'?'已解锁 5 分钟,可以添加和编辑凭据。':'已手动解锁 5 分钟;任务仍需要逐次授权')}
+async function unlock(){await action(async()=>{await request('/unlock','POST',{password:master.value,seconds:300})},'已手动解锁 5 分钟;Bot 使用凭据仍需要逐任务授权。')}
 async function lock(){clearSecrets();showEditor.value=false;await action(async()=>{await request('/lock','POST',{})},'密码库已锁定,任务授权已撤销')}
 function edit(entry?:CredentialSummary){
   clearSecrets();editing.value=entry;name.value=entry?.name??'';username.value=entry?.username??'';kind.value=entry?.target.kind??'website'
@@ -62,8 +63,9 @@ async function save(){
 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 selectCredential(){const plan=selectedCredential.value?.usage;if(plan)operation.value=plan.kind==='website.form'?'website.login':plan.kind}
 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(){
@@ -100,7 +102,7 @@ onBeforeUnmount(()=>{
 <template>
   <section class="credential-vault" :aria-busy="busy">
     <p>秘密只在专用界面录入,由主密码加密保存;不发送给 Bot。密码保险箱默认锁定,重启或到期需重新手动解锁。</p>
-    <p v-if="state?.storageMode==='local'" role="status">本机保险箱:凭据保存在夭夭服务器的数据目录,只有当前账号解锁后可以管理。保存凭据无需配置独立服务;Bot 自动使用需要连接独立密码库服务。</p>
+    <p v-if="state?.storageMode==='local'" role="status">本机保险箱:凭据加密保存在夭夭服务器,当前账号解锁后可以管理。Bot 可申请使用已配置的固定操作,需要你授权具体任务;操作在夭夭服务器执行,密码不交给模型。</p>
     <p v-else 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>
@@ -130,8 +132,8 @@ onBeforeUnmount(()=>{
           <label v-if="kind==='ssh'">SSH 认证方式<select v-model="sshAuth" :disabled="busy" @change="changeSshAuth"><option value="privateKey">私钥登录</option><option value="password">密码登录</option></select></label>
           <label>{{kind==='website'?'密码':sshAuth==='password'?'SSH 登录密码':'私钥'}}<input v-if="kind==='website'||sshAuth==='password'" v-model="secret" type="password" autocomplete="new-password" :required="secretRequired" maxlength="32768" :disabled="busy" /><textarea v-else v-model="secret" autocomplete="off" :required="secretRequired" maxlength="32768" :disabled="busy" /><small v-if="authenticationChanged">认证方式已更换,需要填写新的{{sshAuth==='password'?'登录密码':'私钥'}}。</small><small v-else-if="editing">留空保留原值;没有秘密读取或回显接口。</small></label>
           <label v-if="kind==='ssh'&&sshAuth==='privateKey'">私钥口令(可选)<input v-model="passphrase" type="password" autocomplete="new-password" :disabled="busy" /></label>
-          <label v-if="kind==='ssh'&&sshAuth==='privateKey'&&editing"><input v-model="clearPassphrase" type="checkbox" :disabled="busy" />清除原私钥口令(留空输入默认保留)</label>
-          <label v-if="state.storageMode!=='local'"><input v-model="configureUse" type="checkbox" :disabled="busy||!!editing?.usage" />配置允许 Bot 执行的固定操作</label>
+          <label v-if="kind==='ssh'&&sshAuth==='privateKey'&&editing" class="checkbox-field"><input v-model="clearPassphrase" type="checkbox" :disabled="busy" />清除原私钥口令(留空输入默认保留)</label>
+          <label class="checkbox-field"><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>
@@ -144,21 +146,21 @@ onBeforeUnmount(()=>{
             <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>
+            <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 v-if="state.storageMode!=='local'" @submit.prevent="grant">
-          <h4>本次任务授权</h4><p>仅绑定一个 Bot、当前任务、执行节点和指定操作;最长 60 秒,不建立长期访问权限。</p>
+        <form @submit.prevent="grant">
+          <h4>本次任务授权</h4><p>先为凭据配置允许 Bot 执行的固定操作,再授权活动任务。每次只绑定一个 Bot、当前任务、执行节点和指定操作;最长 60 秒,不建立长期访问权限。</p>
           <ul><li v-for="pending in state.requests" :key="pending.workId+pending.credentialRef">
             {{state.tasks.find(t=>t.workId===pending.workId)?.name??'任务'}} · {{pending.operation}} · {{pending.credentialRef}}
             <button type="button" :disabled="busy||!state.entries.some(e=>e.id===pending.credentialRef)" @click="selectRequest(pending)">选择此请求,核对后授权</button>
           </li></ul>
-          <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="credentialId" required :disabled="busy" @change="selectCredential"><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>
+          <p>批准的使用计划:{{describeUse(selectedCredential)}}</p>
+          <button :disabled="busy||!state.tasks.length||!selectedCredential?.usage">授予本任务 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>
         <form @submit.prevent="rotate"><label>新的主密码<input v-model="replacement" type="password" autocomplete="new-password" minlength="12" maxlength="1024" required :disabled="busy" /></label><button :disabled="busy">轮换主密码与加密密钥并锁定</button></form>
@@ -169,5 +171,6 @@ onBeforeUnmount(()=>{
 </template>
 
 <style scoped>
+.credential-vault label.checkbox-field{display:flex;align-items:center;gap:8px;min-height:40px;cursor:pointer}.credential-vault .checkbox-field input{width:18px;min-height:18px;height:18px;flex:0 0 18px;margin:0;padding:0;cursor:inherit}
 .credential-vault{display:grid;gap:16px}.credential-vault p{margin:0;color:var(--text-secondary);line-height:1.6}.credential-vault form{display:grid;gap:12px;padding:16px;border:1px solid var(--line);border-radius:12px}.credential-vault label{display:grid;gap:6px}.credential-vault input,.credential-vault textarea,.credential-vault select{width:100%;box-sizing:border-box;min-height:40px;padding:8px;border:1px solid var(--line);border-radius:8px;background:var(--surface);color:var(--text-primary)}.credential-vault textarea{min-height:120px}.credential-vault button{min-height:40px;padding:8px 12px;border:1px solid var(--line);border-radius:8px;background:var(--surface);color:var(--text-primary);cursor:pointer}.credential-vault button:disabled{opacity:.55;cursor:default}.credential-vault ul{list-style:none;margin:0;padding:0;display:grid;gap:12px}.credential-vault li{padding:12px;border:1px solid var(--line);border-radius:10px}.credential-vault code{overflow-wrap:anywhere}.credential-vault [role=alert]{color:var(--danger)}.actions{display:flex;flex-wrap:wrap;gap:8px}
 </style>

+ 19 - 0
src/server/credentialVault/adapterExecutor.ts

@@ -0,0 +1,19 @@
+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'
+
+/** Shared bounded protocols. The boot path determines the deployment boundary. */
+export abstract class CredentialAdapterExecutor implements CredentialExecutor {
+  abstract readonly mode:'protected-adapters'|'local-adapters'
+  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)}
+}

+ 3 - 3
src/server/credentialVault/broker.ts

@@ -51,8 +51,8 @@ export class CredentialVaultBroker {
   }
   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'}}
+    try{this.executor.assertAvailable?.();return {execution:this.executor.mode??'dummy-fixture',reason:this.executor.mode==='protected-adapters'?'operator_approved':this.executor.mode==='local-adapters'?'local_controlled':'dummy_fixture_only'}}
+    catch{this.disconnect();return {execution:'disabled',reason:this.executor.mode==='local-adapters'?'executor_unavailable':'isolation_required'}}
   }
   private authorize(owner: string, session: string) {
     if (this.sessions.get(owner) !== session || !this.store.state(owner).unlocked)
@@ -142,7 +142,7 @@ export class CredentialVaultBroker {
     try {
       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')
+      if(result===undefined&&['protected-adapters','local-adapters'].includes(this.executor.mode??''))throw new Error('credential 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(),

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

@@ -17,11 +17,11 @@ interface Session { owner: string; key: string; cookie: string; version: number
 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 命令或指定 SFTP 操作。锁定/离线时等待用户解锁与授权,隔离未批准或复杂网站转人工接管。不得索取或读取密码,不得降级 fill/shell;未知结果不能自动重试。', inputSchema: toolSchema },
+  { id: 'credential_refs', name: 'credential_refs', description: '密码库解锁后,列出当前账号已配置允许 Bot 使用的凭据名称、引用、目标和固定操作。authorized 表示当前任务是否已获短期授权;发现引用不代表获准执行。不包含用户名、密码或私钥。', inputSchema: { type: 'object', properties: {}, additionalProperties: false } },
+  { 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. */
+/** No read-secret tool or response. Local mode trusts the server; independent
+ * deployment must separate its process/IPC identity from Hermes' shell identity. */
 export class CredentialVaultCoordinator {
   private sessions = new Map<string, Session>()
   private grants = new Map<string, Grant>()
@@ -83,7 +83,7 @@ export class CredentialVaultCoordinator {
       || !this.auth.canUseSource(owner, agent.nodeId, agent.profile)) throw new HttpError(410, 'Bot 任务授权已结束', 'vault_task_revoked')
     return { agent, work }
   }
-  private binding(owner: string, agentId: string) { return this.hub.credentialBinding(owner, this.store.require<WorkspaceAgent>(owner, 'agent', agentId)) }
+  private binding(owner: string, agentId: string) { return this.hub.credentialBinding(owner, this.store.require<WorkspaceAgent>(owner, 'agent', agentId), this.client.executionLocation) }
   private tasks(owner: string) {
     return this.store.list<Work>(owner, 'turn').flatMap(w => {
       try { const { agent } = this.task(owner, w.agentId, w.id); return [{ workId: w.id, agentId: agent.id, name: agent.name }] } catch { return [] }
@@ -122,7 +122,6 @@ export class CredentialVaultCoordinator {
         operation: leaseInput.shape.operation, seconds: leaseInput.shape.seconds }).strict(), (ctx.request as any).body)
       this.task(s.owner, body.agentId, body.workId)
       const binding = this.binding(s.owner, body.agentId), state: CredentialVaultStatus = await this.call(s, 'status'), entry = state.entries.find(e => e.id === body.credentialRef)
-      if (state.storageMode === 'local') throw new HttpError(409, 'Bot 自动使用需要连接独立密码库服务', 'vault_executor_not_enabled')
       if (!entry) throw new HttpError(403, '请解锁并选择当前账号的凭据', 'vault_credential_forbidden')
       const summary = await this.call(s, 'grant', { ...body, ...binding, target: entry.target }) as CredentialLeaseSummary
       try {
@@ -153,7 +152,22 @@ export class CredentialVaultCoordinator {
     signal.addEventListener('abort', abort, { once: true })
     const refs = async () => {
       const binding = this.binding(owner, agentId), entries = []
+      const session=this.sessions.get(owner)
+      if(session&&this.valid(session)){
+        let state:CredentialVaultStatus
+        try{state=await this.call(session,'status')}catch(error){if(error instanceof HttpError&&error.code==='vault_offline')return [];throw error}
+        check()
+        if(state.unlocked)for(const entry of state.entries){
+          if(!entry.usage)continue
+          const operation=entry.usage.kind==='website.form'?'website.login':entry.usage.kind
+          const authorized=[...this.grants.values()].some(g=>g.session.owner===owner&&g.summary.agentId===agentId&&g.summary.workId===workId
+            &&g.summary.credentialRef===entry.id&&g.summary.operation===operation&&g.summary.expiresAt>Date.now()
+            &&this.valid(g.session)&&JSON.stringify(g.binding)===JSON.stringify(binding)&&state.leases.some(l=>l.id===g.summary.id))
+          entries.push({credentialRef:entry.id,name:entry.name,operation,allowedTarget:entry.target,allowedUse:entry.usage,authorized})
+        }
+      }
       for (const g of this.grants.values()) {
+        if(entries.some(e=>e.credentialRef===g.summary.credentialRef&&e.operation===g.summary.operation))continue
         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()
@@ -185,7 +199,7 @@ 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'||state.execution==='protected-adapters') {
+                  if (state.execution === 'dummy-fixture'||state.execution==='protected-adapters'||state.execution==='local-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()

+ 1 - 1
src/server/credentialVault/executor.ts

@@ -3,7 +3,7 @@ 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'
+  readonly mode?:'dummy-fixture'|'protected-adapters'|'local-adapters'
   assertAvailable?():void
   execute(entry:Readonly<VaultEntry>,input:LeaseInput,signal:AbortSignal):Promise<CredentialExecutionReceipt|void>
 }

+ 16 - 8
src/server/credentialVault/localClient.ts

@@ -5,28 +5,33 @@ import { CredentialVaultStore } from './store.js'
 import { CredentialVaultBroker } from './broker.js'
 import { HttpError } from '../errors.js'
 import { acquireServiceInstance } from '../serviceInstance.js'
+import { LocalCredentialExecutor } from './localExecutor.js'
+import { z } from 'zod'
+import { parse, leaseInput } from './schema.js'
 
-/** Default encrypted storage. It never supplies an executor or issues Bot leases.
- * Automatic credential use still requires the explicitly configured isolated broker. */
+/** Default encrypted vault with task-scoped execution in the trusted server. */
 export class LocalCredentialVaultClient extends CredentialVaultClient {
   private readonly controlEpoch = randomUUID()
   private broker?: Promise<CredentialVaultBroker>
   private instance?: ReturnType<typeof acquireServiceInstance>
+  private executor?: LocalCredentialExecutor
   private closed = false
 
-  constructor(private readonly home: string) { super() }
+  constructor(private readonly home: string, private readonly testCA?: Buffer) { super() }
+  override get configured(): boolean { return true }
+  override get executionLocation(): 'server' { return 'server' }
 
   override async call(owner: string, session: string, command: string, value?: unknown): Promise<any> {
     if (this.closed) throw new HttpError(503, '密码保险箱已关闭', 'vault_offline')
-    if (['grant', 'execute'].includes(command))
-      throw new HttpError(409, '本机保险箱只保存凭据;Bot 自动使用需要连接独立密码库服务', 'vault_executor_not_enabled')
     this.broker ??= CredentialVaultStore.create(join(this.home, 'credential-vault')).then(store => {
       if (this.closed) throw new Error('closed')
       this.instance = acquireServiceInstance(store.root, 'credential-vault-v1')
-      const broker = new CredentialVaultBroker(store)
+      this.executor = new LocalCredentialExecutor(join(store.root, 'executor'), this.testCA)
+      const broker = new CredentialVaultBroker(store, Date.now, this.executor)
       broker.hello(this.controlEpoch)
       return broker
     }).catch(() => {
+      this.executor?.close(); this.executor = undefined
       this.instance?.release(); this.instance = undefined
       this.broker = undefined
       throw new HttpError(503, '无法打开本机密码保险箱,请检查数据目录权限或是否已被其他服务使用', 'vault_offline')
@@ -34,13 +39,16 @@ export class LocalCredentialVaultClient extends CredentialVaultClient {
     const broker = await this.broker
     if (this.closed) throw new HttpError(503, '密码保险箱已关闭', 'vault_offline')
     try {
-      const result = broker.dispatch(this.controlEpoch, owner, session, command, value)
+      const result = command === 'execute'
+        ? await (() => { const body = parse(z.object({ id: z.string().uuid(), input: leaseInput }).strict(), value); return broker.execute(this.controlEpoch, owner, session, body.id, body.input) })()
+        : broker.dispatch(this.controlEpoch, owner, session, command, value)
       return ['status', 'initialize', 'unlock', 'restore'].includes(command)
         ? { ...(result as object), storageMode: 'local' }
         : result
     } catch (error) {
       if (error instanceof HttpError) throw error
       broker.disconnect()
+      this.onDisconnect()
       throw new HttpError(409, '密码保险箱操作未完成,请刷新状态后重新解锁', 'vault_request_failed')
     }
   }
@@ -48,7 +56,7 @@ export class LocalCredentialVaultClient extends CredentialVaultClient {
   override close(): void {
     this.closed = true
     void this.broker?.then(broker => {
-      try { broker.close() } finally { this.instance?.release(); this.instance = undefined }
+      try { broker.close() } finally { this.executor?.close(); this.executor = undefined; this.instance?.release(); this.instance = undefined }
     }).catch(() => {})
   }
 }

+ 15 - 0
src/server/credentialVault/localExecutor.ts

@@ -0,0 +1,15 @@
+import {CredentialAdapterExecutor} from './adapterExecutor.js'
+import {HttpError} from '../errors.js'
+
+/** Trusted server execution; it does not claim protection from same-UID shells.
+ * The broker enforces owner, session, task, revision and one-use leases. */
+export class LocalCredentialExecutor extends CredentialAdapterExecutor {
+  readonly mode='local-adapters' as const
+  private readonly lifetime:{closed:boolean}
+  constructor(home:string,testCA?:Buffer){
+    const lifetime={closed:false}
+    super(home,{assert(){if(lifetime.closed)throw new HttpError(503,'本机凭据执行器已关闭','vault_executor_unavailable')}},testCA)
+    this.lifetime=lifetime
+  }
+  close(){this.lifetime.closed=true}
+}

+ 3 - 15
src/server/credentialVault/protectedExecutor.ts

@@ -1,17 +1,5 @@
-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 {
+import {CredentialAdapterExecutor} from './adapterExecutor.js'
+/** Independent deployment still supplies the operator-approved isolation gate. */
+export class ProtectedCredentialExecutor extends CredentialAdapterExecutor {
   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)}
 }

+ 4 - 4
src/server/credentialVault/store.ts

@@ -44,9 +44,9 @@ function derive(password: string, e: Envelope) {
 }
 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
- * prevented by deployment. Restart discards the DEK and always starts locked. */
+/** Encrypted storage for a trusted local server or independently isolated broker.
+ * JS strings/GC and WASM are not a secure enclave; same-UID protection requires
+ * deployment isolation. Restart discards the DEK and always starts locked. */
 export class CredentialVaultStore {
   private unlocked = new Map<string, { envelope: Envelope; key: Uint8Array; entries: VaultEntry[]; until: number }>()
   private failures = new Map<string, { count: number; next: number }>()
@@ -184,7 +184,7 @@ export class CredentialVaultStore {
     if (!s.entries.some(v => v.id === id)) throw new HttpError(404, '凭据不存在', 'vault_entry_missing')
     this.commit(owner, s.entries.filter(v => v.id !== id)); this.onLock(owner)
   }
-  /** Only the isolated executor callback may receive this. No read-secret RPC exists. */
+  /** Only a broker-controlled executor callback may receive this. No read-secret RPC exists. */
   withSecret<T>(owner: string, id: string, use: (entry: Readonly<VaultEntry>) => T): T {
     const s = this.active(owner), entry = s.entries.find(v => v.id === id)
     if (!entry) throw new HttpError(404, '凭据不存在', 'vault_entry_missing')

+ 1 - 0
src/server/credentialVault/transport.ts

@@ -43,6 +43,7 @@ export class CredentialVaultClient {
   onDisconnect = () => {}
   constructor(private config?: { socket: string; tokenFile: string; hermesUid: number }) {}
   get configured() { return !!this.config }
+  get executionLocation(): 'node' | 'server' { return 'node' }
   close(): void {}
   private async send(owner: string, session: string, command: string, value?: unknown): Promise<any> {
     const c = this.config

+ 10 - 1
src/server/runnerHub.ts

@@ -53,6 +53,7 @@ export class RunnerHub {
   browserAllowed:(runnerId:string,scope:unknown,grantId?:string)=>boolean=()=>false
   controlAllowed:(id:string,runnerId:string)=>boolean=()=>false
   readonly epoch=randomUUID()
+  private readonly credentialServerId=randomUUID()
   private online = new Map<string,{instance:string;seen:number;epoch:string;features:string[];wake?:()=>void}>()
   private retired = new Map<string,Set<string>>()
   private pending = new Map<string,Map<string,Pending>>()
@@ -95,10 +96,18 @@ export class RunnerHub {
     return result
   }
   records():RunnerRecord[] {return this.store.list('_system','runner')}
-  credentialBinding(owner:string,agent:import('../shared/workspace.js').WorkspaceAgent) {
+  credentialBinding(owner:string,agent:import('../shared/workspace.js').WorkspaceAgent,location:'node'|'server'='node') {
     if(agent.archived||!this.auth.canUseSource(owner,agent.nodeId,agent.profile))throw new HttpError(403,'Bot 凭据授权已结束','vault_agent_forbidden')
     const record=this.records().find(r=>r.enabled&&r.sourceNodeId===agent.nodeId&&(r.sourceOwner==='_system'||r.sourceOwner===owner))
     const online=record&&this.online.get(record.id)
+    if(location==='server'){
+      if(record&&(!online||Date.now()-online.seen>=35000))throw new HttpError(503,'Bot 所在节点离线,请等待节点连接','vault_runner_offline')
+      if(record&&!record.allowedProfiles.includes(agent.profile))throw new HttpError(403,'执行节点未授权当前 Profile','vault_agent_forbidden')
+      // No Runner is needed for direct Hermes tasks. Existing Runner reconnects
+      // still change the binding, even though secrets remain in this server.
+      return {runnerId:this.credentialServerId,runnerInstance:this.epoch,
+        runnerEpoch:hash(JSON.stringify([agent.nodeId,agent.profile,record?.id,online?.instance,online?.epoch]))}
+    }
     if(!record||!online||Date.now()-online.seen>=35000)throw new HttpError(503,'执行节点离线,请等待节点连接','vault_runner_offline')
     if(!record.allowedProfiles.includes(agent.profile))throw new HttpError(403,'执行节点未授权当前 Profile','vault_agent_forbidden')
     if(!online.features.includes('credential-ref-v1'))throw new HttpError(409,'请更新 Runner,旧节点不支持凭据引用授权','vault_runner_upgrade_required')

+ 1 - 1
src/shared/credentialVault.ts

@@ -22,6 +22,6 @@ export interface CredentialVaultStatus {
   protocol: 1; online: boolean; initialized: boolean; unlocked: boolean
   unlockExpiresAt?: number
   storageMode?: 'local'
-  execution: 'disabled' | 'dummy-fixture' | 'protected-adapters'; reason: 'executor_not_enabled' | 'vault_offline' | 'dummy_fixture_only' | 'operator_approved' | 'isolation_required'
+  execution: 'disabled' | 'dummy-fixture' | 'protected-adapters' | 'local-adapters'; reason: 'executor_not_enabled' | 'vault_offline' | 'dummy_fixture_only' | 'operator_approved' | 'isolation_required' | 'local_controlled' | 'executor_unavailable'
   entries: CredentialSummary[]; leases: CredentialLeaseSummary[]
 }

+ 24 - 6
tests/client/credentialVaultPanel.test.ts

@@ -14,9 +14,9 @@ it('shows an offline state without offering secret entry and explicitly keeps re
   state.online=false;const w=await setup()
   expect(w.find('input[type=password]').exists()).toBe(false);expect(w.text()).toContain('尚未启用');expect(w.text()).toContain('不会自动安装或启动服务')
 })
-it('offers creation and credential entry in local storage mode without offering Bot leases',async()=>{
-  state.initialized=false;state.storageMode='local';const w=await setup()
-  expect(w.text()).toContain('保存凭据无需配置独立服务');expect(w.text()).toContain('创建密码库')
+it('offers local credential management and task approval without requiring an independent service',async()=>{
+  state.initialized=false;state.storageMode='local';state.execution='local-adapters';const w=await setup()
+  expect(w.text()).toContain('需要你授权具体任务');expect(w.text()).toContain('创建密码库')
   state.initialized=true;state.unlocked=true
   await w.findAll('button').find(b=>b.text()==='刷新状态')!.trigger('click');await flushPromises()
   await w.findAll('button').find(b=>b.text()==='添加凭据')!.trigger('click')
@@ -25,7 +25,8 @@ it('offers creation and credential entry in local storage mode without offering
   await label('准确网站').get('input').setValue('https://example.test');await label('密码').get('input').setValue('DUMMY-new-secret')
   await editor.trigger('submit');await flushPromises()
   expect(api).toHaveBeenCalledWith('/api/app/vault/entries',expect.objectContaining({method:'POST',body:{name:'Dummy site',username:'dummy',target:{kind:'website',origin:'https://example.test'},secret:'DUMMY-new-secret'}}))
-  expect(w.text()).not.toContain('授予本任务');expect(w.text()).not.toContain('DUMMY-new-secret');expect(w.find('input[type=password]').exists()).toBe(true)
+  expect(w.findAll('button').find(b=>b.text().startsWith('授予本任务'))!.attributes('disabled')).toBeDefined()
+  expect(w.text()).not.toContain('DUMMY-new-secret');expect(w.find('input[type=password]').exists()).toBe(true)
 })
 it('reports a wrong vault master password without expiring the application login or echoing errors',async()=>{
   const w=await setup();await w.get('input[type=password]').setValue('DUMMY-wrong-master')
@@ -89,7 +90,7 @@ it('configures an encrypted fixed SFTP body in the dedicated UI, clears it after
   expect(w.find('textarea').exists()).toBe(false);expect(w.text()).not.toContain('DUMMY-private-write-body')
 })
 
-it('saves an SSH login password through a masked field without a private key passphrase or Bot lease in local mode',async()=>{
+it('saves a masked local SSH password without a private key passphrase and keeps execution subject to task approval',async()=>{
   state.unlocked=true;state.storageMode='local';const w=await setup()
   await w.findAll('button').find(b=>b.text()==='添加凭据')!.trigger('click')
   const editor=w.get('form'),label=(text:string)=>editor.findAll('label').find(l=>l.text().startsWith(text))!
@@ -101,7 +102,7 @@ it('saves an SSH login password through a masked field without a private key pas
   expect(editor.text()).not.toContain('私钥口令');expect(editor.find('textarea').exists()).toBe(false)
   await editor.trigger('submit');await flushPromises()
   expect(api).toHaveBeenCalledWith('/api/app/vault/entries',expect.objectContaining({method:'POST',body:{name:'Dummy password SSH',username:'dummy',target:{kind:'ssh',host:'ssh.example.test',port:22,hostKey:'SHA256:'+'A'.repeat(43),auth:'password'},secret:'DUMMY-ssh-login-password'}}))
-  expect(w.text()).not.toContain('DUMMY-ssh-login-password');expect(w.text()).not.toContain('授予本任务')
+  expect(w.text()).not.toContain('DUMMY-ssh-login-password');expect(w.text()).toContain('本次任务授权')
 })
 
 it('clears SSH secret drafts on authentication changes and requires a new secret when editing a legacy key entry',async()=>{
@@ -129,3 +130,20 @@ it('keeps an existing SSH password when its masked replacement field is left bla
   const body=api.mock.calls.find(([p,o])=>p.endsWith('/password-id')&&o.method==='PUT')![1].body
   expect(body.target.auth).toBe('password');expect(body).not.toHaveProperty('secret');expect(body).not.toHaveProperty('passphrase')
 })
+
+it('grants a local Bot task only its selected configured operation without sending any secrets',async()=>{
+  state.unlocked=true;state.storageMode='local';state.execution='local-adapters'
+  state.entries=[{id:'credential-id',name:'SSH test',username:'dummy',target:{kind:'ssh',host:'ssh.example.test',port:22,hostKey:'SHA256:'+'A'.repeat(43),auth:'password'},usage:{kind:'ssh.exec',command:'/usr/bin/uptime'},revision:1,updatedAt:1}]
+  state.tasks=[{workId:'work-id',agentId:'bot-id',name:'Current Bot task'}]
+  state.requests=[{workId:'work-id',agentId:'bot-id',credentialRef:'credential-id',operation:'ssh.exec'}]
+  const w=await setup(),button=w.findAll('button').find(b=>b.text().startsWith('授予本任务'))!
+  expect(button.attributes('disabled')).toBeDefined()
+  const label=(text:string)=>w.findAll('label').find(l=>l.text().startsWith(text))!
+  await label('凭据').get('select').setValue('credential-id')
+  expect(label('操作').get('select').element.value).toBe('ssh.exec')
+  await w.findAll('button').find(b=>b.text()==='选择此请求,核对后授权')!.trigger('click')
+  expect(button.attributes('disabled')).toBeUndefined()
+  await w.findAll('form')[0]!.trigger('submit');await flushPromises()
+  expect(api).toHaveBeenCalledWith('/api/app/vault/leases',expect.objectContaining({method:'POST',body:{credentialRef:'credential-id',agentId:'bot-id',workId:'work-id',operation:'ssh.exec',seconds:60}}))
+  expect(w.text()).toContain('已授予此任务');expect(w.text()).not.toContain('需要连接独立')
+})

+ 7 - 3
tests/e2e/credential-vault.spec.ts

@@ -12,7 +12,7 @@ test('a default installation creates and saves encrypted passwords through the s
   await page.getByRole('menuitem',{name:'我的设置',exact:true}).click()
   await page.getByRole('button',{name:'密码管理',exact:true}).click()
   const panel=page.locator('.credential-vault')
-  await expect(panel.getByText(/保存凭据无需配置独立服务/)).toBeVisible()
+  await expect(panel.getByText(/需要你授权具体任务/)).toBeVisible()
   await panel.getByLabel('主密码',{exact:true}).fill(master)
   await panel.getByLabel('确认主密码',{exact:true}).fill(master)
   await panel.getByRole('button',{name:'创建密码库',exact:true}).click()
@@ -25,9 +25,9 @@ test('a default installation creates and saves encrypted passwords through the s
   await panel.getByLabel('准确网站 origin',{exact:true}).fill('https://example.test')
   await panel.getByLabel('密码',{exact:true}).fill(secret)
   await panel.getByRole('button',{name:'加密保存',exact:true}).click()
-  await expect(panel.getByText('浏览器测试网站',{exact:true})).toBeVisible()
+  await expect(panel.locator('li strong').filter({hasText:'浏览器测试网站'})).toBeVisible()
   await expect(panel.getByRole('status').filter({hasText:'凭据已加密保存'})).toBeVisible()
-  await expect(panel.getByRole('button',{name:'授予本任务 60 秒引用权限'})).toHaveCount(0)
+  await expect(panel.getByRole('button',{name:'授予本任务 60 秒引用权限'})).toBeDisabled()
   expect(await panel.textContent()).not.toContain(secret)
   await panel.getByRole('button',{name:'添加凭据',exact:true}).click()
   await panel.getByRole('combobox',{name:/^用途/}).selectOption('ssh')
@@ -39,11 +39,15 @@ test('a default installation creates and saves encrypted passwords through the s
   const sshPassword=panel.getByLabel(/^SSH 登录密码/)
   await expect(sshPassword).toHaveAttribute('type','password')
   await sshPassword.fill('DUMMY-browser-ssh-password')
+  await panel.getByLabel('配置允许 Bot 执行的固定操作',{exact:true}).check()
+  await expect(panel.getByRole('combobox',{name:/^允许操作/})).toHaveValue('ssh.exec')
+  await expect(panel.getByLabel('批准的绝对可执行路径(无参数、无 shell 拼接)',{exact:true})).toHaveValue('/usr/bin/uptime')
   await expect(panel.getByLabel('私钥口令(可选)',{exact:true})).toHaveCount(0)
   await page.screenshot({path:evidence+'/ssh-password-editor.png'})
   await panel.getByRole('button',{name:'加密保存',exact:true}).click()
   const sshEntry=panel.locator('li').filter({hasText:'浏览器测试 SSH 密码'})
   await expect(sshEntry).toContainText('密码登录')
+  await expect(panel.getByRole('heading',{name:'本次任务授权',exact:true})).toBeVisible()
   expect(await panel.textContent()).not.toContain('DUMMY-browser-ssh-password')
   await sshEntry.getByRole('button',{name:'编辑',exact:true}).click()
   await expect(sshPassword).toHaveValue('')

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

@@ -20,11 +20,15 @@ import { CredentialVaultBroker } from '../../src/server/credentialVault/broker'
 import { CredentialVaultClient, serveCredentialVault } from '../../src/server/credentialVault/transport'
 import { CredentialVaultCoordinator } from '../../src/server/credentialVault/coordinator'
 import { ProtectedCredentialExecutor } from '../../src/server/credentialVault/protectedExecutor'
+import { LocalCredentialVaultClient } from '../../src/server/credentialVault/localClient'
 import { websiteFixture, sshFixture } from '../fixtures/credentialProtocols'
-import type { RunnerHub } from '../../src/server/runnerHub'
+import { RunnerHub } from '../../src/server/runnerHub'
+import type { Server } from 'node:http'
 import type { WorkspaceRun } from '../../src/shared/workspace'
 
-it.each(['website.login','ssh.exec','sftp.read','sftp.write','website.login.cancel','ssh.exec.cancel','sftp.write.cancel','ssh.exec.password','sftp.read.password','sftp.write.password','ssh.exec.password.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 scenarios=['website.login','ssh.exec','sftp.read','sftp.write','website.login.cancel','ssh.exec.cancel','sftp.write.cancel','ssh.exec.password','sftp.read.password','sftp.write.password','ssh.exec.password.cancel'] as const
+const cases=(['external','local'] as const).flatMap(storage=>scenarios.map(scenario=>({storage,scenario})))
+it.each(cases)('a Bot turn discovers and uses $storage vault references for dummy $scenario with task approval and sanitized receipts',async({storage,scenario})=>{
   const cancelled=scenario.endsWith('.cancel'),operation=scenario.replace('.password','').replace(/\.cancel$/,'') as 'website.login'|'ssh.exec'|'sftp.read'|'sftp.write'
   const home=mkdtempSync(join(tmpdir(),'yaoyao-vault-bot-flow-'))
   const website=await websiteFixture(home),ssh=await sshFixture(scenario.includes('.password')?'password':'privateKey')
@@ -39,7 +43,7 @@ it.each(['website.login','ssh.exec','sftp.read','sftp.write','website.login.canc
   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})
   const hermes=new WebSocketServer({port:0,host:'127.0.0.1'});await once(hermes,'listening')
-  const bindings=new Map<string,any>(),prompts:string[]=[],results:any[]=[],catalogs:any[][]=[]
+  const bindings=new Map<string,any>(),prompts:string[]=[],results:any[]=[],catalogs:any[][]=[],references:any[]=[]
   let integrationError:unknown,credentialRef=''
   const target={url:new URL('http://127.0.0.1:'+(hermes.address() as any).port),client:{directAgent:undefined},session:{webSocketCredential:async()=>({name:'ticket',value:'dummy-ticket'}),request:async(path:string,input?:{body?:any})=>{
     if(path.endsWith('/bind'))bindings.set(input?.body.session_id,input?.body)
@@ -48,12 +52,19 @@ it.each(['website.login','ssh.exec','sftp.read','sftp.write','website.login.canc
   }}} as unknown as GatewayTarget
   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 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'}
+  let client:CredentialVaultClient,ipc:Server|undefined,hub:RunnerHub|undefined
+  if(storage==='local'){
+    client=new LocalCredentialVaultClient(home,website.cert)
+    hub=new RunnerHub(store,auth,target) // The default direct Hermes setup has no registered Runner.
+  }else{
+    writeFileSync(tokenFile,token,{mode:0o600})
+    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)
+    client=new CredentialVaultClient({socket,tokenFile,hermesUid:process.getuid!()+1})
+  }
+  const binding={runnerId:randomUUID(),runnerInstance:randomUUID(),runnerEpoch:'dummy-binding-epoch'}
   // This same-process dummy wiring exercises protocol and turn routing, not OS isolation.
-  const coordinator=new CredentialVaultCoordinator(client,store,auth,{credentialBinding:()=>binding} as unknown as RunnerHub);runtime.credentialVault=coordinator
+  const coordinator=new CredentialVaultCoordinator(client,store,auth,hub??{credentialBinding:()=>binding} as unknown as RunnerHub);runtime.credentialVault=coordinator
   const app=new Koa();app.use(async(ctx,next)=>{try{await next()}catch(e){ctx.status=(e as any).status??500;ctx.body={code:(e as any).code}}})
   app.use(async(ctx,next)=>{const chunks:Buffer[]=[];for await(const c of ctx.req)chunks.push(Buffer.from(c));(ctx.request as any).body=chunks.length?JSON.parse(Buffer.concat(chunks).toString()):{};await next()})
   const router=coordinator.router();app.use(router.routes())
@@ -73,8 +84,9 @@ it.each(['website.login','ssh.exec','sftp.read','sftp.write','website.login.canc
             const catalog=await tool('/tools/list',{});catalogs.push(catalog.tools)
             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},callId:'single-dummy-login'}
+            const discovered=(await tool('/tools/call',{toolId:named('credential_refs'),arguments:{},callId:'before-approval'})).structuredContent.references
+            expect(discovered).toHaveLength(1);expect(discovered[0]).toMatchObject({credentialRef,operation,authorized:false});references.push(discovered)
+            const call={toolId:named('credential_request'),arguments:{credentialRef:discovered[0].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)})
@@ -98,14 +110,14 @@ it.each(['website.login','ssh.exec','sftp.read','sftp.write','website.login.canc
     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(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 blob of [JSON.stringify(results),JSON.stringify(references),JSON.stringify(catalogs),JSON.stringify(prompts),JSON.stringify(store.messages(owner,conversation.id)),readFileSync(join(home,storage==='local'?'credential-vault':'vault','audit.jsonl'),'utf8')]){
       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()));await website.close();await ssh.close()
+    if(ipc)await new Promise<void>(resolve=>ipc!.close(()=>resolve()));hub?.close();await website.close();await ssh.close()
     nodes.close();uploads.close();store.close();rmSync(home,{recursive:true,force:true})
   }
 },15000)

+ 3 - 2
tests/server/credentialVaultBroker.test.ts

@@ -77,8 +77,9 @@ 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()
+const invalidReceipts=[undefined,{status:'complete',operation:'website.login',secret},{status:'complete',operation:'ssh.exec'}]
+it.each((['protected-adapters','local-adapters'] as const).flatMap(mode=>invalidReceipts.map(receipt=>({mode,receipt}))))('never treats missing, raw or scope-mismatched $mode receipts as success',async({mode,receipt})=>{
+  const f=await fixture({mode,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)

+ 12 - 2
tests/server/credentialVaultCoordinator.test.ts

@@ -23,7 +23,7 @@ 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,executor?:CredentialExecutor){
+async function fixture(ipc=false,executor?:CredentialExecutor,configured=false){
   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,Date.now,executor)
   let client:CredentialVaultClient,server:Server|undefined
@@ -45,7 +45,8 @@ async function fixture(ipc=false,executor?:CredentialExecutor){
   cleanups.push(async()=>{coordinator.close();if(server){await new Promise<void>(resolve=>server!.close(()=>resolve()));server.closeAllConnections()}else broker.close();rmSync(home,{recursive:true,force:true})})
   await request('/initialize','POST',{password})
   const unlock=()=>request('/unlock','POST',{password}),status=()=>request('')
-  await unlock();const entry=await request('/entries','POST',{name:'dummy-site',username:'dummy-user',target:{kind:'website',origin:'https://example.test'},secret})
+  await unlock();const entry=await request('/entries','POST',{name:'dummy-site',username:'dummy-user',target:{kind:'website',origin:'https://example.test'},secret,
+    ...(configured?{usage:{kind:'website.form',loginPath:'/login',submitPath:'/login',successPath:'/account',formId:'login',usernameName:'username',passwordName:'password',successSelector:'#success'}}:{})})
   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,server}
@@ -59,6 +60,15 @@ it('uses private IPC for encrypted CRUD and short grants but returns manual take
   const status=await f.status();expect(JSON.stringify(status)).not.toContain(secret);expect(status.execution).toBe('disabled');expect(lease.expiresAt-Date.now()).toBeLessThanOrEqual(60000)
   t.close();await vi.waitFor(async()=>expect((await f.status()).leases).toHaveLength(0))
 })
+it('discovers only configured metadata while unlocked, distinguishes discovery from task approval and hides it after lock',async()=>{
+  const f=await fixture(false,undefined,true),t=f.turn()
+  const references=async()=>((await t.call('credential_refs',{})) as any).references
+  expect(await references()).toEqual([{credentialRef:f.entry.id,name:'dummy-site',operation:'website.login',allowedTarget:f.entry.target,allowedUse:f.entry.usage,authorized:false}])
+  expect(JSON.stringify(await references())).not.toContain(secret);expect(JSON.stringify(await references())).not.toContain('dummy-user')
+  const lease=await f.grant();expect((await references())[0].authorized).toBe(true)
+  await f.request('/leases/'+lease.id,'DELETE');expect((await references())[0].authorized).toBe(false)
+  await f.request('/lock','POST',{});expect(await references()).toEqual([]);t.close()
+})
 it.each(['lock','logout','connection'] as const)('rejects a late unlock response following %s and leaves the vault locked',async cause=>{
   const f=await fixture();await f.request('/lock','POST',{})
   const original=f.client.call.bind(f.client),reached=Promise.withResolvers<void>(),release=Promise.withResolvers<void>()

+ 15 - 4
tests/server/credentialVaultLocalClient.test.ts

@@ -3,6 +3,7 @@ import { afterEach, expect, it } from 'vitest'
 import { mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs'
 import { join } from 'node:path'
 import { tmpdir } from 'node:os'
+import { randomUUID } from 'node:crypto'
 import { createCredentialVaultClient, LocalCredentialVaultClient } from '../../src/server/credentialVault/localClient'
 
 const homes: string[] = [], clients: LocalCredentialVaultClient[] = []
@@ -14,9 +15,9 @@ function client(home = mkdtempSync(join(tmpdir(), 'yaoyao-local-vault-'))) {
 }
 afterEach(async () => { clients.splice(0).forEach(c => c.close()); await Promise.resolve(); homes.splice(0).forEach(h => rmSync(h, { recursive: true, force: true })) })
 
-it('provides encrypted local CRUD by default without an external service or Bot executor', async () => {
+it('provides encrypted local CRUD and bounded task execution by default without an external service', async () => {
   const f = client()
-  expect(await f.call('status')).toMatchObject({ online: true, initialized: false, unlocked: false, execution: 'disabled', storageMode: 'local' })
+  expect(await f.call('status')).toMatchObject({ online: true, initialized: false, unlocked: false, execution: 'local-adapters', reason: 'local_controlled', storageMode: 'local' })
   await f.call('initialize', { password })
   await expect(f.call('add', {})).rejects.toMatchObject({ code: 'vault_locked' })
   await f.call('unlock', { password })
@@ -25,13 +26,23 @@ it('provides encrypted local CRUD by default without an external service or Bot
   const stored = readdirSync(join(f.home, 'credential-vault')).filter(name => name.endsWith('.vault')).map(name => readFileSync(join(f.home, 'credential-vault', name), 'utf8')).join('')
   expect(stored).not.toContain(secret); expect(stored).not.toContain(password); expect(stored).not.toContain('Dummy website')
   expect((await f.call('status')).entries).toHaveLength(1)
-  await expect(f.call('grant', {})).rejects.toMatchObject({ code: 'vault_executor_not_enabled' })
-  await expect(f.call('execute', {})).rejects.toMatchObject({ code: 'vault_executor_not_enabled' })
+  await expect(f.call('read-secret', {id:entry.id})).rejects.toMatchObject({ code: 'vault_command_unknown' })
   await f.call('update', { id: entry.id, entry: { name: 'Updated', username: 'dummy', target: entry.target, revision: entry.revision } })
   expect((await f.call('status')).entries[0].name).toBe('Updated')
   await f.call('remove', { id: entry.id }); expect((await f.call('status')).entries).toEqual([])
 })
 
+it('binds one-use local execution to the owner and session and requires a configured usage policy',async()=>{
+  const f=client();await f.call('initialize',{password});await f.call('unlock',{password})
+  const entry=await f.call('add',{name:'Dummy',username:'dummy',target:{kind:'website',origin:'https://example.test'},secret})
+  const input={credentialRef:entry.id,agentId:randomUUID(),workId:randomUUID(),runnerId:randomUUID(),runnerInstance:randomUUID(),runnerEpoch:randomUUID(),operation:'website.login',target:entry.target,seconds:60}
+  const lease=await f.call('grant',input)
+  await expect(f.call('execute',{id:lease.id,input},'another-owner')).rejects.toMatchObject({code:'vault_lease_revoked'})
+  await expect(f.call('execute',{id:lease.id,input},'dummy-owner','b'.repeat(64))).rejects.toMatchObject({code:'vault_scope_forbidden'})
+  expect(await f.call('execute',{id:lease.id,input})).toEqual({status:'manual_takeover_required',reason:'usage_policy_required',submitted:false})
+  await expect(f.call('execute',{id:lease.id,input})).rejects.toMatchObject({code:'vault_lease_revoked'})
+})
+
 it('isolates accounts and login sessions and starts locked after a server restart', async () => {
   const f = client(); await f.call('initialize', { password }); await f.call('unlock', { password })
   await f.call('add', { name: 'Dummy', username: 'dummy', target: { kind: 'website', origin: 'https://example.test' }, secret })

+ 13 - 0
tests/server/runner.test.ts

@@ -405,3 +405,16 @@ it('advertises only vault references and binds them to the current Runner incarn
   state.features=state.features.filter((f:string)=>f!=='credential-ref-v1')
   expect(()=>hub.credentialBinding('owner',agent)).toThrowError(expect.objectContaining({code:'vault_runner_upgrade_required'}))
 })
+it('binds server vault execution without requiring a Runner and invalidates bindings on source reconnect',()=>{
+  const agent={id:randomUUID(),nodeId:'local',profile:'default',archived:false} as any
+  const first=hub.credentialBinding('owner',agent,'server'),state=(hub as any).online.get(config.runnerId)
+  expect(first.runnerId).not.toBe(config.runnerId);expect(first.runnerInstance).toBe(hub.epoch)
+  state.instance=randomUUID();expect(hub.credentialBinding('owner',agent,'server')).not.toEqual(first)
+  const direct=new RunnerHub(store,auth,{} as GatewayTarget)
+  try{
+    const record=hub.records()[0]!;store.put('_system','runner',record.id,{...record,enabled:false})
+    expect(direct.credentialBinding('owner',agent,'server').runnerInstance).toBe(direct.epoch)
+    expect(()=>direct.credentialBinding('owner',agent)).toThrowError(expect.objectContaining({code:'vault_runner_offline'}))
+    auth.allowed=false;expect(()=>direct.credentialBinding('owner',agent,'server')).toThrowError(expect.objectContaining({code:'vault_agent_forbidden'}))
+  }finally{auth.allowed=true;direct.close()}
+})