Просмотр исходного кода

简化 SSH 密码录入并自动记住首次服务器指纹

Samien 1 месяц назад
Родитель
Сommit
a8c5b5d339

Разница между файлами не показана из-за своего большого размера
+ 6 - 1
docs/credential-vault.md


+ 16 - 8
src/client/components/app/CredentialVaultPanel.vue

@@ -12,10 +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 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 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 editing=ref<CredentialSummary>(), showEditor=ref(false), taskId=ref(''), credentialId=ref(''), operation=ref<CredentialOperation>('website.login')
-const sshAuth=ref<'privateKey'|'password'>('privateKey')
+const sshAuth=ref<'privateKey'|'password'>('password')
 const authenticationChanged=computed(()=>editing.value?.target.kind==='ssh'&&kind.value==='ssh'&&sshAuth.value!==(editing.value.target.auth??'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 secretRequired=computed(()=>!editing.value||authenticationChanged.value)
 const selectedCredential=computed(()=>state.value?.entries.find(entry=>entry.id===credentialId.value))
 const selectedCredential=computed(()=>state.value?.entries.find(entry=>entry.id===credentialId.value))
+const firstSshUse=computed(()=>selectedCredential.value?.target.kind==='ssh'&&!selectedCredential.value.target.hostKey)
 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 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')
 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
 let timer:ReturnType<typeof setInterval>|undefined,alive=true
@@ -23,7 +24,7 @@ const inflight=new Set<AbortController>()
 function clearSecrets(){master.value='';confirmation.value='';replacement.value='';secret.value='';passphrase.value='';contents.value='';clearPassphrase.value=false}
 function clearSecrets(){master.value='';confirmation.value='';replacement.value='';secret.value='';passphrase.value='';contents.value='';clearPassphrase.value=false}
 function failure(cause:unknown){
 function failure(cause:unknown){
   const code=cause&&typeof cause==='object'&&'code' in cause?String(cause.code):''
   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:'凭据执行器暂时不可用,请刷新状态或检查服务。'} 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:'凭据执行器暂时不可用,请刷新状态或检查服务。',vault_host_probe_failed:'无法连接 SSH 服务器;已保存的密码不受影响,请检查主机地址和端口后重试授权。',vault_host_key_required:'首次使用时请选择确认服务器并授权任务。',vault_host_key_rejected:'服务器身份已改变,已停止连接。请核对服务器后再修改高级设置中的指纹。'} as Record<string,string>)[code]??'操作未完成,请检查连接和主密码;秘密不会回显。'
 }
 }
 async function request<T>(path:string,method:'GET'|'POST'|'PUT'|'DELETE'='GET',body?:Record<string,JsonValue>){
 async function request<T>(path:string,method:'GET'|'POST'|'PUT'|'DELETE'='GET',body?:Record<string,JsonValue>){
   const c=new AbortController();inflight.add(c)
   const c=new AbortController();inflight.add(c)
@@ -43,8 +44,8 @@ async function lock(){clearSecrets();showEditor.value=false;await action(async()
 function edit(entry?:CredentialSummary){
 function edit(entry?:CredentialSummary){
   clearSecrets();editing.value=entry;name.value=entry?.name??'';username.value=entry?.username??'';kind.value=entry?.target.kind??'website'
   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:''
   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:''
-  sshAuth.value=entry?.target.kind==='ssh'?entry.target.auth??'privateKey':'privateKey'
+  port.value=entry?.target.kind==='ssh'?entry.target.port:22;hostKey.value=entry?.target.kind==='ssh'?entry.target.hostKey??'':''
+  sshAuth.value=entry?.target.kind==='ssh'?entry.target.auth??'privateKey':'password'
   configureUse.value=!!entry?.usage;useKind.value=entry?.usage&&entry.usage.kind!=='website.form'?entry.usage.kind:'ssh.exec'
   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:''
   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
   maxBytes.value=entry?.usage?.kind==='sftp.read'?entry.usage.maxBytes:65536
@@ -55,7 +56,7 @@ function edit(entry?:CredentialSummary){
 function changeSshAuth(){secret.value='';passphrase.value='';clearPassphrase.value=false}
 function changeSshAuth(){secret.value='';passphrase.value='';clearPassphrase.value=false}
 async function save(){
 async function save(){
   if(secretRequired.value&&!secret.value){error.value=authenticationChanged.value?'更换 SSH 认证方式后,请重新填写密码或私钥。':'请填写密码或私钥。';return}
   if(secretRequired.value&&!secret.value){error.value=authenticationChanged.value?'更换 SSH 认证方式后,请重新填写密码或私钥。':'请填写密码或私钥。';return}
-  const target:Record<string,JsonValue>=kind.value==='website'?{kind:'website',origin:origin.value}:{kind:'ssh',host:host.value,port:port.value,hostKey:hostKey.value,...(sshAuth.value==='password'?{auth:'password'}:{})}
+  const target:Record<string,JsonValue>=kind.value==='website'?{kind:'website',origin:origin.value}:{kind:'ssh',host:host.value,port:port.value,...(hostKey.value.trim()?{hostKey:hostKey.value.trim()}:{}),...(sshAuth.value==='password'?{auth:'password'}:{})}
   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 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}:{}),...(kind.value==='ssh'&&sshAuth.value==='privateKey'?(clearPassphrase.value?{passphrase:''}:passphrase.value?{passphrase:passphrase.value}:{}):{}),...(editing.value?{revision:editing.value.revision}:{})}
   const body={name:name.value,username:username.value,target,...(usage?{usage}:{}),...(secret.value?{secret:secret.value}:{}),...(kind.value==='ssh'&&sshAuth.value==='privateKey'?(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},'凭据已加密保存,密码与私钥不回显')
   await action(async()=>{await request(editing.value?'/entries/'+editing.value.id:'/entries',editing.value?'PUT':'POST',body);showEditor.value=false},'凭据已加密保存,密码与私钥不回显')
@@ -128,11 +129,16 @@ onBeforeUnmount(()=>{
           <label>用户名<input v-model="username" maxlength="256" autocomplete="off" required :disabled="busy" /></label>
           <label>用户名<input v-model="username" maxlength="256" autocomplete="off" required :disabled="busy" /></label>
           <label>用途<select v-model="kind" :disabled="busy||!!editing" @change="changeSshAuth"><option value="website">HTTPS 网站</option><option value="ssh">SSH / SFTP</option></select></label>
           <label>用途<select v-model="kind" :disabled="busy||!!editing" @change="changeSshAuth"><option value="website">HTTPS 网站</option><option value="ssh">SSH / SFTP</option></select></label>
           <label v-if="kind==='website'">准确网站 origin<input v-model="origin" type="url" placeholder="https://example.com" required :disabled="busy" /></label>
           <label v-if="kind==='website'">准确网站 origin<input v-model="origin" type="url" placeholder="https://example.com" required :disabled="busy" /></label>
-          <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 v-else>SSH 主机<input v-model="host" placeholder="IP 地址或域名" required :disabled="busy" /></label>
           <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 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>{{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'">私钥口令(可选)<input v-model="passphrase" type="password" autocomplete="new-password" :disabled="busy" /></label>
           <label v-if="kind==='ssh'&&sshAuth==='privateKey'&&editing" class="checkbox-field"><input v-model="clearPassphrase" type="checkbox" :disabled="busy" />清除原私钥口令(留空输入默认保留)</label>
           <label v-if="kind==='ssh'&&sshAuth==='privateKey'&&editing" class="checkbox-field"><input v-model="clearPassphrase" type="checkbox" :disabled="busy" />清除原私钥口令(留空输入默认保留)</label>
+          <details v-if="kind==='ssh'" class="advanced-connection">
+            <summary>高级连接设置(可选)</summary>
+            <label>端口<input v-model.number="port" type="number" min="1" max="65535" required :disabled="busy" /></label>
+            <label>服务器指纹(可选)<input v-model="hostKey" placeholder="SHA256:…" :disabled="busy" /><small>留空即可保存。首次授权会自动获取并记住服务器指纹;已有指纹会继续校验。</small></label>
+          </details>
           <label class="checkbox-field"><input v-model="configureUse" type="checkbox" :disabled="busy||!!editing?.usage" />配置允许 Bot 执行的固定操作</label>
           <label class="checkbox-field"><input v-model="configureUse" type="checkbox" :disabled="busy||!!editing?.usage" />配置允许 Bot 执行的固定操作</label>
           <template v-if="configureUse&&kind==='website'">
           <template v-if="configureUse&&kind==='website'">
             <p>仅支持主页面的标准 POST 表单。允许跳转仅限此 origin 的成功页;iframe、跨站 SSO、MFA 或验证码转人工。</p>
             <p>仅支持主页面的标准 POST 表单。允许跳转仅限此 origin 的成功页;iframe、跨站 SSO、MFA 或验证码转人工。</p>
@@ -146,7 +152,7 @@ onBeforeUnmount(()=>{
             <label v-else>批准的远端绝对路径<input v-model="remotePath" 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.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>
             <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>首次授权会记住服务器,以后身份改变就停止连接。Bot 只收到退出码或字节数和校验,收不到密码、密钥、输出正文或文件正文。</p>
           </template>
           </template>
           <div class="actions"><button :disabled="busy">加密保存</button><button type="button" :disabled="busy" @click="showEditor=false;clearSecrets()">取消</button></div>
           <div class="actions"><button :disabled="busy">加密保存</button><button type="button" :disabled="busy" @click="showEditor=false;clearSecrets()">取消</button></div>
         </form>
         </form>
@@ -160,7 +166,8 @@ onBeforeUnmount(()=>{
           <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="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>
           <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(selectedCredential)}}</p>
           <p>批准的使用计划:{{describeUse(selectedCredential)}}</p>
-          <button :disabled="busy||!state.tasks.length||!selectedCredential?.usage">授予本任务 60 秒引用权限</button>
+          <p v-if="firstSshUse">首次连接将自动记住这台服务器,之后身份变化会停止连接;无需手填指纹。</p>
+          <button :disabled="busy||!state.tasks.length||!selectedCredential?.usage">{{firstSshUse?'确认服务器并授权本任务 60 秒':'授予本任务 60 秒引用权限'}}</button>
         </form>
         </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>
         <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>
         <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>
@@ -171,6 +178,7 @@ onBeforeUnmount(()=>{
 </template>
 </template>
 
 
 <style scoped>
 <style scoped>
+.advanced-connection{display:grid;gap:12px}.advanced-connection summary{min-height:40px;padding:10px 0;box-sizing:border-box;cursor:pointer;color:var(--text-secondary)}.advanced-connection:not([open])>label{display:none}
 .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 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}
 .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>
 </style>

+ 2 - 1
src/server/credentialVault/adapterExecutor.ts

@@ -2,7 +2,7 @@ import {mkdirSync,lstatSync} from 'node:fs'
 import type {CredentialExecutor,IsolationGate} from './executor.js'
 import type {CredentialExecutor,IsolationGate} from './executor.js'
 import type {VaultEntry,LeaseInput} from './schema.js'
 import type {VaultEntry,LeaseInput} from './schema.js'
 import {WebsiteFormExecutor} from './websiteExecutor.js'
 import {WebsiteFormExecutor} from './websiteExecutor.js'
-import {SshSftpExecutor} from './sshExecutor.js'
+import {SshSftpExecutor,probeSshHostKey} from './sshExecutor.js'
 
 
 /** Shared bounded protocols. The boot path determines the deployment boundary. */
 /** Shared bounded protocols. The boot path determines the deployment boundary. */
 export abstract class CredentialAdapterExecutor implements CredentialExecutor {
 export abstract class CredentialAdapterExecutor implements CredentialExecutor {
@@ -15,5 +15,6 @@ export abstract class CredentialAdapterExecutor implements CredentialExecutor {
     this.website=new WebsiteFormExecutor(home,gate,testCA);this.ssh=new SshSftpExecutor(gate)
     this.website=new WebsiteFormExecutor(home,gate,testCA);this.ssh=new SshSftpExecutor(gate)
   }
   }
   assertAvailable(){this.gate.assert()}
   assertAvailable(){this.gate.assert()}
+  prepareSshHost(target:Extract<LeaseInput['target'],{kind:'ssh'}>,signal:AbortSignal){return probeSshHostKey(target,this.gate,signal)}
   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)}
   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)}
 }
 }

+ 32 - 2
src/server/credentialVault/broker.ts

@@ -16,6 +16,7 @@ export class CredentialVaultBroker {
   private sessions = new Map<string, string>()
   private sessions = new Map<string, string>()
   private epochs = new Map<string, number>()
   private epochs = new Map<string, number>()
   private leases = new Map<string, Lease>()
   private leases = new Map<string, Lease>()
+  private hostPreparations = new Map<AbortController,string>()
   private controlEpoch = ''
   private controlEpoch = ''
   private timer: ReturnType<typeof setInterval>
   private timer: ReturnType<typeof setInterval>
   constructor(readonly store: CredentialVaultStore, private now = Date.now, private executor?: CredentialExecutor) {
   constructor(readonly store: CredentialVaultStore, private now = Date.now, private executor?: CredentialExecutor) {
@@ -67,12 +68,40 @@ export class CredentialVaultBroker {
   }
   }
   revokeOwner(owner: string) {
   revokeOwner(owner: string) {
     this.epochs.set(owner, (this.epochs.get(owner) ?? 0) + 1)
     this.epochs.set(owner, (this.epochs.get(owner) ?? 0) + 1)
+    for(const [controller,preparedOwner] of this.hostPreparations)if(preparedOwner===owner){this.hostPreparations.delete(controller);controller.abort()}
     for (const [id, l] of this.leases) if (l.owner === owner) this.revoke(id)
     for (const [id, l] of this.leases) if (l.owner === owner) this.revoke(id)
   }
   }
-  private revokeAll() { for (const id of [...this.leases.keys()]) this.revoke(id) }
+  private revokeAll() { for (const id of [...this.leases.keys()]) this.revoke(id);for(const controller of this.hostPreparations.keys())controller.abort();this.hostPreparations.clear() }
   private revoke(id: string) { const l = this.leases.get(id); this.leases.delete(id); l?.controller.abort() }
   private revoke(id: string) { const l = this.leases.get(id); this.leases.delete(id); l?.controller.abort() }
   disconnect() { this.store.lockAll(); this.revokeAll(); this.sessions.clear() }
   disconnect() { this.store.lockAll(); this.revokeAll(); this.sessions.clear() }
   close() { clearInterval(this.timer); this.disconnect() }
   close() { clearInterval(this.timer); this.disconnect() }
+  async prepareHost(epoch:string,owner:string,session:string,value:unknown,externalSignal?:AbortSignal){
+    parse(z.string().min(1).max(256),owner);parse(z.string().regex(/^[a-f0-9]{64}$/),session)
+    if(epoch!==this.controlEpoch)throw new HttpError(410,'控制连接已更换,旧授权失效','vault_control_changed')
+    this.authorize(owner,session)
+    const body=parse(z.object({id:z.string().uuid(),revision:z.number().int().positive()}).strict(),value)
+    const entry=this.store.list(owner).find(e=>e.id===body.id)
+    if(!entry||entry.target.kind!=='ssh')throw new HttpError(403,'请选择当前账号的 SSH 凭据','vault_credential_forbidden')
+    if(entry.revision!==body.revision)throw new HttpError(409,'凭据已变化,请刷新','vault_revision_conflict')
+    if(entry.target.hostKey)return entry // A known identity is never replaced by a network probe.
+    if(!this.executor?.prepareSshHost)throw new HttpError(409,'凭据执行器暂时不可用','vault_executor_not_enabled')
+    if(this.hostPreparations.size>=64)throw new HttpError(429,'服务器连接检查过多,请稍后重试','vault_probe_limit')
+    const controller=new AbortController(),generation=this.epochs.get(owner)??0
+    const signal=externalSignal?AbortSignal.any([controller.signal,externalSignal]):controller.signal
+    this.hostPreparations.set(controller,owner)
+    try{
+      signal.throwIfAborted()
+      const hostKey=await this.executor.prepareSshHost(entry.target,signal)
+      signal.throwIfAborted();this.executor.assertAvailable?.();this.authorize(owner,session)
+      if(epoch!==this.controlEpoch||generation!==(this.epochs.get(owner)??0))throw new HttpError(410,'服务器确认已取消,请重新授权','vault_scope_forbidden')
+      this.hostPreparations.delete(controller)
+      const result=this.store.update(owner,entry.id,{name:entry.name,username:entry.username,target:{...entry.target,hostKey},revision:entry.revision})
+      this.audit(owner,'trust-host',entry.id);return result
+    }catch(error){
+      if(error instanceof HttpError)throw error
+      throw new HttpError(409,'服务器确认未完成,请重新解锁并授权','vault_host_probe_failed')
+    }finally{this.hostPreparations.delete(controller)}
+  }
   dispatch(epoch: string, owner: string, session: string, command: string, value: unknown): unknown {
   dispatch(epoch: string, owner: string, session: string, command: string, value: unknown): unknown {
     parse(z.string().min(1).max(256), owner); parse(z.string().regex(/^[a-f0-9]{64}$/), session)
     parse(z.string().min(1).max(256), owner); parse(z.string().regex(/^[a-f0-9]{64}$/), session)
     if (epoch !== this.controlEpoch) throw new HttpError(410, '控制连接已更换,旧授权失效', 'vault_control_changed')
     if (epoch !== this.controlEpoch) throw new HttpError(410, '控制连接已更换,旧授权失效', 'vault_control_changed')
@@ -107,6 +136,7 @@ export class CredentialVaultBroker {
       if (!entry || JSON.stringify(entry.target) !== JSON.stringify(b.target) || (entry.target.kind === 'website') !== (b.operation === 'website.login'))
       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')
         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(entry.usage&&(entry.usage.kind==='website.form'?'website.login':entry.usage.kind)!==b.operation)throw new HttpError(403,'操作不属于用户批准的凭据使用计划','vault_usage_forbidden')
+      if(entry.target.kind==='ssh'&&!entry.target.hostKey)throw new HttpError(409,'首次使用前需要确认 SSH 服务器','vault_host_key_required')
       if ([...this.leases.values()].filter(l => l.owner === owner).length >= 64) throw new HttpError(429, '任务授权数量已达到上限', 'vault_lease_limit')
       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 { 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!) }
       const result: CredentialLeaseSummary = { ...publicInput, id: randomUUID(), expiresAt: Math.min(this.now() + b.seconds * 1000, this.store.state(owner).unlockExpiresAt!) }
@@ -152,7 +182,7 @@ export class CredentialVaultBroker {
       return receipt // No raw executor output, URL, exception, Cookie or secret.
       return receipt // No raw executor output, URL, exception, Cookie or secret.
     } catch(error) {
     } catch(error) {
       this.audit(owner, 'execute-ended', id, 'not-confirmed')
       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
+      if(error instanceof HttpError&&['vault_host_key_rejected','vault_host_key_required','vault_usage_forbidden','vault_isolation_required'].includes(error.code))throw error
       throw new HttpError(409, '操作结果未确认,授权不再允许重试,请人工核对', 'vault_operation_uncertain')
       throw new HttpError(409, '操作结果未确认,授权不再允许重试,请人工核对', 'vault_operation_uncertain')
     } finally { this.revoke(id) }
     } finally { this.revoke(id) }
   }
   }

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

@@ -9,7 +9,7 @@ import type { LocalAuthStore } from '../localAuth.js'
 import type { RunnerHub } from '../runnerHub.js'
 import type { RunnerHub } from '../runnerHub.js'
 import type { WorkspaceAgent, WorkspaceRun } from '../../shared/workspace.js'
 import type { WorkspaceAgent, WorkspaceRun } from '../../shared/workspace.js'
 import type { Work } from '../workspaceScheduler.js'
 import type { Work } from '../workspaceScheduler.js'
-import type { CredentialLeaseSummary, CredentialVaultStatus, CredentialTarget,CredentialUseSummary } from '../../shared/credentialVault.js'
+import type { CredentialLeaseSummary, CredentialVaultStatus, CredentialTarget,CredentialUseSummary,CredentialSummary } from '../../shared/credentialVault.js'
 import { CredentialVaultClient } from './transport.js'
 import { CredentialVaultClient } from './transport.js'
 import { parse, leaseInput } from './schema.js'
 import { parse, leaseInput } from './schema.js'
 
 
@@ -121,8 +121,15 @@ export class CredentialVaultCoordinator {
       const s = this.scope(ctx), body = parse(z.object({ credentialRef: z.string().uuid(), agentId: z.string().uuid(), workId: z.string().uuid(),
       const s = this.scope(ctx), body = parse(z.object({ credentialRef: z.string().uuid(), agentId: z.string().uuid(), workId: z.string().uuid(),
         operation: leaseInput.shape.operation, seconds: leaseInput.shape.seconds }).strict(), (ctx.request as any).body)
         operation: leaseInput.shape.operation, seconds: leaseInput.shape.seconds }).strict(), (ctx.request as any).body)
       this.task(s.owner, body.agentId, body.workId)
       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)
+      const binding = this.binding(s.owner, body.agentId), state: CredentialVaultStatus = await this.call(s, 'status')
+      let entry = state.entries.find(e => e.id === body.credentialRef)
       if (!entry) throw new HttpError(403, '请解锁并选择当前账号的凭据', 'vault_credential_forbidden')
       if (!entry) throw new HttpError(403, '请解锁并选择当前账号的凭据', 'vault_credential_forbidden')
+      if(entry.target.kind==='ssh'&&!entry.target.hostKey){
+        if(!entry.usage||entry.usage.kind!==body.operation)throw new HttpError(403,'请先配置该凭据允许执行的操作','vault_usage_forbidden')
+        entry=await this.call(s,'prepare-host',{id:entry.id,revision:entry.revision}) as CredentialSummary
+        this.task(s.owner,body.agentId,body.workId)
+        if(JSON.stringify(this.binding(s.owner,body.agentId))!==JSON.stringify(binding))throw new HttpError(410,'任务或执行节点已变化,请重新授权','vault_task_revoked')
+      }
       const summary = await this.call(s, 'grant', { ...body, ...binding, target: entry.target }) as CredentialLeaseSummary
       const summary = await this.call(s, 'grant', { ...body, ...binding, target: entry.target }) as CredentialLeaseSummary
       try {
       try {
         this.task(s.owner, body.agentId, body.workId)
         this.task(s.owner, body.agentId, body.workId)

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

@@ -5,6 +5,7 @@ export type CredentialExecutionReceipt =
 export interface CredentialExecutor {
 export interface CredentialExecutor {
   readonly mode?:'dummy-fixture'|'protected-adapters'|'local-adapters'
   readonly mode?:'dummy-fixture'|'protected-adapters'|'local-adapters'
   assertAvailable?():void
   assertAvailable?():void
+  prepareSshHost?(target:Extract<LeaseInput['target'],{kind:'ssh'}>,signal:AbortSignal):Promise<string>
   execute(entry:Readonly<VaultEntry>,input:LeaseInput,signal:AbortSignal):Promise<CredentialExecutionReceipt|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. */
 /** Supplied by the trusted boot path, never by the model or an RPC body. */

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

@@ -41,6 +41,7 @@ export class LocalCredentialVaultClient extends CredentialVaultClient {
     try {
     try {
       const result = command === 'execute'
       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) })()
         ? 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) })()
+        : command === 'prepare-host' ? await broker.prepareHost(this.controlEpoch,owner,session,value)
         : broker.dispatch(this.controlEpoch, owner, session, command, value)
         : broker.dispatch(this.controlEpoch, owner, session, command, value)
       return ['status', 'initialize', 'unlock', 'restore'].includes(command)
       return ['status', 'initialize', 'unlock', 'restore'].includes(command)
         ? { ...(result as object), storageMode: 'local' }
         ? { ...(result as object), storageMode: 'local' }

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

@@ -8,7 +8,7 @@ export const website = z.object({ kind: z.literal('website'), origin: z.string()
   } catch { ctx.addIssue({ code: 'custom', message: '网站需要准确的 HTTPS origin' }); return z.NEVER }
   } catch { ctx.addIssue({ code: 'custom', message: '网站需要准确的 HTTPS origin' }); return z.NEVER }
 }) }).strict()
 }) }).strict()
 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()),
 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}=?$/),
+  port: z.number().int().min(1).max(65535), hostKey: z.string().regex(/^SHA256:[A-Za-z0-9+/]{43}=?$/).optional(),
   auth: z.enum(['privateKey', 'password']).optional() }).strict()
   auth: z.enum(['privateKey', 'password']).optional() }).strict()
 export const target = z.discriminatedUnion('kind', [website, ssh])
 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 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!=='..'))

+ 27 - 4
src/server/credentialVault/sshExecutor.ts

@@ -1,9 +1,30 @@
-import {Client,type SFTPWrapper} from 'ssh2'
+import {Client,type SFTPWrapper,type ServerHostKeyAlgorithm} from 'ssh2'
 import {createHash,timingSafeEqual} from 'node:crypto'
 import {createHash,timingSafeEqual} from 'node:crypto'
 import type {IsolationGate,CredentialExecutionReceipt} from './executor.js'
 import type {IsolationGate,CredentialExecutionReceipt} from './executor.js'
 import type {VaultEntry,LeaseInput} from './schema.js'
 import type {VaultEntry,LeaseInput} from './schema.js'
 import {HttpError} from '../errors.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)))
 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)))
+const serverHostKey:ServerHostKeyAlgorithm[]=['ssh-ed25519','rsa-sha2-512','rsa-sha2-256','ecdsa-sha2-nistp256']
+/** First-use discovery stops at key exchange. It never supplies credentials or authenticates. */
+export async function probeSshHostKey(host:Extract<LeaseInput['target'],{kind:'ssh'}>,gate:IsolationGate,externalSignal:AbortSignal):Promise<string>{
+  gate.assert();externalSignal.throwIfAborted()
+  const conn=new Client(),signal=AbortSignal.any([externalSignal,AbortSignal.timeout(5000)])
+  let abort!:()=>void
+  try{
+    return await new Promise<string>((resolve,reject)=>{
+      let settled=false
+      const fail=()=>{if(!settled){settled=true;reject(new HttpError(503,'无法连接 SSH 服务器,密码已保存,请检查地址和端口','vault_host_probe_failed'))}}
+      abort=()=>{fail();conn.destroy()};signal.addEventListener('abort',abort,{once:true})
+      conn.on('error',fail);conn.on('close',fail)
+      conn.connect({host:host.host.replace(/^\[|\]$/g,''),port:host.port,username:'host-key-probe',authHandler:[],tryKeyboard:false,readyTimeout:5000,
+        algorithms:{serverHostKey},hostVerifier:(key:Buffer)=>{
+          try{signal.throwIfAborted();gate.assert();if(!settled){settled=true;resolve('SHA256:'+createHash('sha256').update(key).digest('base64').replace(/=+$/,''))}}
+          catch{fail()}
+          return false // Reject before authentication even when discovery succeeded.
+        }})
+    })
+  }finally{signal.removeEventListener('abort',abort);conn.destroy()}
+}
 /** Explicit password or private-key authentication with a pinned host. No shell session, command interpolation, PTY,
 /** Explicit password or private-key authentication with a pinned host. No shell session, command interpolation, PTY,
  * environment, agent forwarding, ProxyCommand, jump host or TCP forwarding. */
  * environment, agent forwarding, ProxyCommand, jump host or TCP forwarding. */
 export class SshSftpExecutor {
 export class SshSftpExecutor {
@@ -13,7 +34,9 @@ export class SshSftpExecutor {
     const plan=entry.usage
     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)
     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}
       return {status:'manual_takeover_required',reason:'usage_policy_required',submitted:false}
-    const host=entry.target,expected=Buffer.from(host.hostKey.slice(7),'base64')
+    const host=entry.target
+    if(!host.hostKey)throw new HttpError(403,'首次使用前需要确认 SSH 服务器','vault_host_key_required')
+    const expected=Buffer.from(host.hostKey.slice(7),'base64')
     if(expected.length!==32)throw new HttpError(403,'SSH host key 指纹无效','vault_host_key_rejected')
     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()
     const signal=AbortSignal.any([externalSignal,AbortSignal.timeout(15000)]),conn=new Client()
     let hostRejected=false,finished=false,fail!:(error:unknown)=>void,ready!:()=>void
     let hostRejected=false,finished=false,fail!:(error:unknown)=>void,ready!:()=>void
@@ -24,7 +47,7 @@ export class SshSftpExecutor {
     const invoke=<T>(start:(done:(error:Error|undefined|null,value:T)=>void)=>void)=>Promise.race([call(start),failure])
     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()}
     const abort=()=>{fail(new Error('operation cancelled'));conn.destroy()}
     signal.addEventListener('abort',abort,{once:true})
     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('error',()=>fail(hostRejected?new HttpError(403,'SSH 服务器指纹已改变,已停止连接','vault_host_key_rejected'):new Error('SSH connection unavailable')))
     conn.on('close',()=>{if(!finished)fail(new Error('SSH receipt unavailable'))})
     conn.on('close',()=>{if(!finished)fail(new Error('SSH receipt unavailable'))})
     conn.once('ready',ready)
     conn.once('ready',ready)
     const check=()=>{signal.throwIfAborted();this.gate.assert()}
     const check=()=>{signal.throwIfAborted();this.gate.assert()}
@@ -32,7 +55,7 @@ export class SshSftpExecutor {
       conn.connect({host:host.host.replace(/^\[|\]$/g,''),port:host.port,username:entry.username,
       conn.connect({host:host.host.replace(/^\[|\]$/g,''),port:host.port,username:entry.username,
         ...(host.auth === 'password' ? {password:entry.secret} : {privateKey:entry.secret,...(entry.passphrase?{passphrase:entry.passphrase}:{})}),
         ...(host.auth === 'password' ? {password:entry.secret} : {privateKey:entry.secret,...(entry.passphrase?{passphrase:entry.passphrase}:{})}),
         authHandler:[host.auth === 'password' ? 'password' : 'publickey'],tryKeyboard:false,agentForward:false,readyTimeout:5000,keepaliveInterval:0,
         authHandler:[host.auth === 'password' ? 'password' : 'publickey'],tryKeyboard:false,agentForward:false,readyTimeout:5000,keepaliveInterval:0,
-        algorithms:{serverHostKey:['ssh-ed25519','rsa-sha2-512','rsa-sha2-256','ecdsa-sha2-nistp256']},
+        algorithms:{serverHostKey},
         hostVerifier:(key:Buffer)=>{const digest=createHash('sha256').update(key).digest();const match=timingSafeEqual(digest,expected);hostRejected=!match;return match}})
         hostVerifier:(key:Buffer)=>{const digest=createHash('sha256').update(key).digest();const match=timingSafeEqual(digest,expected);hostRejected=!match;return match}})
       const work=(async()=>{
       const work=(async()=>{
         await Promise.race([connected,failure]);check()
         await Promise.race([connected,failure]);check()

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

@@ -23,6 +23,7 @@ export function serveCredentialVault(broker: CredentialVaultBroker, token: strin
       command: z.string().max(64), value: z.unknown().optional() }).strict(), JSON.parse(Buffer.concat(chunks).toString('utf8')))
       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'
     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); 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) })()
       ? 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) })()
+      : body.command === 'prepare-host' ? await (()=>{const controller=new AbortController();res.once('close',()=>{if(!res.writableEnded)controller.abort()});return broker.prepareHost(body.epoch,body.owner,body.session,body.value,controller.signal)})()
       : broker.dispatch(body.epoch, body.owner, body.session, body.command, body.value)
       : broker.dispatch(body.epoch, body.owner, body.session, body.command, body.value)
     res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify({ result }))
     res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify({ result }))
   })().catch(error => {
   })().catch(error => {

+ 1 - 1
src/shared/credentialVault.ts

@@ -2,7 +2,7 @@
 export const CREDENTIAL_VAULT_PROTOCOL = 1
 export const CREDENTIAL_VAULT_PROTOCOL = 1
 export type CredentialTarget =
 export type CredentialTarget =
   | { kind: 'website'; origin: string }
   | { kind: 'website'; origin: string }
-  | { kind: 'ssh'; host: string; port: number; hostKey: string; auth?: 'privateKey' | 'password' }
+  | { kind: 'ssh'; host: string; port: number; hostKey?: string; auth?: 'privateKey' | 'password' }
 export type CredentialOperation = 'website.login' | 'ssh.exec' | 'sftp.read' | 'sftp.write'
 export type CredentialOperation = 'website.login' | 'ssh.exec' | 'sftp.read' | 'sftp.write'
 export type CredentialUseSummary =
 export type CredentialUseSummary =
   | { kind:'website.form';loginPath:string;submitPath:string;successPath:string;logoutPath?:string;formId:string;usernameName:string;passwordName:string;successSelector:string }
   | { kind:'website.form';loginPath:string;submitPath:string;successPath:string;logoutPath?:string;formId:string;usernameName:string;passwordName:string;successSelector:string }

+ 10 - 7
tests/client/credentialVaultPanel.test.ts

@@ -79,10 +79,11 @@ it('configures an encrypted fixed SFTP body in the dedicated UI, clears it after
   await w.findAll('button').find(b=>b.text()==='添加凭据')!.trigger('click')
   await w.findAll('button').find(b=>b.text()==='添加凭据')!.trigger('click')
   const editor=w.findAll('form')[0]!
   const editor=w.findAll('form')[0]!
   await editor.get('select').setValue('ssh');await editor.get('input[type=checkbox]').setValue(true)
   await editor.get('select').setValue('ssh');await editor.get('input[type=checkbox]').setValue(true)
+  await editor.findAll('label').find(l=>l.text().startsWith('SSH 认证方式'))!.get('select').setValue('privateKey')
   await editor.findAll('label').find(l=>l.text().startsWith('允许操作'))!.get('select').setValue('sftp.write')
   await editor.findAll('label').find(l=>l.text().startsWith('允许操作'))!.get('select').setValue('sftp.write')
   const label=(text:string)=>editor.findAll('label').find(l=>l.text().startsWith(text))!
   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('名称').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('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-key');await label('批准的远端').get('input').setValue('/approved/new.txt')
   await label('固定写入正文').get('textarea').setValue('DUMMY-private-write-body')
   await label('固定写入正文').get('textarea').setValue('DUMMY-private-write-body')
   await editor.trigger('submit');await flushPromises()
   await editor.trigger('submit');await flushPromises()
@@ -90,18 +91,19 @@ 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')
   expect(w.find('textarea').exists()).toBe(false);expect(w.text()).not.toContain('DUMMY-private-write-body')
 })
 })
 
 
-it('saves a masked local SSH password without a private key passphrase and keeps execution subject to task approval',async()=>{
+it('defaults to a masked SSH password and saves it without a host fingerprint or opening advanced settings',async()=>{
   state.unlocked=true;state.storageMode='local';const w=await setup()
   state.unlocked=true;state.storageMode='local';const w=await setup()
   await w.findAll('button').find(b=>b.text()==='添加凭据')!.trigger('click')
   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))!
   const editor=w.get('form'),label=(text:string)=>editor.findAll('label').find(l=>l.text().startsWith(text))!
-  await label('用途').get('select').setValue('ssh');await label('SSH 认证方式').get('select').setValue('password')
+  await label('用途').get('select').setValue('ssh');expect(label('SSH 认证方式').get('select').element.value).toBe('password')
+  expect(editor.get('details').attributes('open')).toBeUndefined();expect(label('服务器指纹').get('input').attributes('required')).toBeUndefined()
   await label('名称').get('input').setValue('Dummy password SSH');await label('用户名').get('input').setValue('dummy')
   await label('名称').get('input').setValue('Dummy password SSH');await label('用户名').get('input').setValue('dummy')
-  await label('SSH 主机').get('input').setValue('ssh.example.test');await label('已人工确认').get('input').setValue('SHA256:'+'A'.repeat(43))
+  await label('SSH 主机').get('input').setValue('ssh.example.test')
   const field=label('SSH 登录密码').get<HTMLInputElement>('input');expect(field.attributes('type')).toBe('password')
   const field=label('SSH 登录密码').get<HTMLInputElement>('input');expect(field.attributes('type')).toBe('password')
   await field.setValue('DUMMY-ssh-login-password')
   await field.setValue('DUMMY-ssh-login-password')
   expect(editor.text()).not.toContain('私钥口令');expect(editor.find('textarea').exists()).toBe(false)
   expect(editor.text()).not.toContain('私钥口令');expect(editor.find('textarea').exists()).toBe(false)
   await editor.trigger('submit');await flushPromises()
   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(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,auth:'password'},secret:'DUMMY-ssh-login-password'}}))
   expect(w.text()).not.toContain('DUMMY-ssh-login-password');expect(w.text()).toContain('本次任务授权')
   expect(w.text()).not.toContain('DUMMY-ssh-login-password');expect(w.text()).toContain('本次任务授权')
 })
 })
 
 
@@ -131,15 +133,16 @@ it('keeps an existing SSH password when its masked replacement field is left bla
   expect(body.target.auth).toBe('password');expect(body).not.toHaveProperty('secret');expect(body).not.toHaveProperty('passphrase')
   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()=>{
+it.each(['pinned','first-use'])('grants a %s local Bot task only its selected configured operation without sending secrets',async trust=>{
   state.unlocked=true;state.storageMode='local';state.execution='local-adapters'
   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.entries=[{id:'credential-id',name:'SSH test',username:'dummy',target:{kind:'ssh',host:'ssh.example.test',port:22,...(trust==='pinned'?{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.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'}]
   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('授予本任务'))!
   const w=await setup(),button=w.findAll('button').find(b=>b.text().startsWith('授予本任务'))!
   expect(button.attributes('disabled')).toBeDefined()
   expect(button.attributes('disabled')).toBeDefined()
   const label=(text:string)=>w.findAll('label').find(l=>l.text().startsWith(text))!
   const label=(text:string)=>w.findAll('label').find(l=>l.text().startsWith(text))!
   await label('凭据').get('select').setValue('credential-id')
   await label('凭据').get('select').setValue('credential-id')
+  if(trust==='first-use'){expect(button.text()).toBe('确认服务器并授权本任务 60 秒');expect(w.text()).toContain('之后身份变化会停止连接')}
   expect(label('操作').get('select').element.value).toBe('ssh.exec')
   expect(label('操作').get('select').element.value).toBe('ssh.exec')
   await w.findAll('button').find(b=>b.text()==='选择此请求,核对后授权')!.trigger('click')
   await w.findAll('button').find(b=>b.text()==='选择此请求,核对后授权')!.trigger('click')
   expect(button.attributes('disabled')).toBeUndefined()
   expect(button.attributes('disabled')).toBeUndefined()

+ 6 - 2
tests/e2e/credential-vault.spec.ts

@@ -31,11 +31,12 @@ test('a default installation creates and saves encrypted passwords through the s
   expect(await panel.textContent()).not.toContain(secret)
   expect(await panel.textContent()).not.toContain(secret)
   await panel.getByRole('button',{name:'添加凭据',exact:true}).click()
   await panel.getByRole('button',{name:'添加凭据',exact:true}).click()
   await panel.getByRole('combobox',{name:/^用途/}).selectOption('ssh')
   await panel.getByRole('combobox',{name:/^用途/}).selectOption('ssh')
-  await panel.getByRole('combobox',{name:/^SSH 认证方式/}).selectOption('password')
+  await expect(panel.getByRole('combobox',{name:/^SSH 认证方式/})).toHaveValue('password')
   await panel.getByLabel('名称',{exact:true}).fill('浏览器测试 SSH 密码')
   await panel.getByLabel('名称',{exact:true}).fill('浏览器测试 SSH 密码')
   await panel.getByLabel('用户名',{exact:true}).fill('dummy-ssh-user')
   await panel.getByLabel('用户名',{exact:true}).fill('dummy-ssh-user')
   await panel.getByLabel('SSH 主机',{exact:true}).fill('ssh.example.test')
   await panel.getByLabel('SSH 主机',{exact:true}).fill('ssh.example.test')
-  await panel.getByLabel('已人工确认的 host key 指纹',{exact:true}).fill('SHA256:'+'A'.repeat(43))
+  await expect(panel.getByLabel(/^服务器指纹/)).toBeHidden()
+  await expect(panel.getByLabel(/^服务器指纹/)).not.toHaveAttribute('required')
   const sshPassword=panel.getByLabel(/^SSH 登录密码/)
   const sshPassword=panel.getByLabel(/^SSH 登录密码/)
   await expect(sshPassword).toHaveAttribute('type','password')
   await expect(sshPassword).toHaveAttribute('type','password')
   await sshPassword.fill('DUMMY-browser-ssh-password')
   await sshPassword.fill('DUMMY-browser-ssh-password')
@@ -52,6 +53,9 @@ test('a default installation creates and saves encrypted passwords through the s
   await sshEntry.getByRole('button',{name:'编辑',exact:true}).click()
   await sshEntry.getByRole('button',{name:'编辑',exact:true}).click()
   await expect(sshPassword).toHaveValue('')
   await expect(sshPassword).toHaveValue('')
   await expect(panel.getByRole('combobox',{name:/^SSH 认证方式/})).toHaveValue('password')
   await expect(panel.getByRole('combobox',{name:/^SSH 认证方式/})).toHaveValue('password')
+  await panel.getByText('高级连接设置(可选)',{exact:true}).click()
+  await expect(panel.getByLabel(/^服务器指纹/)).toHaveValue('')
+  await expect(panel.getByLabel('端口',{exact:true})).toHaveValue('22')
   await panel.getByRole('button',{name:'加密保存',exact:true}).click()
   await panel.getByRole('button',{name:'加密保存',exact:true}).click()
   await expect(sshEntry).toContainText('密码登录')
   await expect(sshEntry).toContainText('密码登录')
   await page.screenshot({path:evidence+'/desktop-saved.png'})
   await page.screenshot({path:evidence+'/desktop-saved.png'})

+ 12 - 3
tests/fixtures/credentialProtocols.ts

@@ -32,8 +32,17 @@ export async function websiteFixture(home:string){
     usage:{kind:'website.form' as const,loginPath:'/login',submitPath:'/login',successPath:'/account',logoutPath:'/logout',formId:'login',usernameName:'username',passwordName:'password',successSelector:'#login-success'},
     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()))}}
     close:async()=>{server.closeAllConnections();await new Promise<void>(r=>server.close(()=>r()))}}
 }
 }
-export async function sshFixture(auth: 'privateKey' | 'password' = 'privateKey'){
-  const host=utils.generateKeyPairSync('ed25519'),key=utils.generateKeyPairSync('ed25519'),parsed=utils.parseKey(key.public) as utils.ParsedKey
+function dummySshKey(){
+  // ssh2 1.17.0 strips leading zero bytes when serializing Ed25519 public
+  // material. Validate generated dummy input, never retry an actual operation.
+  for(let attempt=0;attempt<8;attempt++){
+    const pair=utils.generateKeyPairSync('ed25519')
+    if(!(utils.parseKey(pair.private) instanceof Error)&&!(utils.parseKey(pair.public) instanceof Error))return pair
+  }
+  throw new Error('Unable to generate a valid dummy SSH key')
+}
+export async function sshFixture(auth: 'privateKey' | 'password' = 'privateKey',port=0){
+  const host=dummySshKey(),key=dummySshKey(),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 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 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 executed=Promise.withResolvers<void>(),written=Promise.withResolvers<void>()
@@ -74,7 +83,7 @@ export async function sshFixture(auth: 'privateKey' | 'password' = 'privateKey')
       })
       })
     }))
     }))
   })
   })
-  server.listen(0,'127.0.0.1');await once(server,'listening')
+  server.listen(port,'127.0.0.1');await once(server,'listening')
   return {state,files,executed:executed.promise,written:written.promise,username:'dummy-ssh-user',secret:auth==='password'?password:key.private,target:{kind:'ssh' as const,host:'127.0.0.1',port:(server.address() as any).port,hostKey:fingerprint,...(auth==='password'?{auth}:{})},
   return {state,files,executed:executed.promise,written:written.promise,username:'dummy-ssh-user',secret:auth==='password'?password:key.private,target:{kind:'ssh' as const,host:'127.0.0.1',port:(server.address() as any).port,hostKey:fingerprint,...(auth==='password'?{auth}:{})},
     close:async()=>{for(const client of clients)client.end();await new Promise<void>(r=>server.close(()=>r()))}}
     close:async()=>{for(const client of clients)client.end();await new Promise<void>(r=>server.close(()=>r()))}}
 }
 }

+ 5 - 3
tests/server/credentialVaultBotFlow.test.ts

@@ -26,16 +26,16 @@ import { RunnerHub } from '../../src/server/runnerHub'
 import type { Server } from 'node:http'
 import type { Server } from 'node:http'
 import type { WorkspaceRun } from '../../src/shared/workspace'
 import type { WorkspaceRun } from '../../src/shared/workspace'
 
 
-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 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','ssh.exec.password.first-use','sftp.read.password.first-use','sftp.write.password.first-use'] as const
 const cases=(['external','local'] as const).flatMap(storage=>scenarios.map(scenario=>({storage,scenario})))
 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})=>{
 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 cancelled=scenario.endsWith('.cancel'),firstUse=scenario.endsWith('.first-use'),operation=scenario.replace('.password','').replace('.first-use','').replace(/\.cancel$/,'') as 'website.login'|'ssh.exec'|'sftp.read'|'sftp.write'
   const home=mkdtempSync(join(tmpdir(),'yaoyao-vault-bot-flow-'))
   const home=mkdtempSync(join(tmpdir(),'yaoyao-vault-bot-flow-'))
   const website=await websiteFixture(home),ssh=await sshFixture(scenario.includes('.password')?'password':'privateKey')
   const website=await websiteFixture(home),ssh=await sshFixture(scenario.includes('.password')?'password':'privateKey')
   if(cancelled){website.state.delay=true;ssh.state.delayExec=operation==='ssh.exec';ssh.state.delayWrite=true}
   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 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 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 credentialTarget=isWebsite?{kind:'website',origin:website.origin}:firstUse?(({hostKey:_hostKey,...target})=>target)(ssh.target):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 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 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 store=new WorkspaceStore(home),uploads=new UploadStore(home),auth=new LocalAuthStore(home)
@@ -105,6 +105,7 @@ it.each(cases)('a Bot turn discovers and uses $storage vault references for dumm
   try{
   try{
     await ui('/initialize',{password:master}).expect(200);await ui('/unlock',{password:master}).expect(200)
     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:credentialTarget,usage,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
+    expect(ssh.state.authMethods).toEqual([]) // Saving a password never opens a connection.
     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 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:'请使用我授予当前任务的凭据引用完成本地测试登录。'})
     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})
     await vi.waitFor(()=>expect(['complete','failed']).toContain(store.require<WorkspaceRun>(owner,'run',run.id).status),{timeout:8000})
@@ -114,6 +115,7 @@ it.each(cases)('a Bot turn discovers and uses $storage vault references for dumm
       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)
       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)
     expect((await ui('')).body.leases).toHaveLength(0)
+    if(firstUse){const saved=(await ui('')).body.entries.find((e:any)=>e.id===credentialRef);expect(saved.target.hostKey).toBe(ssh.target.hostKey);expect(saved.revision).toBe(2);expect(ssh.state.authMethods).toEqual(['password'])}
   }finally{
   }finally{
     runtime.close();coordinator.close();await new Promise(resolve=>setTimeout(resolve,10))
     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()))
     for(const ws of hermes.clients)ws.terminate();await new Promise<void>(resolve=>hermes.close(()=>resolve()))

+ 17 - 1
tests/server/credentialVaultBroker.test.ts

@@ -17,7 +17,7 @@ async function fixture(executor?:CredentialExecutorFixture){
   dispatch('initialize',{password});dispatch('unlock',{password})
   dispatch('initialize',{password});dispatch('unlock',{password})
   const entry=dispatch('add',{name:'dummy-site',username:'dummy-username',target:{kind:'website',origin:'https://example.test'},secret})
   const entry=dispatch('add',{name:'dummy-site',username:'dummy-username',target:{kind:'website',origin:'https://example.test'},secret})
   const input:LeaseInput={credentialRef:entry.id,agentId:randomUUID(),workId:randomUUID(),runnerId:randomUUID(),runnerInstance:randomUUID(),runnerEpoch:'dummy-runner-epoch',operation:'website.login',target:entry.target,seconds:60}
   const input:LeaseInput={credentialRef:entry.id,agentId:randomUUID(),workId:randomUUID(),runnerId:randomUUID(),runnerInstance:randomUUID(),runnerEpoch:'dummy-runner-epoch',operation:'website.login',target:entry.target,seconds:60}
-  return {home,store,broker,dispatch,entry,input,grant:()=>dispatch('grant',input),execute:(id:string,b=input,o=owner,s=session)=>broker.execute(epoch,o,s,id,b),advance:(ms:number)=>{now+=ms},reconnect:()=>{epoch=randomUUID();broker.hello(epoch)}}
+  return {home,store,broker,dispatch,entry,input,grant:()=>dispatch('grant',input),prepare:(body:unknown,s=session,o=owner,signal?:AbortSignal)=>broker.prepareHost(epoch,o,s,body,signal),execute:(id:string,b=input,o=owner,s=session)=>broker.execute(epoch,o,s,id,b),advance:(ms:number)=>{now+=ms},reconnect:()=>{epoch=randomUUID();broker.hello(epoch)}}
 }
 }
 afterEach(()=>{brokers.splice(0).forEach(b=>b.close());homes.splice(0).forEach(h=>rmSync(h,{recursive:true,force:true}))})
 afterEach(()=>{brokers.splice(0).forEach(b=>b.close());homes.splice(0).forEach(h=>rmSync(h,{recursive:true,force:true}))})
 it('production has no execution adapter or read-secret RPC, even after user unlock and grant',async()=>{
 it('production has no execution adapter or read-secret RPC, even after user unlock and grant',async()=>{
@@ -72,6 +72,22 @@ it('logout from another login cannot lock the session that unlocked the vault',a
   const f=await fixture();f.grant();f.dispatch('logout',{},otherSession)
   const f=await fixture();f.grant();f.dispatch('logout',{},otherSession)
   expect(f.dispatch('status').unlocked).toBe(true);f.dispatch('logout');expect(f.dispatch('status').unlocked).toBe(false)
   expect(f.dispatch('status').unlocked).toBe(true);f.dispatch('logout');expect(f.dispatch('status').unlocked).toBe(false)
 })
 })
+it.each(['lock','logout','reconnect','update','remove','expiry','close','connection'] as const)('never saves a late first-use host pin after %s',async cause=>{
+  const started=Promise.withResolvers<void>(),release=Promise.withResolvers<string>();let signal:AbortSignal|undefined
+  const f=await fixture({mode:'local-adapters',execute:async()=>{},prepareSshHost:async(_target,s)=>{signal=s;started.resolve();return release.promise}})
+  const target={kind:'ssh',host:'ssh.example.test',port:22,auth:'password'},entry=f.dispatch('add',{name:'dummy ssh',username:'dummy',target,secret})
+  const connection=new AbortController(),run=f.prepare({id:entry.id,revision:entry.revision},session,owner,connection.signal),result=expect(run).rejects.toMatchObject({code:'vault_host_probe_failed'})
+  await started.promise
+  if(cause==='lock'||cause==='logout')f.dispatch(cause)
+  if(cause==='reconnect')f.reconnect()
+  if(cause==='update')f.dispatch('update',{id:entry.id,entry:{name:'changed',username:'dummy',target,revision:1}})
+  if(cause==='remove')f.dispatch('remove',{id:entry.id})
+  if(cause==='expiry'){f.advance(300000);f.dispatch('status')}
+  if(cause==='close')f.broker.close()
+  if(cause==='connection')connection.abort()
+  expect(signal?.aborted).toBe(true);release.resolve('SHA256:'+'A'.repeat(43));await result
+  f.dispatch('unlock',{password});expect(f.dispatch('status').entries.some((e:any)=>e.id===entry.id&&e.target.hostKey)).toBe(false)
+})
 it('refuses symlink audit sinks and leaves their destination unchanged',async()=>{
 it('refuses symlink audit sinks and leaves their destination unchanged',async()=>{
   const f=await fixture(),path=join(f.home,'audit.jsonl'),destination=join(f.home,'dummy-destination')
   const f=await fixture(),path=join(f.home,'audit.jsonl'),destination=join(f.home,'dummy-destination')
   writeFileSync(destination,'unchanged');rmSync(path);symlinkSync(destination,path)
   writeFileSync(destination,'unchanged');rmSync(path);symlinkSync(destination,path)

+ 26 - 0
tests/server/credentialVaultLocalClient.test.ts

@@ -5,6 +5,7 @@ import { join } from 'node:path'
 import { tmpdir } from 'node:os'
 import { tmpdir } from 'node:os'
 import { randomUUID } from 'node:crypto'
 import { randomUUID } from 'node:crypto'
 import { createCredentialVaultClient, LocalCredentialVaultClient } from '../../src/server/credentialVault/localClient'
 import { createCredentialVaultClient, LocalCredentialVaultClient } from '../../src/server/credentialVault/localClient'
+import { sshFixture } from '../fixtures/credentialProtocols'
 
 
 const homes: string[] = [], clients: LocalCredentialVaultClient[] = []
 const homes: string[] = [], clients: LocalCredentialVaultClient[] = []
 const session = 'a'.repeat(64), password = 'DUMMY-local-master-only', secret = 'DUMMY-local-website-secret'
 const session = 'a'.repeat(64), password = 'DUMMY-local-master-only', secret = 'DUMMY-local-website-secret'
@@ -43,6 +44,31 @@ it('binds one-use local execution to the owner and session and requires a config
   await expect(f.call('execute',{id:lease.id,input})).rejects.toMatchObject({code:'vault_lease_revoked'})
   await expect(f.call('execute',{id:lease.id,input})).rejects.toMatchObject({code:'vault_lease_revoked'})
 })
 })
 
 
+it('saves SSH offline, remembers the first host without authenticating, persists its pin and rejects a changed host before sending a password',async()=>{
+  const f=client();await f.call('initialize',{password});await f.call('unlock',{password})
+  let ssh=await sshFixture('password')
+  try{
+    const {hostKey:_initialKey,...target}=ssh.target
+    await ssh.close() // The server is actually offline while the password is saved.
+    const entry=await f.call('add',{name:'Dummy SSH',username:ssh.username,target,secret:ssh.secret,usage:{kind:'ssh.exec',command:'/usr/bin/uptime'}})
+    ssh=await sshFixture('password',target.port);const hostKey=ssh.target.hostKey
+    const input={credentialRef:entry.id,agentId:randomUUID(),workId:randomUUID(),runnerId:randomUUID(),runnerInstance:randomUUID(),runnerEpoch:randomUUID(),operation:'ssh.exec',target,seconds:60}
+    expect(ssh.state.authMethods).toEqual([])
+    await expect(f.call('grant',input)).rejects.toMatchObject({code:'vault_host_key_required'})
+    await expect(f.call('prepare-host',{id:entry.id,revision:entry.revision},'dummy-owner','b'.repeat(64))).rejects.toMatchObject({code:'vault_locked'})
+    const prepared=await f.call('prepare-host',{id:entry.id,revision:entry.revision})
+    expect(prepared.target.hostKey).toBe(hostKey);expect(ssh.state.authMethods).toEqual([])
+    await f.call('lock');await f.call('unlock',{password});expect((await f.call('status')).entries[0].target.hostKey).toBe(hostKey)
+    const port=target.port;await ssh.close();ssh=await sshFixture('password',port)
+    expect(ssh.target.hostKey).not.toBe(hostKey)
+    expect((await f.call('prepare-host',{id:entry.id,revision:prepared.revision})).target.hostKey).toBe(hostKey)
+    const bound={...input,target:prepared.target},lease=await f.call('grant',bound)
+    await expect(f.call('execute',{id:lease.id,input:bound})).rejects.toMatchObject({code:'vault_host_key_rejected'})
+    expect(ssh.state.authMethods).toEqual([]);expect(ssh.state.commands).toEqual([])
+    expect((await f.call('status')).entries[0].target.hostKey).toBe(hostKey)
+  }finally{await ssh.close()}
+},15000)
+
 it('isolates accounts and login sessions and starts locked after a server restart', async () => {
 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 })
   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 })
   await f.call('add', { name: 'Dummy', username: 'dummy', target: { kind: 'website', origin: 'https://example.test' }, secret })

Некоторые файлы не были показаны из-за большого количества измененных файлов