Ver Fonte

支持 macOS App 加密保存并自动启动执行节点

LSamien há 1 mês atrás
pai
commit
5a51b36918

+ 36 - 2
desktop/lifecycle.test.mjs

@@ -1,6 +1,6 @@
 import { test } from 'node:test'
 import assert from 'node:assert/strict'
-import { mkdtemp, readFile, rm, mkdir } from 'node:fs/promises'
+import { mkdtemp, readFile, writeFile, stat, rm, mkdir } from 'node:fs/promises'
 import { tmpdir } from 'node:os'
 import { join, resolve } from 'node:path'
 import { createServer } from 'node:net'
@@ -29,7 +29,7 @@ test('real desktop launches its bundled service, survives window close and recov
     cwd: root, env: { ...process.env, FAKE_HERMES_PORT: String(upstreamPort), FAKE_HERMES_LOCAL_AUTH: '1' }, stdio: 'ignore',
   })
   let desktop
-  let servicePID
+  let servicePID, runnerPID, runnerID
   try {
     await until(() => localJSON(`http://127.0.0.1:${upstreamPort}/api/status`), Boolean)
     desktop = await electron.launch({ ...(process.env.DESKTOP_TEST_EXECUTABLE ? { executablePath: process.env.DESKTOP_TEST_EXECUTABLE } : {}),
@@ -49,6 +49,30 @@ test('real desktop launches its bundled service, survives window close and recov
       try { await window.yaoyaoDesktop.retry();return false } catch { return true }
     })
     assert.equal(denied, true, 'the web app must not have the boot screen native control privilege')
+    const registration=await page.evaluate(async()=>{
+      const bootstrap=await (await fetch('/api/app/bootstrap')).json()
+      const headers={'Content-Type':'application/json','X-CSRF-Token':bootstrap.csrfToken}
+      const setup=await fetch('/api/app/setup',{method:'POST',headers,body:JSON.stringify({username:'desktop-fixture',password:'desktop-fixture-password'})})
+      if(!setup.ok)throw new Error('fixture setup failed')
+      headers['X-CSRF-Token']=(await (await fetch('/api/app/bootstrap')).json()).csrfToken
+      const response=await fetch('/api/app/admin/runners',{method:'POST',headers,body:JSON.stringify({name:'桌面验收节点',allowedProfiles:['default']})})
+      if(!response.ok)throw new Error(`fixture registration failed HTTP ${response.status}: ${(await response.json()).code}`)
+      return response.json()
+    })
+    runnerID=registration.runner.id
+    const configuration={protocol:1,runnerId:runnerID,token:registration.token,serverURL:`http://127.0.0.1:${port}`,hermesURL:`http://127.0.0.1:${upstreamPort}`,allowedProfiles:['default'],artifactRoots:[]}
+    const imported=join(home,'downloaded-runner.json');await writeFile(imported,JSON.stringify(configuration),{mode:0o600})
+    await desktop.evaluate(({dialog,Menu},path)=>{
+      dialog.showOpenDialog=async()=>({canceled:false,filePaths:[path]})
+      Menu.getApplicationMenu().getMenuItemById('runner-import').click()
+    },imported)
+    await until(()=>page.evaluate(async()=>{const data=await (await fetch('/api/app/admin/runners')).json();return data.runners?.some(r=>r.online)}),Boolean)
+    const encrypted=await readFile(join(home,'desktop-runner.enc'))
+    assert.equal(encrypted.includes(registration.token),false)
+    assert.equal((await stat(join(home,'desktop-runner.enc'))).mode&0o077,0)
+    runnerPID=JSON.parse(await readFile(join(home,'runner-state',runnerID,'service-instance.json'),'utf8')).pid
+    await rm(imported)
+
     await desktop.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].close())
     assert.equal(await desktop.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].isVisible()), false)
     assert.ok((await localJSON(`http://127.0.0.1:${port}/healthz`)).ok)
@@ -61,14 +85,24 @@ test('real desktop launches its bundled service, survives window close and recov
     servicePID = replacement.pid
     await until(() => localJSON(`http://127.0.0.1:${port}/healthz`), r => r.ok)
     assert.notEqual(replacement.instanceId, owner.instanceId)
+    await until(()=>page.evaluate(async()=>{const data=await (await fetch('/api/app/admin/runners')).json();return data.runners?.some(r=>r.online)}),Boolean)
     const external = new DesktopServiceManager({ home, port, fork: () => { throw new Error('must not fork a second service') } })
     assert.equal((await external.start()).external, true)
     await external.stop()
     assert.ok((await localJSON(`http://127.0.0.1:${port}/healthz`)).ok, 'a connected client must not stop the owned service')
     await desktop.close();desktop = null
     await until(async () => { try { process.kill(replacement.pid, 0);return false } catch { return true } }, Boolean, 10000)
+    await until(async()=>{try{process.kill(runnerPID,0);return false}catch{return true}},Boolean,10000)
+    desktop=await electron.launch({...(process.env.DESKTOP_TEST_EXECUTABLE?{executablePath:process.env.DESKTOP_TEST_EXECUTABLE}:{}),args:process.env.DESKTOP_TEST_EXECUTABLE?[]:[root],cwd:root,env:{...process.env,HERMES_YAOYAO_DESKTOP_TEST_HOME:home,HERMES_YAOYAO_DESKTOP_PORT:String(port),HERMES_YAOYAO_UPSTREAM:`http://127.0.0.1:${upstreamPort}`}})
+    const reopened=await desktop.firstWindow();await reopened.waitForURL(`http://127.0.0.1:${port}/**`)
+    await until(()=>reopened.evaluate(async()=>{const data=await (await fetch('/api/app/admin/runners')).json();return data.runners?.some(r=>r.online)}),Boolean)
+    servicePID=JSON.parse(await readFile(join(home,'service-instance.json'),'utf8')).pid
+    runnerPID=JSON.parse(await readFile(join(home,'runner-state',runnerID,'service-instance.json'),'utf8')).pid
+    await desktop.close();desktop=null
+    await until(async()=>{try{process.kill(runnerPID,0);return false}catch{return true}},Boolean,10000)
   } finally {
     await desktop?.close().catch(() => {})
+    if(runnerPID){try{process.kill(runnerPID,'SIGTERM')}catch{}}
     if (servicePID) { try { process.kill(servicePID, 'SIGTERM') } catch {} }
     upstream.kill('SIGTERM')
     await rm(home, { recursive: true, force: true })

+ 21 - 3
desktop/main.mjs

@@ -1,9 +1,10 @@
-import { app, BrowserWindow, ipcMain, Menu, Tray, nativeImage, shell, utilityProcess, powerMonitor, systemPreferences } from 'electron'
+import { app, BrowserWindow, ipcMain, Menu, Tray, nativeImage, shell, utilityProcess, powerMonitor, systemPreferences, dialog, safeStorage } from 'electron'
 import { appendFileSync, mkdirSync, readFileSync } from 'node:fs'
 import { join, dirname } from 'node:path'
 import { homedir } from 'node:os'
 import { fileURLToPath, pathToFileURL } from 'node:url'
 import { DesktopServiceManager } from './service-manager.mjs'
+import { DesktopRunnerManager } from './runner-manager.mjs'
 
 const desktopRoot = dirname(fileURLToPath(import.meta.url))
 const fixtureHome = process.env.HERMES_YAOYAO_DESKTOP_TEST_HOME
@@ -12,7 +13,7 @@ if (fixtureHome) app.setPath('userData', join(fixtureHome, 'desktop'))
 app.setName('夭夭')
 if (!app.requestSingleInstanceLock()) { app.quit() }
 else {
-  let window, tray, manager, quitting = false, closing = false, timer
+  let window, tray, manager, runnerManager, quitting = false, closing = false, timer
   const bootURL = pathToFileURL(join(desktopRoot, 'boot.html')).href
   const root = app.isPackaged ? join(process.resourcesPath, 'runtime') : join(app.getAppPath(), '.desktop-build')
   const logRoot = join(app.getPath('logs'), fixtureHome ? 'verification' : 'service')
@@ -99,7 +100,7 @@ else {
     event.preventDefault()
     if (closing) return
     closing = true; clearInterval(timer)
-    void manager?.stop().catch(error => log(error.message)).finally(() => { quitting = true; app.quit() })
+    void (async()=>{await runnerManager?.stop().catch(error=>log(error.message));await manager?.stop().catch(error=>log(error.message))})().finally(() => { quitting = true; app.quit() })
   })
   app.whenReady().then(async () => {
     const packageVersion = JSON.parse(readFileSync(join(root, 'release.json'), 'utf8')).webVersion
@@ -116,12 +117,28 @@ else {
         return utilityProcess.fork(join(root, 'server.mjs'), [], { cwd: root, env, stdio: 'pipe' })
       },
     })
+    const { parseRunnerConfiguration } = await import(pathToFileURL(join(root,'runner-config.mjs')).href)
+    runnerManager = new DesktopRunnerManager({home,validate:parseRunnerConfiguration,
+      encrypt:value=>{if(!safeStorage.isEncryptionAvailable())throw new Error('系统钥匙串不可用,无法保存执行节点凭据');return safeStorage.encryptString(value)},
+      decrypt:bytes=>safeStorage.decryptString(bytes),
+      onState:message=>{log(`Runner: ${message}`);const item=Menu.getApplicationMenu()?.getMenuItemById('runner-status');if(item)item.label=message},
+      fork:()=>utilityProcess.fork(join(root,'runner.mjs'),['--desktop-ipc'],{cwd:root,stdio:'pipe',env:{PATH:process.env.PATH||'/usr/bin:/bin',NODE_ENV:'production',NODE_USE_ENV_PROXY:'0'}}),
+    })
+    async function runnerAction(action){try{await action()}catch(error){await dialog.showMessageBox(window,{type:'error',message:'执行节点操作未完成',detail:error.message})}}
+    async function importRunner(){
+      const selection=await dialog.showOpenDialog(window,{title:'导入执行节点配置',properties:['openFile'],filters:[{name:'Runner 配置',extensions:['json']}]})
+      if(!selection.canceled&&selection.filePaths[0]&&!closing)await runnerAction(()=>runnerManager.importFile(selection.filePaths[0]))
+    }
     Menu.setApplicationMenu(Menu.buildFromTemplate([
       { label: '夭夭', submenu: [{ role: 'about', label: '关于夭夭' }, { type: 'separator' },
         { label: '显示窗口', click: show }, { label: '在浏览器中打开', click: () => { if (manager.state.url) safeExternal(manager.state.url) } },
         { label: '查看服务日志', click: () => shell.showItemInFolder(logFile) }, { type: 'separator' },
         { role: 'hide', label: '隐藏夭夭' }, { role: 'hideOthers', label: '隐藏其他' }, { role: 'unhide', label: '显示全部' },
         { type: 'separator' }, { role: 'quit', label: '退出夭夭' }] },
+      {label:'执行节点',submenu:[{id:'runner-status',label:'未配置执行节点',enabled:false},{type:'separator'},
+        {id:'runner-import',label:'导入节点配置…',click:importRunner},
+        {id:'runner-reconnect',label:'重新连接',click:()=>runnerAction(()=>runnerManager.start())},
+        {id:'runner-forget',label:'断开并忘记配置',click:()=>runnerAction(()=>runnerManager.forget())}]},
       { role: 'editMenu', label: '编辑' }, { role: 'viewMenu', label: '显示' }, { role: 'windowMenu', label: '窗口' },
     ]))
     const icon = nativeImage.createFromPath(join(desktopRoot, 'icon.png')).resize({ width: 20, height: 20 })
@@ -134,5 +151,6 @@ else {
     powerMonitor.on('resume', () => { void manager.check() })
     timer = setInterval(() => { void manager.check() }, 10_000); timer.unref()
     await manager.start().catch(error => log(error.message))
+    if(!closing)await runnerManager.start().catch(error=>{runnerManager.publish(error.message)})
   }).catch(error => { log(error.stack || error.message); quitting = true; app.quit() })
 }

+ 94 - 0
desktop/runner-manager.mjs

@@ -0,0 +1,94 @@
+import { readFile, writeFile, rename, mkdir, rm } from 'node:fs/promises'
+import { join } from 'node:path'
+import { randomUUID } from 'node:crypto'
+
+/** Native-only credentials and child ownership. No browser IPC exposes this class. */
+export class DesktopRunnerManager {
+  constructor(options) {
+    this.options = options
+    this.path = join(options.home, 'desktop-runner.enc')
+    this.state = '未配置执行节点'
+    this.child = null
+    this.chain = Promise.resolve()
+    this.stopping = false
+    this.generation = 0
+    this.restarts = []
+  }
+  publish(message) { this.state = message; this.options.onState?.(message) }
+  serial(work) { const result = this.chain.catch(() => {}).then(work); this.chain = result; return result }
+  async read() {
+    let encrypted
+    try { encrypted = await readFile(this.path) } catch(error) { if(error.code === 'ENOENT') return; throw error }
+    try { return this.options.validate(JSON.parse(this.options.decrypt(encrypted))) }
+    catch { throw new Error('无法解锁执行节点配置,请检查钥匙串或重新导入') }
+  }
+  importFile(path) {
+    const generation=++this.generation
+    return this.serial(async () => {
+      let config
+      try { config = this.options.validate(JSON.parse(await readFile(path, 'utf8'))) }
+      catch { throw new Error('执行节点配置无效,请从夭夭重新下载配置') }
+      // Encrypt before stopping a working node: failed Keychain access preserves it.
+      if(generation!==this.generation)return
+      const bytes = this.options.encrypt(JSON.stringify(config))
+      await this.stopChild()
+      if(generation!==this.generation)return
+      await mkdir(this.options.home, { recursive: true, mode: 0o700 })
+      const temporary = `${this.path}.${randomUUID()}`
+      try { await writeFile(temporary, bytes, { flag: 'wx', mode: 0o600 }); await rename(temporary, this.path) }
+      finally { await rm(temporary, { force: true }) }
+      if(generation!==this.generation)return
+      this.stopping = false; this.restarts = []
+      await this.startChild(config)
+    })
+  }
+  start() { const generation=++this.generation; return this.serial(async () => { const config=await this.read();if(generation!==this.generation)return;this.stopping=false;if(config)await this.startChild(config) }) }
+  async startChild(config) {
+    if(this.child || this.stopping) return
+    const child = this.options.fork()
+    this.child = child
+    // Drain diagnostics without persisting possible upstream or parser secrets.
+    child.stdout?.resume();child.stderr?.resume()
+    this.publish('执行节点正在连接')
+    child.once('spawn', () => {
+      if(this.child !== child || this.stopping) { child.postMessage({type:'shutdown'}); return }
+      child.postMessage({ type: 'configure', config, home: join(this.options.home, 'runner-state') })
+    })
+    child.on('message', message => {
+      if(this.child !== child) return
+      if(['status','error'].includes(message?.type) && typeof message.message === 'string') this.publish(message.message.slice(0,200))
+    })
+    child.exited = new Promise(resolve => child.once('exit', code => {
+      child.finished = true
+      resolve()
+      if(this.child !== child) return
+      this.child = null
+      if(this.stopping) return
+      if(code === 0) { this.publish('执行节点已停止'); return }
+      this.restarts = this.restarts.filter(time => Date.now()-time < 60000)
+      if(this.restarts.length >= 3) { this.publish('执行节点反复退出,请检查配置后重连'); return }
+      this.restarts.push(Date.now())
+      this.publish('执行节点意外退出,正在重连')
+      this.restartTimer = setTimeout(() => { void this.start().catch(() => this.publish('无法读取执行节点配置,请重新导入')) },1000)
+      this.restartTimer.unref()
+    }))
+  }
+  async stopChild() {
+    this.stopping = true; clearTimeout(this.restartTimer)
+    const child = this.child
+    if(!child) return
+    child.postMessage({type:'shutdown'})
+    let timer
+    try { await Promise.race([child.exited,new Promise(resolve => { timer = setTimeout(resolve,5000) })]) }
+    finally { clearTimeout(timer) }
+    if(!child.finished) {
+      child.kill()
+      try { await Promise.race([child.exited,new Promise(resolve => { timer = setTimeout(resolve,5000) })]) }
+      finally { clearTimeout(timer) }
+    }
+    if(!child.finished) throw new Error('执行节点尚未确认退出,暂不启动替代进程')
+    if(this.child === child) this.child = null
+  }
+  stop() { this.generation++;this.stopping = true; clearTimeout(this.restartTimer); return this.serial(() => this.stopChild()) }
+  forget() { this.generation++;this.stopping = true; return this.serial(async () => { await this.stopChild(); await rm(this.path,{force:true}); this.publish('未配置执行节点') }) }
+}

+ 48 - 0
desktop/runner-manager.test.mjs

@@ -0,0 +1,48 @@
+import {test} from 'node:test'
+import assert from 'node:assert/strict'
+import {EventEmitter} from 'node:events'
+import {mkdtemp,readFile,writeFile,stat,rm} from 'node:fs/promises'
+import {tmpdir} from 'node:os'
+import {join} from 'node:path'
+import {randomUUID,randomBytes,createCipheriv,createDecipheriv} from 'node:crypto'
+import {DesktopRunnerManager} from './runner-manager.mjs'
+
+async function fixture(run){
+  const home=await mkdtemp(join(tmpdir(),'yaoyao-native-runner-')),key=randomBytes(32),children=[]
+  const encrypt=value=>{const iv=randomBytes(12),cipher=createCipheriv('aes-256-gcm',key,iv);const bytes=Buffer.concat([cipher.update(value),cipher.final()]);return Buffer.concat([iv,cipher.getAuthTag(),bytes])}
+  const decrypt=bytes=>{const cipher=createDecipheriv('aes-256-gcm',key,bytes.subarray(0,12));cipher.setAuthTag(bytes.subarray(12,28));return Buffer.concat([cipher.update(bytes.subarray(28)),cipher.final()]).toString()}
+  const manager=new DesktopRunnerManager({home,encrypt,decrypt,validate:value=>{if(value.protocol!==1)throw new Error('invalid');return value},fork:()=>{
+    const child=new EventEmitter();child.messages=[]
+    child.postMessage=value=>{child.messages.push(value);if(value.type==='shutdown')queueMicrotask(()=>child.emit('exit',0))}
+    child.kill=()=>{queueMicrotask(()=>child.emit('exit',0));return true}
+    children.push(child);queueMicrotask(()=>child.emit('spawn'));return child
+  }})
+  const config={protocol:1,runnerId:randomUUID(),token:randomBytes(32).toString('base64url'),serverURL:'https://fixture.invalid',hermesURL:'http://127.0.0.1:9119',allowedProfiles:['default'],artifactRoots:[]}
+  const file=join(home,'download.json');await writeFile(file,JSON.stringify(config))
+  try{await run({home,manager,children,config,file})}finally{await manager.stop();await rm(home,{recursive:true,force:true})}
+}
+test('stores only encrypted credentials and restarts from private IPC configuration',()=>fixture(async({manager,children,config,file})=>{
+  await manager.importFile(file)
+  const encrypted=await readFile(manager.path)
+  assert.equal(encrypted.includes(config.token),false)
+  assert.equal((await stat(manager.path)).mode&0o077,0)
+  assert.deepEqual(children[0].messages.find(m=>m.type==='configure').config,config)
+  await manager.stop();await manager.start()
+  assert.equal(children.length,2)
+  assert.deepEqual(children[1].messages.find(m=>m.type==='configure').config,config)
+  await manager.forget()
+  await assert.rejects(readFile(manager.path),{code:'ENOENT'})
+  await manager.start();assert.equal(children.length,2)
+}))
+test('a Keychain failure preserves a running node and its previous encrypted configuration',()=>fixture(async({manager,children,file})=>{
+  await manager.importFile(file);const encrypted=await readFile(manager.path)
+  manager.options.encrypt=()=>{throw new Error('keychain unavailable')}
+  await assert.rejects(manager.importFile(file),/keychain/)
+  assert.equal(children[0].finished,undefined)
+  assert.deepEqual(await readFile(manager.path),encrypted)
+}))
+test('a queued import cannot start a node after stop was requested',()=>fixture(async({manager,children,file})=>{
+  const imported=manager.importFile(file),stopped=manager.stop()
+  await Promise.all([imported,stopped])
+  assert.equal(children.length,0)
+}))

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

@@ -64,3 +64,5 @@
 - 下一阶段必须继续执行节点、受控 Worker、VM、临时助手、资源租约、文件/产物搬运、后台常驻、更新回退等未完成项。当前 App 安装包是桌面基础快照,不是最终完整平台交付。
 - Runner 阶段:新增独立执行节点、管理员注册/配置下载/停用入口,以及打包 CLI。最终类型检查和 680 项全量测试通过(1 项真实协议测试按需跳过);独立打包 Runner 的 13 项协议/CLI 测试通过;浏览器注册流程通过并核对桌面/窄屏截图。
 - 单独启用 `HERMES_TEAM_RUNNER=1` 后,真实 Hermes 隔离协议验收通过,包含原生工具创建成员、组队、结构化分派、验收、完成、结果回传和双管理员工具隔离。执行细节和当前限制见 `docs/runner.md`。这是传输层验收,尚非 VM 或真实模型自主决策验收。
+- 桌面 Runner 阶段:App 新增原生配置导入、系统加密存储、私有进程传参、自动启动与有界重连。开发 App 的 4 项生命周期/凭据测试通过;打包 arm64 App 的真实生命周期测试再次通过,覆盖配置导入、服务崩溃后的重新连接、窗口关闭保留连接、退出清理与再次打开自动连接。
+- 此次打包验收使用 `/tmp/yaoyao-runner-package-130917f/mac-arm64/夭夭.app`,为临时 ad-hoc 签名测试包,严格签名检查通过。未替换 `desktop-release` 中较早的开发证书快照,也未安装、部署、公证或发布。服务全量 680 项通过;配置与 Runner 定向 23 项通过。

+ 1 - 1
docs/runner.md

@@ -18,7 +18,7 @@ Runner 在运行 Hermes 的电脑上主动连接夭夭服务。用于 Web 位于
 node runner.mjs --config /完整路径/runner.json
 ```
 
-macOS App 的打包构建也包含该入口;当前 App 仍自动托管本地 Web 与缺失的 Hermes 服务。Runner 的原生配置导入与自动托管入口尚待接入,不能把“已打包入口”视作“已自动运行 Runner”。
+macOS App 可直接通过菜单栏“执行节点 → 导入节点配置…”导入下载的配置,无需另外安装 Node.js。App 使用系统钥匙串支持的加密存储保存配置,通过私有进程消息传入内置 Runner;凭据不进入命令行或子进程环境变量。随后每次打开 App 都会自动启动已配置的 Runner。关闭窗口保持连接,退出 App 时清理自己管理的 Runner;“断开并忘记配置”移除本机保存的配置。原始下载文件仍由用户管理。
 
 配置文件至少包括以下字段。`token` 由注册操作生成,不需要填写模型密钥。
 

+ 2 - 0
electron-builder.yml

@@ -13,6 +13,8 @@ extraResources:
       - server.mjs.map
       - runner.mjs
       - runner.mjs.map
+      - runner-config.mjs
+      - runner-config.mjs.map
       - release.json
       - ui/**/*
 asar: true

+ 2 - 2
scripts/build-runner.mjs

@@ -6,8 +6,8 @@ import { fileURLToPath } from 'node:url'
 export async function buildRunner(directory = resolve(import.meta.dirname, '../.runner-build')) {
   await mkdir(directory, { recursive: true })
   await build({
-    entryPoints: [resolve(import.meta.dirname, '../src/runner/index.ts')],
-    outfile: resolve(directory, 'runner.mjs'), bundle: true, platform: 'node',
+    entryPoints: { runner:resolve(import.meta.dirname, '../src/runner/index.ts'), 'runner-config':resolve(import.meta.dirname, '../src/runner/config.ts') },
+    outdir:directory, outExtension:{'.js':'.mjs'}, bundle: true, platform: 'node',
     format: 'esm', target: 'node24', sourcemap: true,
     banner: { js: "import { createRequire as __createRequire } from 'node:module'; const require = __createRequire(import.meta.url);" },
   })

+ 20 - 0
src/runner/config.ts

@@ -0,0 +1,20 @@
+import { isAbsolute } from 'node:path'
+import { z } from 'zod'
+import type { RunnerConfiguration } from '../shared/runner.js'
+
+const schema=z.object({
+  protocol:z.literal(1),serverURL:z.string().url(),runnerId:z.string().uuid(),token:z.string().min(32).max(4096),
+  hermesURL:z.string().url().default('http://127.0.0.1:9119'),
+  allowedProfiles:z.array(z.string().trim().min(1).max(256).regex(/^[^/\\\u0000-\u001f]+$/)).min(1).max(256),
+  artifactRoots:z.array(z.string().refine(isAbsolute,'产物目录必须为绝对路径')).max(64).default([]),
+  allowInsecureLan:z.boolean().optional(),hermesCredentials:z.object({username:z.string().max(256),password:z.string().max(4096)}).strict().optional(),
+}).strict()
+export function parseRunnerConfiguration(value:unknown):RunnerConfiguration {
+  const parsed=schema.safeParse(value)
+  if(!parsed.success)throw new Error('执行节点配置无效,请检查字段格式')
+  const config=parsed.data,web=new URL(config.serverURL),hermes=new URL(config.hermesURL)
+  if(web.username||web.password||web.search||web.hash||web.pathname!=='/'||!['http:','https:'].includes(web.protocol))throw new Error('夭夭服务地址无效')
+  if(web.protocol==='http:'&&!['127.0.0.1','localhost','[::1]'].includes(web.hostname)&&!config.allowInsecureLan)throw new Error('远程执行节点需要 HTTPS 或明确启用可信局域网 HTTP')
+  if(hermes.username||hermes.password||hermes.search||hermes.hash||!['127.0.0.1','[::1]'].includes(hermes.hostname)||!['http:','https:'].includes(hermes.protocol))throw new Error('Runner 必须连接本机 Hermes')
+  return config
+}

+ 48 - 11
src/runner/index.ts

@@ -1,16 +1,53 @@
 import { readFileSync, statSync, mkdirSync } from 'node:fs'
-import { resolve, dirname, isAbsolute } from 'node:path'
-import { z } from 'zod'
+import { resolve, dirname, isAbsolute, join } from 'node:path'
+import type { ParentPort } from 'electron'
 import { RunnerAgent } from './agent.js'
+import { parseRunnerConfiguration } from './config.js'
 import { acquireServiceInstance } from '../server/serviceInstance.js'
 
-const index=process.argv.indexOf('--config'),path=index>=0?process.argv[index+1]:undefined
-if(!path)throw new Error('用法:yaoyao-runner --config /path/to/runner.json')
-if(process.platform!=='win32'&&(statSync(path).mode&0o077)!==0)throw new Error('执行节点配置必须为仅当前用户可读(chmod 600)')
-const config=z.object({protocol:z.literal(1),serverURL:z.string().url(),runnerId:z.string().uuid(),token:z.string().min(32),hermesURL:z.string().url().default('http://127.0.0.1:9119'),allowedProfiles:z.array(z.string().trim().min(1).max(256).regex(/^[^/\\\u0000-\u001f]+$/)).min(1).max(256),artifactRoots:z.array(z.string().refine(isAbsolute,'产物目录必须为绝对路径')).max(64).default([]),allowInsecureLan:z.boolean().optional(),hermesCredentials:z.object({username:z.string(),password:z.string()}).optional()}).strict().parse(JSON.parse(readFileSync(path,'utf8')))
-const home=resolve(dirname(path),'state',config.runnerId);mkdirSync(home,{recursive:true,mode:0o700})
-const instance=acquireServiceInstance(home,'1.0.0'),controller=new AbortController()
+const controller=new AbortController()
 process.once('SIGTERM',()=>controller.abort());process.once('SIGINT',()=>controller.abort())
-process.once('exit',()=>instance.release())
-const agent=new RunnerAgent(config,home,fetch,message=>process.stderr.write(`${new Date().toISOString()} ${message}\n`))
-try {await agent.run(controller.signal)}finally{instance.release()}
+const parent=(process as unknown as {parentPort?:ParentPort}).parentPort
+async function input():Promise<{config:ReturnType<typeof parseRunnerConfiguration>;home:string}> {
+  if(process.argv.includes('--desktop-ipc')) {
+    if(!parent)throw new Error('桌面配置只允许通过 App 私有进程通道传入')
+    return new Promise((resolveInput,reject)=>{
+      const timer=setTimeout(()=>reject(new Error('未收到桌面配置')),10000)
+      parent.once('message',event=>{
+        clearTimeout(timer)
+        try {
+          const data=event.data
+          if(data?.type!=='configure'||typeof data.home!=='string'||!isAbsolute(data.home))throw new Error('桌面配置无效')
+          const config=parseRunnerConfiguration(data.config)
+          resolveInput({config,home:join(data.home,config.runnerId)})
+        }catch{reject(new Error('桌面配置无效'))}
+      })
+    })
+  }
+  const index=process.argv.indexOf('--config'),path=index>=0?process.argv[index+1]:undefined
+  if(!path)throw new Error('用法:yaoyao-runner --config /path/to/runner.json')
+  if(process.platform!=='win32'&&(statSync(path).mode&0o077)!==0)throw new Error('执行节点配置必须为仅当前用户可读(chmod 600)')
+  let value:unknown
+  try{value=JSON.parse(readFileSync(path,'utf8'))}catch{throw new Error('无法读取执行节点 JSON 配置')}
+  const config=parseRunnerConfiguration(value)
+  return {config,home:resolve(dirname(path),'state',config.runnerId)}
+}
+try {
+  const {config,home}=await input()
+  parent?.on('message',event=>{if(event.data?.type==='shutdown')controller.abort()})
+  mkdirSync(home,{recursive:true,mode:0o700})
+  const instance=acquireServiceInstance(home,'1.0.0')
+  process.once('exit',()=>instance.release())
+  const agent=new RunnerAgent(config,home,fetch,message=>{
+    if(parent)parent.postMessage({type:'status',message})
+    else process.stderr.write(`${new Date().toISOString()} ${message}\n`)
+  })
+  try{await agent.run(controller.signal)}finally{instance.release()}
+}catch(error){
+  if(parent)parent.postMessage({type:'error',message:'执行节点启动失败,请检查配置或重新导入'})
+  else process.stderr.write(`${error instanceof Error?error.message:'执行节点启动失败'}\n`)
+  process.exitCode=1
+}
+
+// The utility IPC listener keeps Electron children alive; exit only after cleanup.
+if(parent)process.exit(process.exitCode ?? 0)

+ 22 - 0
tests/server/runnerConfig.test.ts

@@ -0,0 +1,22 @@
+// @vitest-environment node
+import {expect,it} from 'vitest'
+import {randomUUID} from 'node:crypto'
+import {parseRunnerConfiguration} from '../../src/runner/config'
+const config={protocol:1,runnerId:randomUUID(),token:'secret-fixture-'.repeat(3),serverURL:'https://example.invalid',hermesURL:'http://127.0.0.1:9119',allowedProfiles:['default'],artifactRoots:[]}
+it.each([
+  {serverURL:'http://example.invalid'},
+  {serverURL:'https://user:password@example.invalid'},
+  {serverURL:'https://example.invalid/path'},
+  {hermesURL:'http://host.docker.internal:9119'},
+  {hermesURL:'http://user:password@127.0.0.1:9119'},
+  {artifactRoots:['relative/path']},
+  {allowedProfiles:['../default']},
+  {protocol:2},
+  {command:'/bin/sh'},
+])('rejects invalid or expanded node configuration without echoing credentials (%j)',patch=>{
+  expect(()=>parseRunnerConfiguration({...config,...patch})).toThrow()
+  try{parseRunnerConfiguration({...config,...patch})}catch(error){expect(String(error)).not.toContain(config.token);expect(String(error)).not.toContain('password')}
+})
+it('accepts explicit LAN opt-in and keeps service credentials in the private configuration',()=>{
+  expect(parseRunnerConfiguration({...config,serverURL:'http://192.168.1.2:15300',allowInsecureLan:true,hermesCredentials:{username:'fixture',password:'private'}})).toMatchObject({allowInsecureLan:true,hermesCredentials:{username:'fixture',password:'private'}})
+})