Răsfoiți Sursa

修复默认工作目录导致虚拟机创建失败并解除模型依赖

LSamien 1 lună în urmă
părinte
comite
e74692e256

+ 2 - 0
docs/local-vm.md

@@ -13,6 +13,8 @@
 
 虚拟机实例移除或重建均保留工作目录和浏览器资料。正常完成任务和主动交还后桌面进入空闲状态;空闲 5 分钟后自动停止。取消、授权撤销、异常退出仍停止旧执行实例,以隔离迟到操作。基础 Profile 工作目录变化会在空闲时重建运行实例,数据保留。
 
+`terminal.cwd` 默认的 `.`(以及 `auto`、`cwd`、未设置)指向虚拟机的持久工作区 `/home/cua/workspace`;相对路径以该工作区为起点,显式绝对路径继续沿用配置。创建、启动和人工接管只读取工作目录,不要求模型可用;停止和移除已有实例也不依赖 Profile 配置。模型仅在运行 Agent 时解析,Profile、目录和模型错误分别提示。
+
 ## 手机端
 
 iOS/Android 没有全局虚拟桌面清单、镜像设置或执行环境选择。入口在 **当前 Agent 聊天右上角的电脑图标**。单个 Agent 直接打开画面,群聊有多台电脑时选择当前聊天成员的电脑,同一共享环境只显示一次。

+ 1 - 3
src/runner/agent.ts

@@ -121,9 +121,7 @@ export class RunnerAgent {
       }
       if(input.op==='lifecycle'){
         const action=z.enum(['create','start','stop','recreate','remove']).parse(input.action)
-        this.computers.assertTarget(input.target)
-        const resolved=await this.computers.resolve(input.profile);authorize()
-        await this.computers.pool.desktop({id:input.target.environmentId,ownerKey:input.target.ownerKey,imageId:this.computers.config.imageId,cwd:resolved.cwd,network:this.computers.config.network??'none'},action,authorize)
+        await this.computers.desktop(input.target,input.profile,action,authorize)
         return {ok:true}
       }
       if(input.op==='status')return this.computers.controls.status(input.target)

+ 1 - 1
src/runner/worker/control.ts

@@ -63,7 +63,7 @@ export class ComputerControls {
     state.pending=(async()=>{
       if(state.gateway){state.lease=await state.gateway.takeControl(id,()=>this.guard(state));state.spec=state.gateway.specification}
       else{
-        const resolved=await this.runtime.resolve(profile);await check();this.guard(state)
+        const resolved=await this.runtime.resolveWorkspace(profile);await check();this.guard(state)
         state.spec={id:meta.environmentId,ownerKey:meta.ownerKey,imageId:this.runtime.config.imageId,cwd:resolved.cwd,network:this.runtime.config.network??'none'}
         await this.runtime.pool.configure(state.spec,()=>this.guard(state))
         state.lease=await this.runtime.pool.acquire(state.spec,`human:${id}`,()=>this.guard(state))

+ 20 - 2
src/runner/worker/gateway.ts

@@ -9,7 +9,7 @@ import {HttpError} from '../../server/errors.js'
 import type {GatewayFrame} from '../../server/workspaceGateway.js'
 import {UNCONFIGURED_COMPUTER_IMAGE} from '../../shared/runner.js'
 import type {RunnerConfiguration} from '../../shared/runner.js'
-import {ContainerComputerProvider,CUA_DRIVER,CUA_SOCKET,type ComputerSpecification} from '../computers/container.js'
+import {ContainerComputerProvider,COMPUTER_WORKSPACE,CUA_DRIVER,CUA_SOCKET,type ComputerSpecification} from '../computers/container.js'
 import {ComputerPool,type ComputerLease} from '../computers/pool.js'
 import {ComputerControls} from './control.js'
 import {ComputerPublicProxy} from '../network/computerProxy.js'
@@ -78,10 +78,28 @@ export class ComputerRuntime {
     return session
   }
   async resolve(profile:string,signal?:AbortSignal){
-    const resolver=new HermesWorkerProcess(this.config.python,this.script,{mode:'resolve',profile,hermesSource:this.config.hermesSource,hermesHome:this.config.hermesHome})
+    return this.resolveProfile(profile,'resolve',signal)
+  }
+  async resolveWorkspace(profile:string,signal?:AbortSignal){
+    return this.resolveProfile(profile,'resolve-workspace',signal)
+  }
+  private async resolveProfile(profile:string,mode:'resolve'|'resolve-workspace',signal?:AbortSignal){
+    const resolver=new HermesWorkerProcess(this.config.python,this.script,{mode,profile,defaultCwd:COMPUTER_WORKSPACE,hermesSource:this.config.hermesSource,hermesHome:this.config.hermesHome})
     const abort=()=>{void resolver.close()};signal?.addEventListener('abort',abort,{once:true});if(signal?.aborted)abort()
     try{return await resolver.wait('resolved')}finally{signal?.removeEventListener('abort',abort);await resolver.close()}
   }
+  async desktop(meta:ComputerTarget,profile:string,action:'create'|'start'|'stop'|'recreate'|'remove',authorize:()=>void){
+    this.assertTarget(meta);authorize()
+    // Stopping/removing an existing desktop must remain possible even when
+    // its Profile or model was removed or is temporarily misconfigured.
+    if(action==='stop'||action==='remove'){
+      const spec=this.pool.definition(meta.ownerKey,meta.environmentId)
+      if(spec)await this.pool.desktop(spec,action,authorize)
+      return
+    }
+    const resolved=await this.resolveWorkspace(profile);authorize()
+    await this.pool.desktop({id:meta.environmentId,ownerKey:meta.ownerKey,imageId:this.config.imageId,cwd:resolved.cwd,network:this.config.network??'none'},action,authorize)
+  }
 }
 export class ComputerGateway {
   onEvent:(frame:GatewayFrame)=>void=()=>{}

+ 50 - 17
src/runner/worker/hermes_worker.py

@@ -24,15 +24,21 @@ def emit(kind, **values):
         WIRE.write(json.dumps({"nonce": NONCE, "type": kind, **values}, ensure_ascii=False) + "\n")
         WIRE.flush()
 
-def resolve_model():
+class ConfigurationError(Exception):
+    def __init__(self, code):
+        self.code = code
+        super().__init__(code)
+
+
+def load_profile():
     source = Path(BOOT["hermesSource"]).resolve()
     root = Path(BOOT["hermesHome"]).resolve()
     profile = BOOT["profile"]
     if not re.fullmatch(r"[a-z0-9][a-z0-9_-]{0,63}", profile):
-        raise ValueError("invalid profile")
+        raise ConfigurationError("computer_profile_invalid")
     home = root if profile == "default" else root / "profiles" / profile
     if not (home / "config.yaml").is_file():
-        raise ValueError("profile config missing")
+        raise ConfigurationError("computer_profile_missing")
     os.environ["HERMES_HOME"] = str(home)
     sys.path.insert(0, str(source))
     from dotenv import dotenv_values
@@ -42,21 +48,46 @@ def resolve_model():
     from hermes_constants import set_hermes_home_override
     set_hermes_home_override(str(home))
     from hermes_cli.config import load_config_readonly
-    from hermes_cli.runtime_provider import resolve_runtime_provider
-    config = load_config_readonly()
+    try:
+        return load_config_readonly()
+    except Exception:
+        raise ConfigurationError("computer_profile_config_invalid") from None
+
+
+def resolve_workspace(config):
+    terminal = config.get("terminal", {})
+    if not isinstance(terminal, dict):
+        raise ConfigurationError("computer_cwd_invalid")
+    raw = terminal.get("cwd")
+    if raw is None or raw == "":
+        raw = "."
+    if not isinstance(raw, str) or not raw.strip() or len(raw) > 4096 or re.search(r"[\x00-\x1f\x7f]", raw):
+        raise ConfigurationError("computer_cwd_invalid")
+    configured = raw.strip()
+    # Placeholders refer to the assigned Linux workspace, never the Web/App
+    # release directory inherited by this host-side configuration reader.
+    base = BOOT.get("defaultCwd", "/home/cua/workspace")
+    path = "." if configured in (".", "auto", "cwd") else os.path.expanduser(configured)
+    cwd = os.path.normpath(path if os.path.isabs(path) else os.path.join(base, path))
+    if not os.path.isabs(path) and os.path.commonpath([base, cwd]) != base:
+        raise ConfigurationError("computer_cwd_invalid")
+    return {"cwd": cwd, "configuredCwd": configured}
+
+
+def resolve_model():
+    config = load_profile()
+    workspace = resolve_workspace(config)
     model = config.get("model", {})
     if not isinstance(model, dict) or not model.get("default"):
-        raise ValueError("model config missing")
-    runtime = resolve_runtime_provider(requested=model.get("provider"), explicit_api_key=model.get("api_key"), explicit_base_url=model.get("base_url"), target_model=model["default"])
+        raise ConfigurationError("computer_model_missing")
+    try:
+        from hermes_cli.runtime_provider import resolve_runtime_provider
+        runtime = resolve_runtime_provider(requested=model.get("provider"), explicit_api_key=model.get("api_key"), explicit_base_url=model.get("base_url"), target_model=model["default"])
+    except Exception:
+        raise ConfigurationError("computer_model_unavailable") from None
     if runtime.get("api_mode") not in ("chat_completions", "anthropic_messages", "codex_responses"):
-        raise ValueError("provider cannot run with closed tool dispatch")
-    cwd = config.get("terminal", {}).get("cwd")
-    if not isinstance(cwd, str) or not cwd.strip():
-        raise ValueError("terminal.cwd required")
-    cwd = os.path.normpath(os.path.expanduser(cwd.strip()))
-    if not os.path.isabs(cwd):
-        raise ValueError("absolute terminal.cwd required")
-    emit("resolved", model={key: runtime[key] for key in ("provider", "api_mode", "base_url", "api_key") if key in runtime} | {"model": model["default"]}, cwd=cwd, configuredCwd=config["terminal"]["cwd"])
+        raise ConfigurationError("computer_model_unsupported")
+    emit("resolved", model={key: runtime[key] for key in ("provider", "api_mode", "base_url", "api_key") if key in runtime} | {"model": model["default"]}, **workspace)
 
 
 def run_model():
@@ -161,7 +192,9 @@ def run_model():
 
 try:
     with contextlib.redirect_stdout(sys.stderr):
-        if BOOT.get("mode") == "resolve":
+        if BOOT.get("mode") == "resolve-workspace":
+            emit("resolved", **resolve_workspace(load_profile()))
+        elif BOOT.get("mode") == "resolve":
             resolve_model()
         else:
             run_model()
@@ -170,5 +203,5 @@ except BaseException as error:
         import traceback
         traceback.print_exc(file=sys.stderr)
     # Never serialize SDK exception strings, request headers, or credentials.
-    emit("failed", message="Hermes Worker 执行失败,请核对基础 Profile 与模型连接")
+    emit("failed", code=error.code if isinstance(error, ConfigurationError) else "computer_worker_failed")
     sys.exit(1)

+ 13 - 1
src/runner/worker/process.ts

@@ -6,6 +6,15 @@ import {HttpError} from '../../server/errors.js'
 export interface WorkerTool {id?:string;name:string;description:string;inputSchema:Record<string,unknown>}
 export interface WorkerModel {provider:string;api_mode:string;base_url?:string;api_key?:string;model:string}
 export interface WorkerFrame {type:string;[key:string]:any}
+const configurationErrors:Record<string,string>={
+  computer_profile_invalid:'基础 Profile 名称无效',
+  computer_profile_missing:'未找到基础 Profile 配置,请检查执行节点上的 Hermes Profile',
+  computer_profile_config_invalid:'基础 Profile 配置无法读取,请检查 config.yaml',
+  computer_cwd_invalid:'基础 Profile 的 terminal.cwd 无效,请使用工作区内的相对路径或有效绝对路径',
+  computer_model_missing:'基础 Profile 尚未配置模型,请先选择模型后再运行 Agent',
+  computer_model_unavailable:'基础 Profile 的模型配置无法使用,请检查 Provider 与认证信息',
+  computer_model_unsupported:'基础 Profile 的模型接口暂不支持隔离电脑 Agent',
+}
 export class HermesWorkerProcess {
   readonly nonce=randomUUID()
   readonly child:ChildProcessWithoutNullStreams
@@ -34,7 +43,10 @@ export class HermesWorkerProcess {
   private receive(frame:WorkerFrame){
     const waiting=this.waiters.get(frame.type)
     if(waiting){clearTimeout(waiting.timer);this.waiters.delete(frame.type);waiting.resolve(frame)}
-    if(frame.type==='failed')this.fail(new HttpError(502,'Hermes Worker 执行失败,请核对基础 Profile 与模型连接','computer_worker_failed'))
+    if(frame.type==='failed'){
+      const code=typeof frame.code==='string'&&Object.hasOwn(configurationErrors,frame.code)?frame.code:'computer_worker_failed'
+      this.fail(new HttpError(502,configurationErrors[code]??'Hermes Worker 执行失败,请核对基础 Profile 与模型连接',code))
+    }
     if(frame.type==='tool'){
       const work=Promise.resolve().then(()=>this.onTool(String(frame.name),frame.arguments,String(frame.id),typeof frame.callId==='string'?frame.callId:undefined)).then(result=>this.send({type:'result',id:frame.id,result}),()=>this.send({type:'result',id:frame.id,result:{error:'工具执行失败或授权已失效'}}))
       this.pending.add(work);void work.finally(()=>this.pending.delete(work)).catch(()=>{})

+ 6 - 4
tests/live/localVm.live.test.ts

@@ -17,11 +17,13 @@ it.skipIf(!process.env.YAOYAO_HERMES_PYTHON||!process.env.YAOYAO_COMPUTER_IMAGE)
  const upstream=createServer((req,res)=>{
   res.setHeader('Content-Type','application/json')
   const path=new URL(req.url!,'http://fixture').pathname
-  res.end(JSON.stringify(path==='/api/profiles'?{profiles:[{name:'default'}]}:path==='/api/config'?{terminal:{cwd:'/projects/local-vm'}}:{ok:true}))
+  res.end(JSON.stringify(path==='/api/profiles'?{profiles:[{name:'default'}]}:path==='/api/config'?{terminal:{cwd:'.'}}:{ok:true}))
  })
  await new Promise<void>(done=>upstream.listen(0,'127.0.0.1',done))
  const upstreamURL=`http://127.0.0.1:${(upstream.address() as any).port}`
- await writeFile(join(hermes,'config.yaml'),`model:\n  provider: custom\n  default: fixture-model\n  base_url: ${upstreamURL}/v1\n  api_key: fixture\nterminal:\n  cwd: /projects/local-vm\n`,{mode:0o600})
+ // Match fresh real installations: placeholder cwd and no usable model.
+ // Desktop creation and human control must not resolve provider credentials.
+ await writeFile(join(hermes,'config.yaml'),`model:\n  provider: nonexistent-fixture-provider\n  default: fixture-model\nterminal:\n  cwd: .\n`,{mode:0o600})
  const app=createApplication({config:loadServerConfig({HERMES_YAOYAO_HOME:join(root,'web'),HERMES_YAOYAO_UPSTREAM:upstreamURL})})
  vi.spyOn(app.localVm as any,'installation').mockReturnValue({hermesHome:hermes,hermesSource:process.env.YAOYAO_HERMES_SOURCE,python:process.env.YAOYAO_HERMES_PYTHON})
  app.app.use(serve(resolve('dist')))
@@ -74,14 +76,14 @@ it.skipIf(!process.env.YAOYAO_HERMES_PYTHON||!process.env.YAOYAO_COMPUTER_IMAGE)
   await screen.click({position:{x:box.width*568/1280,y:box.height*875/900}})
   await page.waitForTimeout(1500)
   await screen.focus()
-  await page.keyboard.type('printf vm-ui-proof > /projects/local-vm/proof.txt',{delay:25})
+  await page.keyboard.type('printf vm-ui-proof > /home/cua/workspace/proof.txt',{delay:25})
   await page.keyboard.press('Enter')
   const workspace=join(root,'web','runner-state','local-vm',app.runners.records()[0]!.id,'computer-workspaces',agent.id)
   try {await expect.poll(()=>readFile(join(workspace,'proof.txt'),'utf8').catch(()=>''),{timeout:30000}).toBe('vm-ui-proof')}
   catch(error){await page.screenshot({path:join(evidence,'input-failed.png')});throw new Error(`${String(error)}\n${JSON.stringify({inputs,failures,text:await viewer.innerText()})}`)}
   await screen.focus()
   await page.keyboard.type('erase-this',{delay:20});await page.keyboard.press('Control+u')
-  await page.keyboard.type('printf hotkey-proof > /projects/local-vm/hotkey.txt',{delay:20});await page.keyboard.press('Enter')
+  await page.keyboard.type('printf hotkey-proof > /home/cua/workspace/hotkey.txt',{delay:20});await page.keyboard.press('Enter')
   await expect.poll(()=>readFile(join(workspace,'hotkey.txt'),'utf8').catch(()=>''),{timeout:30000}).toBe('hotkey-proof')
   await page.screenshot({path:join(evidence,'interactive-desktop.png')})
   await viewer.getByRole('button',{name:'交还并关闭',exact:true}).click()

+ 3 - 1
tests/server/computerControl.test.ts

@@ -12,7 +12,8 @@ it('separates view from control and fences stale, duplicate and out-of-frame inp
   let permitted=true
   try{
     await runtime.ready
-    vi.spyOn(runtime,'resolve').mockResolvedValue({type:'resolved',cwd:'/projects/fixture',configuredCwd:'/projects/fixture',model:{}})
+    const model=vi.spyOn(runtime,'resolve').mockRejectedValue(new Error('model unavailable'))
+    vi.spyOn(runtime,'resolveWorkspace').mockResolvedValue({type:'resolved',cwd:'/projects/fixture',configuredCwd:'/projects/fixture'})
     vi.spyOn(runtime.provider,'ensure').mockResolvedValue({id:'fixture',containerId:'fixture',running:true,workspace:'/fixture',isolation:'container'})
     vi.spyOn(runtime.provider,'stop').mockResolvedValue();vi.spyOn(runtime.provider,'remove').mockResolvedValue()
     vi.spyOn(runtime.provider,'capture').mockImplementation(async()=>({data:'image-fixture',width:100,height:80,capturedAt:Date.now()}))
@@ -21,6 +22,7 @@ it('separates view from control and fences stale, duplicate and out-of-frame inp
     await expect(runtime.controls.input(meta,id,randomUUID(),1,randomUUID(),{kind:'key',key:'Return'})).rejects.toMatchObject({code:'computer_control_required'})
     await runtime.controls.take(meta,'default',id,async()=>{if(!permitted)throw new Error('revoked')})
     await vi.waitFor(async()=>expect((await runtime.controls.status(meta)).mode).toBe('human'))
+    expect(model).not.toHaveBeenCalled()
     const frame=await runtime.controls.frame(meta,()=>{})
     expect(execute).not.toHaveBeenCalled()
     await expect(runtime.controls.input(meta,id,randomUUID(),frame.generation-1,frame.id,{kind:'key',key:'Return'})).rejects.toMatchObject({code:'computer_control_stale'})

+ 19 - 0
tests/server/localVm.test.ts

@@ -70,6 +70,25 @@ it('refuses lifecycle changes while a human or Agent holds the VM',async()=>{
   await f.runtime.pool.release(lease)
  }finally{await f.close()}
 })
+it('creates without a model and can stop or remove an existing desktop after Profile resolution fails',async()=>{
+ const f=await fixture(),meta={environmentId:randomUUID(),agentId:randomUUID(),ownerKey:'owner'}
+ try{
+  f.runtime.config.imageId='sha256:'+'a'.repeat(64)
+  const model=vi.spyOn(f.runtime,'resolve').mockRejectedValue(new Error('model unavailable'))
+  const workspace=vi.spyOn(f.runtime,'resolveWorkspace').mockResolvedValue({type:'resolved',cwd:'/home/cua/workspace',configuredCwd:'.'})
+  await f.runtime.desktop(meta,'default','create',()=>{})
+  expect(f.running.has(meta.environmentId)).toBe(true)
+  expect(f.runtime.pool.definition('owner',meta.environmentId)?.cwd).toBe('/home/cua/workspace')
+  workspace.mockRejectedValue(new Error('Profile was removed'))
+  await f.runtime.desktop(meta,'default','stop',()=>{})
+  expect(f.running.has(meta.environmentId)).toBe(false)
+  await f.runtime.desktop(meta,'default','remove',()=>{})
+  expect(f.runtime.pool.definition('owner',meta.environmentId)).toBeUndefined()
+  expect(workspace).toHaveBeenCalledOnce()
+  expect(model).not.toHaveBeenCalled()
+  expect(f.runtime.provider.deleteWorkspace).not.toHaveBeenCalled()
+ }finally{await f.close()}
+})
 it('keeps uncertain preparation cleanup fenced and retries cleanup when runtime recovers',async()=>{
  const f=await fixture(),manager=new LocalVmImages(f.runtime,f.db),imageId='sha256:'+'a'.repeat(64)
  let canStop=false

+ 55 - 0
tests/server/workerResolution.test.ts

@@ -0,0 +1,55 @@
+// @vitest-environment node
+import {afterEach,beforeEach,expect,it} from 'vitest'
+import {mkdtemp,mkdir,writeFile,rm} from 'node:fs/promises'
+import {tmpdir,homedir} from 'node:os'
+import {join,resolve} from 'node:path'
+import {HermesWorkerProcess} from '../../src/runner/worker/process'
+
+let root:string,source:string,home:string
+const workers:HermesWorkerProcess[]=[]
+beforeEach(async()=>{
+ root=await mkdtemp(join(tmpdir(),'yaoyao-resolver-'));source=join(root,'source');home=join(root,'profile')
+ await mkdir(join(source,'hermes_cli'),{recursive:true});await mkdir(home)
+ // Only replace the installed Hermes config/SDK boundary. Exercise the real
+ // Python reader and Node protocol, including failures and secret filtering.
+ await writeFile(join(source,'dotenv.py'),'def dotenv_values(path): return {}\n')
+ await writeFile(join(source,'hermes_constants.py'),'def set_hermes_home_override(path): pass\n')
+ await writeFile(join(source,'hermes_cli','config.py'),'import os,json\nfrom pathlib import Path\ndef load_config_readonly(): return json.loads((Path(os.environ["HERMES_HOME"])/"config.yaml").read_text())\n')
+ await writeFile(join(source,'hermes_cli','runtime_provider.py'),'raise RuntimeError("provider secret must never escape")\n')
+})
+afterEach(async()=>{await Promise.all(workers.splice(0).map(w=>w.close()));await rm(root,{recursive:true,force:true})})
+function read(mode='resolve-workspace',profile='default'){
+ const worker=new HermesWorkerProcess('python3',resolve('src/runner/worker/hermes_worker.py'),{mode,profile,hermesSource:source,hermesHome:home})
+ workers.push(worker);return worker.wait('resolved')
+}
+it.each([
+ ['.','/home/cua/workspace'],['auto','/home/cua/workspace'],['cwd','/home/cua/workspace'],
+ ['projects/demo','/home/cua/workspace/projects/demo'],['/projects/demo','/projects/demo'],['~/projects',join(homedir(),'projects')],
+])('resolves %s without importing the model SDK',async(configured,cwd)=>{
+ await writeFile(join(home,'config.yaml'),JSON.stringify({terminal:{cwd:configured}}))
+ expect(await read()).toMatchObject({cwd,configuredCwd:configured})
+})
+it('handles default/missing cwd and named Profiles without mixing Profile settings',async()=>{
+ await writeFile(join(home,'config.yaml'),'{}')
+ expect(await read()).toMatchObject({cwd:'/home/cua/workspace',configuredCwd:'.'})
+ await mkdir(join(home,'profiles','writer'),{recursive:true})
+ await writeFile(join(home,'profiles','writer','config.yaml'),JSON.stringify({terminal:{cwd:'/projects/writer'}}))
+ expect(await read('resolve-workspace','writer')).toMatchObject({cwd:'/projects/writer'})
+})
+it('reports directory and Profile errors without mislabelling them as model errors',async()=>{
+ for(const cwd of ['../../escape','\u0000',32]){
+  await writeFile(join(home,'config.yaml'),JSON.stringify({terminal:{cwd}}))
+  await expect(read()).rejects.toMatchObject({code:'computer_cwd_invalid'})
+ }
+ await expect(read('resolve-workspace','missing')).rejects.toMatchObject({code:'computer_profile_missing'})
+ await expect(read('resolve-workspace','../escape')).rejects.toMatchObject({code:'computer_profile_invalid'})
+ await writeFile(join(home,'config.yaml'),'not valid config')
+ await expect(read()).rejects.toMatchObject({code:'computer_profile_config_invalid'})
+})
+it('still requires a usable model for an Agent run and does not expose SDK details',async()=>{
+ await writeFile(join(home,'config.yaml'),'{}')
+ await expect(read('resolve')).rejects.toMatchObject({code:'computer_model_missing'})
+ await writeFile(join(home,'config.yaml'),JSON.stringify({model:{default:'fixture',provider:'fixture'}}))
+ await expect(read('resolve')).rejects.toMatchObject({code:'computer_model_unavailable'})
+ try{await read('resolve')}catch(error){expect(String(error)).not.toContain('provider secret')}
+})