Sfoglia il codice sorgente

fix(bot): 去掉卫星执行节点和远程机器人

执行节点只服务虚拟环境,不再接管聊天。远程 Bot 不再导入或转发到别的服务器。
Samien 1 mese fa
parent
commit
e79d455

+ 9 - 17
src/client/components/app/RunnerSettingsPanel.vue

@@ -10,14 +10,14 @@ const bundleAvailable=ref(false),fixedDesktops=ref(false)
 const runners = ref<Runner[]>([]), sources = ref<Array<{id:string;name:string}>>([])
 const busy = ref(false), error = ref(''), notice = ref('')
 const name = ref(''), source = ref('local'), profiles = ref('default')
-const serverURL = ref(window.location.origin), hermesURL = ref('http://127.0.0.1:9119'), satellite = ref(false), serverHermes = ref('')
+const serverURL = ref(window.location.origin), hermesURL = ref('http://127.0.0.1:9119')
 const computerNetwork=ref<'none'|'public-proxy'>('public-proxy')
 const computers=ref(!!props.computerSetup),computerRuntime=ref<'docker'|'podman'>('docker'),computerImage=ref(''),computerPython=ref(''),hermesSource=ref(''),hermesHome=ref('')
 const artifactRoots = ref(''), insecure = ref(false), configuration = ref<RunnerConfiguration>()
 
 async function refresh() {
   const data = await apiRequest<{runners:Runner[];sources:Array<{id:string;name:string}>;bundleAvailable?:boolean;fixedDesktops?:boolean;hermesUpstream?:string}>('/api/app/admin/runners')
-  runners.value = data.runners; sources.value = data.sources;bundleAvailable.value=!!data.bundleAvailable;fixedDesktops.value=!!data.fixedDesktops;serverHermes.value=data.hermesUpstream||''
+  runners.value = data.runners; sources.value = data.sources;bundleAvailable.value=!!data.bundleAvailable;fixedDesktops.value=!!data.fixedDesktops
 }
 async function action(work:()=>Promise<void>) {
   if(busy.value) return
@@ -27,21 +27,15 @@ async function action(work:()=>Promise<void>) {
 }
 async function register() {
   await action(async()=>{
-    const hermesTarget = satellite.value ? serverHermes.value : hermesURL.value
-    if(satellite.value && !hermesTarget)throw new Error('服务器尚未配置 Hermes 地址')
-    const web = new URL(serverURL.value), local = new URL(hermesTarget)
+    const web = new URL(serverURL.value), local = new URL(hermesURL.value)
     if(!['http:','https:'].includes(web.protocol)||web.username||web.password||web.search||web.hash||web.pathname!=='/')throw new Error('请填写不带路径的夭夭服务地址')
     if(web.protocol==='http:'&&!['localhost','127.0.0.1','[::1]'].includes(web.hostname)&&!insecure.value)throw new Error('远程节点请使用 HTTPS,或勾选可信局域网 HTTP')
-    if(satellite.value){
-      if(['127.0.0.1','localhost','[::1]'].includes(local.hostname))throw new Error('服务器 Hermes 仍是本机回环地址,卫星连不上。请把上游配成卫星能访问的地址。')
-      if(!['http:','https:'].includes(local.protocol)||local.username||local.password)throw new Error('服务器 Hermes 地址无效')
-    }else if(!['127.0.0.1','[::1]'].includes(local.hostname)||!['http:','https:'].includes(local.protocol)||local.username||local.password)throw new Error('Hermes 地址必须指向 Runner 所在电脑的回环地址')
+    if(!['127.0.0.1','[::1]'].includes(local.hostname)||!['http:','https:'].includes(local.protocol)||local.username||local.password)throw new Error('Hermes 地址必须指向 Runner 所在电脑的回环地址')
     const allowedProfiles = [...new Set(profiles.value.split(/[\s,,]+/).filter(Boolean))]
     if(computers.value&&((!!computerImage.value&&!/^sha256:[a-f0-9]{64}$/.test(computerImage.value))||[computerPython.value,hermesSource.value,hermesHome.value].some(path=>path&&!path.startsWith('/'))))throw new Error('自定义路径必须为执行电脑上的绝对路径;默认安装可留空。镜像 ID 可留空或填写完整 sha256 ID。')
     const result = await apiRequest<{runner:Runner;token:string}>('/api/app/admin/runners', {method:'POST',body:{name:name.value.trim(),sourceNodeId:source.value,allowedProfiles}})
-    const lanHermes = satellite.value && local.protocol==='http:'
-    configuration.value = {protocol:1,serverURL:web.origin,runnerId:result.runner.id,token:result.token,hermesURL:local.toString(),...(satellite.value?{satellite:true}:{}),allowedProfiles,artifactRoots:artifactRoots.value.split('\n').map(p=>p.trim()).filter(Boolean),...(insecure.value||lanHermes?{allowInsecureLan:true}:{}),...(computers.value?{computers:{...(fixedDesktops.value?{managedBy:'compose' as const}:{}),network:fixedDesktops.value?'none':computerNetwork.value,runtime:computerRuntime.value,imageId:computerImage.value||UNCONFIGURED_COMPUTER_IMAGE,python:computerPython.value,hermesSource:hermesSource.value,hermesHome:hermesHome.value}}:{})}
-    notice.value = satellite.value ? '节点已注册。请在卫星电脑上启动 Runner。会话连接服务器 Hermes,不要在卫星上启动本机 Hermes。' : '节点已注册,请下载配置并在 Hermes 所在电脑启动 Runner。'
+    configuration.value = {protocol:1,serverURL:web.origin,runnerId:result.runner.id,token:result.token,hermesURL:local.toString(),allowedProfiles,artifactRoots:artifactRoots.value.split('\n').map(p=>p.trim()).filter(Boolean),...(insecure.value?{allowInsecureLan:true}:{}),...(computers.value?{computers:{...(fixedDesktops.value?{managedBy:'compose' as const}:{}),network:fixedDesktops.value?'none':computerNetwork.value,runtime:computerRuntime.value,imageId:computerImage.value||UNCONFIGURED_COMPUTER_IMAGE,python:computerPython.value,hermesSource:hermesSource.value,hermesHome:hermesHome.value}}:{})}
+    notice.value = '节点已注册,请下载配置并在 Hermes 所在电脑启动 Runner。虚拟环境才会用到它。'
     await refresh()
   })
 }
@@ -63,7 +57,7 @@ onMounted(()=>{void action(refresh)})
 
 <template>
   <section class="runner-panel" aria-label="执行节点设置">
-    <p>在运行 Hermes 的电脑上启动 Runner,它会主动连接夭夭,接收 Bot 任务并提供本轮团队工具。注册后,该来源的 Bot 任务会交给 Runner;节点离线时无法启动新任务,恢复连接后可重试。</p>
+    <p>执行节点只给虚拟环境用。聊天和 Mac 桌面不经过这里。节点要和 Hermes 装在同一台电脑上。</p>
     <p v-if="bundleAvailable"><a href="/api/app/admin/runners/bundle" download>下载配套执行节点程序</a>(包含桌面资源,需要 Node.js 24 或更高版本)。</p>
     <div class="runner-toolbar"><strong>已注册节点</strong><button type="button" :disabled="busy" @click="action(refresh)">刷新状态</button></div>
     <ul v-if="runners.length" class="runner-list">
@@ -77,10 +71,8 @@ onMounted(()=>{void action(refresh)})
       <label>节点名称<input v-model="name" required maxlength="100" autocomplete="off" placeholder="我的 Mac" :disabled="busy"></label>
       <label>接管的 Bot 来源<select v-model="source" :disabled="busy"><option v-for="item in sources" :key="item.id" :value="item.id">{{ item.name }}</option></select></label>
       <label>允许的 Profile<input v-model="profiles" required placeholder="default, writer" :disabled="busy"><small>填写 Hermes 的 Profile 名称,以逗号分隔;账号权限仍然生效。</small></label>
-      <label class="runner-check"><input v-model="satellite" type="checkbox" :disabled="busy">卫星电脑(会话使用服务器 Hermes,不连接本机 9119)</label>
       <label>夭夭服务地址<input v-model="serverURL" type="url" required :disabled="busy"><small>必须能从 Runner 所在电脑访问。</small></label>
-      <label v-if="!satellite">Runner 本机 Hermes 地址<input v-model="hermesURL" type="url" required :disabled="busy"></label>
-      <p v-else>会话连接服务器 Hermes:{{ serverHermes || '尚未配置' }}</p>
+      <label>Runner 本机 Hermes 地址<input v-model="hermesURL" type="url" required :disabled="busy"></label>
       <label>允许导出的产物目录<textarea v-model="artifactRoots" rows="2" :disabled="busy" placeholder="每行一个绝对路径,留空时禁止导出文件"></textarea></label>
       <label class="runner-check"><input v-model="insecure" type="checkbox" :disabled="busy">使用可信局域网 HTTP</label>
       <label class="runner-check"><input v-model="computers" type="checkbox" :disabled="busy">启用隔离电脑 Worker</label>
@@ -100,7 +92,7 @@ onMounted(()=>{void action(refresh)})
     </form>
     <div v-else class="runner-setup">
       <button class="solid-button" type="button" @click="download">下载 Runner 配置</button>
-      <p>{{ configuration.satellite ? '配置包含连接凭据,仅本次可下载。将文件放到卫星电脑,设置为仅自己可读。不要在卫星上启动 Hermes。' : '配置包含本节点的连接凭据,仅本次可下载。将文件放在 Hermes 所在电脑,设置为仅自己可读:' }}</p>
+      <p>配置包含本节点的连接凭据,仅本次可下载。将文件放在 Hermes 所在电脑,设置为仅自己可读:</p>
       <code v-if="bundleAvailable">mkdir -p yaoyao-runner<br>tar -xzf yaoyao-runner.tar.gz -C yaoyao-runner<br>chmod 600 runner.json<br>node yaoyao-runner/runner.mjs --config /完整路径/runner.json</code>
       <code v-else>chmod 600 runner.json<br>npm run runner -- --config /完整路径/runner.json</code>
       <small>{{bundleAvailable?'在运行 Hermes 和 Docker/Podman 的电脑上执行,节点连接后返回本地虚拟机设置准备桌面。':'在 yaoyao 项目中先运行 npm run build。'}}配置丢失后可停用节点并重新注册。</small>

+ 0 - 5
src/client/components/app/SettingsCenterDialog.vue

@@ -6,7 +6,6 @@ import AgentIdentityPanel from '@/components/app/AgentIdentityPanel.vue'
 import DuplexVoicePanel from '@/components/app/DuplexVoicePanel.vue'
 import ModelServicesPanel from '@/components/app/ModelServicesPanel.vue'
 import NodePairingPanel from '@/components/app/NodePairingPanel.vue'
-import WorkspaceNodesPanel from '@/components/workspace/WorkspaceNodesPanel.vue'
 import WorkspaceVoiceProviders from '@/components/workspace/WorkspaceVoiceProviders.vue'
 import SystemManagementPanel from '@/components/app/SystemManagementPanel.vue'
 import SystemOverviewPanel from '@/components/app/SystemOverviewPanel.vue'
@@ -27,7 +26,6 @@ type SettingsPage =
   | 'system-users'
   | 'system-connection'
   | 'system-push'
-  | 'system-nodes'
   | 'system-voice'
   | 'system-update'
 
@@ -110,7 +108,6 @@ const systemItems: NavigationItem[] = [
   { key: 'system-overview', label: '系统概览', icon: 'panel' },
   { key: 'system-connection', label: 'Hermes 连接', icon: 'link' },
   { key: 'system-users', label: '用户与权限', icon: 'users' },
-  { key: 'system-nodes', label: '节点与设备', icon: 'panel' },
   { key: 'system-push', label: '消息推送', icon: 'bell' },
   { key: 'system-voice', label: '双流语音', icon: 'audio' },
   { key: 'system-update', label: '更新与回滚', icon: 'refresh' },
@@ -138,7 +135,6 @@ const activeTitle = computed(() => ({
   'system-users': '用户与权限',
   'system-connection': 'Hermes 连接',
   'system-push': '消息推送',
-  'system-nodes': '节点与设备',
   'system-voice': '双流语音',
   'system-update': '更新与回滚',
 })[activePage.value])
@@ -378,7 +374,6 @@ function requestModeSwitch() {
                 <SystemManagementPanel v-else-if="activePage === 'system-users' && isAdmin" section="users" :profiles="profiles" :active="true" @dirty-change="setDirty('system-users', $event)" />
                 <SystemManagementPanel v-else-if="activePage === 'system-connection' && isAdmin" section="connection" :active="true" :profiles="profiles" :upstream-ready="upstreamReady" :upstream-error="upstreamError" @dirty-change="setDirty('system-connection', $event)" />
                 <SystemManagementPanel v-else-if="activePage === 'system-push' && isAdmin" section="push" :active="true" @dirty-change="setDirty('system-push', $event)" />
-                <WorkspaceNodesPanel v-else-if="activePage === 'system-nodes' && isAdmin" />
                 <section v-else-if="activePage === 'system-voice' && isAdmin"><DuplexVoicePanel @dirty-change="setDirty('system-voice', $event)" /><WorkspaceVoiceProviders /></section>
                 <SystemUpdatePanel v-else-if="activePage === 'system-update' && isAdmin" :active="true" @lock-change="updateLocked = $event" />
               </div>

+ 1 - 7
src/client/components/app/SystemOverviewPanel.vue

@@ -7,7 +7,7 @@ import { pairedDevices, type PairedDevicesResponse } from '@/api/pairing'
 import { getPushSystemStatus, type PushSystemStatus } from '@/api/push'
 import { systemUpdateStatus, type SystemUpdateStatus } from '@/api/systemUpdate'
 
-type Destination = 'system-users' | 'system-connection' | 'system-push' | 'system-nodes' | 'system-voice' | 'system-update'
+type Destination = 'system-users' | 'system-connection' | 'system-push' | 'system-voice' | 'system-update'
 
 const props = withDefaults(defineProps<{
   active?: boolean
@@ -37,7 +37,6 @@ const pushStatus = computed(() => {
   const fcm = push.value.providers?.fcm
   return `${apns} · ${fcm?.configured && fcm.healthy ? 'FCM 已启用' : 'FCM 未配置'}`
 })
-const deviceStatus = computed(() => devices.value ? `${devices.value.devices.length} 台已授权` : '尚未读取设备状态')
 const voiceStatus = computed(() => voice.value ? `已配置 ${voice.value.voices.length} 个音色` : '尚未读取语音状态')
 const updateStatus = computed(() => {
   if (!update.value) return '尚未读取版本状态'
@@ -92,11 +91,6 @@ watch(() => props.active, active => { if (active) void refresh() }, { immediate:
         <span><strong>消息推送</strong><small>{{ pushStatus }}</small></span>
         <AppIcon class="chevron" name="chevron-left" :size="18" />
       </button>
-      <button type="button" @click="emit('navigate', 'system-nodes')">
-        <AppIcon name="panel" :size="22" />
-        <span><strong>节点与设备</strong><small>{{ deviceStatus }}</small></span>
-        <AppIcon class="chevron" name="chevron-left" :size="18" />
-      </button>
       <button type="button" @click="emit('navigate', 'system-voice')">
         <AppIcon name="audio" :size="22" />
         <span><strong>双流语音 <em>全局</em></strong><small>{{ voiceStatus }}</small></span>

+ 2 - 6
src/client/components/app/WorkspaceShell.vue

@@ -33,7 +33,6 @@ type SettingsPage =
   | 'system-users'
   | 'system-connection'
   | 'system-push'
-  | 'system-nodes'
   | 'system-voice'
   | 'system-update'
 
@@ -95,7 +94,6 @@ const emit = defineEmits<{
   saveIdentity: [input: ProfileIdentityInput]
   closeInspector: []
   createAgent: []
-  createRemoteAgent: []
   createGroup: []
   botSettingsChanged: []
 }>()
@@ -202,11 +200,10 @@ async function openCreateMenu(event: Event) {
   await nextTick()
   createMenu.value?.querySelector<HTMLButtonElement>('[role="menuitem"]')?.focus()
 }
-function chooseCreate(kind: 'agent' | 'group' | 'remote-agent') {
+function chooseCreate(kind: 'agent' | 'group') {
   closeCreateMenu()
   mobileDrawerOpen.value = false
-  if (kind === 'remote-agent' && props.isAdmin) emit('createRemoteAgent')
-  else if (kind === 'agent') emit('createAgent')
+  if (kind === 'agent') emit('createAgent')
   else emit('createGroup')
 }
 function actionMenuKeydown(event: KeyboardEvent) {
@@ -746,7 +743,6 @@ defineExpose({openLocalVm:showLocalVmSettings})
       <div v-if="createMenuOpen" class="workspace-create-dismiss" @pointerdown.self="closeCreateMenu" @keydown.esc.prevent.stop="closeCreateMenu">
         <div ref="createMenu" class="workspace-create-menu" :style="createPosition" role="menu" aria-label="新建聊天" @keydown="actionMenuKeydown">
           <button type="button" role="menuitem" @click="chooseCreate('agent')"><AppIcon name="users" :size="17" />新建 Bot</button>
-          <button v-if="isAdmin" type="button" role="menuitem" @click="chooseCreate('remote-agent')"><AppIcon name="users" :size="17" />添加远程机器人</button>
           <button type="button" role="menuitem" @click="chooseCreate('group')"><AppIcon name="groups" :size="17" />新建群聊</button>
         </div>
       </div>

+ 0 - 14
src/client/views/ConversationsView.vue

@@ -6,7 +6,6 @@ import type { ServerIdentity } from '@shared/serverIdentity'
 import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, shallowRef, watch } from 'vue'
 import { useRoute, useRouter, onBeforeRouteUpdate, onBeforeRouteLeave } from 'vue-router'
 import WorkspaceShell from '@/components/app/WorkspaceShell.vue'
-import RemoteAgentPicker from '@/components/workspace/RemoteAgentPicker.vue'
 import ConversationList from '@/components/workspace/ConversationList.vue'
 import BotProfileDialog from '@/components/workspace/BotProfileDialog.vue'
 import WorkspaceBotPanel from '@/components/workspace/WorkspaceBotPanel.vue'
@@ -176,17 +175,7 @@ const dialog = ref<'agent' | 'group' | 'editAgent' | 'editGroup' | null>(null),
   dialogElement = ref<HTMLDialogElement>()
 const editingConversation = computed(() => conversations.value.find(c => c.id === editingConversationId.value) ?? (active.value?.id === editingConversationId.value ? active.value : undefined))
 const editingBot = computed(() => agents.value.find(agent => agent.id === editingId.value))
-const remotePickerOpen=ref(false)
 const isRemoteAgent=computed(()=>dialog.value==='editAgent' && !!agents.value.find(a=>a.id===editingId.value)?.remoteAgentId)
-async function remoteAdded(agent:Agent){
-  remotePickerOpen.value=false;await refresh()
-  if(dialog.value==='group'||dialog.value==='editGroup'){
-    if(!form.memberIds.includes(agent.id))form.memberIds.push(agent.id)
-  }else{
-    const conversation=conversations.value.find(c=>c.kind==='direct'&&c.memberIds[0]===agent.id)
-    if(conversation)await select(conversation.id)
-  }
-}
 const form = reactive({
   name: '',
   avatar: '',
@@ -993,7 +982,6 @@ onBeforeUnmount(() => {
     @save-identity="saveProfileIdentity"
     @create-agent="openDialog('agent')"
     @create-group="openDialog('group')"
-    @create-remote-agent="remotePickerOpen = true"
     @bot-settings-changed="knowledgeChanged"
   >
     <template #sidebar
@@ -1076,7 +1064,6 @@ onBeforeUnmount(() => {
     <Teleport to="body"><div v-if="lifecycleError" class="lifecycle-error" role="alert"><span>{{ lifecycleError }}</span><button type="button" aria-label="关闭聊天操作提示" @click="lifecycleError = ''"><AppIcon name="close" :size="16" /></button></div></Teleport>
     <PreviewModal v-if="preview" :item="preview" :items="media" @close="preview = null" />
     <ImagePreviewLightbox v-model="mediaIndex" :images="lightboxMedia" />
-    <RemoteAgentPicker v-if="remotePickerOpen && !auth.isBotOnly" @close="remotePickerOpen = false" @added="remoteAdded" />
     <BotProfileDialog v-if="dialog === 'editAgent' && editingBot" :key="editingBot.id" :agent="editingBot" :draft="form" :sources="sources" :profiles="auth.profiles" :agents="agents" :conversations="conversations" :conversation-id="editingConversationId" :is-admin="auth.user?.role === 'admin'" :knowledge-enabled="knowledgeEnabled" :busy="busy" :error="error" @close="closeDialog" @save="saveBotProfile" @changed="knowledgeChanged" />
     <Teleport to="body"><dialog v-if="dialog && dialog !== 'editAgent'" ref="dialogElement" class="editor" aria-labelledby="conversation-editor-title" @cancel.prevent="closeDialog">
       <form @submit.prevent="save">
@@ -1141,7 +1128,6 @@ onBeforeUnmount(() => {
           <label class="team-management-permission"><span><input v-model="form.memoryEnabled" type="checkbox" />自动记录长期事实</span></label>
           <button v-if="editingId" type="button" @click="knowledgePanel?.open('agent', editingId)">查看此 Bot 的记忆</button>
         </template>
-        <button v-if="!isAgentDialog && !auth.isBotOnly" type="button" class="remote-picker" @click="remotePickerOpen = true">添加远程机器人</button>
         <fieldset v-if="selectedPreset" class="preset-role-mapping">
           <legend>角色分配</legend>
           <label v-for="(role, index) in selectedPreset.roles" :key="role.name">

+ 3 - 8
src/runner/config.ts

@@ -24,14 +24,9 @@ export function parseRunnerConfiguration(value:unknown):RunnerConfiguration {
   assertRunnerHermes(hermes, config.satellite, config.allowInsecureLan)
   return config
 }
-const loopbackHost = (host:string) => ['127.0.0.1','localhost','[::1]'].includes(host)
-/** Local runners must use the Hermes on that machine. Satellite runners must use the server Hermes. */
-export function assertRunnerHermes(hermes:URL, satellite:boolean|undefined, allowInsecureLan:boolean|undefined) {
+/** Runners only talk to Hermes on the same machine. Satellite session takeover is gone. */
+export function assertRunnerHermes(hermes:URL, satellite:boolean|undefined, _allowInsecureLan:boolean|undefined) {
   if(hermes.username||hermes.password||hermes.search||hermes.hash||!['http:','https:'].includes(hermes.protocol))throw new Error('Hermes 地址无效')
-  if(satellite){
-    if(loopbackHost(hermes.hostname))throw new Error('卫星执行节点不能连接本机 Hermes')
-    if(hermes.protocol==='http:'&&!allowInsecureLan)throw new Error('卫星执行节点连接服务器 Hermes 需要 HTTPS 或明确启用可信局域网 HTTP')
-    return
-  }
+  if(satellite)throw new Error('卫星执行节点已停用,Runner 只连接本机 Hermes')
   if(!['127.0.0.1','[::1]'].includes(hermes.hostname))throw new Error('Runner 必须连接本机 Hermes')
 }

+ 2 - 10
src/server/workspaceGateway.ts

@@ -90,16 +90,7 @@ export class WorkspaceNodes {
       key = `${owner}:${id}`
     let target = this.targets.get(key)
     if (!target) {
-      if (node.transport === 'paired-web') {
-        const { token } = this.open<{ token: string }>(node.secret)
-        const url = new URL(`node/${node.deviceId}`, node.url), client = new UpstreamClient(url, this.local.client.fetchImpl)
-        target = { url, client, pairedToken: token, session: {
-          request: (path, options = {}) => client.request(path, new CookieJar(), { ...options, headers: { ...options.headers, Authorization: `Bearer ${token}` } }),
-          webSocketCredential: async () => { throw new Error('Paired Web nodes use HTTP+SSE') },
-        } }
-        this.targets.set(key, target)
-        return target
-      }
+      if (node.transport === 'paired-web') throw new HttpError(410, '远程机器人已停用', 'remote_agent_removed')
       const credentials = this.open<{ username: string; password: string }>(node.secret)
       const url = new URL(node.url),
         client = new UpstreamClient(url)
@@ -117,6 +108,7 @@ export class WorkspaceNodes {
       if(!target)throw new HttpError(409,'隔离电脑需要连接执行节点','computer_runner_required')
       return target
     }
+    if (agent.remoteAgentId) throw new HttpError(410, '远程机器人已停用', 'remote_agent_removed')
     const base = this.target(owner, agent.nodeId)
     if (!agent.remoteAgentId) return base
     if (!base.pairedToken) throw new HttpError(409, '引用远端机器人需要扫码子节点', 'paired_node_required')

+ 2 - 4
src/server/workspaceRoutes.ts

@@ -95,17 +95,15 @@ export function workspaceRouter(
   })
   router.get('/api/app/agents', async (ctx) => {
     const user=owner(ctx), agents=store.list<WorkspaceAgent>(user,'agent')
-    for (const agent of agents) if (agent.remoteAgentId) void nodes.refreshRemoteAgent(user,agent).catch(()=>{})
     ctx.body = { agents: agents.map(agent=>store.agentSummary(agent)) }
   })
   router.get('/api/app/nodes/:id/agents', async ctx => {
     auth.requireAdmin(ctx)
-    ctx.body = { agents: await nodes.remoteAgents(owner(ctx),ctx.params.id) }
+    throw new HttpError(410, '远程机器人已停用', 'remote_agent_removed')
   })
   router.post('/api/app/agents/remote', async ctx => {
     auth.requireAdmin(ctx)
-    const input = parse(z.object({nodeId:z.string().uuid(),agentId:z.string().uuid()}).strict(),body(ctx))
-    ctx.body = { agent: await nodes.importRemoteAgent(owner(ctx),input.nodeId,input.agentId) };ctx.status=201
+    throw new HttpError(410, '远程机器人已停用', 'remote_agent_removed')
   })
   router.post('/api/app/agents/import-preview', async (ctx) => {
     const input = parse(z.object({ url: z.string().max(500) }).strict(), body(ctx))

+ 1 - 1
src/server/workspaceRuntime.ts

@@ -292,7 +292,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
     this.requireAuthorization(owner, run.runId)
     this.store.requireAgentTask(owner,agent,c.id,run.conversationTaskId)
     this.nodes.requireSource(owner, agent)
-    if (agent.remoteAgentId) agent = await this.nodes.refreshRemoteAgent(owner,agent)
+    if (agent.remoteAgentId) throw new HttpError(410, '远程机器人已停用', 'remote_agent_removed')
     const key = this.bindingKey(c.id, run.conversationTaskId, agent.id),
       dispatchedVm = globalComputers(this.store.home).vm && !!agent.envs && !agent.remoteAgentId && !agent.temporaryGoalId,
       computerExecution = !dispatchedVm && !!(agent.remoteAgentId || (agent.execution==='computer' && (!agent.envs || this.computerAvailable?.(owner,agent) !== false))),

+ 2 - 5
tests/server/runnerConfig.test.ts

@@ -36,9 +36,6 @@ it('resolves optional Hermes paths on the execution host while preserving explic
  expect(()=>parseRunnerConfiguration({...config,computers:{...computers,hermesHome:'relative'}})).toThrow()
 })
 
-it('satellite runner uses the server Hermes and refuses loopback',()=>{
-  expect(parseRunnerConfiguration({...config,satellite:true,hermesURL:'https://hermes.example',allowInsecureLan:true})).toMatchObject({satellite:true,hermesURL:'https://hermes.example'})
-  expect(parseRunnerConfiguration({...config,satellite:true,hermesURL:'http://192.168.1.8:9119',allowInsecureLan:true}).hermesURL).toBe('http://192.168.1.8:9119')
-  expect(()=>parseRunnerConfiguration({...config,satellite:true})).toThrow('卫星执行节点不能连接本机 Hermes')
-  expect(()=>parseRunnerConfiguration({...config,satellite:true,hermesURL:'http://192.168.1.8:9119'})).toThrow('可信局域网')
+it('rejects the retired satellite runner',()=>{
+  expect(()=>parseRunnerConfiguration({...config,satellite:true,hermesURL:'https://hermes.example',allowInsecureLan:true})).toThrow('卫星执行节点已停用')
 })

+ 2 - 7
tests/server/workspaceRemoteAgents.test.ts

@@ -97,16 +97,11 @@ describe('referenced remote Bot Agents',()=>{
     runtime.workspace.updateAgent('owner',agent.id,{archived:true})
     await call(grant,'get',`/${agent.id}`).expect(410)
   })
-  it('can reference an Agent backed by another paired child without exposing parent credentials',async()=>{
+  it('refuses to run a bot that lives on a paired child',async()=>{
     const nodes=runtime.workspaceRuntime.nodes, nodeId=randomUUID(), childDevice=randomUUID()
     runtime.workspace.put('owner','node',nodeId,{id:nodeId,name:'nested',url:'http://nested.test:15300/',transport:'paired-web',deviceId:childDevice,secret:nodes.seal({token:'nested-secret'})})
     const agent=runtime.workspace.createAgent('owner',{name:'二级远端 Agent',profile:'remote-profile',nodeId,instructions:'nested rules'})
     const grant=pair('owner'), base=`/${agent.id}/gateway/api/realtime`
-    const channel=(await call(grant,'post',`${base}/channels`).send({channel:'chat'}).expect(201)).body.id
-    await call(grant,'post',`${base}/channels/${channel}/commands`).set('Idempotency-Key',randomUUID())
-      .send({jsonrpc:'2.0',method:'session.create',params:{profile:'wrong'}}).expect(200)
-    expect(forwarded.at(-1).headers.get('authorization')).toBe('Bearer nested-secret')
-    expect(forwarded.at(-1).body.params.profile).toBe('remote-profile')
-    expect(forwarded.at(-1).headers.get('x-yaoyao-agent-hops')).toBe('1')
+    await call(grant,'post',`${base}/channels`).send({channel:'chat'}).expect(410)
   })
 })