Przeglądaj źródła

接入显式授权的可信共享电脑及跨端验收

LSamien 1 miesiąc temu
rodzic
commit
5e0e17d7af

+ 4 - 0
.dockerignore

@@ -11,6 +11,10 @@
 !index.html
 !public/
 !public/**
+!deploy/
+!deploy/**
+!third-party/
+!third-party/**
 !scripts/
 !scripts/**
 !src/

+ 3 - 1
Dockerfile

@@ -6,12 +6,14 @@ WORKDIR /app
 
 COPY package.json package-lock.json ./
 COPY release.json ./
-RUN npm ci
+RUN ELECTRON_SKIP_BINARY_DOWNLOAD=1 npm ci
 
 COPY tsconfig.json tsconfig.client.json tsconfig.client.build.json tsconfig.server.json vite.config.ts ./
 COPY index.html ./
 COPY public ./public
 COPY scripts ./scripts
+COPY deploy ./deploy
+COPY third-party ./third-party
 COPY src ./src
 
 RUN npm run build \

+ 4 - 0
docs/agent-platform-implementation.md

@@ -91,3 +91,7 @@
 - 维护阶段最终验证:类型检查和 767 项全量测试通过;真实新镜像的工作区恢复后重启读写通过;打包 Runner / 新镜像的完整 Worker 验收 2 项通过,包含公网、临时助手和人工接管。恢复保留挂载根目录以兼容 macOS 文件共享,并测试中断回退。
 
 - 桌面补齐阶段:内置服务版本与 App 对齐校验,已有外部服务显示实际版本;登录启动为原生显式开关,支持登录时仅驻留菜单栏。App 内置服务禁用源码部署更新器,提供 App 手动更新/数据回退说明。6 项桌面生命周期及凭据/偏好/版本测试、14 项更新器定向测试通过。真实开机与最后完整包验收仍需区分,见 `docs/macos-app.md`。
+
+- 可信共享阶段:用户显式授权同来源/Profile 的持久成员,新建共享工作区;电脑租约串行,成员模型历史独立,临时助手与模型组队工具不能自行加入共享。共享电脑固定原 Runner;人工控制按整个环境互斥。真实 Docker / 新镜像 / 打包 Worker 的跨成员文件和会话边界验收通过。见 `docs/shared-computers.md`。
+- Web 6 项流程通过;iOS、Android 原生电脑面板的只读/接管/输入/交还 UI 与 HTTP 往返均通过,截图已核对。iOS 28 项 Workspace 测试、Android 模型与 Debug 编译通过。最终完整 App 打包、默认 Docker 控制面验收和清单核对继续推进。
+- 共享阶段最终源码验证:770 项全量测试通过;旧 Runner 能力拒绝保留原错误契约,子账号隔离用例定向 20 项及最终全量均通过。共享电脑 UI 6 项通过,真实共享 Worker 2 项通过。镜像配方和许可证纳入默认 Docker 构建输入。

+ 1 - 1
docs/computer-control.md

@@ -26,4 +26,4 @@ Web 和 macOS 共用电脑面板;iOS、Android 使用相同的账号 API。只
 - 控制面测试:只读状态不返回凭据、其他账号/页面拒绝、令牌和授权版本校验、同一接管请求重试。
 - Runner 测试:查看不调用输入、代次/画面/坐标校验、输入去重、撤权停止、原电脑租约转交。
 - 真实 Docker + 打包 Runner + Hermes 模型循环:执行中接管、原任务转为等待、真实截图、点击打开终端、输入命令生成文件、错误令牌拒绝、交还后原任务完成。模型响应来自本地确定性测试服务,未调用付费模型。
-- Web 浏览器测试覆盖只读、接管、输入、交还和窄屏;iOS 28 项 Workspace 测试通过,包含授权/画面代次和撤权清理;Android 模型测试与 Debug Kotlin 编译通过。原生电脑面板的模拟器视觉验收还需完成,不能以编译替代。
+- Web 浏览器测试覆盖只读、接管、输入、交还和窄屏;iOS 28 项 Workspace 测试通过,包含授权/画面代次和撤权清理;Android 模型测试与 Debug Kotlin 编译通过。iOS 和 Android 的原生电脑面板已通过模拟器 UI/HTTP 往返,实际截图已核对,见 `docs/shared-computers.md`。

+ 2 - 2
docs/computer-provider.md

@@ -12,7 +12,7 @@
 - 默认 `--network none`,不发布任何端口。容器只有 loopback 网卡,不能直接访问宿主服务或外网。自己的主机名映射到 loopback,保证 XFCE/VNC 在无网络环境下正常启动。可通过专属进程通道启用受控公网代理,仍不增加外部网卡或端口。详见 `docs/computer-network.md`。
 - 桌面通过容器内的 CUA socket 操作;验收已读取真实截图。查看和人工接管已接入账号鉴权及独立控制租约,见 `docs/computer-control.md`。
 - 取消或无法确认的 guest 命令会停止所属私有容器,确认停止后才释放串行执行位置。仅终止 `docker exec` 客户端不能证明 guest 进程已停止。
-- 删除容器保留工作目录,重建后可继续读取工作文件。停止后的工作区备份、校验恢复和重建见 `docs/computer-maintenance.md`;共享桌面仍待实现。
+- 删除容器保留工作目录,重建后可继续读取工作文件。停止后的工作区备份、校验恢复和重建见 `docs/computer-maintenance.md`;可信共享电脑见 `docs/shared-computers.md`。
 
 权限校验以回调传入,在异步检查与实际执行的前后重验。ComputerPool 在 SQLite 中保存持有者、租约代次、期限与停止状态,默认每个 Runner 同时 2 个环境、总计最多 32 个环境。启动时必须先恢复未确认停止的资源;旧租约先失效,再停止并删除旧容器身份,保留工作区。不确认停止的环境保持占用,不能交给新任务。当前 Provider 与资源池都不作为裸命令接口对浏览器或模型开放,通过 Worker 和任务授权检查使用。
 
@@ -31,4 +31,4 @@ YAOYAO_COMPUTER_IMAGE=sha256:本机已验证镜像的完整ID \
   npx vitest run tests/live/computer.live.test.ts
 ```
 
-本轮 arm64 验证镜像为 `sha256:574b51992c53e22d48997d4286ba83e47b872de34250276c99d2f390bf3640e0`。镜像构建与跨端电脑面板已有实现,见对应文档;共享环境仍待完成,不能以本测试替代整个 VM 功能验收。
+本轮 arm64 验证镜像为 `sha256:574b51992c53e22d48997d4286ba83e47b872de34250276c99d2f390bf3640e0`。镜像构建与跨端电脑面板已有实现,见对应文档;可信共享环境见 `docs/shared-computers.md`,不能以本测试替代整个 VM 功能验收。

+ 33 - 0
docs/shared-computers.md

@@ -0,0 +1,33 @@
+# 可信成员共享电脑
+
+默认每个 Agent 使用自己的电脑、工作目录及浏览器资料。用户可在 Web/macOS 的“电脑 → 电脑共享设置”中选择 2–8 名持久成员,明确确认共享工作文件与浏览器登录资料后创建共享电脑。成员必须属于当前账号,使用同一来源、同一 Profile 和同一个支持 `shared-computer-v1` 的 Runner。
+
+## 执行与数据边界
+
+- 共享电脑是新工作区。原独立电脑的数据保留,不隐式搬运或合并。
+- 共享成员共用电脑环境及浏览器资料,所以只适用于彼此可信的成员;他们不拥有彼此的模型会话历史。Worker 会话同时核对 Agent、账号、环境和 Profile。
+- 同一电脑的租约互斥。一个成员执行时,其他成员等待资源,未提交的任务不消耗执行重试次数;取消只停止持有该租约的工作。
+- 电脑面板的成员选择器可查看不同的独立电脑;选择共享成员时看到同一环境。同一画面只允许一个人工控制会话,不能从另一个成员入口绕过控制权。
+- 创建和解除共享都要求成员没有排队或在途任务、没有有效人工接管。模型组队工具不能传入共享环境 ID,也不能创建或修改共享授权。临时助手始终使用任务所属的独立电脑。
+- 共享电脑固定原 Runner。更换来源 Runner 时明确报错,不在另一台机器静默创建空环境。旧 Runner 能力不足时拒绝共享执行。
+- 解除共享后,各成员回到原独立电脑;共享数据保留在原 Runner,仍可通过维护工具按环境 ID 备份。解除共享不是删除共享文件。
+
+同一共享电脑是一个桌面,输入串行;多名 Agent 的独立电脑可并行运行。当前没有在同一容器中提供多个独立 X 桌面,也不将共享浏览器资料宣称为成员间隔离。
+
+## 客户端
+
+Web 与 macOS 提供创建/解除入口。iOS、Android 显示共享名称、资料范围并支持查看和接管;共享成员设置引导到 Web/macOS。所有操作仍由服务器检查权限,移动端不是绕过共享授权的通道。
+
+## 验证
+
+- 服务端覆盖显式信任、账号/成员/来源边界、临时助手拒绝、模型参数拒绝、重复请求、在途任务与人工接管期间拒绝修改、解除后旧授权拒绝。
+- 真实 Docker、新镜像、打包 Runner 与 Hermes Worker:两名 Agent 先后访问同一工作文件,等待占用的电脑;第二人的模型历史不包含第一人的用户消息;原独立文件保留。共享电脑不能经第二个成员入口重复人工接管,控制中不能解除共享。
+- Web 六项流程通过,包含创建和解除共享,窄屏截图已核对。
+- iOS 28 项 Workspace 测试和 Android 模型/编译通过;两端生产电脑面板的 UI 测试均经过一次真实 HTTP 往返,验证只读、接管、文字输入、交还说明并回到 Agent 状态。界面截图使用固定测试图片;实际电脑输入效果由上述 Docker 验收证明,两类证据分别记录。
+
+原生 UI 的隔离测试服务可用以下命令启动,测试结束后关闭:
+
+```sh
+python3 tests/fixtures/native-computer.py --port 18842 --image public/icons/icon-512.png # iOS
+python3 tests/fixtures/native-computer.py --port 18841 --image public/icons/icon-512.png # Android
+```

+ 2 - 0
scripts/copy-worker.mjs

@@ -7,3 +7,5 @@ await copyFile(resolve(import.meta.dirname,'../src/runner/worker/hermes_worker.p
 await copyFile(resolve(import.meta.dirname,'../src/runner/network/guest_proxy.py'),resolve(directory,'guest_proxy.py'))
 
 await cp(resolve(import.meta.dirname,'../deploy/computer'),resolve(directory,'../computers/computer-image'),{recursive:true})
+
+await cp(resolve(import.meta.dirname,'../third-party'),resolve(directory,'../computers/third-party'),{recursive:true})

+ 24 - 2
src/client/components/workspace/ComputerPanel.vue

@@ -5,9 +5,12 @@ import {createUuid} from '@/utils/id'
 import type {WorkspaceAgent} from '@shared/workspace'
 import type {ComputerControlStatus,ComputerFrame,ComputerInput} from '@shared/computerControl'
 import type {JsonValue} from '@shared/types'
-const props=defineProps<{agents:WorkspaceAgent[]}>(),emit=defineEmits<{close:[]}>()
+const props=defineProps<{agents:WorkspaceAgent[]}>(),emit=defineEmits<{close:[];changed:[]}>()
 const selected=ref(props.agents[0]?.id??''),dialog=ref<HTMLDialogElement>()
 const state=ref<ComputerControlStatus>({mode:'off'}),frame=ref<ComputerFrame>(),ticket=ref<{controlId:string;token:string}>()
+const managing=ref(false),shareName=ref('团队共享电脑'),shareMembers=ref<string[]>([]),trusted=ref(false)
+const selectedAgent=computed(()=>props.agents.find(agent=>agent.id===selected.value))
+const candidates=computed(()=>props.agents.filter(agent=>!agent.temporaryGoalId&&!agent.computerEnvironmentId&&!agent.archived))
 const error=ref(''),busy=ref(false),text=ref(''),notes=ref('')
 let takeRequestId:string|undefined
 let timer:ReturnType<typeof setTimeout>|undefined,closed=false,polling=false,lastRenew=0,generation=0
@@ -47,6 +50,9 @@ function click(event:MouseEvent){
   const rect=(event.currentTarget as HTMLImageElement).getBoundingClientRect()
   send({kind:'click',x:Math.min(frame.value.width-1,Math.max(0,Math.floor((event.clientX-rect.left)*frame.value.width/rect.width))),y:Math.min(frame.value.height-1,Math.max(0,Math.floor((event.clientY-rect.top)*frame.value.height/rect.height))),button:event.button===2?'right':'left',count:event.detail>1?2:1})
 }
+function manage(){managing.value=!managing.value;shareMembers.value=candidates.value.map(agent=>agent.id);trusted.value=false}
+function share(){void run(async()=>{await apiRequest('/api/app/computers/shared',{method:'POST',body:{requestId:createUuid(),name:shareName.value,memberIds:shareMembers.value,trusted:trusted.value}});managing.value=false;frame.value=undefined;state.value={mode:'off'};takeRequestId=undefined;emit('changed')})}
+function unshare(){const id=selectedAgent.value?.computerEnvironmentId;if(!id)return;void run(async()=>{await apiRequest(`/api/app/computers/shared/${encodeURIComponent(id)}`,{method:'DELETE'});managing.value=false;frame.value=undefined;state.value={mode:'off'};takeRequestId=undefined;emit('changed')})}
 async function giveBack(){if(ticket.value){await post('/giveback',{...ticket.value,notes:notes.value});ticket.value=undefined;takeRequestId=undefined;notes.value=''}}
 async function close(){if(busy.value)return;await run(giveBack);if(!ticket.value){closed=true;dialog.value?.close();emit('close')}}
 async function cycle(){await refresh();if(!closed)timer=setTimeout(()=>void cycle(),1200)}
@@ -70,6 +76,21 @@ onBeforeUnmount(()=>{closed=true;generation++;clearTimeout(timer)})
       </div>
       <p v-if="error" class="computer-error" role="alert">{{ error }}</p>
       <footer>
+        <p v-if="selectedAgent?.computerEnvironmentId">共享电脑:{{ selectedAgent.computerEnvironmentName }}。成员共用工作文件和浏览器资料,同一时间只有一名操作者。</p>
+        <button v-if="!ticket&&(candidates.length>1||selectedAgent?.computerEnvironmentId)" type="button" :disabled="busy" @click="manage">{{ managing?'返回电脑':'电脑共享设置' }}</button>
+        <section v-if="managing" class="sharing-settings" aria-label="电脑共享设置">
+          <template v-if="selectedAgent?.computerEnvironmentId">
+            <p>解除共享后,成员回到各自原有的独立电脑;共享工作区保留,可由执行节点备份或恢复。</p>
+            <button type="button" :disabled="busy" @click="unshare">解除这台电脑的共享</button>
+          </template>
+          <template v-else>
+            <label>共享电脑名称<input v-model="shareName" maxlength="100"></label>
+            <fieldset><legend>选择可信成员(同一来源和 Profile)</legend><label v-for="agent in candidates" :key="agent.id" class="share-option"><input v-model="shareMembers" type="checkbox" :value="agent.id">{{ agent.name }} · {{ agent.profile }}</label></fieldset>
+            <p>新建共享工作区,原独立电脑的文件仍然保留。请先停止所选成员的任务并交还电脑控制权。</p>
+            <label class="share-option"><input v-model="trusted" type="checkbox">我确认这些成员可以访问同一工作区和浏览器登录资料</label>
+            <button type="button" :disabled="busy||!trusted||shareMembers.length<2||shareMembers.length>8||!shareName.trim()" @click="share">创建可信共享电脑</button>
+          </template>
+        </section>
         <p v-if="!ticket">查看不会输入任何内容。接管后 Agent 会暂停,已有操作结束后才允许人工操作。</p>
         <button v-if="!ticket&&state.mode!=='human'" type="button" :disabled="busy" @click="take">{{ state.mode==='off'?'打开并控制':'接管电脑' }}</button>
         <template v-if="mine">
@@ -87,8 +108,9 @@ onBeforeUnmount(()=>{closed=true;generation++;clearTimeout(timer)})
 <style scoped>
 .computer-panel{width:min(1120px,calc(100vw - 32px));max-height:calc(100dvh - 32px);padding:0;border:1px solid var(--line);border-radius:16px;background:var(--surface);color:var(--text-primary);overflow:auto}
 .computer-panel::backdrop{background:rgba(0,0,0,.45)}header{display:flex;align-items:center;gap:14px;padding:14px 18px;border-bottom:1px solid var(--line)}header div{display:grid;gap:4px;margin-right:auto}small{font-size:12px;color:var(--text-muted);line-height:1.5}
-button,select,textarea{font:inherit;border:1px solid var(--line);border-radius:8px;background:var(--surface-raised);color:var(--text-primary)}button,select{min-height:44px;padding:8px 12px}button{cursor:pointer}button:disabled{opacity:.5;cursor:default}button:focus-visible,select:focus-visible,textarea:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
+button,select,textarea,input{font:inherit;border:1px solid var(--line);border-radius:8px;background:var(--surface-raised);color:var(--text-primary)}button,select{min-height:44px;padding:8px 12px}button{cursor:pointer}button:disabled{opacity:.5;cursor:default}button:focus-visible,select:focus-visible,textarea:focus-visible,input:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
 .computer-screen{position:relative;display:flex;align-items:center;justify-content:center;min-height:220px;background:#14181e}.computer-screen img{max-width:100%;max-height:58vh;width:auto;height:auto;user-select:none}.controlling img{cursor:crosshair}.computer-screen p{color:#d8dee7}.view-label{position:absolute;top:10px;left:12px;padding:4px 8px;border-radius:6px;background:rgba(0,0,0,.65);color:white;font-size:12px;pointer-events:none}
 footer{padding:16px 18px;display:grid;gap:12px}footer p{margin:0;font-size:13px;color:var(--text-secondary);line-height:1.6}label{display:grid;gap:6px;font-size:13px}textarea{padding:10px;min-width:0;resize:vertical}.key-row{display:flex;gap:6px;flex-wrap:wrap}.computer-error{color:var(--danger);padding:0 18px;font-size:13px}
+.sharing-settings{display:grid;gap:12px;padding:12px;border:1px solid var(--line);border-radius:10px}.sharing-settings input:not([type=checkbox]){min-height:44px;padding:8px 10px}.share-option{display:flex;align-items:center;gap:8px;min-height:44px}.share-option input{width:18px;height:18px;flex:none}fieldset{border:1px solid var(--line);border-radius:8px}
 @media(max-width:600px){.computer-panel{width:100vw;max-height:100dvh;border-radius:0}header{flex-wrap:wrap;padding:12px}footer{padding:12px}.computer-screen img{max-height:48vh}}
 </style>

+ 1 - 1
src/client/views/ConversationsView.vue

@@ -688,7 +688,7 @@ onBeforeUnmount(() => {
         :mention-options="active.kind === 'group' ? members.map(a => ({id:a.id,label:a.name,insertText:`@${a.name} `})) : []"
         @send="sendFromComposer" @stop="control('stop')" @tool-trace-toggle="showThinking = !showThinking" @clear-reference="quoted = null" @error="error = $event" />
     </section>
-    <ComputerPanel v-if="computerOpen" :agents="computerAgents" @close="computerOpen=false" />
+    <ComputerPanel v-if="computerOpen" :agents="computerAgents" @changed="load()" @close="computerOpen=false" />
     <FloatingResourceSearch section="groups" label="搜索聊天" :items="searchItems" tabbed @select="select" />
     <PreviewModal v-if="preview" :item="preview" :items="media" @close="preview = null" />
     <ImagePreviewLightbox v-model="mediaIndex" :images="lightboxMedia" />

+ 1 - 1
src/runner/agent.ts

@@ -51,7 +51,7 @@ export class RunnerAgent {
     if(url.hostname==='localhost')url.hostname='127.0.0.1'
     const response=await (isLocalAuthorizationTarget(url)&&this.fetchImpl===fetch?this.controlTransport.fetch.bind(this.controlTransport):this.fetchImpl)(url,{
       method:body===undefined?'GET':'POST',redirect:'error',signal:AbortSignal.any([this.controlAbort.signal,AbortSignal.timeout(25000)]),
-      headers:{Authorization:`Bearer ${this.config.token}`,'x-runner-instance':this.instance,'x-runner-protocol':'1','x-runner-features':this.computers?'computer-worker-v1,artifact-chunks-v1,helper-retirement-v1,computer-control-v1':'',...(this.serverEpoch?{'x-runner-epoch':this.serverEpoch}:{}),'Content-Type':'application/json',...(path==='poll'&&!this.connected?{'x-runner-reset':'1'}:{})},
+      headers:{Authorization:`Bearer ${this.config.token}`,'x-runner-instance':this.instance,'x-runner-protocol':'1','x-runner-features':this.computers?'computer-worker-v1,artifact-chunks-v1,helper-retirement-v1,computer-control-v1,shared-computer-v1':'',...(this.serverEpoch?{'x-runner-epoch':this.serverEpoch}:{}),'Content-Type':'application/json',...(path==='poll'&&!this.connected?{'x-runner-reset':'1'}:{})},
       ...(body===undefined?{}:{body:JSON.stringify(body)}),
     })
     if(!response.ok)throw new HttpError(response.status,`执行节点连接被拒绝(HTTP ${response.status})`,response.status===403?'runner_unauthorized':'runner_request_failed')

+ 3 - 3
src/runner/worker/gateway.ts

@@ -15,7 +15,7 @@ import {ComputerPublicProxy} from '../network/computerProxy.js'
 import {HermesWorkerProcess,type WorkerModel,type WorkerTool,type WorkerFrame} from './process.js'
 
 export interface ComputerTarget {environmentId:string;ownerKey:string;agentId:string}
-export interface WorkerSession {id:string;profile:string;ownerKey:string;environmentId:string;cwd:string;configuredCwd:string;history:any[];outcome?:'complete'|'failed'|'uncertain'}
+export interface WorkerSession {agentId?:string;id:string;profile:string;ownerKey:string;environmentId:string;cwd:string;configuredCwd:string;history:any[];outcome?:'complete'|'failed'|'uncertain'}
 interface Live {workMarker?:string;paused?:boolean;pauseJob?:Promise<ComputerLease>;segment?:number;toolCalls:Set<Promise<unknown>>;journal:Map<string,{name:string;result:unknown}>;proxy?:ComputerPublicProxy;session:WorkerSession;model:WorkerModel;lease:ComputerLease;controller:AbortController;worker?:HermesWorkerProcess;finishing?:Promise<void>;stopping?:Promise<void>;releasing?:Promise<void>;images:any[];running:boolean;received:boolean}
 const object=(properties:Record<string,unknown>,required:string[])=>({type:'object',properties,required,additionalProperties:false})
 const text={type:'string'}
@@ -72,7 +72,7 @@ export class ComputerRuntime {
   session(id:string,meta:ComputerTarget,profile:string):WorkerSession {
     const row=this.db.prepare('SELECT value FROM computer_sessions WHERE id=?').get(id) as {value:string}|undefined
     const session:WorkerSession|undefined=row?JSON.parse(row.value):undefined
-    if(!session||session.ownerKey!==meta.ownerKey||session.environmentId!==meta.environmentId||session.profile!==profile)throw new HttpError(404,'电脑会话不存在或不属于当前任务','computer_session_missing')
+    if(!session||(session.agentId??session.environmentId)!==meta.agentId||session.ownerKey!==meta.ownerKey||session.environmentId!==meta.environmentId||session.profile!==profile)throw new HttpError(404,'电脑会话不存在或不属于当前任务','computer_session_missing')
     return session
   }
   async resolve(profile:string,signal?:AbortSignal){
@@ -107,7 +107,7 @@ export class ComputerGateway {
       if(method==='session.resume'&&params.recoverOnly===true){const session=this.runtime.session(String(params.session_id),this.meta,String(params.profile));this.recoverySession=session;const resource=this.runtime.pool.status(this.meta.ownerKey).find(item=>item.environmentId===this.meta.environmentId);if(resource?.holderId===this.workId)await this.runtime.pool.stopHolder(this.meta.ownerKey,this.meta.environmentId,this.workId);return {session_id:session.id,stored_session_id:session.id,running:false,info:{profile_name:session.profile}}}
       const profile=String(params.profile),resolved=await this.runtime.resolve(profile,this.controller.signal)
       await this.authorized();this.guard()
-      const session:WorkerSession=method==='session.resume'?this.runtime.session(String(params.session_id),this.meta,profile):{id:randomUUID(),ownerKey:this.meta.ownerKey,environmentId:this.meta.environmentId,profile,cwd:resolved.cwd,configuredCwd:resolved.configuredCwd,history:[]}
+      const session:WorkerSession=method==='session.resume'?this.runtime.session(String(params.session_id),this.meta,profile):{id:randomUUID(),agentId:this.meta.agentId,ownerKey:this.meta.ownerKey,environmentId:this.meta.environmentId,profile,cwd:resolved.cwd,configuredCwd:resolved.configuredCwd,history:[]}
       session.cwd=resolved.cwd;session.configuredCwd=resolved.configuredCwd
       await this.runtime.pool.configure(this.spec(session),()=>this.guard())
       await this.authorized()

+ 3 - 0
src/server/app.ts

@@ -49,6 +49,7 @@ import {
   HermesChatNotificationResolver,
 } from './pushEvents.js'
 import { ChatCacheCoordinator, ChatCacheStore } from './chatCache.js'
+import {SharedComputers} from './sharedComputers.js'
 import {ComputerControlService} from './computerControls.js'
 import { RunnerHub } from './runnerHub.js'
 
@@ -209,6 +210,7 @@ export function createApplication(options: ApplicationOptions = {}): Application
   const workspaceNodes = new WorkspaceNodes(workspace, config, { url: config.upstream, client: upstream, session: upstreamSession }, pairings.nodeID)
   workspaceNodes.sourceAllowed = (owner, nodeId, profile) => auth.canUseSource(owner, nodeId, profile)
   const runners=new RunnerHub(workspace,auth,workspaceNodes.local)
+  const sharedComputers=new SharedComputers(workspace,auth,workspaceNodes,runners)
   const computerControls=new ComputerControlService(workspace,auth,workspaceNodes,runners)
   runners.controlAllowed=(id,runnerId)=>computerControls.allowed(id,runnerId)
   workspaceNodes.runnerTarget=(owner,nodeId,computer)=>runners.target(owner,nodeId,computer)
@@ -375,6 +377,7 @@ export function createApplication(options: ApplicationOptions = {}): Application
     } else await next()
   })
 
+  const sharedComputerRouter=sharedComputers.router();app.use(sharedComputerRouter.routes());app.use(sharedComputerRouter.allowedMethods())
   const computerRouter=computerControls.router();app.use(computerRouter.routes());app.use(computerRouter.allowedMethods())
   const applicationRouter = workspaceRouter(workspace, workspaceRuntime, workspaceNodes, workspaceAssets, uploads, auth, push)
   app.use(runners.adminRouter().routes())

+ 5 - 5
src/server/computerControls.ts

@@ -8,7 +8,7 @@ import type {WorkspaceAgent} from '../shared/workspace.js'
 import type {WorkspaceNodes} from './workspaceGateway.js'
 import type {RunnerHub} from './runnerHub.js'
 const hash=(value:string)=>createHash('sha256').update(value).digest()
-interface Grant {id:string;agentId:string;owner:string;requestId:string;runnerId:string;authVersion:number;expiresAt:number;tokenHash:string;sealedToken:string}
+interface Grant {environmentId?:string;id:string;agentId:string;owner:string;requestId:string;runnerId:string;authVersion:number;expiresAt:number;tokenHash:string;sealedToken:string}
 export class ComputerControlService {
   constructor(readonly store:WorkspaceStore,readonly auth:LocalAuthStore,readonly nodes:WorkspaceNodes,readonly hub:RunnerHub){}
   private agent(owner:string,id:string){
@@ -19,7 +19,7 @@ export class ComputerControlService {
   allowed(id:string,runnerId:string):boolean{
     const grant=this.store.get<Grant>('_system','computer-control',id)
     if(!grant||grant.runnerId!==runnerId||grant.expiresAt<=Date.now()||this.auth.pushAuthorizationVersion(grant.owner)!==grant.authVersion)return false
-    try{this.agent(grant.owner,grant.agentId);return true}catch{return false}
+    try{const agent=this.agent(grant.owner,grant.agentId);return (agent.computerEnvironmentId??agent.id)===(grant.environmentId??grant.agentId)}catch{return false}
   }
   private token(owner:string,agentId:string,body:unknown){
     const input=parse(z.object({controlId:z.string().uuid(),token:z.string().min(32).max(256)}).passthrough(),body)
@@ -40,11 +40,11 @@ export class ComputerControlService {
     })
     router.post('/api/app/agents/:id/computer/take',async ctx=>{
       const owner=this.auth.require(ctx).id,agent=this.agent(owner,ctx.params.id),input=parse(z.object({requestId:z.string().uuid()}).strict(),(ctx.request as any).body)
-      const old=this.store.list<Grant>('_system','computer-control').find(grant=>grant.owner===owner&&grant.agentId===agent.id&&grant.expiresAt>Date.now()&&this.allowed(grant.id,grant.runnerId))
-      if(old&&old.requestId!==input.requestId)throw new HttpError(409,'电脑已由另一个页面控制,关闭原页面或等待控制权到期','computer_control_busy')
+      const old=this.store.list<Grant>('_system','computer-control').find(grant=>grant.owner===owner&&(grant.environmentId??grant.agentId)===(agent.computerEnvironmentId??agent.id)&&grant.expiresAt>Date.now()&&this.allowed(grant.id,grant.runnerId))
+      if(old&&(old.agentId!==agent.id||old.requestId!==input.requestId))throw new HttpError(409,'电脑已由另一个页面控制,关闭原页面或等待控制权到期','computer_control_busy')
       const runnerId=this.hub.computerRunner(owner,agent).id
       const token=old?this.nodes.open<string>(old.sealedToken):randomBytes(32).toString('base64url')
-      const grant:Grant=old??{id:randomUUID(),requestId:input.requestId,owner,agentId:agent.id,runnerId,authVersion:this.auth.pushAuthorizationVersion(owner)??0,expiresAt:Date.now()+30000,tokenHash:hash(token).toString('hex'),sealedToken:this.nodes.seal(token)}
+      const grant:Grant=old??{environmentId:agent.computerEnvironmentId??agent.id,id:randomUUID(),requestId:input.requestId,owner,agentId:agent.id,runnerId,authVersion:this.auth.pushAuthorizationVersion(owner)??0,expiresAt:Date.now()+30000,tokenHash:hash(token).toString('hex'),sealedToken:this.nodes.seal(token)}
       this.store.put('_system','computer-control',grant.id,grant)
       const status=await this.hub.computer(owner,agent,'take',{controlId:grant.id},()=>{if(!this.allowed(grant.id,runnerId))throw new HttpError(403,'电脑控制请求已失效','computer_control_expired')})
       ctx.body={...status,controlId:grant.id,token,expiresAt:grant.expiresAt}

+ 8 - 4
src/server/runnerHub.ts

@@ -81,7 +81,10 @@ export class RunnerHub {
     const requireProfile=(profile:string)=>{
       if(!this.auth.canUseSource(owner,sourceNodeId,profile)||!record.allowedProfiles.includes(profile))throw new HttpError(403,'执行节点未授权这个基础 Profile','runner_profile_forbidden')
     }
-    const requireComputer=()=>{if(computer&&!this.online.get(record.id)?.features.includes('computer-worker-v1'))throw new HttpError(409,'执行节点未提供隔离 Worker 能力,请检查配置或更新 Runner','computer_unavailable')}
+    const requireComputer=()=>{
+      if(computer&&!this.online.get(record.id)?.features.includes('computer-worker-v1'))throw new HttpError(409,'执行节点未提供隔离 Worker 能力,请检查配置或更新 Runner','computer_unavailable')
+      if(computer&&computer.environmentId!==computer.agentId&&!this.online.get(record.id)?.features.includes('shared-computer-v1'))throw new HttpError(409,'执行节点不支持共享电脑,请更新 Runner','shared_computer_unavailable')
+    }
     const requestHTTP=async(path:string,options:UpstreamRequestOptions={}):Promise<UpstreamResponse>=>{
       requireComputer()
       const profile=options.search?.get('profile')??'default'
@@ -141,10 +144,11 @@ export class RunnerHub {
     if(!record.allowedProfiles.includes(agent.profile)||!this.auth.canUseSource(owner,agent.nodeId,agent.profile))throw new HttpError(403,'电脑 Profile 未授权','computer_profile_forbidden')
     return record
   }
+  sharedComputerRunner(owner:string,agent:import('../shared/workspace.js').WorkspaceAgent){const record=this.computerRunner(owner,agent);if(agent.computerEnvironmentId&&this.store.require<import('./sharedComputers.js').SharedComputer>(owner,'shared-computer',agent.computerEnvironmentId).runnerId!==record.id)throw new HttpError(409,'共享电脑的原执行节点已变化','shared_runner_changed');if(!this.online.get(record.id)?.features.includes('shared-computer-v1'))throw new HttpError(409,'执行节点不支持共享电脑,请更新 Runner','shared_computer_unavailable');return record}
   async computer(owner:string,agent:import('../shared/workspace.js').WorkspaceAgent,op:string,data:Record<string,unknown>,authorize:()=>void):Promise<any>{
-    const record=this.computerRunner(owner,agent),version=this.auth.pushAuthorizationVersion(owner)
+    const record=agent.computerEnvironmentId?this.sharedComputerRunner(owner,agent):this.computerRunner(owner,agent),version=this.auth.pushAuthorizationVersion(owner)
     const valid=()=>{try{authorize();return this.auth.isUserActive(owner)&&this.auth.pushAuthorizationVersion(owner)===version}catch{return false}}
-    const result=await this.request(record.id,'computer.control',{target:{environmentId:agent.id,agentId:agent.id,ownerKey:hash(owner)},profile:agent.profile,op,...data},valid)
+    const result=await this.request(record.id,'computer.control',{target:{environmentId:agent.computerEnvironmentId??agent.id,agentId:agent.id,ownerKey:hash(owner)},profile:agent.profile,op,...data},valid)
     if(!valid())throw new HttpError(403,'电脑请求授权已失效','computer_control_expired')
     return result
   }
@@ -183,7 +187,7 @@ export class RunnerHub {
         retired.add(previous.instance);this.retired.set(record.id,retired);this.disconnect(record.id);previous=undefined
       }
       if(match[2]!=='poll'&&ctx.get('x-runner-epoch')!==previous?.epoch)throw new HttpError(409,'执行连接代次已改变','runner_epoch_changed')
-      const state=this.online.get(record.id)??{instance,seen:Date.now(),features:[],epoch:`${this.epoch}:${randomUUID()}`};state.seen=Date.now();if(match[2]==='poll')state.features=ctx.get('x-runner-features').split(',').filter(value=>['computer-worker-v1','artifact-chunks-v1','helper-retirement-v1','computer-control-v1'].includes(value));this.online.set(record.id,state)
+      const state=this.online.get(record.id)??{instance,seen:Date.now(),features:[],epoch:`${this.epoch}:${randomUUID()}`};state.seen=Date.now();if(match[2]==='poll')state.features=ctx.get('x-runner-features').split(',').filter(value=>['computer-worker-v1','artifact-chunks-v1','helper-retirement-v1','computer-control-v1','shared-computer-v1'].includes(value));this.online.set(record.id,state)
       ctx.set('Cache-Control','no-store')
       if(match[2]==='poll') {
         if(ctx.method!=='GET')throw new HttpError(405,'仅允许 GET','method_not_allowed')

+ 59 - 0
src/server/sharedComputers.ts

@@ -0,0 +1,59 @@
+import Router from '@koa/router'
+import {randomUUID} from 'node:crypto'
+import {z} from 'zod'
+import type {WorkspaceAgent} from '../shared/workspace.js'
+import type {WorkspaceStore} from './workspaceStore.js'
+import {parse} from './workspaceStore.js'
+import {HttpError} from './errors.js'
+import type {WorkspaceNodes} from './workspaceGateway.js'
+import type {RunnerHub} from './runnerHub.js'
+import type {LocalAuthStore} from './localAuth.js'
+export interface SharedComputer {id:string;name:string;memberIds:string[];nodeId:string;profile:string;runnerId:string;archived:boolean;createdAt:number}
+export function requireSharedComputer(store:WorkspaceStore,owner:string,agent:{id?:string;nodeId:string;profile:string;computerEnvironmentId?:string}){
+  if(!agent.computerEnvironmentId)return
+  const shared=store.require<SharedComputer>(owner,'shared-computer',agent.computerEnvironmentId)
+  if(shared.archived||!agent.id||!shared.memberIds.includes(agent.id)||shared.nodeId!==agent.nodeId||shared.profile!==agent.profile)throw new HttpError(403,'当前成员没有这台共享电脑的授权','shared_computer_forbidden')
+}
+export class SharedComputers {
+  constructor(readonly store:WorkspaceStore,readonly auth:LocalAuthStore,readonly nodes:WorkspaceNodes,readonly hub:RunnerHub){}
+  private idle(owner:string,ids:string[],environmentId?:string){
+    if(this.store.list<{agentId:string;status:string}>(owner,'turn').some(work=>ids.includes(work.agentId)&&['queued','running','waiting','uncertain','cancelling'].includes(work.status)))throw new HttpError(409,'请先停止这些成员的当前任务,再修改电脑共享','shared_computer_busy')
+    if(this.store.list<{owner:string;agentId:string;environmentId?:string;expiresAt:number}>('_system','computer-control').some(grant=>grant.owner===owner&&grant.expiresAt>Date.now()&&(ids.includes(grant.agentId)||(environmentId&&grant.environmentId===environmentId))))throw new HttpError(409,'请先交还电脑控制权,再修改共享','shared_computer_busy')
+  }
+  create(owner:string,input:unknown){
+    const body=parse(z.object({requestId:z.string().uuid(),name:z.string().trim().min(1).max(100),memberIds:z.array(z.string().uuid()).min(2).max(8),trusted:z.literal(true)}).strict(),input)
+    return this.store.command(owner,body.requestId,{operation:'computer.share',...body},()=>this.store.atomic(()=>{
+      if(new Set(body.memberIds).size!==body.memberIds.length)throw new HttpError(400,'成员不能重复','shared_computer_members')
+      if(this.store.list<SharedComputer>(owner,'shared-computer').filter(value=>!value.archived).length>=16)throw new HttpError(409,'共享电脑数量已达上限','shared_computer_limit')
+      const agents=body.memberIds.map(id=>this.store.require<WorkspaceAgent>(owner,'agent',id)),first=agents[0]!
+      for(const agent of agents){
+        this.nodes.requireSource(owner,agent)
+        if(agent.archived||agent.temporaryGoalId||agent.remoteAgentId||agent.execution!=='computer'||agent.computerEnvironmentId||agent.nodeId!==first.nodeId||agent.profile!==first.profile)throw new HttpError(400,'请选择同一来源和 Profile 的持久隔离成员,且成员尚未加入其他共享电脑','shared_computer_members')
+      }
+      this.idle(owner,body.memberIds)
+      const runnerId=this.hub.sharedComputerRunner(owner,first).id
+      const shared:SharedComputer={id:randomUUID(),name:body.name,memberIds:body.memberIds,nodeId:first.nodeId,profile:first.profile,runnerId,archived:false,createdAt:Date.now()}
+      this.store.put(owner,'shared-computer',shared.id,shared)
+      for(const agent of agents){agent.computerEnvironmentId=shared.id;agent.computerEnvironmentName=shared.name;agent.revision++;agent.updatedAt=Date.now();this.store.put(owner,'agent',agent.id,agent);this.store.event(owner,'agent.changed',agent)}
+      return shared
+    }))
+  }
+  remove(owner:string,id:string){
+    return this.store.atomic(()=>{
+      const shared=this.store.require<SharedComputer>(owner,'shared-computer',id)
+      if(shared.archived)return
+      this.idle(owner,shared.memberIds,id)
+      for(const agent of this.store.list<WorkspaceAgent>(owner,'agent').filter(agent=>agent.computerEnvironmentId===id)){
+        delete agent.computerEnvironmentId;delete agent.computerEnvironmentName;agent.revision++;agent.updatedAt=Date.now();this.store.put(owner,'agent',agent.id,agent);this.store.event(owner,'agent.changed',agent)
+      }
+      shared.archived=true;this.store.put(owner,'shared-computer',id,shared)
+    })
+  }
+  router(){
+    const router=new Router()
+    router.get('/api/app/computers/shared',ctx=>{const owner=this.auth.require(ctx).id;ctx.body={computers:this.store.list<SharedComputer>(owner,'shared-computer').filter(value=>!value.archived)}})
+    router.post('/api/app/computers/shared',ctx=>{ctx.body={computer:this.create(this.auth.require(ctx).id,(ctx.request as any).body)};ctx.status=201})
+    router.delete('/api/app/computers/shared/:id',ctx=>{this.remove(this.auth.require(ctx).id,ctx.params.id);ctx.body={ok:true}})
+    return router
+  }
+}

+ 6 - 3
src/server/workspaceGateway.ts

@@ -1,4 +1,5 @@
 import WebSocket from 'ws'
+import {requireSharedComputer} from './sharedComputers.js'
 import { randomUUID, randomBytes, createHash, createCipheriv, createDecipheriv } from 'node:crypto'
 import { readFileSync, writeFileSync } from 'node:fs'
 import { join } from 'node:path'
@@ -42,7 +43,8 @@ export interface GatewayFrame {
 export class WorkspaceNodes {
   runnerTarget?: (owner:string,nodeId:string,computer?:{environmentId:string;ownerKey:string;agentId:string})=>GatewayTarget|undefined
   sourceAllowed: (owner: string, nodeId: string, profile: string) => boolean = () => true
-  requireSource(owner: string, agent: { nodeId: string; profile: string }): void {
+  requireSource(owner: string, agent: { id?:string;nodeId: string; profile: string;computerEnvironmentId?:string }): void {
+    requireSharedComputer(this.store,owner,agent)
     if (!this.sourceAllowed(owner, agent.nodeId, agent.profile)) throw new HttpError(403, '基础 Agent 未分配给当前账号', 'agent_source_forbidden')
   }
   private readonly key: Buffer
@@ -105,10 +107,11 @@ export class WorkspaceNodes {
     }
     return target
   }
-  targetForAgent(owner: string, agent: { id?:string;nodeId: string; remoteAgentId?: string;execution?:string;helperRunnerId?:string }): GatewayTarget {
+  targetForAgent(owner: string, agent: { id?:string;nodeId: string; remoteAgentId?: string;execution?:string;helperRunnerId?:string;computerEnvironmentId?:string }): GatewayTarget {
     if(agent.execution==='computer'){
       if(!agent.id||agent.remoteAgentId)throw new HttpError(409,'隔离电脑需要当前服务器管理的 Agent','computer_agent_required')
-      const target=this.runnerTarget?.(owner,agent.nodeId,{environmentId:agent.id,agentId:agent.id,ownerKey:createHash('sha256').update(owner).digest('hex')})
+      const target=this.runnerTarget?.(owner,agent.nodeId,{environmentId:agent.computerEnvironmentId??agent.id,agentId:agent.id,ownerKey:createHash('sha256').update(owner).digest('hex')})
+      if(agent.computerEnvironmentId&&this.store.require<import('./sharedComputers.js').SharedComputer>(owner,'shared-computer',agent.computerEnvironmentId).runnerId!==target?.runner?.id)throw new HttpError(409,'共享电脑的原执行节点已变化,请先恢复原节点','shared_runner_changed')
       if(agent.helperRunnerId&&target?.runner?.id!==agent.helperRunnerId)throw new HttpError(409,'临时助手的原执行节点已变化,请创建新助手','helper_runner_changed')
       if(!target)throw new HttpError(409,'隔离电脑需要连接执行节点','computer_runner_required')
       return target

+ 1 - 1
src/server/workspaceRoutes.ts

@@ -67,7 +67,7 @@ export function workspaceRouter(
         'conversationTasks',
         'editableGroups',
         'agentTeamManagement',
-        'computerControl',
+        'computerControl','sharedComputers',
         'files',
         'voice',
         'context',

+ 4 - 2
src/server/workspaceRuntime.ts

@@ -26,6 +26,7 @@ type Run = WorkspaceRun
 export interface WorkspaceBinding {
   runnerId?: string
   execution?:string
+  computerEnvironmentId?:string
   remoteAgentId?: string
   id: string
   nodeId: string
@@ -186,10 +187,10 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
     },authorize:()=>{
       this.requireAuthorization(owner,run.runId);this.nodes.requireSource(owner,agent)
       const current=this.getWork(owner,run.id),latest=this.store.require<Agent>(owner,'agent',agent.id),conversation=this.store.require<Conversation>(owner,'conversation',c.id)
-      if(this.closing||conversation.archived||!this.store.taskMemberIds(owner,conversation,run.conversationTaskId).includes(agent.id)||this.store.require<Run>(owner,'run',run.runId).stopRequested||current.cancelRequested||['interrupted','complete','failed'].includes(current.status)||latest.archived||(latest.execution??'profile')!==(agent.execution??'profile')||latest.teamAuthorizationVersion!==agent.teamAuthorizationVersion)throw new HttpError(403,'本轮 Agent 或任务授权已结束','run_authorization_revoked')
+      if(this.closing||conversation.archived||!this.store.taskMemberIds(owner,conversation,run.conversationTaskId).includes(agent.id)||this.store.require<Run>(owner,'run',run.runId).stopRequested||current.cancelRequested||['interrupted','complete','failed'].includes(current.status)||latest.archived||latest.computerEnvironmentId!==agent.computerEnvironmentId||(latest.execution??'profile')!==(agent.execution??'profile')||latest.teamAuthorizationVersion!==agent.teamAuthorizationVersion)throw new HttpError(403,'本轮 Agent 或任务授权已结束','run_authorization_revoked')
     }})
     let binding = this.store.get<WorkspaceBinding>(owner, 'binding', key)
-    const movedRunner=!!binding&&(binding.runnerId!==target.runner?.id||(binding.execution??'profile')!==(agent.execution??'profile'))
+    const movedRunner=!!binding&&(binding.computerEnvironmentId!==agent.computerEnvironmentId||binding.runnerId!==target.runner?.id||(binding.execution??'profile')!==(agent.execution??'profile'))
     if(movedRunner) {
       if(recovering)throw new HttpError(409,'执行节点已变化,不能在另一节点重放原执行','runner_target_changed')
       binding=undefined
@@ -468,6 +469,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
       binding = {
         runnerId: target.runner?.id,
         execution:agent.execution,
+        computerEnvironmentId:agent.computerEnvironmentId,
         id: key,
         nodeId: agent.nodeId,
         remoteAgentId: agent.remoteAgentId,

+ 1 - 0
src/server/workspaceStore.ts

@@ -344,6 +344,7 @@ export class WorkspaceStore {
     return this.atomic(() => {
       const agent = this.require<Agent>(owner, 'agent', id)
       if(agent.temporaryGoalId&&(Object.keys(patch).some(key=>key!=='archived')||patch.archived===false))throw new HttpError(409,'临时助手由所属任务管理,不能修改权限或恢复为持久成员','helper_task_bound')
+      if(agent.computerEnvironmentId&&patch.execution==='profile')throw new HttpError(409,'请先解除电脑共享,再切换执行环境','shared_computer_active')
       if(patch.execution&&patch.execution!==(agent.execution??'profile')&&this.list<{agentId:string;status:string}>(owner,'turn').some(work=>work.agentId===id&&['running','waiting','uncertain'].includes(work.status)))throw new HttpError(409,'请先停止当前任务,再切换执行环境','agent_execution_busy')
       if (agent.remoteAgentId && Object.keys(patch).some(key => key !== 'archived'))
         throw new HttpError(409, '引用 Agent 的配置由远端管理', 'remote_agent_read_only')

+ 2 - 0
src/shared/workspace.ts

@@ -6,6 +6,8 @@ export interface WorkspaceAgent {
   instructions: string
   /** Explicit account-owner grant; absent on older records means disabled. */
   execution?: 'profile' | 'computer'
+  computerEnvironmentId?:string
+  computerEnvironmentName?:string
   canManageTeam?: boolean
   teamAuthorizationVersion?: number
   temporaryGoalId?:string

+ 27 - 0
tests/e2e/team-tools.spec.ts

@@ -161,3 +161,30 @@ test('computer viewing is read-only until takeover and returns control with a us
   await panel.getByRole('button',{name:'关闭',exact:true}).click()
   await expect(panel).not.toBeVisible()
 })
+
+
+test('trusted shared computers require an explicit data-sharing grant and can be detached',async({page},testInfo)=>{
+  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 expect(page.locator('.desktop-sidebar .sidebar-create-trigger')).toBeVisible()
+  const seeded=await page.request.post('/__test/computer?shared=1',{data:{}});expect(seeded.ok(),await seeded.text()).toBe(true)
+  const fixture=await seeded.json();await page.goto(`/conversations/${fixture.conversationId}`)
+  await page.getByRole('button',{name:'电脑',exact:true}).click()
+  const panel=page.getByRole('dialog',{name:'隔离电脑',exact:true})
+  await panel.getByRole('button',{name:'电脑共享设置',exact:true}).click()
+  const create=panel.getByRole('button',{name:'创建可信共享电脑',exact:true})
+  await expect(create).toBeDisabled()
+  await panel.getByRole('checkbox',{name:'我确认这些成员可以访问同一工作区和浏览器登录资料'}).check()
+  await create.click()
+  await expect(panel).toContainText('共享电脑:团队共享电脑')
+  await page.setViewportSize({width:390,height:844})
+  await panel.getByRole('button',{name:'电脑共享设置',exact:true}).click()
+  await panel.getByRole('button',{name:'解除这台电脑的共享',exact:true}).scrollIntoViewIfNeeded()
+  await page.screenshot({path:testInfo.outputPath('shared-computer-mobile.png')})
+  await panel.getByRole('button',{name:'解除这台电脑的共享',exact:true}).click()
+  await expect(panel).not.toContainText('共享电脑:团队共享电脑')
+  const agents=(await (await page.request.get('/api/app/agents')).json()).agents
+  expect(agents.find((agent:any)=>agent.id===fixture.agentId).computerEnvironmentId).toBeUndefined()
+})

+ 30 - 0
tests/fixtures/native-computer.py

@@ -0,0 +1,30 @@
+"""Disposable UI fixture; use distinct ports for concurrent native clients."""
+import argparse, base64, json, time, threading
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+from pathlib import Path
+parser=argparse.ArgumentParser();parser.add_argument('--port',type=int,required=True);parser.add_argument('--image',required=True);args=parser.parse_args()
+image=base64.b64encode(Path(args.image).read_bytes()).decode()
+state={'mode':'agent','generation':1,'controlId':None,'actions':[],'notes':''}
+lock=threading.Lock()
+class Handler(BaseHTTPRequestHandler):
+    def log_message(self,*args): pass
+    def respond(self,value):
+        data=json.dumps(value,ensure_ascii=False).encode();self.send_response(200);self.send_header('Content-Type','application/json');self.send_header('Content-Length',str(len(data)));self.end_headers();self.wfile.write(data)
+    def do_GET(self):
+        with lock:
+            if self.path=='/fixture/state': return self.respond(state)
+            if self.path.endswith('/frame'): return self.respond({'id':'22222222-2222-4222-8222-222222222222','generation':state['generation'],'data':image,'width':512,'height':512,'capturedAt':int(time.time()*1000)})
+            if self.path.endswith('/computer'): return self.respond({**state,'canResume':True})
+            return self.respond({'ok':True,'csrfToken':'fixture','features':['computerControl']})
+    def do_POST(self):
+        data=json.loads(self.rfile.read(int(self.headers.get('Content-Length','0'))) or b'{}')
+        with lock:
+            if self.path=='/fixture/reset': state.update(mode='agent',generation=1,controlId=None,actions=[],notes='')
+            elif self.path.endswith('/take'):
+                state.update(mode='human',generation=state['generation']+1,controlId='11111111-1111-4111-8111-111111111111')
+                return self.respond({**state,'token':'fixture-private-control-token-1234567890','canResume':True})
+            elif self.path.endswith('/input'): state['actions'].append(data.get('action'))
+            elif self.path.endswith('/giveback'): state.update(mode='agent',generation=state['generation']+1,controlId=None,notes=data.get('notes',''))
+            return self.respond({'ok':True})
+print(f'native computer fixture on 127.0.0.1:{args.port}',flush=True)
+ThreadingHTTPServer(('127.0.0.1',args.port),Handler).serve_forever()

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

@@ -350,7 +350,8 @@ runtime.app.use((ctx,next) => {
   ctx.body={conversationId:team.id,firstTaskId:first.id,secondTaskId:second.id,sourceConversationId:source.id}
 })
 const computerFixtures=new Map<string,{mode:string;generation:number;controlId?:string;actions:unknown[]}>()
-const originalComputer=runtime.runners.computer.bind(runtime.runners),originalRunner=runtime.runners.computerRunner.bind(runtime.runners)
+const originalComputer=runtime.runners.computer.bind(runtime.runners),originalRunner=runtime.runners.computerRunner.bind(runtime.runners),originalSharedRunner=runtime.runners.sharedComputerRunner.bind(runtime.runners)
+runtime.runners.sharedComputerRunner=(owner,agent)=>computerFixtures.has(agent.id)?{id:'11111111-1111-4111-8111-111111111111'} as any:originalSharedRunner(owner,agent)
 runtime.runners.computerRunner=(owner,agent)=>computerFixtures.has(agent.id)?{id:'11111111-1111-4111-8111-111111111111'} as any:originalRunner(owner,agent)
 runtime.runners.computer=async(owner,agent,op,data,authorize)=>{
   const state=computerFixtures.get(agent.id);if(!state)return originalComputer(owner,agent,op,data,authorize)
@@ -365,8 +366,14 @@ runtime.app.use((ctx,next)=>{
   if(ctx.path!=='/__test/computer')return next()
   const owner=auth.require(ctx).id
   if(ctx.method==='POST'){
-    const agent=runtime.workspace.createAgent(owner,{name:'电脑面板验收',profile:'default',execution:'computer'})
+    const agent=runtime.workspace.createAgent(owner,{name:ctx.query.shared==='1'?'共享电脑主成员':'电脑面板验收',profile:'default',execution:'computer'})
     computerFixtures.set(agent.id,{mode:'agent',generation:1,actions:[]})
+    if(ctx.query.shared==='1'){
+      const second=runtime.workspace.createAgent(owner,{name:'可信共享成员',profile:'default',execution:'computer'})
+      computerFixtures.set(second.id,{mode:'off',generation:1,actions:[]})
+      const team=runtime.workspace.createGroup(owner,{name:'共享电脑验收',memberIds:[agent.id,second.id],administratorId:agent.id})
+      ctx.body={agentId:agent.id,secondId:second.id,conversationId:team.id};return
+    }
     ctx.body={agentId:agent.id,conversationId:runtime.workspace.list<any>(owner,'conversation').find(c=>c.memberIds[0]===agent.id)!.id};return
   }
   ctx.body={actions:[...computerFixtures.values()].flatMap(state=>state.actions)}

+ 23 - 1
tests/live/worker.live.test.ts

@@ -64,7 +64,7 @@ it.skipIf(!process.env.YAOYAO_HERMES_PYTHON||!process.env.YAOYAO_COMPUTER_IMAGE)
     const current=input.messages.slice(input.messages.findLastIndex((message:any)=>message.role==='user')+1)
     const called=current.some((message:any)=>message.role==='tool')
     const exported=current.some((message:any)=>message.role==='tool'&&message.name==='computer_export')
-    const message=called&&!team&&!helper&&!exported?{role:'assistant',content:null,tool_calls:['result.txt','bulk.bin'].map(path=>({id:randomUUID(),type:'function',function:{name:'computer_export',arguments:JSON.stringify({path})}}))}:called?{role:'assistant',content:'隔离任务完成'}:{role:'assistant',content:null,tool_calls:[{id:randomUUID(),type:'function',function:{name:helper?'workspace_create_helper':team?'workspace_create_agent':'computer_shell',arguments:JSON.stringify(helper?{requestId:randomUUID(),goalId:helper[1],name:'临时核验助手',title:'临时核验',brief:'在隔离电脑完成 fixture:helper-work 并导出结果'}:team?{requestId:randomUUID(),name:'隔离创建成员',profile:'default'}:{command:control?'sleep 2; printf paused > pause-proof.txt':slow?'sleep 30; printf forbidden > late-write.txt':(process.env.YAOYAO_WORKER_PUBLIC_NETWORK==='1'?'curl --fail --silent --show-error --max-time 20 https://example.com > public.html; ':'')+'printf worker-proof > result.txt; head -c 2100000 /dev/zero > bulk.bin; id -u; pwd'})}}]}
+    const message=called&&!team&&!helper&&!exported?{role:'assistant',content:null,tool_calls:['result.txt','bulk.bin'].map(path=>({id:randomUUID(),type:'function',function:{name:'computer_export',arguments:JSON.stringify({path})}}))}:called?{role:'assistant',content:'隔离任务完成'}:{role:'assistant',content:null,tool_calls:[{id:randomUUID(),type:'function',function:{name:helper?'workspace_create_helper':team?'workspace_create_agent':'computer_shell',arguments:JSON.stringify(helper?{requestId:randomUUID(),goalId:helper[1],name:'临时核验助手',title:'临时核验',brief:'在隔离电脑完成 fixture:helper-work 并导出结果'}:team?{requestId:randomUUID(),name:'隔离创建成员',profile:'default'}:{command:String(user).includes('fixture:shared-write')?'sleep 6; printf shared-proof > result.txt; head -c 10 /dev/zero > bulk.bin':String(user).includes('fixture:shared-read')?'cat result.txt':control?'sleep 2; printf paused > pause-proof.txt':slow?'sleep 30; printf forbidden > late-write.txt':(process.env.YAOYAO_WORKER_PUBLIC_NETWORK==='1'?'curl --fail --silent --show-error --max-time 20 https://example.com > public.html; ':'')+'printf worker-proof > result.txt; head -c 2100000 /dev/zero > bulk.bin; id -u; pwd'})}}]}
     if(input.stream){res.setHeader('Content-Type','text/event-stream');res.end('data: '+JSON.stringify({id:randomUUID(),object:'chat.completion.chunk',model:'fixture-model',choices:[{index:0,delta:{...message,...(message.tool_calls?{tool_calls:message.tool_calls.map((tool:any,index:number)=>({...tool,index}))}:{})},finish_reason:message.tool_calls?'tool_calls':'stop'}]})+'\n\ndata: [DONE]\n\n')}
     else res.end(JSON.stringify({id:randomUUID(),object:'chat.completion',model:'fixture-model',choices:[{index:0,message,finish_reason:message.tool_calls?'tool_calls':'stop'}],usage:{prompt_tokens:10,completion_tokens:10,total_tokens:20}}))
   })
@@ -142,6 +142,28 @@ it.skipIf(!process.env.YAOYAO_HERMES_PYTHON||!process.env.YAOYAO_COMPUTER_IMAGE)
     await app.workspaceRuntime.stop(owner,cancelling.id)
     await expect.poll(()=>app.workspace.require<any>(owner,'run',cancelling.id).status,{timeout:20000}).toBe('interrupted')
     await expect(access(join(runnerHome,'computer-workspaces',worker.id,'late-write.txt'))).rejects.toMatchObject({code:'ENOENT'})
+    const second=app.workspace.createAgent(owner,{name:'共享核验成员',profile:'default',execution:'computer'})
+    const secondChat=app.workspace.list<any>(owner,'conversation').find(c=>c.memberIds[0]===second.id)!
+    const sharedAPI=(method:'post'|'delete',path:string,data:unknown)=>request(app.app.callback())[method](path).set('Host',`127.0.0.1:${app.config.port}`).set('Origin',address).set('Cookie',cookies).set('X-CSRF-Token',fresh.body.csrfToken).send(data)
+    const shared=await sharedAPI('post','/api/app/computers/shared',{requestId:randomUUID(),name:'真实共享电脑',memberIds:[worker.id,second.id],trusted:true}).expect(201)
+    const sharedWrite=app.workspaceRuntime.send(owner,direct.id,{requestId:randomUUID(),content:'fixture:shared-write'})
+    await expect.poll(()=>app.workspace.messages(owner,direct.id).some(message=>message.runId===sharedWrite.id&&message.tools.some(tool=>tool.name==='computer_shell')),{timeout:30000}).toBe(true)
+    const sharedRead=app.workspaceRuntime.send(owner,secondChat.id,{requestId:randomUUID(),content:'fixture:shared-read'})
+    await expect.poll(()=>app.workspace.require<any>(owner,'run',sharedRead.id).status,{timeout:45000}).toMatch(/complete|failed/)
+    expect(app.workspace.require<any>(owner,'run',sharedWrite.id).status).toBe('complete')
+    expect(app.workspace.require<any>(owner,'run',sharedRead.id).status,JSON.stringify(app.workspace.messages(owner,secondChat.id))).toBe('complete')
+    expect(await readFile(join(runnerHome,'computer-workspaces',shared.body.computer.id,'result.txt'),'utf8')).toBe('shared-proof')
+    expect(await readFile(join(runnerHome,'computer-workspaces',worker.id,'result.txt'),'utf8')).toBe('worker-proof')
+    const sharedReaderRequests=commands.filter(input=>String(input.messages.filter((message:any)=>message.role==='user').at(-1)?.content).includes('fixture:shared-read'))
+    expect(sharedReaderRequests.length).toBeGreaterThan(0)
+    expect(sharedReaderRequests.some(input=>input.messages.some((message:any)=>message.role==='user'&&String(message.content).includes('fixture:shared-write')))).toBe(false)
+    expect(sharedReaderRequests.some(input=>input.messages.some((message:any)=>message.role==='tool'&&String(message.content).includes('shared-proof')))).toBe(true)
+    const sharedTake=await api('/take',{requestId:randomUUID()}).expect(200)
+    await sharedAPI('post',`/api/app/agents/${second.id}/computer/take`,{requestId:randomUUID()}).expect(409)
+    await sharedAPI('delete',`/api/app/computers/shared/${shared.body.computer.id}`,{}).expect(409)
+    await api('/giveback',{controlId:sharedTake.body.controlId,token:sharedTake.body.token,notes:''}).expect(200)
+    await sharedAPI('delete',`/api/app/computers/shared/${shared.body.computer.id}`,{}).expect(200)
+    expect(app.workspace.require<any>(owner,'agent',worker.id).computerEnvironmentId).toBeUndefined()
     const manager=app.workspace.createAgent(owner,{name:'隔离组队者',profile:'default',execution:'computer',canManageTeam:true})
     const managerChat=app.workspace.list<any>(owner,'conversation').find(c=>c.memberIds[0]===manager.id)!
     const managed=app.workspaceRuntime.send(owner,managerChat.id,{requestId:randomUUID(),content:'fixture:team'})

+ 48 - 0
tests/server/sharedComputers.test.ts

@@ -0,0 +1,48 @@
+// @vitest-environment node
+import {expect,it} from 'vitest'
+import {mkdtempSync,rmSync} from 'node:fs'
+import {tmpdir} from 'node:os'
+import {join} from 'node:path'
+import {randomUUID} from 'node:crypto'
+import {WorkspaceStore} from '../../src/server/workspaceStore'
+import {WorkspaceNodes} from '../../src/server/workspaceGateway'
+import {loadServerConfig} from '../../src/server/config'
+import {SharedComputers} from '../../src/server/sharedComputers'
+import type {WorkspaceAgent} from '../../src/shared/workspace'
+it('requires an explicit human grant, one compatible source and idle permanent members',()=>{
+  const home=mkdtempSync(join(tmpdir(),'yaoyao-shared-')),store=new WorkspaceStore(home),nodes=new WorkspaceNodes(store,loadServerConfig({HERMES_YAOYAO_HOME:home}),{} as any)
+  const service=new SharedComputers(store,{} as any,nodes,{sharedComputerRunner:()=>({id:'runner'})} as any)
+  try{
+    const first=store.createAgent('owner',{name:'第一位',execution:'computer',profile:'default'}),second=store.createAgent('owner',{name:'第二位',execution:'computer',profile:'default'})
+    const command={requestId:randomUUID(),name:'可信研发电脑',memberIds:[first.id,second.id],trusted:true}
+    expect(()=>service.create('owner',{...command,trusted:false})).toThrow()
+    expect(()=>store.createAgent('owner',{name:'模型新建',profile:'default',computerEnvironmentId:randomUUID()})).toThrow()
+    store.put('owner','turn','busy',{agentId:first.id,status:'queued'})
+    expect(()=>service.create('owner',command)).toThrow('先停止')
+    store.remove('owner','turn','busy')
+    const shared=service.create('owner',command)
+    expect(service.create('owner',command).id).toBe(shared.id)
+    const linked=store.require<WorkspaceAgent>('owner','agent',first.id)
+    expect(linked.computerEnvironmentId).toBe(shared.id)
+    expect(()=>nodes.requireSource('other',linked)).toThrow()
+    expect(()=>nodes.requireSource('owner',{...linked,id:randomUUID()})).toThrow()
+    expect(()=>store.updateAgent('owner',first.id,{execution:'profile'})).toThrow('解除电脑共享')
+    store.put('_system','computer-control','fixture',{owner:'owner',agentId:first.id,environmentId:shared.id,expiresAt:Date.now()+30000})
+    expect(()=>service.remove('owner',shared.id)).toThrow('交还')
+    store.remove('_system','computer-control','fixture')
+    service.remove('owner',shared.id)
+    expect(store.require<WorkspaceAgent>('owner','agent',first.id).computerEnvironmentId).toBeUndefined()
+    expect(()=>nodes.requireSource('owner',linked)).toThrow('授权')
+    expect(store.require<any>('owner','shared-computer',shared.id).archived).toBe(true)
+  }finally{nodes.close();store.close();rmSync(home,{recursive:true,force:true})}
+})
+it('does not grant temporary helpers or mixed profiles a shared desktop',()=>{
+  const home=mkdtempSync(join(tmpdir(),'yaoyao-shared-')),store=new WorkspaceStore(home),nodes=new WorkspaceNodes(store,loadServerConfig({HERMES_YAOYAO_HOME:home}),{} as any),service=new SharedComputers(store,{} as any,nodes,{sharedComputerRunner:()=>({id:'runner'})} as any)
+  try{
+    const first=store.createAgent('owner',{name:'第一位',execution:'computer',profile:'default'}),second=store.createAgent('owner',{name:'第二位',execution:'computer',profile:'different'})
+    const command={requestId:randomUUID(),name:'共享',memberIds:[first.id,second.id],trusted:true}
+    expect(()=>service.create('owner',command)).toThrow('同一来源')
+    store.put('owner','agent',second.id,{...second,profile:'default',temporaryGoalId:randomUUID()})
+    expect(()=>service.create('owner',command)).toThrow('持久隔离成员')
+  }finally{nodes.close();store.close();rmSync(home,{recursive:true,force:true})}
+})