Эх сурвалжийг харах

feat: Bot 账号授权改为弹窗浏览器登录并保留文件导入

Samien 1 сар өмнө
parent
commit
7ea7edaa9c

+ 9 - 1
docs/execution-environments.md

@@ -24,7 +24,11 @@
 
 - **环境变量**:JSON 字符串映射,按资源授予 Bot。保留运行时内部变量、进程加载选项、HOME/PATH 和网络代理变量,防止覆盖执行边界。同一 Bot 的变量名不能重复。
 - **API 服务**:管理员指定 HTTPS 根地址、Bearer 令牌,以及可选的脚本变量名。适用于服务本身发行的模型或生图 API 密钥;`yaoyao_service_request` 只接受该根路径下的请求,不跟随重定向。具体请求格式由 Skill 指定。返回体上限 8 MiB,超大的图像响应应通过虚拟机 SDK 写入文件再导出。
-- **Codex CLI 登录**:显式导入 `auth.json`,仅在虚拟机命令期间生成临时 `CODEX_HOME`;CLI 必须已安装。登录文件采用临时副本,CLI 的续期结果不回写全系统授权;到期后由管理员重新导入。它不会被解释为通用生图 API 密钥。Grok Bot 云登录同样不能直接当作 xAI API 密钥。
+- **Codex 账号登录**:点击「添加授权或变量」打开独立窗口,默认选择「浏览器授权」。点击「打开浏览器授权」,在 ChatGPT 登录页输入窗口中显示的设备码;完成后自动更新为授权成功,再选择允许使用的 Bot 并保存。「导入授权文件」作为另一种选择,支持选择 `auth.json` 文件或粘贴内容。编辑已有授权时,不重新登录也能单独调整名称与 Bot 权限。
+
+浏览器登录遵循 [Codex 官方设备码授权流程](https://learn.chatgpt.com/docs/auth#login-on-headless-devices),可用于局域网和远程访问。账号需要允许设备码登录;Yaoyao 服务器需安装 Codex CLI,并使 `codex` 在服务的 PATH 中可用,或设置 `HERMES_YAOYAO_CODEX_BIN` 为可执行文件路径。macOS 还会检测已安装的 ChatGPT / Codex 应用内的 CLI。每次登录使用独立临时 `CODEX_HOME`,不读取或改写宿主已有登录;成功后立即清理临时目录,待保存的凭据只在服务器内存中保留至本次授权期限结束。取消、切换登录方式、关闭窗口、权限失效和服务关闭都会清理本次登录。登录失败或设备码过期可以重试;弹窗被拦截时可使用「打开浏览器继续授权」。
+
+两种登录方式最终写入同一个加密授权库。仅在虚拟机命令期间生成临时 `CODEX_HOME`,虚拟机内仍需安装 CLI;CLI 的续期结果不回写全系统授权,到期后可重新浏览器授权或导入文件。登录文件不会被解释为通用生图 API 密钥。Grok Bot 云登录同样不能直接当作 xAI API 密钥。
 
 普通接口、模型提示和工具参数不含凭据。Runner 在任务授权有效时通过专用接口读取任务环境;值不写入命令队列或全局进程环境。命令通过 stdin 收到变量和文件,临时文件结束后清理,常见输出中的凭据值被隐藏。授权变更或撤销使旧任务后续调用失效。
 
@@ -51,6 +55,10 @@ DNS 在虚拟机内映射为临时地址,连接时还原域名;Runner 经同
 
 管理员接口前缀 `/api/app/admin/execution`:`GET /`、`PUT /environment`、`PUT /proxy`、`POST /proxy/test`,以及 `/resources` 的新增、按 ID 更新和删除。更新使用配置版本防止覆盖并发编辑;密钥字段省略表示保留,代理密码传空字符串表示清除。
 
+浏览器登录使用 `POST /codex-auth`(请求 UUID)、`GET /codex-auth/:id` 和 `DELETE /codex-auth/:id`。授权请求绑定发起管理员及其权限版本;状态接口只返回进度、设备码与登录链接,不返回凭据。保存 Codex 资源时传 `authAttemptId` 由服务器取用本次已完成的登录,不与 `authJson` 同时传入;校验失败保留本次登录以便修改后重试。
+
 Runner 新增 `execution-context-v1` 和 `vm-egress-v1` 能力、仅机器认证可访问的任务环境/网络配置接口,以及 `network.configure`、`network.test` 指令。任务上下文回调仍绑定原连接、Bot、账号和本轮授权。
 
 自动测试覆盖管理员边界、默认环境与多环境授权独立、旧会话迁移、托管浏览器优先、跨电脑文件传输与消息来源、加密存储、Bot 授权撤销与共享成员检查、服务请求范围、命令临时文件清理、HTTP/SOCKS5 握手与经代理的 TLS DNS 请求、代理并发切换与失效连接回收及表单行为;`tests/live/managedNetwork.live.test.ts` 使用真实 Docker 验证无代理变量的 TCP 程序、DNS、代理故障无直连、业务 root 无网络管理权以及资源清理。
+
+授权弹窗的浏览器验收:先运行 `npm run build`,再运行 `npx playwright test -c playwright.execution-auth.config.ts`。测试使用隔离服务器、模拟 CLI 子进程和模拟登录页面,覆盖设备码、自动完成、保存持久化、凭据不回显及手机端文件导入,不连接真实第三方账号。

+ 1 - 1
playwright.config.ts

@@ -6,7 +6,7 @@ const upstreamPort = Number(process.env.FAKE_HERMES_PORT || 19119)
 export default defineConfig({
   testDir: './tests/e2e',
   // These suites own different fixtures and run with their dedicated configurations.
-  testIgnore: ['**/workspace-chat.spec.ts', '**/team-tools.spec.ts', '**/avatarReference.spec.ts', '**/avatarRoundTrip.spec.ts', '**/serverIdentity.spec.ts', '**/bot-panels.spec.ts', '**/grok-auth.spec.ts', '**/native-environment.spec.ts', '**/hermes-bridge.spec.ts', '**/desktop-hosts.spec.ts'],
+  testIgnore: ['**/workspace-chat.spec.ts', '**/team-tools.spec.ts', '**/avatarReference.spec.ts', '**/avatarRoundTrip.spec.ts', '**/serverIdentity.spec.ts', '**/bot-panels.spec.ts', '**/grok-auth.spec.ts', '**/execution-auth.spec.ts', '**/native-environment.spec.ts', '**/hermes-bridge.spec.ts', '**/desktop-hosts.spec.ts'],
   timeout: 30_000,
   fullyParallel: false,
   workers: 1,

+ 14 - 0
playwright.execution-auth.config.ts

@@ -0,0 +1,14 @@
+import {defineConfig,devices} from '@playwright/test'
+import {resolve} from 'node:path'
+import {tmpdir} from 'node:os'
+process.env.YAOYAO_CODEX_FIXTURE_COMPLETE ??= resolve(tmpdir(),'yaoyao-codex-browser-fixture-complete-'+process.pid)
+process.env.NO_PROXY = [process.env.NO_PROXY,'127.0.0.1','localhost'].filter(Boolean).join(',')
+export default defineConfig({
+  testDir:'./tests/e2e',testMatch:'execution-auth.spec.ts',workers:1,timeout:45000,reporter:'list',
+  use:{baseURL:'http://127.0.0.1:18847',viewport:{width:1440,height:1000},trace:'retain-on-failure',screenshot:'only-on-failure'},
+  projects:[{name:'chromium',use:{...devices['Desktop Chrome']}}],
+  webServer:{
+    command:'node --import tsx tests/fixtures/workspace-server.ts',url:'http://127.0.0.1:18847/healthz',reuseExistingServer:false,timeout:60000,
+    env:{WORKSPACE_FIXTURE_PORT:'18847',WORKSPACE_FIXTURE_UPSTREAM_PORT:'19147',HERMES_YAOYAO_CODEX_BIN:resolve('tests/fixtures/codex-login.mjs'),YAOYAO_CODEX_FIXTURE_COMPLETE:process.env.YAOYAO_CODEX_FIXTURE_COMPLETE},
+  },
+})

+ 125 - 0
src/client/components/app/ExecutionResourceDialog.vue

@@ -0,0 +1,125 @@
+<script setup lang="ts">
+import {computed, nextTick, onBeforeUnmount, ref, watch} from 'vue'
+import {apiRequest} from '@/api/client'
+import {createUuid} from '@/utils/id'
+import StandaloneDialog from '@/components/common/StandaloneDialog.vue'
+import type {CodexAuthorizationAttempt, ExecutionSettingsView, SystemResourceSummary} from '@shared/executionEnvironment'
+
+const props=defineProps<{resource?:SystemResourceSummary; agents:ExecutionSettingsView['agents']}>()
+const emit=defineEmits<{close:[]; saved:[]}>()
+const root='/api/app/admin/execution', deviceUrl='https://auth.openai.com/codex/device'
+const name=ref(props.resource?.name??''),kind=ref(props.resource?.kind??'codex')
+const agentIds=ref([...(props.resource?.agentIds??[])]),baseUrl=ref(props.resource?.baseUrl??'')
+const values=ref(''),token=ref(''),tokenEnv=ref(props.resource?.tokenEnv??''),authJson=ref(''),fileName=ref('')
+const method=ref<'browser'|'file'>('browser'),attempt=ref<CodexAuthorizationAttempt>()
+const busy=ref(false),starting=ref(false),reading=ref(false),error=ref(''),errorBox=ref<HTMLElement>()
+const pending=computed(()=>['starting','pending'].includes(attempt.value?.status??''))
+const canSave=computed(()=>!busy.value&&!starting.value&&!reading.value&&!pending.value&&(kind.value!=='codex'||!!props.resource||(method.value==='browser'?attempt.value?.status==='authorized':!!authJson.value)))
+let timer:ReturnType<typeof setTimeout>|undefined,closed=false,generation=0
+async function fail(message:string){error.value=message;await nextTick();errorBox.value?.focus()}
+async function cancelRequest(id:string){try{await apiRequest(root+'/codex-auth/'+id,{method:'DELETE'})}catch{/* Server expiry also clears abandoned login attempts. */}}
+function abandon(){generation++;clearTimeout(timer);const id=attempt.value?.id;attempt.value=undefined;starting.value=false;if(id)void cancelRequest(id)}
+function close(){if(busy.value)return;abandon();emit('close')}
+function schedule(id:string,version:number){
+  clearTimeout(timer)
+  if(!closed&&version===generation&&pending.value)timer=setTimeout(()=>poll(id,version),1500)
+}
+async function poll(id:string,version:number){
+  try{
+    const result=await apiRequest<CodexAuthorizationAttempt>(root+'/codex-auth/'+id)
+    if(closed||version!==generation)return
+    attempt.value=result;error.value=''
+  }catch(e){if(!closed&&version===generation)error.value=e instanceof Error?e.message:'无法读取授权状态,请重试'}
+  schedule(id,version)
+}
+async function begin(){
+  if(busy.value||starting.value)return
+  abandon();error.value=''
+  const id=createUuid(),version=generation
+  starting.value=true;attempt.value={id,status:'starting',expiresAt:Date.now()+15*60_000}
+  // Open during the click, before awaiting the server; desktop clients route this to the system browser.
+  window.open(deviceUrl,'_blank','noopener,noreferrer,popup,width=640,height=760')
+  try{
+    const result=await apiRequest<CodexAuthorizationAttempt>(root+'/codex-auth',{method:'POST',body:{requestId:id}})
+    if(closed||version!==generation){void cancelRequest(id);return}
+    attempt.value=result;schedule(id,version)
+  }catch(e){if(!closed&&version===generation){abandon();void cancelRequest(id);await fail(e instanceof Error?e.message:'无法开始授权')}}
+  finally{if(version===generation)starting.value=false}
+}
+function validateFile(content:string){
+  try{const data=JSON.parse(content);if(!data||Array.isArray(data)||typeof data!=='object'||!(typeof data.OPENAI_API_KEY==='string'&&data.OPENAI_API_KEY||typeof data.tokens?.access_token==='string'&&data.tokens.access_token))throw new Error()}
+  catch{throw new Error('请选择有效的 Codex auth.json 授权文件')}
+  if(new TextEncoder().encode(content).length>64000)throw new Error('授权文件不能超过 64 KB')
+}
+async function readFile(event:Event){
+  const input=event.target as HTMLInputElement,file=input.files?.[0];if(!file)return
+  authJson.value='';fileName.value='';error.value='';reading.value=true
+  const version=generation
+  try{if(file.size>64000)throw new Error('授权文件不能超过 64 KB');const content=await file.text();validateFile(content);if(!closed&&version===generation){authJson.value=content;fileName.value=file.name}}
+  catch(e){if(!closed&&version===generation)await fail(e instanceof Error?e.message:'无法读取授权文件')}
+  finally{reading.value=false;input.value=''}
+}
+async function save(){
+  if(!canSave.value)return
+  busy.value=true;error.value=''
+  try{
+    let variables:Record<string,string>|undefined
+    if(values.value.trim()){
+      try{variables=JSON.parse(values.value);if(!variables||Array.isArray(variables)||typeof variables!=='object')throw new Error()}
+      catch{throw new Error('环境变量需要 JSON 对象,例如 {"MY_API_KEY":"值"}')}
+    }
+    if(kind.value==='codex'&&method.value==='file'&&authJson.value)validateFile(authJson.value)
+    const body={name:name.value,kind:kind.value,agentIds:agentIds.value,revision:props.resource?.revision??0,
+      ...(variables?{variables}:{}),
+      ...(kind.value==='api'?{baseUrl:baseUrl.value,...(token.value?{token:token.value}:{}),tokenEnv:tokenEnv.value}:{}),
+      ...(kind.value==='codex'&&method.value==='file'&&authJson.value?{authJson:authJson.value}:{}),
+      ...(kind.value==='codex'&&method.value==='browser'&&attempt.value?.status==='authorized'?{authAttemptId:attempt.value.id}:{})}
+    await apiRequest(root+'/resources'+(props.resource?'/'+props.resource.id:''),{method:props.resource?'PUT':'POST',body})
+    attempt.value=undefined;authJson.value='';token.value='';values.value='';emit('saved')
+  }catch(e){await fail(e instanceof Error?e.message:'保存授权失败')}
+  finally{busy.value=false}
+}
+watch([kind,method],()=>{abandon();authJson.value='';fileName.value='';error.value=''})
+onBeforeUnmount(()=>{closed=true;abandon();authJson.value='';token.value='';values.value=''})
+</script>
+
+<template>
+  <StandaloneDialog :title="resource?'编辑授权':'添加授权'" :before-close="()=>!busy" @close="close">
+    <form class="resource-editor" :aria-busy="busy" @submit.prevent="save">
+      <p v-if="error" ref="errorBox" role="alert" tabindex="-1">{{error}}</p>
+      <label>授权类型<select v-model="kind" :disabled="busy||!!resource"><option value="codex">Codex 账号登录</option><option value="api">API 服务</option><option value="variables">环境变量</option></select></label>
+      <template v-if="kind==='codex'">
+        <div class="methods" role="group" aria-label="登录方式"><button type="button" :aria-pressed="method==='browser'" :disabled="busy" @click="method='browser'">浏览器授权(推荐)</button><button type="button" :aria-pressed="method==='file'" :disabled="busy" @click="method='file'">导入授权文件</button></div>
+        <p v-if="resource">已有登录会保留;完成新授权并保存后才会替换。</p>
+        <section v-if="method==='browser'" class="login-card" aria-label="浏览器授权">
+          <p>打开浏览器登录 ChatGPT,输入本次授权码。完成后此窗口会自动更新。</p>
+          <p v-if="attempt?.status==='starting'" role="status">正在获取授权码…</p>
+          <div v-if="attempt?.status==='pending'" class="device-code" role="status"><span>在浏览器中输入授权码</span><strong>{{attempt.userCode}}</strong><small>有效期至 {{new Date(attempt.expiresAt).toLocaleTimeString('zh-CN')}},仅用于你主动发起的本次登录。</small></div>
+          <p v-if="attempt?.status==='authorized'" role="status">浏览器授权成功。选择允许使用的 Bot,然后保存授权。</p>
+          <p v-if="attempt?.error||attempt?.status==='expired'||attempt?.status==='cancelled'" role="alert">{{attempt.error||(attempt.status==='expired'?'授权已过期,请重新登录。':'授权已取消,请重新登录。')}}</p>
+          <div class="actions">
+            <template v-if="pending"><a :href="deviceUrl" target="_blank" rel="noopener noreferrer">打开浏览器继续授权</a><button type="button" :disabled="busy" @click="abandon">取消本次登录</button></template>
+            <button v-else type="button" :disabled="busy" @click="begin">{{attempt?.status==='authorized'?'重新登录':'打开浏览器授权'}}</button>
+          </div>
+          <p class="hint">账号需在 ChatGPT 安全设置中允许设备码登录。浏览器窗口未打开时,可点击“打开浏览器继续授权”。</p>
+        </section>
+        <section v-else class="login-card" aria-label="授权文件导入">
+          <label>选择授权文件<input type="file" accept=".json,application/json" :disabled="busy||reading" @change="readFile"></label>
+          <p v-if="fileName" role="status">已读取 {{fileName}}</p>
+          <details><summary>或粘贴 auth.json 内容</summary><label>Codex auth.json<textarea v-model="authJson" autocomplete="off" spellcheck="false" :disabled="busy" :placeholder="resource?'留空保留原登录':'粘贴 Codex auth.json 内容'" /></label></details>
+        </section>
+        <p class="hint">登录供获授权 Bot 的虚拟机 Codex CLI 使用。登录失效时可重新浏览器授权或导入文件。</p>
+      </template>
+      <template v-if="kind==='api'"><label>服务根地址<input v-model="baseUrl" type="url" placeholder="https://api.example.com/v1/" required :disabled="busy"></label><label>API 授权令牌<input v-model="token" type="password" autocomplete="new-password" :required="!resource" :placeholder="resource?'留空保留原授权':''" :disabled="busy"></label><label>提供给脚本的变量名(可选)<input v-model="tokenEnv" placeholder="例如 XAI_API_KEY" :disabled="busy"></label><p>使用该服务发行的 API 凭据。Grok Bot 云登录与通用生图 API 授权用途不同。</p></template>
+      <label>名称<input v-model="name" required maxlength="80" placeholder="例如:工作账号" :disabled="busy"></label>
+      <label>环境变量 JSON{{resource?'(留空保留原值)':''}}<textarea v-model="values" autocomplete="off" spellcheck="false" placeholder='{"MY_API_KEY":"值"}' :disabled="busy" /></label>
+      <fieldset :disabled="busy"><legend>允许使用的 Bot</legend><p v-if="!agents.length">创建 Bot 后可回来授予权限。</p><label v-for="agent in agents" :key="agent.id" class="check"><input v-model="agentIds" type="checkbox" :value="agent.id">{{agent.name}} <small>账号 {{agent.owner}}</small></label></fieldset>
+      <p class="hint">登录凭据加密保存在当前服务器;只有勾选的 Bot 可以使用。</p>
+      <div class="actions"><button type="submit" :disabled="!canSave">保存授权</button><button type="button" :disabled="busy" @click="close">取消</button></div>
+    </form>
+  </StandaloneDialog>
+</template>
+
+<style scoped>
+.resource-editor{display:grid;gap:16px;max-width:640px;margin:auto;font-size:14px}.resource-editor p{margin:0;line-height:1.65;color:var(--text-secondary);overflow-wrap:anywhere}.resource-editor label{display:grid;gap:8px}.resource-editor input,.resource-editor select,.resource-editor textarea,.resource-editor button,.resource-editor a{min-height:44px;padding:10px 12px;border:1px solid var(--line);border-radius:8px;background:var(--surface);color:var(--text-primary);font:inherit;max-width:100%;box-sizing:border-box}.resource-editor textarea{min-height:90px;resize:vertical;width:100%}.resource-editor button,.resource-editor a,.resource-editor summary{cursor:pointer}.resource-editor a{text-decoration:none;display:inline-flex;align-items:center}.resource-editor :disabled{opacity:.6;cursor:default}.resource-editor :focus-visible{outline:2px solid var(--accent);outline-offset:2px}.resource-editor [role=alert]{color:var(--danger)}.methods,.actions{display:flex;gap:10px;flex-wrap:wrap}.methods button{flex:1}.methods [aria-pressed=true]{border-color:var(--accent);background:var(--surface-soft);font-weight:600}.login-card{display:grid;gap:14px;background:var(--settings-panel);border:1px solid var(--line);border-radius:12px;padding:18px}.device-code{display:grid;gap:8px}.device-code strong{font:600 28px var(--font-mono,monospace);letter-spacing:.08em}.device-code small,.hint{font-size:12px;color:var(--text-secondary)}.resource-editor fieldset{border:1px solid var(--line);border-radius:8px;min-width:0}.resource-editor .check{display:flex;align-items:center;gap:10px;min-height:44px;flex-wrap:wrap}.check input{min-height:20px;width:20px;height:20px}.resource-editor summary{min-height:44px;align-content:center}@media(max-width:600px){.login-card{padding:14px}.methods button{flex-basis:100%}}
+</style>

+ 5 - 19
src/client/components/app/ExecutionSettingsPanel.vue

@@ -1,22 +1,17 @@
 <script setup lang="ts">
 import {onMounted,ref,nextTick} from 'vue'
 import {apiRequest} from '@/api/client'
+import ExecutionResourceDialog from './ExecutionResourceDialog.vue'
 import type {ExecutionSettingsView,SystemResourceSummary} from '@shared/executionEnvironment'
 const errorBox=ref<HTMLElement>()
 const state=ref<ExecutionSettingsView>(),busy=ref(false),error=ref(''),status=ref('')
 const mode=ref<'none'|'server'|'virtual'>('none'),password=ref(''),clearPassword=ref(false)
 const editing=ref<SystemResourceSummary>(),showResource=ref(false)
-const name=ref(''),kind=ref<'variables'|'api'|'codex'>('variables'),values=ref(''),baseUrl=ref(''),token=ref(''),tokenEnv=ref(''),authJson=ref(''),agentIds=ref<string[]>([])
 const root='/api/app/admin/execution'
 async function load(){state.value=await apiRequest<ExecutionSettingsView>(root);mode.value=state.value.execution.mode}
 async function action(work:()=>Promise<void>,message:string){if(busy.value)return;busy.value=true;error.value='';status.value='';try{await work();status.value=message}catch(e){error.value=e instanceof Error?e.message:'保存失败';await nextTick();errorBox.value?.focus()}finally{busy.value=false}}
-function edit(resource?:SystemResourceSummary){editing.value=resource;showResource.value=true;name.value=resource?.name??'';kind.value=resource?.kind??'variables';agentIds.value=[...(resource?.agentIds??[])];baseUrl.value=resource?.baseUrl??'';values.value='';token.value='';authJson.value='';tokenEnv.value=resource?.tokenEnv??''}
-async function saveResource(){await action(async()=>{
-  let vars:Record<string,string>|undefined
-  if(values.value.trim()){vars=JSON.parse(values.value);if(!vars||Array.isArray(vars)||typeof vars!=='object')throw new Error('环境变量需要 JSON 对象,例如 {"MY_API_KEY":"值"}')}
-  const body={name:name.value,kind:kind.value,agentIds:agentIds.value,revision:editing.value?.revision??0,...(vars?{variables:vars}:{}),...(kind.value==='api'?{baseUrl:baseUrl.value,...(token.value?{token:token.value}:{}),tokenEnv:tokenEnv.value}:{}),...(kind.value==='codex'&&authJson.value?{authJson:authJson.value}:{})}
-  await apiRequest(root+'/resources'+(editing.value?'/'+editing.value.id:''),{method:editing.value?'PUT':'POST',body});showResource.value=false;values.value='';token.value='';authJson.value='';await load()
-},'系统授权已保存')}
+function edit(resource?:SystemResourceSummary){editing.value=resource;showResource.value=true}
+async function resourceSaved(){showResource.value=false;await action(load,'系统授权已保存')}
 async function saveEnvironment(){await action(async()=>{await apiRequest(root+'/environment',{method:'PUT',body:{mode:mode.value,revision:state.value!.execution.revision}});await load()},'默认运行环境已保存,从下一轮任务生效')}
 async function saveProxy(){await action(async()=>{const {hasPassword,...proxy}=state.value!.proxy;await apiRequest(root+'/proxy',{method:'PUT',timeoutMs:60000,body:{...proxy,...(clearPassword.value?{password:''}:password.value?{password:password.value}:{})}});password.value='';clearPassword.value=false;await load()},'代理配置已保存,请查看各节点生效状态')}
 async function testProxy(){await action(async()=>{const result=await apiRequest<{nodes:Array<{name:string;ok:boolean;error?:string}>}>(root+'/proxy/test',{method:'POST',timeoutMs:60000,body:{}});if(result.nodes.some(n=>!n.ok))throw new Error(result.nodes.filter(n=>!n.ok).map(n=>`${n.name}:${n.error||'代理连接失败,请核对地址和认证'}`).join(';'))},'在线节点的网络网关已就绪,均可连接已保存的代理')}
@@ -44,17 +39,8 @@ onMounted(()=>action(load,''))
         <h4>Yaoyao 环境与服务授权</h4>
         <p>系统统一保存,按 Bot 授权使用。不受默认运行环境限制;变量和临时登录文件仅注入虚拟机脚本,API 服务通过授权工具使用。授权值不会回显。共享虚拟机的成员必须统一获得变量权限,才能注入脚本。</p>
         <ul><li v-for="resource in state.resources" :key="resource.id"><div><strong>{{resource.name}}</strong><p>{{resource.kind==='codex'?'Codex CLI 登录':resource.kind==='api'?'模型 / 生图等 API 服务':'环境变量'}} · {{resource.agentIds.length}} 个 Bot · {{resource.envKeys.join('、')||'通过服务工具调用'}}</p></div><button type="button" :disabled="busy" @click="edit(resource)">编辑 {{resource.name}}</button><button type="button" :disabled="busy" @click="action(async()=>{await apiRequest(root+'/resources/'+resource.id,{method:'DELETE'});await load()},'授权已撤销')">撤销</button></li></ul>
-        <button type="button" :disabled="busy" @click="edit()">添加变量或服务授权</button>
-        <form v-if="showResource" @submit.prevent="saveResource">
-          <label>名称<input v-model="name" required maxlength="80" :disabled="busy"></label>
-          <label>类型<select v-model="kind" :disabled="busy||!!editing"><option value="variables">环境变量</option><option value="api">API 服务(含 Grok 等模型与生图服务)</option><option value="codex">Codex CLI 登录</option></select></label>
-          <template v-if="kind==='api'"><label>服务根地址<input v-model="baseUrl" type="url" placeholder="https://api.example.com/v1/" required :disabled="busy"></label><label>API 授权令牌<input v-model="token" type="password" autocomplete="new-password" :required="!editing" :placeholder="editing?'留空保留原授权':''" :disabled="busy"></label><label>提供给脚本的变量名(可选)<input v-model="tokenEnv" placeholder="例如 XAI_API_KEY;留空仅供服务工具使用" :disabled="busy"></label><p>使用该服务发行的 API 凭据。Grok Bot 云登录与通用生图 API 授权用途不同。</p></template>
-          <p v-if="kind==='codex'">供虚拟机中的 Codex CLI 使用临时登录副本,续期不回写系统;到期后请重新导入。在服务器执行时使用节点本机登录。</p>
-          <label v-if="kind==='codex'">Codex auth.json<textarea v-model="authJson" autocomplete="off" spellcheck="false" :required="!editing" :placeholder="editing?'留空保留原登录':'粘贴要共享的 Codex auth.json 内容'" :disabled="busy" /></label>
-          <label>环境变量 JSON{{editing?'(留空保留原值)':''}}<textarea v-model="values" autocomplete="off" spellcheck="false" placeholder='{"MY_API_KEY":"值"}' :disabled="busy" /></label>
-          <fieldset :disabled="busy"><legend>允许使用的 Bot</legend><p v-if="!state.agents.length">创建 Bot 后可回来授予权限。</p><label v-for="agent in state.agents" :key="agent.id" class="check"><input v-model="agentIds" type="checkbox" :value="agent.id">{{agent.name}} <small>账号 {{agent.owner}}</small></label></fieldset>
-          <div class="actions"><button :disabled="busy">保存授权</button><button type="button" :disabled="busy" @click="showResource=false;values='';token='';authJson=''">取消</button></div>
-        </form>
+        <button type="button" :disabled="busy" @click="edit()">添加授权或变量</button>
+        <ExecutionResourceDialog v-if="showResource" :resource="editing" :agents="state.agents" @close="showResource=false" @saved="resourceSaved" />
       </section>
       <form @submit.prevent="saveProxy">
         <h4>虚拟机全局代理</h4>

+ 1 - 0
src/server/app.ts

@@ -603,6 +603,7 @@ export function createApplication(options: ApplicationOptions = {}): Application
     desktopEnvironments.close()
     desktopHosts.close()
       grokCloud.authorization.close()
+      executionSettings.close()
       workspaceRoutines.close()
       workspacePlugins.close()
       workspaceRuntime.close()

+ 128 - 0
src/server/codexAuthorization.ts

@@ -0,0 +1,128 @@
+import {spawn, type ChildProcess} from 'node:child_process'
+import {existsSync, mkdtempSync, readFileSync, rmSync, statSync} from 'node:fs'
+import {homedir, tmpdir} from 'node:os'
+import {delimiter, join} from 'node:path'
+import type {LocalAuthStore} from './localAuth.js'
+import type {CodexAuthorizationAttempt} from '../shared/executionEnvironment.js'
+import {HttpError} from './errors.js'
+
+const TTL = 15 * 60_000
+export const CODEX_DEVICE_URL = 'https://auth.openai.com/codex/device'
+function codexBinary() {
+  if (process.env.HERMES_YAOYAO_CODEX_BIN) return process.env.HERMES_YAOYAO_CODEX_BIN
+  const command = process.platform === 'win32' ? 'codex.exe' : 'codex'
+  const candidates = (process.env.PATH ?? '').split(delimiter).filter(Boolean).map(path => join(path, command))
+  if (process.platform === 'darwin') for (const base of ['/Applications',join(homedir(),'Applications')]) {
+    candidates.push(join(base,'ChatGPT.app/Contents/Resources/codex-cli/CodexCLI.app/Contents/MacOS/codex'),join(base,'Codex.app/Contents/Resources/codex'))
+  }
+  return candidates.find(path => existsSync(path)) ?? command
+}
+interface Attempt {
+  owner:string; version:number; state:CodexAuthorizationAttempt; directory:string
+  child?:ChildProcess; timer?:ReturnType<typeof setTimeout>; output:string; authJson?:string
+}
+
+/** Each login has its own credential store; the host's Codex login is never read. */
+export class CodexAuthorization {
+  private attempts = new Map<string, Attempt>()
+  constructor(private auth:LocalAuthStore, private launch:typeof spawn = spawn) {}
+
+  begin(owner:string, id:string):CodexAuthorizationAttempt {
+    const previous = this.attempts.get(owner)
+    if (previous?.state.id === id) return this.snapshot(owner, id)
+    if (previous) this.stop(previous, 'cancelled')
+    if (!this.auth.isAdminActive(owner)) throw new HttpError(403, '需要管理员权限', 'admin_required')
+    const directory = mkdtempSync(join(tmpdir(), 'yaoyao-codex-login-'))
+    const attempt:Attempt = {owner, version:this.auth.pushAuthorizationVersion(owner) ?? 0, directory, output:'',
+      state:{id, status:'starting', expiresAt:Date.now() + TTL}}
+    this.attempts.set(owner, attempt)
+    attempt.timer = setTimeout(() => this.stop(attempt, 'expired'), TTL)
+    attempt.timer.unref()
+    const env:NodeJS.ProcessEnv = {...process.env, CODEX_HOME:directory}
+    for (const key of ['OPENAI_API_KEY','CODEX_API_KEY','CODEX_ACCESS_TOKEN','OPENAI_FEDERATION_RULE_ID','OPENAI_IDENTITY_TOKEN_FILE']) delete env[key]
+    try {
+      const child = this.launch(codexBinary(),
+        ['login','--device-auth','-c','cli_auth_credentials_store="file"'],
+        {cwd:directory, env, stdio:['ignore','pipe','pipe'], windowsHide:true})
+      attempt.child = child
+      const receive = (chunk:Buffer) => {
+        if (!this.active(attempt) || !['starting','pending'].includes(attempt.state.status)) return
+        // The CLI uses ANSI styling and can split either the URL or the code across chunks.
+        attempt.output = (attempt.output + chunk.toString()).slice(-16000)
+        const plain = attempt.output.replace(/\x1b\[[0-9;]*[A-Za-z]/g, '')
+        const code = plain.match(/^\s*([A-Z0-9]{4}-[A-Z0-9]{4,5})\s*$/m)?.[1]
+        if (plain.includes(CODEX_DEVICE_URL) && code) {
+          attempt.state = {...attempt.state, status:'pending', loginUrl:CODEX_DEVICE_URL, userCode:code}
+        }
+      }
+      child.stdout?.on('data', receive)
+      child.stderr?.on('data', receive)
+      child.once('error', (error:NodeJS.ErrnoException) => {
+        this.stop(attempt, 'failed', error.code === 'ENOENT'
+          ? '服务器未找到 Codex CLI,请安装后重试,或选择导入授权文件。'
+          : '无法启动浏览器授权,请稍后重试或导入授权文件。')
+      })
+      child.once('close', code => {
+        attempt.child = undefined
+        try {
+          if (!this.active(attempt) || !['starting','pending'].includes(attempt.state.status)) return
+          if (code !== 0) throw new Error('login failed')
+          const path = join(directory, 'auth.json')
+          if (statSync(path).size > 64000) throw new Error('invalid credentials')
+          const authJson = readFileSync(path, 'utf8'), data = JSON.parse(authJson)
+          if (typeof data?.tokens?.access_token !== 'string' || !data.tokens.access_token) throw new Error('missing credentials')
+          attempt.authJson = authJson
+          attempt.output = ''
+          attempt.state = {id, status:'authorized', expiresAt:attempt.state.expiresAt}
+        } catch {
+          this.stop(attempt, 'failed', '浏览器授权未完成,请确认账号已开启设备码登录后重试,或导入授权文件。')
+        } finally { this.cleanup(attempt) }
+      })
+    } catch { this.stop(attempt, 'failed', '无法启动浏览器授权,请检查服务器上的 Codex CLI,或导入授权文件。') }
+    return {...attempt.state}
+  }
+
+  private active(attempt:Attempt) {
+    if (this.attempts.get(attempt.owner) !== attempt) return false
+    if (!this.auth.isAdminActive(attempt.owner) || this.auth.pushAuthorizationVersion(attempt.owner) !== attempt.version) {
+      this.stop(attempt, 'cancelled'); return false
+    }
+    if (Date.now() >= attempt.state.expiresAt) { this.stop(attempt, 'expired'); return false }
+    return true
+  }
+
+  private require(owner:string, id:string) {
+    const attempt = this.attempts.get(owner)
+    if (!attempt || attempt.state.id !== id) throw new HttpError(404, '这次授权已结束,请重新登录', 'codex_auth_not_found')
+    this.active(attempt)
+    return attempt
+  }
+
+  snapshot(owner:string, id:string) { return {...this.require(owner, id).state} }
+  credentials(owner:string, id:string) {
+    const attempt = this.require(owner, id)
+    if (attempt.state.status !== 'authorized' || !attempt.authJson) throw new HttpError(409, '请先完成浏览器授权', 'codex_auth_incomplete')
+    return attempt.authJson
+  }
+  cancel(owner:string, id:string) {
+    const attempt = this.require(owner, id)
+    this.stop(attempt, 'cancelled')
+    return {...attempt.state}
+  }
+  private cleanup(attempt:Attempt) { rmSync(attempt.directory, {recursive:true, force:true}) }
+  private stop(attempt:Attempt, status:CodexAuthorizationAttempt['status'], error?:string) {
+    clearTimeout(attempt.timer)
+    attempt.output = ''; attempt.authJson = undefined
+    attempt.state = {id:attempt.state.id, expiresAt:attempt.state.expiresAt, status, ...(error ? {error} : {})}
+    const child = attempt.child
+    if (child && child.exitCode === null && child.signalCode === null) {
+      child.kill()
+      const force = setTimeout(() => child.kill('SIGKILL'), 2000); force.unref()
+      child.once('close', () => clearTimeout(force))
+    } else this.cleanup(attempt)
+  }
+  close() {
+    for (const attempt of this.attempts.values()) this.stop(attempt, 'cancelled')
+    this.attempts.clear()
+  }
+}

+ 19 - 1
src/server/executionSettings.ts

@@ -10,6 +10,7 @@ import type {LocalAuthStore} from './localAuth.js'
 import type {WorkspaceAgent} from '../shared/workspace.js'
 import type {ExecutionSelection,VmProxySettings,TaskEnvironment,SystemResourceSummary} from '../shared/executionEnvironment.js'
 import {HttpError} from './errors.js'
+import {CodexAuthorization} from './codexAuthorization.js'
 
 const SYSTEM='_system', KIND='execution-settings'
 const key=z.string().regex(/^[A-Za-z_][A-Za-z0-9_]{0,127}$/).refine(k=>! /^(?:YAOYAO_|HERMES_YAOYAO_|NODE_OPTIONS$|LD_|DYLD_|PYTHONPATH$|PYTHONHOME$|BASH_ENV$|ENV$|HOME$|PATH$|CODEX_HOME$|HTTPS?_PROXY$|ALL_PROXY$|NO_PROXY$)/i.test(k),'该变量由运行环境管理')
@@ -23,11 +24,13 @@ export const SERVICE_TOOLS=[{id:'yaoyao_service_request',name:'yaoyao_service_re
 
 /** One encrypted system store; grants reference globally unique Bot IDs. */
 export class ExecutionSettings {
+  readonly codexAuthorization:CodexAuthorization
   testProxy:()=>Promise<unknown>=async()=>{throw new HttpError(409,'未连接虚拟环境执行节点','proxy_node_unavailable')}
   onProxyChange:()=>Promise<void>=async()=>{}
   nodeStatus:()=>Array<{id:string;name:string;online:boolean;status:string}>=()=>[]
   activeTurns:()=>Array<{agentId:string;mode:ExecutionSelection['mode'];revision:number}>=()=>[]
   constructor(readonly store:WorkspaceStore,readonly nodes:WorkspaceNodes,readonly auth:LocalAuthStore){
+    this.codexAuthorization=new CodexAuthorization(auth)
     if(!store.get(SYSTEM,KIND,'selection')){
       const existing=existsSync(join(store.home,'host-tools.json'))||!!store.db.prepare("SELECT 1 FROM workspace_entities WHERE kind='agent' LIMIT 1").get()
       store.put(SYSTEM,KIND,'selection',{mode:existing?'server':'none',revision:1})
@@ -88,7 +91,22 @@ export class ExecutionSettings {
   router(){const router=new Router({prefix:'/api/app/admin/execution'});router.use(async(ctx,next)=>{this.auth.requireAdmin(ctx);ctx.set('Cache-Control','no-store');await next()});const body=(ctx:any)=>ctx.request.body
     router.get('/',ctx=>{ctx.body=this.view()});router.put('/environment',ctx=>{ctx.body=this.saveSelection(body(ctx))});router.put('/proxy',async ctx=>{ctx.body=await this.saveProxy(body(ctx))})
     router.post('/proxy/test',async ctx=>{ctx.body=await this.testProxy()})
-    router.post('/resources',ctx=>{ctx.body=this.saveResource(body(ctx));ctx.status=201});router.put('/resources/:id',ctx=>{ctx.body=this.saveResource(body(ctx),ctx.params.id)});router.delete('/resources/:id',ctx=>{this.removeResource(ctx.params.id);ctx.body={ok:true}})
+    router.post('/codex-auth',ctx=>{const {requestId}=parse(z.object({requestId:z.string().uuid()}).strict(),body(ctx));ctx.body=this.codexAuthorization.begin(this.auth.requireAdmin(ctx).id,requestId)})
+    router.get('/codex-auth/:id',ctx=>{ctx.body=this.codexAuthorization.snapshot(this.auth.requireAdmin(ctx).id,ctx.params.id)})
+    router.delete('/codex-auth/:id',ctx=>{ctx.body=this.codexAuthorization.cancel(this.auth.requireAdmin(ctx).id,ctx.params.id)})
+    const save=(ctx:any,id?:string)=>{
+      const {authAttemptId,...input}=parse(resourceInput.extend({authAttemptId:z.string().uuid().optional()}),body(ctx))
+      const owner=this.auth.requireAdmin(ctx).id
+      if(authAttemptId){
+        if(input.kind!=='codex'||input.authJson!==undefined)throw new HttpError(400,'请选择一种登录授权方式','execution_resource_invalid')
+        input.authJson=this.codexAuthorization.credentials(owner,authAttemptId)
+      }
+      const result=this.saveResource(input,id)
+      if(authAttemptId)this.codexAuthorization.cancel(owner,authAttemptId)
+      return result
+    }
+    router.post('/resources',ctx=>{ctx.body=save(ctx);ctx.status=201});router.put('/resources/:id',ctx=>{ctx.body=save(ctx,ctx.params.id)});router.delete('/resources/:id',ctx=>{this.removeResource(ctx.params.id);ctx.body={ok:true}})
     return router
   }
+  close(){this.codexAuthorization.close()}
 }

+ 4 - 0
src/shared/executionEnvironment.ts

@@ -8,6 +8,10 @@ export interface ExecutionSelection {
 }
 export interface VmProxySettings { enabled: boolean; protocol: 'http' | 'socks5'; host: string; port: number; username: string; password?: string; revision: number }
 export interface TaskEnvironment { variables: Record<string,string>; files: Array<{name:string;content:string;envKey:string}> }
+export interface CodexAuthorizationAttempt {
+  id:string; status:'starting'|'pending'|'authorized'|'failed'|'expired'|'cancelled'
+  expiresAt:number; loginUrl?:string; userCode?:string; error?:string
+}
 export interface SystemResourceSummary {
   id:string; name:string; kind:'variables'|'api'|'codex'; revision:number; agentIds:string[]
   envKeys:string[]; tokenEnv?:string; baseUrl?:string; configured:boolean

+ 68 - 0
tests/client/executionResourceDialog.test.ts

@@ -0,0 +1,68 @@
+import {flushPromises,mount,type VueWrapper} from '@vue/test-utils'
+import {afterEach,beforeEach,expect,it,vi} from 'vitest'
+import Dialog from '@/components/app/ExecutionResourceDialog.vue'
+import {apiRequest} from '@/api/client'
+vi.mock('@/api/client',()=>({apiRequest:vi.fn()}))
+const request=vi.mocked(apiRequest),root='/api/app/admin/execution'
+let wrapper:VueWrapper
+const mountDialog=(resource?:any)=>mount(Dialog,{props:{agents:[{id:'bot-1',name:'夭夭',owner:'admin'}],resource},global:{stubs:{StandaloneDialog:{template:'<div role="dialog"><slot /></div>'}}}})
+const button=(text:string)=>wrapper.findAll('button').find(b=>b.text()===text)!
+beforeEach(()=>{request.mockReset();vi.spyOn(window,'open').mockReturnValue(null);request.mockResolvedValue({})})
+afterEach(()=>{wrapper?.unmount();vi.restoreAllMocks();vi.useRealTimers()})
+it('defaults to browser login, opens on the click, polls completion and saves only the attempt reference',async()=>{
+  vi.useFakeTimers();let resolveStart!:(value:any)=>void
+  request.mockImplementation(async(path,options)=>{
+    if(path===root+'/codex-auth')return new Promise(resolve=>{resolveStart=resolve})
+    if(path.startsWith(root+'/codex-auth/')&&!options?.method)return {id:'attempt',status:'authorized',expiresAt:Date.now()+60000}
+    return {}
+  })
+  wrapper=mountDialog();expect(button('浏览器授权(推荐)').attributes('aria-pressed')).toBe('true')
+  expect(wrapper.find('input[type=file]').exists()).toBe(false);expect(button('保存授权').attributes('disabled')).toBeDefined()
+  await button('打开浏览器授权').trigger('click')
+  expect(window.open).toHaveBeenCalledWith('https://auth.openai.com/codex/device','_blank',expect.stringContaining('noopener'))
+  expect(wrapper.text()).toContain('正在获取授权码')
+  const id=(request.mock.calls[0]![1]!.body as any).requestId
+  resolveStart({id,status:'pending',loginUrl:'https://auth.openai.com/codex/device',userCode:'ABCD-12345',expiresAt:Date.now()+60000});await flushPromises()
+  expect(wrapper.get('a').attributes('href')).toBe('https://auth.openai.com/codex/device')
+  expect(wrapper.text()).toContain('ABCD-12345')
+  await vi.advanceTimersByTimeAsync(1500);await flushPromises()
+  expect(wrapper.text()).toContain('浏览器授权成功')
+  await wrapper.get('input[maxlength="80"]').setValue('工作账号');await wrapper.get('input[type=checkbox]').setValue(true)
+  await wrapper.get('form').trigger('submit');await flushPromises()
+  expect(request).toHaveBeenCalledWith(root+'/resources',{method:'POST',body:{name:'工作账号',kind:'codex',agentIds:['bot-1'],revision:0,authAttemptId:'attempt'}})
+  expect(wrapper.emitted('saved')).toHaveLength(1)
+})
+it('cancels a late login response when switching to file import and keeps file grants explicit',async()=>{
+  let finish!:(v:any)=>void
+  request.mockImplementation(async path=>path===root+'/codex-auth'?new Promise(resolve=>{finish=resolve}):{})
+  wrapper=mountDialog();await button('打开浏览器授权').trigger('click')
+  const id=(request.mock.calls[0]![1]!.body as any).requestId
+  await button('导入授权文件').trigger('click')
+  finish({id,status:'authorized',expiresAt:Date.now()+60000});await flushPromises()
+  expect(request).toHaveBeenCalledWith(root+'/codex-auth/'+id,{method:'DELETE'})
+  expect(wrapper.text()).not.toContain('浏览器授权成功')
+  const input=wrapper.get('input[type=file]'),content=JSON.stringify({tokens:{access_token:'fixture-secret'}})
+  Object.defineProperty(input.element,'files',{value:[{name:'auth.json',size:content.length,text:async()=>content}]})
+  await input.trigger('change');await flushPromises();expect(wrapper.text()).toContain('已读取 auth.json')
+  expect((wrapper.get('input[type=checkbox]').element as HTMLInputElement).checked).toBe(false)
+  await wrapper.get('input[maxlength="80"]').setValue('导入账号');await wrapper.get('form').trigger('submit');await flushPromises()
+  expect(request).toHaveBeenCalledWith(root+'/resources',{method:'POST',body:{name:'导入账号',kind:'codex',agentIds:[],revision:0,authJson:content}})
+})
+it('validates pasted credentials without echoing secret content in the error',async()=>{
+  wrapper=mountDialog();await button('导入授权文件').trigger('click')
+  await wrapper.get('details textarea').setValue('invalid fixture-secret');await wrapper.get('form').trigger('submit');await flushPromises()
+  expect(wrapper.get('[role=alert]').text()).toBe('请选择有效的 Codex auth.json 授权文件')
+  expect(request).not.toHaveBeenCalled()
+})
+it('retains an existing login when only changing its name or grants',async()=>{
+  wrapper=mountDialog({id:'resource',kind:'codex',name:'旧账号',agentIds:['bot-1'],revision:2})
+  await wrapper.get('input[maxlength="80"]').setValue('新名称');await wrapper.get('form').trigger('submit');await flushPromises()
+  expect(request).toHaveBeenCalledWith(root+'/resources/resource',{method:'PUT',body:{name:'新名称',kind:'codex',agentIds:['bot-1'],revision:2}})
+})
+it('shows failed and expired login states and permits a fresh attempt',async()=>{
+  request.mockResolvedValue({id:'attempt',status:'expired',expiresAt:0})
+  wrapper=mountDialog();await button('打开浏览器授权').trigger('click');await flushPromises()
+  expect(wrapper.text()).toContain('授权已过期');expect(button('保存授权').attributes('disabled')).toBeDefined()
+  request.mockResolvedValue({id:'next',status:'failed',expiresAt:0,error:'服务器未找到 Codex CLI'})
+  await button('打开浏览器授权').trigger('click');await flushPromises();expect(wrapper.text()).toContain('未找到 Codex CLI')
+})

+ 3 - 2
tests/client/executionSettingsPanel.test.ts

@@ -20,8 +20,9 @@ it('keeps proxy credentials write-only and allows explicitly clearing the saved
   expect(JSON.stringify(request.mock.calls)).not.toContain('hasPassword');wrapper.unmount()
 })
 it('does not grant new resources to every Bot and reports invalid variable JSON without sending it',async()=>{
-  const wrapper=mount(ExecutionSettingsPanel);await flushPromises()
-  await wrapper.findAll('button').find(b=>b.text()==='添加变量或服务授权')!.trigger('click')
+  const wrapper=mount(ExecutionSettingsPanel,{global:{stubs:{StandaloneDialog:{template:'<div role="dialog"><slot /></div>'}}}});await flushPromises()
+  await wrapper.findAll('button').find(b=>b.text()==='添加授权或变量')!.trigger('click')
+  await wrapper.get('[role=dialog] select').setValue('variables')
   expect(wrapper.findAll('fieldset input').every(i=>!(i.element as HTMLInputElement).checked)).toBe(true)
   await wrapper.get('textarea').setValue('{broken')
   const count=request.mock.calls.length;await wrapper.findAll('form')[1]!.trigger('submit');await flushPromises()

+ 40 - 0
tests/e2e/execution-auth.spec.ts

@@ -0,0 +1,40 @@
+import {test,expect} from '@playwright/test'
+import {rmSync,writeFileSync} from 'node:fs'
+
+test('authorizes in a separate window, persists the login and retains file import on mobile',async({page,context},testInfo)=>{
+  const marker=process.env.YAOYAO_CODEX_FIXTURE_COMPLETE!
+  rmSync(marker,{force:true})
+  try{
+    await context.route('https://auth.openai.com/codex/device',route=>route.fulfill({contentType:'text/html; charset=utf-8',body:'<!doctype html><html lang="zh"><title>模拟账号授权</title><h1>模拟账号授权</h1><p>仅用于测试,不连接真实账号。</p><button>完成授权</button></html>'}))
+    await page.goto('/conversations')
+    await page.getByRole('textbox',{name:'账号',exact:true}).fill('fixture');await page.getByRole('textbox',{name:'密码',exact:true}).fill('fixture-pass');await page.getByRole('button',{name:'登录',exact:true}).click()
+    await page.locator('.desktop-sidebar').getByRole('button',{name:'工具',exact:true}).click();await page.getByRole('menuitem',{name:'Bot 设置',exact:true}).click()
+    const settings=page.getByRole('dialog',{name:'Bot 设置',exact:true})
+    await settings.getByRole('button',{name:'环境与授权',exact:true}).click();await settings.getByRole('button',{name:'添加授权或变量',exact:true}).click()
+    const dialog=page.getByRole('dialog',{name:'添加授权',exact:true})
+    await expect(dialog.getByRole('button',{name:'浏览器授权(推荐)',exact:true})).toHaveAttribute('aria-pressed','true')
+    await expect(dialog.getByRole('button',{name:'保存授权',exact:true})).toBeDisabled()
+    const popupPromise=context.waitForEvent('page');await dialog.getByRole('button',{name:'打开浏览器授权',exact:true}).click();const popup=await popupPromise
+    await expect(popup.getByRole('heading',{name:'模拟账号授权'})).toBeVisible()
+    await expect(dialog.getByText('TEST-12345',{exact:true})).toBeVisible()
+    await page.screenshot({path:testInfo.outputPath('browser-authorization.png')})
+    await popup.getByRole('button',{name:'完成授权',exact:true}).click();writeFileSync(marker,'complete');await popup.close();await page.bringToFront()
+    await expect(dialog.getByText('浏览器授权成功。选择允许使用的 Bot,然后保存授权。',{exact:true})).toBeVisible()
+    await dialog.getByRole('textbox',{name:'名称',exact:true}).fill('浏览器授权验收')
+    await dialog.getByRole('button',{name:'保存授权',exact:true}).click();await expect(dialog).toBeHidden()
+    await expect(settings.getByRole('button',{name:'编辑 浏览器授权验收',exact:true})).toBeVisible()
+    const resources=await(await page.request.get('/api/app/admin/execution')).json()
+    expect(resources.resources[0]).toMatchObject({name:'浏览器授权验收',kind:'codex',agentIds:[],configured:true})
+    expect(JSON.stringify(resources)).not.toContain('codex-browser-fixture-token')
+    await settings.getByRole('button',{name:'编辑 浏览器授权验收',exact:true}).click()
+    const edit=page.getByRole('dialog',{name:'编辑授权',exact:true});await expect(edit.getByText('已有登录会保留;完成新授权并保存后才会替换。',{exact:true})).toBeVisible()
+    await page.setViewportSize({width:390,height:844})
+    await edit.getByRole('button',{name:'导入授权文件',exact:true}).click()
+    await edit.getByLabel('选择授权文件').setInputFiles({name:'auth.json',mimeType:'application/json',buffer:Buffer.from(JSON.stringify({tokens:{access_token:'file-fixture-token'}}))})
+    await expect(edit.getByText('已读取 auth.json',{exact:true})).toBeVisible()
+    await page.screenshot({path:testInfo.outputPath('file-import-mobile.png')})
+    expect(await edit.evaluate(el=>el.scrollWidth<=el.clientWidth)).toBe(true)
+    await edit.getByRole('button',{name:'保存授权',exact:true}).click();await expect(edit).toBeHidden()
+    const updated=await(await page.request.get('/api/app/admin/execution')).json();expect(updated.resources[0].revision).toBe(2)
+  }finally{rmSync(marker,{force:true})}
+})

+ 12 - 0
tests/fixtures/codex-login.mjs

@@ -0,0 +1,12 @@
+#!/usr/bin/env node
+// Browser acceptance only: no network, host credentials, or real provider account.
+import {existsSync,writeFileSync} from 'node:fs'
+import {join} from 'node:path'
+if (!process.env.YAOYAO_CODEX_FIXTURE_COMPLETE || !process.env.CODEX_HOME?.includes('yaoyao-codex-login-')) process.exit(1)
+process.stdout.write('Open https://auth.openai.com/codex/device\n\x1b[94mTEST-12345\x1b[0m\n')
+const timer=setInterval(()=>{
+  if (!existsSync(process.env.YAOYAO_CODEX_FIXTURE_COMPLETE)) return
+  clearInterval(timer)
+  writeFileSync(join(process.env.CODEX_HOME,'auth.json'),JSON.stringify({tokens:{access_token:'codex-browser-fixture-token',refresh_token:'codex-browser-fixture-refresh'}}),{mode:0o600})
+  process.exit(0)
+},100)

+ 1 - 2
tests/fixtures/workspace-server.ts

@@ -1,4 +1,3 @@
-import {nativeEnvironmentFixture} from './native-environment.js'
 import {FixtureGrokAuthProvider} from './grok-auth-provider.js'
 import { FixtureBotPlugins } from './bot-plugins.js'
 import { defaultAgentIdentity, encodeAgentAvatar } from '../../src/shared/agentIdentity.js'
@@ -380,7 +379,7 @@ const auth = new LocalAuthStore(home, false)
 const grokAuth=process.env.WORKSPACE_FIXTURE_GROK_AUTH==='1'?new FixtureGrokAuthProvider():undefined
 const runtime = createApplication({ config, auth, grokFetch:grokAuth?.fetch, pluginFetch: pluginsFixture?.fetch }),
   node = createNodeServer(runtime)
-const closeNative=process.env.WORKSPACE_FIXTURE_NATIVE==='1'?nativeEnvironmentFixture(runtime,home,port):undefined
+const closeNative=process.env.WORKSPACE_FIXTURE_NATIVE==='1'?(await import('./native-environment.js')).nativeEnvironmentFixture(runtime,home,port):undefined
 if (process.env.WORKSPACE_FIXTURE_COMMUNICATION === '1') {
   const owner = JSON.parse(readFileSync(usersPath, 'utf8')).users[0].id as string
   const store = runtime.workspace

+ 70 - 0
tests/server/codexAuthorization.test.ts

@@ -0,0 +1,70 @@
+// @vitest-environment node
+import {afterEach,beforeEach,expect,it,vi} from 'vitest'
+import {EventEmitter} from 'node:events'
+import {PassThrough} from 'node:stream'
+import {existsSync,statSync,writeFileSync} from 'node:fs'
+import {join} from 'node:path'
+import {randomUUID} from 'node:crypto'
+import {CodexAuthorization,CODEX_DEVICE_URL} from '../../src/server/codexAuthorization'
+
+let auth:CodexAuthorization,version:number,admin:boolean
+const processes:Array<{child:any;directory:string;options:any;args:string[]}>=[]
+const secret=JSON.stringify({tokens:{access_token:'fixture-access-secret',refresh_token:'fixture-refresh-secret'}})
+beforeEach(()=>{
+  version=1;admin=true;processes.length=0
+  const launch=vi.fn((_command,args,options)=>{
+    const child=Object.assign(new EventEmitter(),{stdout:new PassThrough(),stderr:new PassThrough(),exitCode:null,signalCode:null,
+      kill:vi.fn(()=>{child.signalCode='SIGTERM';child.emit('close',null);return true})})
+    processes.push({child,directory:options.cwd,options,args});return child
+  })
+  auth=new CodexAuthorization({isAdminActive:()=>admin,pushAuthorizationVersion:()=>version} as any,launch as any)
+})
+afterEach(()=>{auth.close();vi.useRealTimers();vi.unstubAllEnvs()})
+function pending(){const id=randomUUID();auth.begin('alice',id);const p=processes.at(-1)!;p.child.stdout.write(`Open ${CODEX_DEVICE_URL}\n\x1b[94mABCD-`);p.child.stdout.write('12345\x1b[0m\n');return {id,...p}}
+function complete(p:ReturnType<typeof pending>){writeFileSync(join(p.directory,'auth.json'),secret);p.child.exitCode=0;p.child.emit('close',0)}
+it('starts an isolated CLI login, parses split styled output and returns no credentials',()=>{
+  vi.stubEnv('CODEX_ACCESS_TOKEN','host-secret')
+  const p=pending()
+  expect(p.args).toEqual(['login','--device-auth','-c','cli_auth_credentials_store="file"'])
+  expect(p.options.env.CODEX_HOME).toBe(p.directory);expect(p.options.env.CODEX_ACCESS_TOKEN).toBeUndefined()
+  expect(statSync(p.directory).mode&0o777).toBe(0o700)
+  expect(auth.snapshot('alice',p.id)).toMatchObject({status:'pending',loginUrl:CODEX_DEVICE_URL,userCode:'ABCD-12345'})
+  complete(p)
+  expect(existsSync(p.directory)).toBe(false)
+  expect(auth.credentials('alice',p.id)).toBe(secret)
+  expect(auth.snapshot('alice',p.id)).toEqual({id:p.id,status:'authorized',expiresAt:expect.any(Number)})
+})
+it('isolates owners and invalidates replaced and cancelled attempts, including late completion',()=>{
+  const p=pending();expect(()=>auth.credentials('bob',p.id)).toThrow('已结束')
+  expect(()=>auth.credentials('alice',p.id)).toThrow('请先完成')
+  auth.begin('alice',p.id);expect(processes).toHaveLength(1)
+  const next=auth.begin('alice',randomUUID());expect(p.child.kill).toHaveBeenCalled();expect(existsSync(p.directory)).toBe(false)
+  expect(()=>auth.snapshot('alice',p.id)).toThrow('已结束')
+  auth.cancel('alice',next.id);expect(auth.snapshot('alice',next.id).status).toBe('cancelled')
+  p.child.emit('close',0);expect(()=>auth.credentials('alice',next.id)).toThrow('请先完成')
+})
+it.each(['permission','expiry'])('discards authorized credentials after %s changes',reason=>{
+  vi.useFakeTimers();const p=pending();complete(p)
+  if(reason==='permission')version++
+  else vi.advanceTimersByTime(15*60_000)
+  expect(()=>auth.credentials('alice',p.id)).toThrow('请先完成')
+  expect(auth.snapshot('alice',p.id).status).toBe(reason==='permission'?'cancelled':'expired')
+})
+it('stops an in-flight login when admin rights are removed and ignores late output',()=>{
+  const p=pending();admin=false
+  expect(auth.snapshot('alice',p.id).status).toBe('cancelled');expect(p.child.kill).toHaveBeenCalled()
+  p.child.stdout.write(`\n${CODEX_DEVICE_URL}\nABCD-12345\n`)
+  expect(auth.snapshot('alice',p.id).userCode).toBeUndefined()
+})
+it('reports launch and provider failures without exposing raw output',()=>{
+  const p=pending();p.child.emit('error',Object.assign(new Error('sensitive error'),{code:'ENOENT'}))
+  expect(auth.snapshot('alice',p.id).error).toContain('未找到 Codex CLI')
+  const next=pending();next.child.stderr.write('fixture-access-secret');next.child.exitCode=1;next.child.emit('close',1)
+  expect(auth.snapshot('alice',next.id).status).toBe('failed')
+  expect(JSON.stringify(auth.snapshot('alice',next.id))).not.toContain('fixture-access-secret')
+  expect(existsSync(next.directory)).toBe(false)
+})
+it('does not treat an exit code alone as successful login',()=>{
+  const p=pending();p.child.exitCode=0;p.child.emit('close',0)
+  expect(auth.snapshot('alice',p.id).status).toBe('failed')
+})

+ 22 - 2
tests/server/executionSettings.test.ts

@@ -13,6 +13,7 @@ import {parseHostTools} from '../../src/server/hostToolSettings'
 import {TASK_COMMAND,redactTaskOutput} from '../../src/runner/worker/taskEnvironment'
 import {spawnSync} from 'node:child_process'
 import Koa from 'koa'
+import {bodyParser} from '@koa/bodyparser'
 import request from 'supertest'
 import {LocalAuthStore} from '../../src/server/localAuth'
 let home:string,store:WorkspaceStore,settings:ExecutionSettings
@@ -24,7 +25,7 @@ beforeEach(()=>{
   settings=new ExecutionSettings(store,nodes,{isUserActive:()=>true} as any)
   for(const [owner,agentId] of [['alice',id],['bob',other]])store.put(owner!,'agent',agentId!,{id:agentId,name:owner,nodeId:'local',profile:'default',archived:false})
 })
-afterEach(()=>{vi.restoreAllMocks();store.db.close();rmSync(home,{recursive:true,force:true})})
+afterEach(()=>{settings.close();vi.restoreAllMocks();store.db.close();rmSync(home,{recursive:true,force:true})})
 it('defaults a new installation to no environment; shared resources require explicit Bot grants',()=>{
   expect(settings.selection()).toMatchObject({mode:'none',credentialSource:'yaoyao'})
   const resource=settings.saveResource({name:'API',kind:'api',baseUrl:'https://api.example.test/v1/',token:'fixture-sensitive-token',tokenEnv:'XAI_API_KEY',agentIds:[id]})
@@ -74,7 +75,7 @@ it('requires an administrator on every settings endpoint and never returns crede
   app.use(router.routes())
   for(const role of ['', 'member']){
     const status=role?403:401
-    for(const [method,path] of [['get',''],['put','/environment'],['put','/proxy'],['post','/proxy/test'],['post','/resources'],['put','/resources/'+randomUUID()],['delete','/resources/'+randomUUID()]]){
+    for(const [method,path] of [['get',''],['put','/environment'],['put','/proxy'],['post','/proxy/test'],['post','/resources'],['put','/resources/'+randomUUID()],['delete','/resources/'+randomUUID()],['post','/codex-auth'],['get','/codex-auth/'+randomUUID()],['delete','/codex-auth/'+randomUUID()]]){
       await (request(app.callback()) as any)[method!]('/api/app/admin/execution'+path).set('x-role',role).expect(status)
     }
   }
@@ -82,6 +83,25 @@ it('requires an administrator on every settings endpoint and never returns crede
   expect(response.headers['cache-control']).toBe('no-store')
   expect(JSON.stringify(response.body)).not.toContain('private-fixture-key')
 })
+it('saves a browser login through the encrypted resource store and only consumes it after successful validation',async()=>{
+  const secret=JSON.stringify({tokens:{access_token:'browser-fixture-secret'}}),attemptId=randomUUID()
+  settings.auth.requireAdmin=()=>({id:'alice',role:'admin'}) as any
+  const credentials=vi.spyOn(settings.codexAuthorization,'credentials').mockReturnValue(secret)
+  const consume=vi.spyOn(settings.codexAuthorization,'cancel').mockReturnValue({id:attemptId,status:'cancelled',expiresAt:0})
+  const app=new Koa(),router=settings.router()
+  app.use(async(ctx,next)=>{try{await next()}catch(error){ctx.status=(error as any).status??500;ctx.body={error:'rejected'}}})
+  app.use(bodyParser());app.use(router.routes())
+  const body={name:'浏览器登录',kind:'codex',agentIds:[id],authAttemptId:attemptId}
+  await request(app.callback()).post('/api/app/admin/execution/resources').send({...body,revision:9}).expect(409)
+  expect(consume).not.toHaveBeenCalled()
+  await request(app.callback()).post('/api/app/admin/execution/resources').send({...body,authJson:secret}).expect(400)
+  const result=await request(app.callback()).post('/api/app/admin/execution/resources').send(body).expect(201)
+  expect(credentials).toHaveBeenCalledWith('alice',attemptId);expect(consume).toHaveBeenCalledWith('alice',attemptId)
+  expect(JSON.stringify(result.body)).not.toContain('browser-fixture-secret')
+  expect(JSON.stringify(store.list('_system','execution-resource'))).not.toContain('browser-fixture-secret')
+  expect(settings.openTurn('alice',id).vmTaskEnvironment().files).toEqual([{name:'auth.json',content:secret,envKey:'CODEX_HOME'}])
+  expect(settings.openTurn('bob',other).vmTaskEnvironment().files).toEqual([])
+})
 it('requires every co-resident Bot to be granted credentials before injecting them into a shared VM',()=>{
   const environment=randomUUID()
   for(const [owner,agentId] of [['alice',id],['bob',other]])store.put(owner!,'agent',agentId!,{...store.require<any>(owner!,'agent',agentId!),computerEnvironmentId:environment})