Jelajahi Sumber

发布 v0.3.28:完善子账号权限与远程 Agent 节点

LSamien 1 bulan lalu
induk
melakukan
fafc88ac01
47 mengubah file dengan 1414 tambahan dan 206 penghapusan
  1. 1 1
      README.md
  2. 21 0
      docs/hermes-bot-relay.md
  3. 10 0
      docs/releases/v0.3.28.md
  4. 21 0
      docs/subaccount-permissions.md
  5. 27 0
      docs/workspace-chat.md
  6. 2 2
      package-lock.json
  7. 1 1
      package.json
  8. 3 3
      release.json
  9. 8 2
      src/client/App.vue
  10. 4 3
      src/client/api/admin.ts
  11. 1 1
      src/client/api/pairing.ts
  12. 7 7
      src/client/components/app/SettingsCenterDialog.vue
  13. 24 2
      src/client/components/app/SystemManagementPanel.vue
  14. 8 4
      src/client/components/app/WorkspaceShell.vue
  15. 41 0
      src/client/components/workspace/RemoteAgentPicker.vue
  16. 36 54
      src/client/components/workspace/WorkspaceNodesPanel.vue
  17. 2 1
      src/client/stores/auth.ts
  18. 27 5
      src/client/views/ConversationsView.vue
  19. 23 1
      src/server/app.ts
  20. 89 0
      src/server/hermesBotRelay.ts
  21. 21 2
      src/server/localAuth.ts
  22. 22 6
      src/server/pairing.ts
  23. 17 7
      src/server/realtimeApi.ts
  24. 13 3
      src/server/realtimeBroker.ts
  25. 16 9
      src/server/routes.ts
  26. 4 2
      src/server/workspaceAssets.ts
  27. 163 49
      src/server/workspaceGateway.ts
  28. 125 0
      src/server/workspacePairedNode.ts
  29. 139 0
      src/server/workspaceRemoteAgents.ts
  30. 38 20
      src/server/workspaceRoutes.ts
  31. 12 3
      src/server/workspaceRuntime.ts
  32. 2 0
      src/server/workspaceStore.ts
  33. 1 0
      src/shared/workspace.ts
  34. 5 1
      tests/client/workspaceShell.test.ts
  35. 47 2
      tests/e2e/workspace-chat.spec.ts
  36. 3 1
      tests/fixtures/workspace-server.ts
  37. 1 1
      tests/server/agentManagementRoutes.test.ts
  38. 2 0
      tests/server/authenticatedApplication.ts
  39. 62 0
      tests/server/hermesBotRelay.test.ts
  40. 7 7
      tests/server/kanbanRoutes.test.ts
  41. 7 1
      tests/server/realtimeApi.test.ts
  42. 14 1
      tests/server/realtimeBroker.test.ts
  43. 99 0
      tests/server/subaccountPermissions.test.ts
  44. 2 2
      tests/server/workspace.test.ts
  45. 107 0
      tests/server/workspacePairedNodes.test.ts
  46. 105 0
      tests/server/workspaceRemoteAgents.test.ts
  47. 24 2
      tests/server/workspaceRoutes.test.ts

+ 1 - 1
README.md

@@ -1,6 +1,6 @@
 # 夭夭 Web
 
-当前发布版本:**v0.3.27**,配套 iOS **1.3(184)**。详见 [发布说明](docs/releases/v0.3.27.md)。
+当前发布版本:**v0.3.28**,配套 iOS **1.3(188)**。详见 [发布说明](docs/releases/v0.3.28.md)。
 
 夭夭 Web 为网页和 iOS 提供统一的 Agent、群聊、文件库及语音配置服务。Hermes 通过标准 9119 HTTP/WebSocket 接口执行任务;无需安装 Yaoyao 插件。
 

+ 21 - 0
docs/hermes-bot-relay.md

@@ -0,0 +1,21 @@
+# Hermes Bot 原生转接
+
+Hermes Bot 使用 Hermes Desktop 的 Profile、机器人和群聊数据;与 Web 自建的 Bot 模式分开。手机不创建 Profile 或群聊,也不复制群聊到子节点。
+
+- 本地路径:手机 → 主服务器 15300 → 本机 Hermes Gateway。
+- 远程路径:手机 → 主服务器 15300 → 已配对的子节点 15300 → 子节点 Hermes Gateway。
+- `GET /api/app/hermes-bot/roster` 返回当前账号已配对子节点的原生名单及各节点错误。
+- `/api/app/hermes-bot/nodes/:id/api/...` 转接名单、历史、附件和 HTTP/SSE 聊天。主账号拥有节点记录;子节点凭据只保存在服务端。保留命令幂等键、事件游标;禁止跟随重定向。
+- 搜索类型为“机器人和群聊 / 仅机器人 / 仅群聊”,另有显示隐藏机器人的开关;置顶和隐藏保留原生语义。
+- 群聊支持新帖子、讨论串切换、回复、分享。未发送的新帖子没有记录;选择按账号和群聊保存。成员配置只读,仅解析已确认的连接身份。
+- 同步只更新主节点已有群聊的消息及讨论串,保留名称、成员、头像等原生配置。不存在或已删除的群聊拒绝写入;不会通过裁剪清除其他群聊配置。
+
+旧版仅保存在手机上的子节点需在主服务器重新扫码授权。保留旧记录,不上传手机中的长期凭据。已经添加到主服务器的扫码节点可直接使用;子节点需支持现有配对及 HTTP/SSE 协议。
+
+验证:`hermesBotRelay.test.ts`、`workspacePairedNodes.test.ts`、`workspaceRemoteAgents.test.ts`、`workspaceRoutes.test.ts`;iOS 的 Bots、BotGroupRuntime、BotGatewayConnectionPool、WorkspaceChat 测试及原生名单、新帖子、只读成员界面测试。远程协议使用受控子节点夹具验证,未以用户真实远程机器人发送消息。
+
+## 已有原生会话的恢复授权
+
+本地机器人使用 `/api/app/hermes-bot/local/api/...`;远程单聊使用 `/api/app/hermes-bot/nodes/:id/native/api/...`,主服务转到子节点 `/node/:device/api/hermes-bot/api/...`。普通节点请求继续走原路径,保持群聊工作会话的授权语义。
+
+原生通道拥有独立身份。恢复前由服务端通过原生 `profiles.list` 读取 Profile 的 `ui_meta.hermes-bots.chat`,只允许恢复该绑定会话。REST `/api/profiles` 不包含这份元数据,不能用于这一授权判断。普通聊天的原生历史仍然只读;原生通道不会把旧聊天导入普通 Web 聊天的所有权记录。

+ 10 - 0
docs/releases/v0.3.28.md

@@ -0,0 +1,10 @@
+# v0.3.28
+
+- 子账号仅可使用 Bot 模式。管理员在创建账号或修改权限时分配基础 Agent,子账号只能基于获分配的本机 Profile 创建自己的 Bot Agent。
+- 服务端拒绝子账号访问原生聊天、历史、实时通道、Hermes Bot 中继、节点管理、全局设置和原生媒体路径;账号自己的聊天、任务、文件、事件继续按 owner 隔离。
+- 修改分配后旧会话失效,并请求停止已有任务;创建、发送、执行和审批阶段重新检查来源权限。升级前的子账号默认没有基础 Agent 分配,管理员需手动授权。
+- Bot 模式支持通过配对子节点引用远程 Agent,配置由远端管理;节点地址修改验证身份,支持重新扫码续期。
+- 原生 Hermes Bot 通过专用中继使用本机或配对子节点,保留与 Bot 模式工作区的独立边界。
+- 配套 iOS 强制子账号进入 Bot 模式,并按服务端能力隐藏远程管理入口。
+
+配套 iOS 1.3(188)。已有账号和聊天数据保留。应用账号授权不替代 Hermes 工具执行环境的权限约束,详见 [子账号权限](../subaccount-permissions.md)。

+ 21 - 0
docs/subaccount-permissions.md

@@ -0,0 +1,21 @@
+# 子账号权限
+
+子账号(`role=user`)只能使用 Bot 模式。管理员在「设置 → 用户与权限」创建账号时分配当前服务的基础 Profile,也可以随后修改分配。子账号只能基于这些 Profile 创建自己的 Bot Agent;不能读取原生 Profile、聊天和历史,不能使用原生 Hermes Bot、看板、节点配对或全局设置。
+
+## 服务端边界
+
+- `assignedProfiles` 保存在本地账号记录中。旧账号没有该字段时视为未分配,不自动授权 default 或全部 Profile。
+- 创建 Agent 只接受当前账号获分配的本机 Profile;远程引用和节点管理仅管理员可用。Agent 修改不能更换来源。
+- 账号 ID 从登录会话取得。Agent、聊天、任务、运行、交互、文件和事件继续按 owner 隔离,管理员也不会通过自己的 Bot 列表读取子账号会话。
+- 创建群、加入成员、发送消息、启动/恢复执行、建立上游会话、提交 prompt 和审批答复都会校验来源权限。异步读取基础目录之后再次校验,避免撤权竞态。
+- 修改分配、重置密码、禁用或删除账号会让旧会话失效,并请求停止该账号已存在的运行。撤销后的 Agent 配置和本人历史保留,但不能继续调用、编辑或加入新群。上游已经执行完成的工具副作用无法撤销。
+- 子账号采用允许列表访问应用接口;原生 REST、HTTP/SSE 实时接口、原生 Bot 中继、全局语音密钥、原生媒体路径(`/Users/...`、`/attachments/...`)均拒绝。自己的附件使用 owner 隔离的 `/api/app/files/:id/...`。
+- Web 在渲染路由前强制进入 `/conversations`。iOS 按当前服务身份强制 Bot 模式;服务器仍是最终权限边界,旧客户端也无法绕过。
+
+本实现是 Web/iOS 账号与服务 API 的授权隔离,不创建 Hermes 工具执行的操作系统沙箱。分配的 Profile 仍按 Hermes 自身的工具权限运行,工具可访问的磁盘和外部服务范围需要在 Hermes 执行环境中约束。
+
+## 验证
+
+- `tests/server/subaccountPermissions.test.ts` 使用真实账号、Cookie 和 CSRF,覆盖默认拒绝、Profile/节点伪造、REST/实时/媒体/密钥入口、跨 owner 资源和撤权。
+- `tests/e2e/workspace-chat.spec.ts` 的 subaccount 用例验证管理员分配、首次改密、限制后的创建列表、隐藏模式菜单和手输旧路由重定向。
+- iOS `WorkspaceChatTests` 和 `AuthAndRESTTests` 覆盖服务权限解析、能力撤回和保留的原生模式设置被纠正。

+ 27 - 0
docs/workspace-chat.md

@@ -40,3 +40,30 @@
 ## 退出旧插件
 
 旧 Yaoyao API 返回 410;没有安装、reconcile、版本要求或升级流程。新数据不导入旧插件目录。旧插件数据原地保留。原生对话和可选 Kanban 继续使用各自的独立接口。
+
+## 15300 子节点
+
+Bot 模式的远程节点由当前主服务器按用户保存。通过 `POST /api/app/nodes`
+提交 `{qrPayload, name}`,二维码必须是目标 15300 Web 的 `yaoyao://pair` 一次性子节点码;
+服务器登录码和 9119 账号密码表单不再作为添加节点入口。主服务器验证节点身份、兑换授权并加密保存,
+手机不会切换登录服务器。节点仅提供基础 Profile,创建的 Agent 和群聊仍属于主服务器。
+
+`PATCH /api/app/nodes/:id` 接受 `{name, url}`。修改 IP 前验证目标的 nodeId 和 fingerprint;
+验证失败不修改记录,也不向错误地址发送保存的令牌。保存后 node ID 保持不变,已有 Agent/群成员关联继续使用。
+子节点执行使用 `/node/:deviceId/api/realtime` 的 HTTP/SSE 协议。
+
+升级主服务器后即可使用;子节点需要提供现有 v1 配对协议和 HTTP/SSE 能力。
+旧密码连接请重新扫码建立子节点连接,不自动复制主服务器账号或升级子节点权限。
+
+## 引用远端 Bot Agent
+
+“添加远程 Agent”使用 `GET /api/app/nodes/:id/agents` 获取已配对子节点的 Bot 模式 Agent,
+然后以 `POST /api/app/agents/remote {nodeId, agentId}` 添加引用。主节点保存稳定的本地成员 ID 和远端 Agent ID,
+不会把它变成可独立编辑的角色;重复添加复用同一引用。名称、头像、角色规则由远端管理,本地仍可归档引用。
+
+远端按签发二维码的账号授权读取与执行,私有会话不会混入引用聊天。会话按设备和 Agent 隔离,
+执行时固定远端 Profile,并注入远端当前规则;客户端不能借此修改远端模型或角色配置。
+远端 Agent 即使使用它自己的子节点,也沿既有授权连接执行,引用链超过 8 跳时拒绝。
+
+此功能需要主节点和远端都更新。旧配对没有 Bot Agent 的账号授权信息时,在节点详情选择“更新扫码授权”;
+新版续期保留设备身份及现有 Agent/群成员关联。不要用登录二维码替代子节点二维码。

+ 2 - 2
package-lock.json

@@ -1,12 +1,12 @@
 {
   "name": "hermes-yaoyao",
-  "version": "0.3.27",
+  "version": "0.3.28",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "hermes-yaoyao",
-      "version": "0.3.27",
+      "version": "0.3.28",
       "license": "BSL-1.1",
       "dependencies": {
         "@koa/bodyparser": "^5.0.0",

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "hermes-yaoyao",
-  "version": "0.3.27",
+  "version": "0.3.28",
   "private": true,
   "license": "BSL-1.1",
   "description": "夭夭 Web:直连 Hermes 9119 的聊天、团队、文件库与产物工作台",

+ 3 - 3
release.json

@@ -1,6 +1,6 @@
 {
   "schemaVersion": 1,
-  "releaseVersion": "0.3.27",
-  "webVersion": "0.3.27",
-  "gitTag": "v0.3.27"
+  "releaseVersion": "0.3.28",
+  "webVersion": "0.3.28",
+  "gitTag": "v0.3.28"
 }

+ 8 - 2
src/client/App.vue

@@ -1,6 +1,6 @@
 <script setup lang="ts">
 import { computed, onMounted, watch } from 'vue'
-import { useRoute } from 'vue-router'
+import { useRoute, useRouter } from 'vue-router'
 import LoadingScreen from '@/components/app/LoadingScreen.vue'
 import LoginView from '@/views/LoginView.vue'
 import PasswordChangeView from '@/views/PasswordChangeView.vue'
@@ -15,6 +15,11 @@ const chat = useChatStore()
 const kanban = useKanbanStore()
 const theme = useThemeStore()
 const route = useRoute()
+const router = useRouter()
+const allowedRoute = computed(() => !auth.isBotOnly || /^\/conversations(?:\/|$)/.test(route.path))
+watch([() => auth.isBotOnly, () => route.path], () => {
+  if (!allowedRoute.value) void router.replace('/conversations')
+}, { immediate: true, flush: 'sync' })
 const agentIdentityFixture = import.meta.env.DEV
   && new URLSearchParams(window.location.search).get('fixture') === 'agent-identity'
 
@@ -49,5 +54,6 @@ onMounted(() => {
   <LoadingScreen v-else-if="auth.status === 'checking'" />
   <LoginView v-else-if="!auth.isAuthenticated" />
   <PasswordChangeView v-else-if="auth.user?.mustChangePassword" />
-  <RouterView v-else />
+  <RouterView v-else-if="allowedRoute" />
+  <LoadingScreen v-else />
 </template>

+ 4 - 3
src/client/api/admin.ts

@@ -5,6 +5,7 @@ export interface ManagedUser {
   id: string
   username: string
   role: 'admin' | 'user'
+  assignedProfiles?: string[]
   enabled: boolean
   mustChangePassword: boolean
   createdAt: number
@@ -16,13 +17,13 @@ export async function listUsers(): Promise<ManagedUser[]> {
   return response.items
 }
 
-export async function createUser(username: string, password: string): Promise<ManagedUser> {
+export async function createUser(username: string, password: string, assignedProfiles: string[] = []): Promise<ManagedUser> {
   return apiRequest('/api/app/admin/users', {
-    method: 'POST', body: { username, password } as unknown as JsonValue,
+    method: 'POST', body: { username, password, assignedProfiles } as unknown as JsonValue,
   })
 }
 
-export async function updateUser(id: string, input: { enabled?: boolean; password?: string }): Promise<ManagedUser> {
+export async function updateUser(id: string, input: { enabled?: boolean; password?: string; assignedProfiles?: string[] }): Promise<ManagedUser> {
   return apiRequest(`/api/app/admin/users/${encodeURIComponent(id)}`, {
     method: 'PATCH', body: input as unknown as JsonValue,
   })

+ 1 - 1
src/client/api/pairing.ts

@@ -42,7 +42,7 @@ export async function createPairing(
 }
 
 export async function pairChildNode(qrPayload: string, name?: string): Promise<void> {
-  await apiRequest('/api/app/groups/nodes/pair', {
+  await apiRequest('/api/app/nodes', {
     method: 'POST', body: { qrPayload, name: name || '' } as unknown as JsonValue,
   })
 }

+ 7 - 7
src/client/components/app/SettingsCenterDialog.vue

@@ -94,15 +94,15 @@ const updateLocked = ref(false)
 const accountCanSave = ref(false)
 const dirtyPages = reactive<Partial<Record<SettingsPage, boolean>>>({})
 
-const agentItems = computed<NavigationItem[]>(() => [
+const agentItems = computed<NavigationItem[]>(() => !props.isAdmin ? [] : [
   { key: 'agent-identity', label: '身份与头像', icon: 'users' },
   ...(props.isAdmin ? [{ key: 'agent-models', label: '模型与 Provider', icon: 'model' } satisfies NavigationItem] : []),
 ])
-const accountItems: NavigationItem[] = [
+const accountItems = computed<NavigationItem[]>(() => [
   { key: 'account-security', label: '登录与安全', icon: 'settings' },
-  { key: 'account-mobile', label: '手机登录', icon: 'panel' },
+  ...(props.isAdmin ? [{ key: 'account-mobile', label: '手机登录', icon: 'panel' } satisfies NavigationItem] : []),
   { key: 'appearance', label: '外观', icon: 'sun' },
-]
+])
 const systemItems: NavigationItem[] = [
   { key: 'system-overview', label: '系统概览', icon: 'panel' },
   { key: 'system-users', label: '用户与权限', icon: 'users' },
@@ -115,7 +115,7 @@ const systemItems: NavigationItem[] = [
 
 const allAllowedPages = computed(() => new Set<SettingsPage>([
   ...agentItems.value.map(item => item.key),
-  ...accountItems.map(item => item.key),
+  ...accountItems.value.map(item => item.key),
   ...(props.isAdmin ? systemItems.map(item => item.key) : []),
 ]))
 const activeDirty = computed(() => Boolean(dirtyPages[activePage.value]))
@@ -296,7 +296,7 @@ function requestModeSwitch() {
                 </div>
 
                 <nav>
-                  <section><button type="button" :disabled="updateLocked" @click="requestModeSwitch"><AppIcon :name="botMode ? 'chat' : 'users'" :size="18" /><span>{{ botMode ? '进入聊天模式' : '进入 Bot 模式' }}</span></button></section>
+                  <section v-if="isAdmin"><button type="button" :disabled="updateLocked" @click="requestModeSwitch"><AppIcon :name="botMode ? 'chat' : 'users'" :size="18" /><span>{{ botMode ? '进入聊天模式' : '进入 Bot 模式' }}</span></button></section>
                   <section>
                     <h3>当前 Agent</h3>
                     <button v-for="item in agentItems" :key="item.key" type="button" :class="{ active: activePage === item.key }" :aria-current="activePage === item.key ? 'page' : undefined" @click="selectPage(item.key)"><AppIcon :name="item.icon" :size="20" /><span>{{ item.label }}</span></button>
@@ -356,7 +356,7 @@ function requestModeSwitch() {
                   </div>
                 </section>
                 <SystemOverviewPanel v-else-if="activePage === 'system-overview' && isAdmin" :active="true" :upstream-ready="upstreamReady" :upstream-error="upstreamError" @navigate="selectPage" />
-                <SystemManagementPanel v-else-if="activePage === 'system-users' && isAdmin" section="users" :active="true" @dirty-change="setDirty('system-users', $event)" />
+                <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" :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" />

+ 24 - 2
src/client/components/app/SystemManagementPanel.vue

@@ -1,5 +1,6 @@
 <script setup lang="ts">
 import { computed, ref, watch } from 'vue'
+import type { Profile } from '@shared/types'
 import AppIcon from '@/components/common/AppIcon.vue'
 import {
   createUser,
@@ -25,18 +26,22 @@ import {
 type SystemManagementSection = 'users' | 'connection' | 'push'
 
 const props = withDefaults(defineProps<{
+  profiles?: Profile[]
   section: SystemManagementSection
   active?: boolean
   upstreamReady?: boolean
   upstreamError?: string
 }>(), {
   active: true,
+  profiles: () => [],
   upstreamReady: false,
   upstreamError: '',
 })
 
 const emit = defineEmits<{ 'dirty-change': [dirty: boolean] }>()
 
+const assignedProfiles = ref<string[]>([])
+const userAssignments = ref<Record<string, string[]>>({})
 const users = ref<ManagedUser[]>([])
 const username = ref('')
 const password = ref('')
@@ -187,6 +192,7 @@ async function refresh() {
   error.value = ''
   if (props.section === 'users') {
     users.value = await listUsers()
+    userAssignments.value = Object.fromEntries(users.value.map(user => [user.id, [...(user.assignedProfiles ?? [])]]))
     return
   }
   if (props.section === 'connection') {
@@ -235,12 +241,17 @@ async function run(action: () => Promise<void>, refreshAfter = true) {
 
 function add() {
   void run(async () => {
-    await createUser(username.value.trim(), password.value)
+    await createUser(username.value.trim(), password.value, assignedProfiles.value)
+    assignedProfiles.value = []
     username.value = ''
     password.value = ''
   })
 }
 
+function saveAssignments(user: ManagedUser) {
+  void run(async () => { await updateUser(user.id, { assignedProfiles: userAssignments.value[user.id] ?? [] }) })
+}
+
 function toggle(user: ManagedUser) {
   void run(async () => { await updateUser(user.id, { enabled: !user.enabled }) })
 }
@@ -401,6 +412,12 @@ watch(() => [props.active, props.section] as const, ([active, section]) => {
           <small>{{ user.role === 'admin' ? '管理员' : user.enabled ? (user.mustChangePassword ? '等待修改临时密码' : '普通用户') : '已禁用' }}</small>
         </span>
         <template v-if="user.role !== 'admin'">
+          <label class="user-assignment">基础 Agent
+            <select v-model="userAssignments[user.id]" multiple :disabled="busy" :aria-label="`分配给 ${user.username} 的基础 Agent`">
+              <option v-for="profile in profiles" :key="profile.name" :value="profile.name">{{ profile.agentName || profile.displayName || profile.name }}</option>
+            </select>
+          </label>
+          <button type="button" :disabled="busy" @click="saveAssignments(user)">保存分配</button>
           <button type="button" :aria-label="`为用户 ${user.username} 重置密码`" :disabled="busy" @click="reset(user)">重置密码</button>
           <button type="button" :aria-label="`${user.enabled ? '禁用' : '启用'}用户 ${user.username}`" :disabled="busy" @click="toggle(user)">{{ user.enabled ? '禁用' : '启用' }}</button>
           <button class="danger" type="button" :aria-label="`删除用户 ${user.username}`" :disabled="busy" @click="remove(user)">删除</button>
@@ -409,6 +426,7 @@ watch(() => [props.active, props.section] as const, ([active, section]) => {
       <form class="user-create-form" aria-label="创建用户" @submit.prevent="add">
         <label><span>新用户名</span><input v-model="username" name="managed-username" autocomplete="off" :disabled="busy" /></label>
         <label><span>临时密码</span><input v-model="password" name="managed-temporary-password" type="password" autocomplete="new-password" :disabled="busy" /><small>至少 8 位</small></label>
+        <label class="create-assignment"><span>分配基础 Agent</span><select v-model="assignedProfiles" multiple :disabled="busy" aria-label="新子账号的基础 Agent"><option v-for="profile in profiles" :key="profile.name" :value="profile.name">{{ profile.agentName || profile.displayName || profile.name }}</option></select><small>子账号仅可使用 Bot 模式,基于分配的 Agent 创建自己的 Agent;未分配时不可创建。保存权限后需重新登录。</small></label>
         <button class="solid-button" :disabled="busy || !username.trim() || password.length < 8">创建用户</button>
       </form>
     </div>
@@ -564,7 +582,11 @@ watch(() => [props.active, props.section] as const, ([active, section]) => {
 .push-error { background: color-mix(in srgb, var(--danger) 8%, transparent); }
 .push-notice { background: color-mix(in srgb, var(--success, #21845b) 9%, transparent); color: var(--success, #21845b) !important; }
 .push-config-form footer { display: flex; justify-content: flex-end; }
-.user { display: flex; min-height: 58px; align-items: center; gap: 10px; border-bottom: 1px solid var(--line); }
+.user { display: flex; flex-wrap: wrap; padding: 12px 0; min-height: 58px; align-items: center; gap: 10px; border-bottom: 1px solid var(--line); }
+.user > span { flex-basis: 100%; }
+.user-assignment { display: grid; flex: 1; min-width: 150px; gap: 6px; font-size: 12px; color: var(--text-secondary); }
+.user-assignment select, .create-assignment select { width: 100%; min-height: 76px; padding: 6px; border: 1px solid var(--line); border-radius: 8px; background: var(--surface-raised); color: var(--text-primary); font: inherit; }
+.create-assignment { grid-column: 1 / -1; }
 .user span { display: flex; min-width: 0; flex: 1; flex-direction: column; }
 .user b { font-size: 14px; }
 .user small { color: var(--text-muted); font-size: 12px; }

+ 8 - 4
src/client/components/app/WorkspaceShell.vue

@@ -88,6 +88,7 @@ const emit = defineEmits<{
   saveIdentity: [input: ProfileIdentityInput]
   closeInspector: []
   createAgent: []
+  createRemoteAgent: []
   createGroup: []
 }>()
 
@@ -128,7 +129,7 @@ const navItems: NavItem[] = [
 
 // The active workspace is already represented by its main canvas and sidebar
 // context. Keep this strip focused on destinations the user can switch to.
-const featureNavItems = computed(() => navItems.filter(item => item.key !== 'groups' && item.key !== activeNav.value.key
+const featureNavItems = computed(() => !props.isAdmin ? [] : navItems.filter(item => item.key !== 'groups' && item.key !== activeNav.value.key
  ))
 
 const applicationWorkspace = computed(() => route.path.startsWith('/conversations'))
@@ -183,10 +184,11 @@ async function openCreateMenu(event: Event) {
   await nextTick()
   createMenu.value?.querySelector<HTMLButtonElement>('[role="menuitem"]')?.focus()
 }
-function chooseCreate(kind: 'agent' | 'group') {
+function chooseCreate(kind: 'agent' | 'group' | 'remote-agent') {
   closeCreateMenu()
   mobileDrawerOpen.value = false
-  if (kind === 'agent') emit('createAgent')
+  if (kind === 'remote-agent' && props.isAdmin) emit('createRemoteAgent')
+  else if (kind === 'agent') emit('createAgent')
   else emit('createGroup')
 }
 function actionMenuKeydown(event: KeyboardEvent) {
@@ -220,6 +222,7 @@ function chooseSettingsAction(action: 'settings' | 'bots') {
   openSettings(applicationWorkspace.value ? 'account-security' : 'agent-identity')
 }
 function switchInterfaceMode() {
+  if (!props.isAdmin) return
   settingsOpen.value = false
   void navigate(applicationWorkspace.value ? '/chat' : '/conversations')
 }
@@ -644,12 +647,13 @@ onBeforeUnmount(() => {
       <div v-if="settingsMenuOpen" class="workspace-create-dismiss" @pointerdown.self="closeSettingsMenu" @keydown.esc.prevent.stop="closeSettingsMenu">
         <div ref="settingsMenu" class="workspace-create-menu workspace-settings-menu" :style="settingsMenuPosition" role="menu" aria-label="设置与模式" @keydown="actionMenuKeydown">
           <button type="button" role="menuitem" @click="chooseSettingsAction('settings')"><AppIcon name="settings" :size="17" />进入设置</button>
-          <button type="button" role="menuitem" @click="chooseSettingsAction('bots')"><AppIcon :name="applicationWorkspace ? 'chat' : 'users'" :size="17" />{{ applicationWorkspace ? '进入聊天模式' : '进入 Bot 模式' }}</button>
+          <button v-if="isAdmin" type="button" role="menuitem" @click="chooseSettingsAction('bots')"><AppIcon :name="applicationWorkspace ? 'chat' : 'users'" :size="17" />{{ applicationWorkspace ? '进入聊天模式' : '进入 Bot 模式' }}</button>
         </div>
       </div>
       <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" />添加远程 Agent</button>
           <button type="button" role="menuitem" @click="chooseCreate('group')"><AppIcon name="groups" :size="17" />新建群聊</button>
         </div>
       </div>

+ 41 - 0
src/client/components/workspace/RemoteAgentPicker.vue

@@ -0,0 +1,41 @@
+<script setup lang="ts">
+import { onMounted, ref, watch } from 'vue'
+import { apiRequest } from '@/api/client'
+import type { WorkspaceAgent } from '@shared/workspace'
+const emit=defineEmits<{close:[];added:[agent:WorkspaceAgent]}>()
+const dialog=ref<HTMLDialogElement>(), nodes=ref<Array<{id:string;name:string}>>([]), nodeId=ref(''), agents=ref<WorkspaceAgent[]>([]), error=ref(''), busy=ref(false), adding=ref(false)
+let generation=0
+watch(nodeId,async id=>{
+  const token=++generation;agents.value=[];if(!id)return;busy.value=true;error.value=''
+  try {const value=await apiRequest<{agents:WorkspaceAgent[]}>(`/api/app/nodes/${id}/agents`);if(token===generation)agents.value=value.agents}
+  catch(cause){if(token===generation)error.value=cause instanceof Error?cause.message:'读取失败'}
+  finally{if(token===generation)busy.value=false}
+})
+onMounted(async()=>{
+  dialog.value?.showModal()
+  try{nodes.value=(await apiRequest<{nodes:typeof nodes.value}>('/api/app/nodes')).nodes;nodeId.value=nodes.value[0]?.id??''}
+  catch(cause){error.value=String(cause)}
+})
+async function add(agent:WorkspaceAgent){
+  busy.value=true;adding.value=true;error.value=''
+  try{const value=await apiRequest<{agent:WorkspaceAgent}>('/api/app/agents/remote',{method:'POST',body:{nodeId:nodeId.value,agentId:agent.id}});emit('added',value.agent)}
+  catch(cause){error.value=cause instanceof Error?cause.message:'添加失败'}
+  finally{busy.value=false;adding.value=false}
+}
+</script>
+<template>
+  <dialog ref="dialog" class="remote-agent-picker" @cancel.prevent="!adding && emit('close')">
+    <header><h2>添加远程 Agent</h2><button type="button" :disabled="adding" @click="emit('close')">关闭</button></header>
+    <p>直接引用远端 Bot 模式的 Agent,配置由远端管理,可用于聊天和群聊。</p>
+    <label>远程子节点<select v-model="nodeId" :disabled="busy"><option v-for="node in nodes" :key="node.id" :value="node.id">{{node.name}}</option></select></label>
+    <p v-if="error" role="alert">{{error}}</p>
+    <p v-if="!nodes.length">请先在远程节点中扫码添加子节点。</p>
+    <p v-else-if="!busy && !agents.length && !error">这个节点暂无可添加的 Agent。</p>
+    <button v-for="agent in agents" :key="agent.id" class="agent-option" :disabled="busy" @click="add(agent)">{{agent.name}}<span>添加</span></button>
+    <p v-if="busy">加载中…</p>
+  </dialog>
+</template>
+<style scoped>
+.remote-agent-picker{width:min(480px,calc(100vw - 32px));border:1px solid var(--line);border-radius:16px;padding:24px;background:var(--surface);color:var(--text-primary)}
+.remote-agent-picker::backdrop{background:#0005}header{display:flex;align-items:center;justify-content:space-between;gap:16px}h2{font-size:18px}p{font-size:13px;color:var(--text-muted);line-height:1.7}label{display:grid;gap:8px}select,button{padding:10px;border:1px solid var(--line);border-radius:8px;background:var(--surface-soft);color:inherit}.agent-option{display:flex;justify-content:space-between;width:100%;margin-top:12px}[role=alert]{color:var(--danger)}
+</style>

+ 36 - 54
src/client/components/workspace/WorkspaceNodesPanel.vue

@@ -1,75 +1,56 @@
 <script setup lang="ts">
 import { onMounted, ref } from 'vue'
 import { apiRequest } from '@/api/client'
-const nodes = ref<Array<{ id: string; name: string; url: string }>>([]),
-  name = ref(''),
-  url = ref(''),
-  username = ref(''),
-  password = ref(''),
-  error = ref(''),
-  busy = ref(false)
+type Node = { id: string; name: string; url: string }
+const nodes = ref<Node[]>([]), name = ref(''), qrPayload = ref(''), reauthorizing = ref(''), editing = ref<Node>(), error = ref(''), busy = ref(false)
 async function load() {
-  try {
-    nodes.value = (await apiRequest<{ nodes: typeof nodes.value }>('/api/app/nodes')).nodes
-  } catch (e) {
-    error.value = String(e)
-  }
+  try { nodes.value = (await apiRequest<{nodes: Node[]}>('/api/app/nodes')).nodes }
+  catch (cause) { error.value = cause instanceof Error ? cause.message : '读取节点失败' }
 }
 async function add() {
-  busy.value = true
-  error.value = ''
+  busy.value = true; error.value = ''
   try {
-    await apiRequest('/api/app/nodes', {
-      method: 'POST',
-      body: {
-        name: name.value,
-        url: url.value,
-        username: username.value,
-        password: password.value,
-      },
-    })
-    password.value = ''
-    name.value = ''
-    url.value = ''
-    await load()
-  } catch (e) {
-    error.value = e instanceof Error ? e.message : '连接失败'
-  } finally {
-    busy.value = false
-  }
+    await apiRequest('/api/app/nodes', {method:'POST',body:{qrPayload:qrPayload.value.trim(),name:name.value.trim(),...(reauthorizing.value?{nodeId:reauthorizing.value}:{})}})
+    qrPayload.value = ''; name.value = ''; reauthorizing.value = ''; await load()
+  } catch (cause) { error.value = cause instanceof Error ? cause.message : '连接失败' }
+  finally { busy.value = false }
 }
-async function revoke(id: string) {
+async function save() {
+  if (!editing.value) return
+  busy.value = true; error.value = ''
   try {
-    await apiRequest(`/api/app/nodes/${id}`, { method: 'DELETE' })
-    await load()
-  } catch (e) {
-    error.value = String(e)
-  }
+    const raw = editing.value.url.trim(), url = new URL(raw.includes('://') ? raw : `http://${raw}`)
+    if (!url.port) url.port = '15300'
+    await apiRequest(`/api/app/nodes/${editing.value.id}`, {method:'PATCH',body:{name:editing.value.name.trim(),url:url.href}})
+    editing.value = undefined; await load()
+  } catch (cause) { error.value = cause instanceof Error ? cause.message : '保存失败' }
+  finally { busy.value = false }
+}
+async function revoke(id: string) {
+  try { await apiRequest(`/api/app/nodes/${id}`, {method:'DELETE'}); await load() }
+  catch (cause) { error.value = cause instanceof Error ? cause.message : '断开失败' }
 }
 onMounted(load)
 </script>
 <template>
   <section class="nodes-panel">
-    <p>连接其他 Hermes 节点后,可用它的基础 Agent 创建角色。连接只属于当前登录用户。</p>
+    <p>远程节点连接 15300 夭夭 Web,仅作为当前服务器的子节点。连接后可用它的基础智能体创建自己的 Agent 并加入群聊。</p>
     <p v-if="error" role="alert">{{ error }}</p>
     <article v-for="node in nodes" :key="node.id">
-      <div>
-        <strong>{{ node.name }}</strong
-        ><small>{{ node.url }}</small>
-      </div>
-      <button @click="revoke(node.id)">断开</button>
+      <div><strong>{{ node.name }}</strong><small>{{ node.url }} · 子节点</small></div>
+      <button @click="editing = { ...node }">管理</button><button @click="reauthorizing = node.id; name = node.name">更新扫码授权</button><button @click="revoke(node.id)">断开</button>
     </article>
+    <form v-if="editing" @submit.prevent="save">
+      <label>节点名称<input v-model="editing.name" required maxlength="100" /></label>
+      <label>IP 或 Web 地址<input v-model="editing.url" required placeholder="http://服务器:15300" /></label>
+      <p>新地址需指向原来的子节点;省略端口时使用 15300。</p>
+      <button :disabled="busy">保存</button><button type="button" @click="editing = undefined">取消</button>
+    </form>
     <form @submit.prevent="add">
-      <label>节点名称<input v-model="name" required maxlength="100" /></label
-      ><label
-        >Hermes 地址<input
-          v-model="url"
-          type="url"
-          required
-          placeholder="http://服务器:9119" /></label
-      ><label>用户名<input v-model="username" autocomplete="off" /></label
-      ><label>密码<input v-model="password" type="password" autocomplete="new-password" /></label
-      ><button :disabled="busy">{{ busy ? '正在连接…' : '添加节点' }}</button>
+      <label>子节点名称<input v-model="name" maxlength="100" /></label>
+      <label>子节点配对码<textarea v-model="qrPayload" required placeholder="yaoyao://pair?…" /></label>
+      <p>在目标 15300 的“手机与节点”页面生成二维码,用手机扫码添加,或在这里粘贴配对码。服务器登录二维码不能用于子节点连接。</p>
+      <button :disabled="busy">{{ busy ? '正在连接…' : reauthorizing ? '更新节点授权' : '添加子节点' }}</button>
     </form>
   </section>
 </template>
@@ -111,6 +92,7 @@ label {
   font-size: 13px;
 }
 input,
+textarea,
 button {
   border: 1px solid var(--line);
   border-radius: 9px;

+ 2 - 1
src/client/stores/auth.ts

@@ -25,6 +25,7 @@ export const useAuthStore = defineStore('auth', () => {
   const activeProfile = computed(() => profiles.value.find(profile => profile.name === activeProfileName.value)
     ?? profiles.value.find(profile => profile.isDefault)
     ?? profiles.value[0])
+  const isBotOnly = computed(() => Boolean(user.value && user.value.role !== 'admin'))
   const isAuthenticated = computed(() => status.value === 'authenticated')
 
   function publish(response: BootstrapResponse): void {
@@ -138,7 +139,7 @@ export const useAuthStore = defineStore('auth', () => {
 
   return {
     status, user, profiles, activeProfileName, activeProfile, csrfToken, error, authRequired, setupRequired, insecureLan, groupUploadsEnabled,
-    upstreamReady, upstreamError, isAuthenticated, bootstrap, login, setup, logout, selectProfile, refreshProfiles, refreshProfileAvatars,
+    upstreamReady, upstreamError, isAuthenticated, isBotOnly, bootstrap, login, setup, logout, selectProfile, refreshProfiles, refreshProfileAvatars,
     changeCredentials, updateAccountAvatar, expire,
   }
 })

+ 27 - 5
src/client/views/ConversationsView.vue

@@ -2,6 +2,7 @@
 import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
 import { useRoute, useRouter } 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 FloatingResourceSearch from '@/components/app/FloatingResourceSearch.vue'
 import type { SidebarItem } from '@/components/app/types'
@@ -116,6 +117,17 @@ const dialog = ref<'agent' | 'group' | 'editAgent' | 'editGroup' | null>(null),
   scroller = ref<HTMLElement>(),
   fileInput = ref<HTMLInputElement>(),
   dialogElement = ref<HTMLDialogElement>()
+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: '',
@@ -300,6 +312,10 @@ async function openDialog(kind: NonNullable<typeof dialog.value>) {
       if (!a) return
       editingId.value = a.id
       Object.assign(form, a)
+      if(a.remoteAgentId){
+        const remote=(await apiRequest<{agents:Agent[]}>(`/api/app/nodes/${a.nodeId}/agents`)).agents.find(candidate=>candidate.id===a.remoteAgentId)
+        if(remote)Object.assign(form,remote)
+      }
     }
     if (kind === 'editGroup' && active.value) {
       editingId.value = active.value.id
@@ -329,6 +345,7 @@ watch(
   },
 )
 async function save() {
+  if(isRemoteAgent.value)return
   busy.value = true
   error.value = ''
   try {
@@ -542,6 +559,7 @@ onBeforeUnmount(() => {
     @save-identity="saveProfileIdentity"
     @create-agent="openDialog('agent')"
     @create-group="openDialog('group')"
+    @create-remote-agent="remotePickerOpen = true"
   >
     <template #sidebar
       ><ConversationList :conversations="conversations" :agents="agents" :selected="selected" @select="select" @pin="action('pin', $event)" @archive="action('archive', $event)"
@@ -582,6 +600,7 @@ onBeforeUnmount(() => {
     <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" />
+    <RemoteAgentPicker v-if="remotePickerOpen && !auth.isBotOnly" @close="remotePickerOpen = false" @added="remoteAdded" />
     <dialog v-if="dialog" ref="dialogElement" class="editor" @cancel.prevent="closeDialog">
       <form @submit.prevent="save">
         <header>
@@ -592,9 +611,10 @@ onBeforeUnmount(() => {
         </header>
         <p v-if="error" class="error" role="alert">{{ error }}</p>
         <TeamPresetPicker v-if="dialog === 'group'" :selected="selectedPresetId" :available="agents.filter(a => !a.archived).length" @select="choosePreset" />
-        <label>名称<input v-model="form.name" required maxlength="100" /></label>
-        <AgentIdentityPanel v-if="isAgentDialog" :key="`${dialog}:${editingId}`" :profile="avatarProfile" embedded :show-name="false" :show-default-model="false" :show-actions="false" @avatar-change="form.avatar = $event" />
-        <div v-else class="team-avatar-settings">
+        <label>名称<input v-model="form.name" :readonly="isRemoteAgent" required maxlength="100" /></label>
+        <p v-if="isRemoteAgent">引用的 Agent 配置由远端管理,请在远端修改名称、头像和角色规则。</p>
+        <AgentIdentityPanel v-if="isAgentDialog && !isRemoteAgent" :key="`${dialog}:${editingId}`" :profile="avatarProfile" embedded :show-name="false" :show-default-model="false" :show-actions="false" @avatar-change="form.avatar = $event" />
+        <div v-else-if="!isAgentDialog" class="team-avatar-settings">
           <TeamAvatar :name="form.name" :members="workspaceAvatarMembers(form.memberIds, agents, dialog === 'editGroup' ? active : undefined)" :size="64" />
           <small>群聊头像由成员头像自动组合,随成员头像更新。</small>
         </div>
@@ -609,12 +629,13 @@ onBeforeUnmount(() => {
               {{ s.name }} · {{ s.nodeId === 'local' ? '当前服务' : s.nodeId }}
             </option></select
           ><small v-if="!sources.length"
-            >没有可用的基础 Agent,请检查 Web 的 Hermes 连接。</small
+            >{{ auth.isBotOnly ? '暂无可用的已分配 Agent,请联系管理员分配或检查连接。' : '没有可用的基础 Agent,请检查 Web 的 Hermes 连接。' }}</small
           ></label
         ><label
           >{{ isAgentDialog ? '角色提示词与规则' : '群规则'
           }}<textarea
             v-model="form.instructions"
+            :readonly="isRemoteAgent"
             rows="6"
             maxlength="24000"
             :placeholder="
@@ -624,6 +645,7 @@ onBeforeUnmount(() => {
             "
           />
         </label>
+        <button v-if="!isAgentDialog && !auth.isBotOnly" type="button" @click="remotePickerOpen = true">添加远程 Agent</button>
         <fieldset v-if="selectedPreset" class="preset-role-mapping">
           <legend>角色分配</legend>
           <label v-for="(role, index) in selectedPreset.roles" :key="role.name">
@@ -694,7 +716,7 @@ onBeforeUnmount(() => {
         ></template>
         <footer>
           <button class="quiet-button" type="button" @click="closeDialog">取消</button
-          ><button class="solid-button" :disabled="busy || (!isAgentDialog && (form.memberIds.length < (dialog === 'group' ? 2 : 1) || form.memberIds.length > 8))">
+          ><button v-if="!isRemoteAgent" class="solid-button" :disabled="busy || (!isAgentDialog && (form.memberIds.length < (dialog === 'group' ? 2 : 1) || form.memberIds.length > 8))">
             {{ busy ? '保存中…' : '保存' }}
           </button>
         </footer>

+ 23 - 1
src/server/app.ts

@@ -1,3 +1,5 @@
+import { hermesBotRelay } from './hermesBotRelay.js'
+import { remoteAgentExports } from './workspaceRemoteAgents.js'
 import { randomBytes, createHash } from 'node:crypto'
 import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
 import { createServer as createHttpServer, type Server as HttpServer } from 'node:http'
@@ -201,7 +203,8 @@ export function createApplication(options: ApplicationOptions = {}): Application
   })
   // The application owns group events. No Dashboard plugin is queried.
   const workspace = new WorkspaceStore(config.home)
-  const workspaceNodes = new WorkspaceNodes(workspace, config, { url: config.upstream, client: upstream, session: upstreamSession })
+  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 workspaceRuntime = new WorkspaceRuntime(workspace, workspaceNodes, uploads, owner => auth.isUserActive(owner))
   const workspaceAssets = new WorkspaceAssets(workspace, workspaceNodes, config.home)
   workspaceRuntime.onMessage = (owner, message) => workspaceAssets.archive(owner, message)
@@ -282,6 +285,22 @@ export function createApplication(options: ApplicationOptions = {}): Application
     }
     await next()
   })
+  app.use(async (ctx, next) => {
+    const user = auth.current(ctx)
+    if (user && user.role !== 'admin' && (ctx.path.startsWith('/api/') || ctx.path.startsWith('/node/') || ctx.path.startsWith('/ws/') || ctx.path.startsWith('/Users/') || ctx.path.startsWith('/attachments/'))) {
+      const path = ctx.path
+      const publicPath = ['/api/status', '/api/auth/providers', '/api/auth/me', '/api/profiles', '/api/app/bootstrap', '/api/app/login', '/api/app/logout'].includes(path)
+      const accountPath = ['/api/account/credentials', '/api/app/account/credentials', '/api/app/account/avatar'].includes(path)
+      const workspacePath = /^\/api\/app\/(?:capabilities|agents|conversations|runs|interactions|events|uploads|files|message-files)(?:\/|$)/.test(path)
+        && path !== '/api/app/agents/remote'
+      const pushPath = /^\/api\/(?:app\/)?push\/v1\//.test(path)
+      if (!publicPath && !accountPath && !workspacePath && !pushPath)
+        throw new HttpError(403, '子账号只能使用 Bot 模式', 'bot_mode_required')
+    }
+    await next()
+  })
+  app.use(hermesBotRelay(workspaceNodes, auth, csrf))
+  app.use(remoteAgentExports(workspace,workspaceNodes,pairings,auth))
   app.use(realtime.middleware())
   const parseBody = (limit: string) => bodyParser({
     encoding: 'utf-8',
@@ -362,6 +381,9 @@ export function createApplication(options: ApplicationOptions = {}): Application
     }
   })
   const router = createApiRouter({
+    onUserAccessChanged: async owner => {
+      for (const c of workspace.list<{ id: string }>(owner, 'conversation')) await workspaceRuntime.stopConversation(owner, c.id)
+    },
     workspace,
     config,
     csrf,

+ 89 - 0
src/server/hermesBotRelay.ts

@@ -0,0 +1,89 @@
+import type Koa from 'koa'
+import { Readable } from 'node:stream'
+import type { LocalAuthStore } from './localAuth.js'
+import type { CsrfProtection } from './security.js'
+import { WorkspaceGateway, type WorkspaceNodes, type WorkspaceNode } from './workspaceGateway.js'
+import { HttpError } from './errors.js'
+
+/** Native Hermes transport, scoped to a primary account's paired child node.
+ * Never imports a Profile or creates a workspace Agent/group. */
+export function hermesBotRelay(nodes: WorkspaceNodes, auth: LocalAuthStore, csrf: CsrfProtection): Koa.Middleware {
+  return async (ctx, next) => {
+    const native = /^(?:\/api\/app\/hermes-bot\/local|\/node\/([0-9a-f-]{36})\/api\/hermes-bot)(\/api\/.*)$/.exec(ctx.path)
+    if (native) {
+      const path = native[2]!
+      const allowed = path.startsWith('/api/realtime/') || (ctx.method === 'GET' &&
+        /^\/api\/(?:profiles(?:\/sessions)?|sessions(?:\/[^/]+(?:\/messages)?)?|files\/download)$/.test(path))
+      if (!allowed) throw new HttpError(403, '不支持的原生机器人操作', 'hermes_bot_path_forbidden')
+      if (!native[1]) auth.require(ctx)
+      ctx.state.hermesBotNative = true
+      ctx.path = native[1] ? `/node/${native[1]}${path}` : path
+      return next()
+    }
+    if (ctx.path === '/api/app/hermes-bot/roster' && ctx.method === 'GET') {
+      const owner = auth.require(ctx).id
+      const nodesList = nodes.store.list<WorkspaceNode>(owner, 'node').filter(n => n.transport === 'paired-web')
+      const roster = await Promise.all(nodesList.map(async node => {
+        const gateway = new WorkspaceGateway(nodes.target(owner, node.id))
+        try {
+          await gateway.connect()
+          const snapshot = await gateway.rpc('profiles.list', {include_sessions: true})
+          return {id: node.id, name: node.name, remoteNodeId: node.remoteNodeId, fingerprint: node.fingerprint, snapshot}
+        } catch { return {id: node.id, name: node.name, remoteNodeId: node.remoteNodeId, error: '节点不可用,请检查连接或扫码授权'} }
+        finally { gateway.close() }
+      }))
+      ctx.set('Cache-Control', 'no-store'); ctx.body = {nodes: roster}; return
+    }
+    const match = /^\/api\/app\/hermes-bot\/nodes\/([0-9a-f-]{36})(\/native)?(\/api\/.*)$/.exec(ctx.path)
+    if (!match) return next()
+    const owner = auth.require(ctx).id, nodeID = match[1]!, path = match[3]!
+    const node = nodes.store.require<WorkspaceNode>(owner, 'node', nodeID)
+    if (node.transport !== 'paired-web') throw new HttpError(409, '请重新扫码授权 15300 子节点', 'paired_node_required')
+    const realtime = /^\/api\/realtime\/(?:capabilities|channels(?:\/[0-9a-f-]{36}(?:\/(?:commands|events))?)?|commands\/[A-Za-z0-9:_-]{1,200})$/.test(path)
+    const read = /^\/api\/(?:profiles(?:\/sessions)?|sessions(?:\/[^/]+(?:\/messages)?)?|files\/download)$/.test(path)
+    if (!realtime && !(read && ctx.method === 'GET')) throw new HttpError(403, '不支持的 Hermes Bot 操作', 'hermes_bot_path_forbidden')
+    const target = nodes.target(owner, nodeID)
+    if (!target.pairedToken) throw new HttpError(409, '请重新扫码授权子节点', 'paired_node_required')
+    let body: Buffer | undefined
+    if (!['GET', 'HEAD', 'DELETE'].includes(ctx.method)) {
+      if (!ctx.is('application/json')) throw new HttpError(415, 'JSON body required', 'invalid_content_type')
+      const chunks: Buffer[] = []; let length = 0
+      for await (const chunk of ctx.req) {
+        length += Buffer.byteLength(chunk)
+        if (length > 15 * 1024 * 1024) throw new HttpError(413, '请求过大', 'body_too_large')
+        chunks.push(Buffer.from(chunk))
+      }
+      body = Buffer.concat(chunks)
+    }
+    const controller = new AbortController()
+    const timeout = setTimeout(() => controller.abort(), 30_000)
+    ctx.res.once('close', () => controller.abort())
+    let response: Response
+    try {
+      response = await target.client.fetchImpl(new URL(`${target.url.href.replace(/\/$/, '')}${match[2] ? '/api/hermes-bot' : ''}${path}${ctx.search}`), {
+        method: ctx.method, redirect: 'error', signal: controller.signal,
+        headers: { Authorization: `Bearer ${target.pairedToken}`, 'Content-Type': 'application/json',
+          'Last-Event-ID': ctx.get('last-event-id'), 'Idempotency-Key': ctx.get('idempotency-key') },
+        ...(body ? { body: new Uint8Array(body) } : {}),
+      })
+    } finally { clearTimeout(timeout) }
+    ctx.status = response.status
+    ctx.set('Cache-Control', 'no-store')
+    for (const name of ['content-type', 'content-disposition']) {
+      const value = response.headers.get(name); if (value) ctx.set(name, value)
+    }
+    if (response.headers.get('content-type')?.startsWith('text/event-stream') && response.body) {
+      const timer = setInterval(() => {
+        try {
+          if (auth.require(ctx).id !== owner || nodes.store.get<WorkspaceNode>(owner, 'node', nodeID)?.secret !== node.secret) controller.abort()
+        } catch { controller.abort() }
+      }, 5_000); timer.unref()
+      const stream = Readable.fromWeb(response.body as any)
+      const clear = () => clearInterval(timer)
+      stream.once('close', clear); stream.once('error', clear); ctx.res.once('close', clear)
+      ctx.body = stream
+    } else if (path === '/api/realtime/capabilities' && response.ok) {
+      ctx.body = { ...await response.json() as object, csrfToken: csrf.issue(ctx) }
+    } else if (response.status !== 204) ctx.body = Buffer.from(await response.arrayBuffer())
+  }
+}

+ 21 - 2
src/server/localAuth.ts

@@ -35,6 +35,7 @@ interface StoredUser {
   avatar?: string
   normalizedUsername: string
   role: LocalRole
+  assignedProfiles?: string[]
   enabled: boolean
   mustChangePassword: boolean
   salt: string
@@ -58,6 +59,7 @@ export interface LocalUser {
   username: string
   avatar?: string
   role: LocalRole
+  assignedProfiles?: string[]
   enabled: boolean
   mustChangePassword: boolean
   createdAt: number
@@ -93,6 +95,7 @@ function publicUser(user: StoredUser): LocalUser {
     username: user.username,
     ...(validAvatarImage(user.avatar) ? { avatar: user.avatar } : {}),
     role: user.role,
+    assignedProfiles: [...(user.assignedProfiles ?? [])],
     enabled: user.enabled,
     mustChangePassword: user.mustChangePassword,
     createdAt: user.createdAt,
@@ -232,6 +235,18 @@ export class LocalAuthStore {
     return user
   }
 
+  validateAssignedProfiles(value: unknown): string[] {
+    if (!Array.isArray(value) || value.length > 256 || value.some(p => typeof p !== 'string' || !p.trim() || p.length > 256 || /[\/\\\u0000-\u001f]/.test(p))) {
+      throw new HttpError(400, '分配的基础 Agent 无效', 'invalid_assigned_profiles')
+    }
+    return [...new Set(value.map(p => p.trim()))]
+  }
+
+  canUseSource(userID: string, nodeId: string, profile: string): boolean {
+    const user = this.#users.find(candidate => candidate.id === userID && candidate.enabled)
+    return Boolean(user && (user.role === 'admin' || (nodeId === 'local' && user.assignedProfiles?.includes(profile))))
+  }
+
   isUserActive(userID: string): boolean {
     return this.#users.some(user => user.id === userID && user.enabled)
   }
@@ -246,7 +261,7 @@ export class LocalAuthStore {
     return this.#users.map(publicUser).sort((a, b) => a.createdAt - b.createdAt)
   }
 
-  create(admin: LocalUser, usernameValue: string, passwordValue: string): LocalUser {
+  create(admin: LocalUser, usernameValue: string, passwordValue: string, assignedProfiles: unknown = []): LocalUser {
     if (admin.role !== 'admin') throw new HttpError(403, '需要管理员权限', 'admin_required')
     const username = canonicalUsername(usernameValue)
     const normalizedUsername = username.toLocaleLowerCase('en-US')
@@ -256,16 +271,20 @@ export class LocalAuthStore {
     const password = validatePassword(passwordValue)
     const now = Date.now()
     const user = this.#newUser(username, password, 'user', true, now)
+    user.assignedProfiles = this.validateAssignedProfiles(assignedProfiles)
     this.#users.push(user)
     this.#saveUsers()
     return publicUser(user)
   }
 
-  updateUser(admin: LocalUser, userID: string, input: { enabled?: boolean; password?: string }): LocalUser {
+  updateUser(admin: LocalUser, userID: string, input: { enabled?: boolean; password?: string; assignedProfiles?: unknown }): LocalUser {
     if (admin.role !== 'admin') throw new HttpError(403, '需要管理员权限', 'admin_required')
     const user = this.#users.find(candidate => candidate.id === userID)
     if (!user) throw new HttpError(404, '用户不存在', 'user_not_found')
     if (user.role === 'admin') throw new HttpError(409, '管理员账号请在账号设置中修改', 'admin_account_protected')
+    const assigned = input.assignedProfiles === undefined ? undefined : this.validateAssignedProfiles(input.assignedProfiles)
+    if (input.password !== undefined) validatePassword(input.password)
+    if (assigned !== undefined) user.assignedProfiles = assigned
     if (typeof input.enabled === 'boolean') user.enabled = input.enabled
     if (input.password !== undefined) {
       this.#setPassword(user, validatePassword(input.password), true)

+ 22 - 6
src/server/pairing.ts

@@ -36,6 +36,7 @@ interface PersistedDevice {
   name: string
   tokenHash: string
   encryptedCookie: string
+  workspaceOwner?: string
   scopes: NodeScope[]
   createdAt: number
   lastUsedAt: number
@@ -51,6 +52,7 @@ interface PendingPairing {
   id: string
   secretHash: Buffer
   cookieHeader: string
+  workspaceOwner?: string
   scopes: NodeScope[]
   createdAt: number
   expiresAt: number
@@ -76,6 +78,8 @@ export interface PairedDeviceSummary {
 }
 
 export interface PairingClaim {
+  existingDeviceID?: string
+  existingToken?: string
   pairingID: string
   secret: string
   deviceName: string
@@ -176,7 +180,7 @@ export class NodePairingStore {
       .digest('base64url')
   }
 
-  create(cookieHeader: string | undefined, requestedScopes?: readonly string[]): PairingSession {
+  create(cookieHeader: string | undefined, requestedScopes?: readonly string[], workspaceOwner?: string): PairingSession {
     const cookies = cookieHeader?.trim() ?? ''
     if (!cookies) {
       throw new HttpError(401, 'An authenticated Hermes session is required', 'authentication_required')
@@ -194,6 +198,7 @@ export class NodePairingStore {
       id,
       secretHash: hashSecret(`${id}:${secret}`),
       cookieHeader: cookies,
+      workspaceOwner,
       scopes: parseScopes(requestedScopes),
       createdAt: now,
       expiresAt: now + PAIRING_TTL_MS,
@@ -238,21 +243,25 @@ export class NodePairingStore {
       || !timingSafeEqual(supplied, pending.secretHash)) {
       throw new HttpError(401, 'Pairing secret is invalid', 'invalid_pairing_secret')
     }
-    if (this.#state.devices.length >= MAX_PAIRED_DEVICES) {
+    const existing = input.existingDeviceID
+      ? (this.authorize(input.existingDeviceID,input.existingToken ?? ''), this.#state.devices.find(d=>d.id===input.existingDeviceID)) : undefined
+    if (!existing && this.#state.devices.length >= MAX_PAIRED_DEVICES) {
       throw new HttpError(409, 'Paired device limit reached', 'paired_device_limit')
     }
     const now = this.#clock()
-    const token = randomBytes(TOKEN_BYTES).toString('base64url')
+    const token = existing ? input.existingToken! : randomBytes(TOKEN_BYTES).toString('base64url')
     const device: PersistedDevice = {
-      id: randomUUID().toLowerCase(),
+      id: existing?.id ?? randomUUID().toLowerCase(),
       name: canonicalDeviceName(input.deviceName),
       tokenHash: hashSecret(token).toString('base64url'),
       encryptedCookie: this.#encrypt(pending.cookieHeader),
+      workspaceOwner: pending.workspaceOwner,
       scopes: [...pending.scopes],
-      createdAt: now,
+      createdAt: existing?.createdAt ?? now,
       lastUsedAt: now,
     }
-    this.#state.devices.push(device)
+    if (existing) Object.assign(existing,device)
+    else this.#state.devices.push(device)
     pending.claimedDeviceID = device.id
     pending.cookieHeader = ''
     this.#save()
@@ -317,6 +326,13 @@ export class NodePairingStore {
     return this.#decrypt(device.encryptedCookie)
   }
 
+  workspaceOwner(deviceID: string, token: string, scope: NodeScope): string {
+    this.authorize(deviceID, token, scope)
+    const owner = this.#state.devices.find(device => device.id === deviceID)?.workspaceOwner
+    if (!owner) throw new HttpError(403, '请升级远端并重新扫码授权,以读取远端 Bot 模式 Agent', 'workspace_agent_pairing_required')
+    return owner
+  }
+
   updateCookies(deviceID: string, cookieHeader: string | undefined): void {
     const id = canonicalDeviceID(deviceID)
     const device = this.#state.devices.find((candidate) => candidate.id === id)

+ 17 - 7
src/server/realtimeApi.ts

@@ -1,3 +1,4 @@
+import type { AgentExportContext } from './workspaceRemoteAgents.js'
 import { randomBytes } from 'node:crypto'
 import type { Server, IncomingMessage } from 'node:http'
 import type { Duplex } from 'node:stream'
@@ -29,6 +30,9 @@ export class RealtimeAPI {
     return owner ? this.ownsSession?.(owner, profile, sessionId) ?? false : false
   }
   private principal(ctx: Koa.Context, device?: string): RealtimePrincipal {
+    const exported=ctx.state.workspaceAgentExport as AgentExportContext | undefined
+    const upstream=exported?.target.client ?? this.upstream
+    const endpoint=exported?.target.url ?? this.config.upstream
     let key: string
     let valid: () => boolean
     let authorize: RealtimePrincipal['authorize']
@@ -38,9 +42,9 @@ export class RealtimeAPI {
     if (device) {
       const bearer = ctx.get('authorization').match(/^Bearer\s+(.+)$/i)?.[1] ?? ''
       jar = new CookieJar(this.pairings.authorize(device, bearer))
-      key = `device:${device}`
+      key = `device:${device}${exported ? ':agent:'+exported.id : ''}`
       owner = key
-      valid = () => this.pairings.hasDevice(device)
+      valid = () => this.pairings.hasDevice(device) && (!exported || exported.valid())
       authorize = (kind, method) => {
         this.pairings.authorize(device, bearer, kind === 'groups' ? 'groups.read'
           : method === 'profiles.list' || method === 'profiles.get_asset' ? 'agents.read' : 'sessions.execute')
@@ -59,10 +63,12 @@ export class RealtimeAPI {
         }
       }
     }
+    const nativeBot = ctx.state.hermesBotNative === true
+    if (nativeBot) key = `native:${key}`
     return {
-      key, instanceKey: this.config.upstream.href, upstreamKey: `${this.config.upstream.href}:${device ? `device:${device}` : 'service'}`, paired: Boolean(device), valid, authorize,
+      key, nativeBot, instanceKey: endpoint.href, upstreamKey: `${endpoint.href}:${device ? key : 'service'}`, paired: Boolean(device), valid, authorize,
       canResume: (profile, sessionId) => this.canResume(profile, sessionId, jar, owner),
-      agent: this.upstream.directAgent,
+      agent: upstream.directAgent,
       observeCommand: f => observer?.observeClientFrame(f),
       observeEvent: f => observer?.observeUpstreamFrame(Buffer.from(f), false),
       url: async (kind, anchor) => {
@@ -70,14 +76,15 @@ export class RealtimeAPI {
         // Paired devices retain their delegated identity; never upgrade a device
         // cookie into the local service's loopback authorization.
         let credential: { name: string; value: string }
-        if (jar) {
+        if (exported && endpoint.href !== this.config.upstream.href) credential = await exported.target.session.webSocketCredential()
+        else if (jar) {
           const response = await this.upstream.request('/api/auth/ws-ticket', jar, { method: 'POST' })
           if (response.status !== 200) throw new HttpError(502, 'Upstream authentication failed', 'upstream_auth_failed')
           const ticket = JSON.parse(response.body.toString()).ticket
           if (typeof ticket !== 'string' || !ticket) throw new HttpError(502, 'Invalid upstream ticket', 'invalid_ticket')
           credential = { name: 'ticket', value: ticket }
         } else credential = await this.session.webSocketCredential()
-        const url = new URL(this.config.upstream)
+        const url = new URL(endpoint)
         url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
         url.pathname = `${url.pathname.replace(/\/$/, '')}/api/ws`
         url.search = ''; url.searchParams.set(credential.name, credential.value)
@@ -161,6 +168,7 @@ export class RealtimeAPI {
       this.checkMutation(ctx, Boolean(device))
       if (ctx.method === 'POST' && path === '/channels') {
         const body = await this.body(ctx)
+        if (ctx.state.workspaceAgentExport && body.channel !== 'chat') throw new HttpError(403,'Agent 仅支持聊天通道','invalid_channel')
         if (body.channel !== 'chat' && body.channel !== 'groups') throw new HttpError(400, 'Invalid channel', 'invalid_channel')
         const c = await this.broker.create(principal, body.channel, body.channel === 'groups'
           ? { epoch: canonicalEpoch(body.epoch), cursor: groupCursor(body.cursor) } : undefined)
@@ -173,7 +181,9 @@ export class RealtimeAPI {
       const c = this.broker.get(principal, channel[1]!)
       if (ctx.method === 'DELETE' && !channel[2]) { this.broker.detach(principal, c.id); ctx.status = 204; return }
       if (ctx.method === 'POST' && channel[2] === 'commands') {
-        const body = await this.body(ctx, CHAT_MAX_PAYLOAD)
+        let body = await this.body(ctx, CHAT_MAX_PAYLOAD)
+        const exported=ctx.state.workspaceAgentExport as AgentExportContext | undefined
+        if (exported) body=exported.transform(body)
         ctx.body = await this.broker.command(c, ctx.get('idempotency-key'), body)
         return
       }

+ 13 - 3
src/server/realtimeBroker.ts

@@ -11,6 +11,7 @@ export interface RealtimePrincipal {
   upstreamKey: string
   instanceKey?: string
   paired: boolean
+  nativeBot?: boolean
   agent?: WebSocket.ClientOptions['agent']
   valid(): boolean
   authorize?(kind: 'chat' | 'groups', method?: string): void
@@ -197,7 +198,16 @@ export class RealtimeBroker {
     const execute = async () => {
       if (!c.principal.valid()) throw new HttpError(401, 'Authentication expired', 'authentication_required')
       if (method === 'session.resume') {
-        if (c.principal.canResume && !await c.principal.canResume(String(p.profile), String(p.session_id))) {
+        let permitted = !c.principal.canResume || await c.principal.canResume(String(p.profile), String(p.session_id))
+        if (c.principal.nativeBot) {
+          await this.connect(u)
+          const roster = await this.rpc(u, 'profiles.list', {include_sessions: false})
+          const profiles = roster.result?.profiles
+          const bot = Array.isArray(profiles) ? profiles.find((value: Frame) => value.name === p.profile) : undefined
+          permitted = !roster.error && typeof p.session_id === 'string' && p.session_id.length > 0 &&
+            bot?.ui_meta?.['hermes-bots']?.chat === p.session_id
+        }
+        if (!permitted) {
           throw new HttpError(403, '此会话属于历史记录,不能继续聊天', 'history_session_read_only')
         }
         const ownerKey = JSON.stringify([c.principal.instanceKey ?? c.principal.upstreamKey, p.profile, p.session_id])
@@ -407,8 +417,8 @@ export class RealtimeBroker {
     }
   }
   private nativeOwner(principal: RealtimePrincipal): string | undefined {
-    const device = /^device:([^:]+)$/.exec(principal.key)
-    if (device) return `device:${device[1]}`
+    const device = /^device:[^:]+(?::agent:[^:]+)?$/.test(principal.key)
+    if (device) return principal.key
     const match = /^(?:user|push):([^:]+)/.exec(principal.key)
     return match?.[1]
   }

+ 16 - 9
src/server/routes.ts

@@ -51,6 +51,7 @@ import { chatCacheKey, type ChatCacheCoordinator } from './chatCache.js'
 type JsonObject = Record<string, unknown>
 
 export interface RouteDependencies {
+  onUserAccessChanged?: (owner: string) => Promise<void>
   workspace: WorkspaceStore
   config: ServerConfig
   csrf: CsrfProtection
@@ -846,7 +847,7 @@ async function bootstrap(
         }),
       ])
       status = upstream.status
-      profiles = upstream.profiles
+      profiles = user.role === 'admin' ? upstream.profiles : []
       upstreamReady = true
     } catch (error) {
       upstreamError = error instanceof Error ? error.message : '9119 不可用'
@@ -1209,7 +1210,7 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
     json(ctx, 200, { profiles: await dependencies.profileIdentities.load() })
   })
   router.get('/api/profiles', async (ctx) => {
-    dependencies.auth.require(ctx, true)
+    if (dependencies.auth.require(ctx, true).role !== 'admin') { json(ctx, 200, { profiles: [] }); return }
     const profilesResponse = await dependencies.upstreamSession.request('/api/profiles')
 
     json(ctx, profilesResponse.status, {
@@ -1387,7 +1388,7 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
   })
 
   router.post('/api/app/pairings', async (ctx) => {
-    dependencies.auth.requireAdmin(ctx)
+    const pairingOwner = dependencies.auth.requireAdmin(ctx)
     await withJar(ctx, async (jar) => {
       await requireGatewayAuthentication(ctx, dependencies, jar)
       const request = optionalBody(ctx)
@@ -1396,6 +1397,7 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
       const pairing = dependencies.pairings.create(
         cookieHeader,
         pairingScopes(request?.scopes),
+        pairingOwner.id,
       )
       const origin = requestOrigin(ctx, dependencies.config)
       const deepLink = new URL('yaoyao://pair')
@@ -1465,7 +1467,9 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
     const pairingID = typeof request.pairingId === 'string' ? request.pairingId : ''
     const secret = typeof request.secret === 'string' ? request.secret : ''
     const deviceName = typeof request.deviceName === 'string' ? request.deviceName : ''
-    const claimed = dependencies.pairings.claim({ pairingID, secret, deviceName })
+    const existingDeviceID = typeof request.existingDeviceId==='string' ? request.existingDeviceId : undefined
+    const claimed = dependencies.pairings.claim({ pairingID, secret, deviceName, existingDeviceID,
+      existingToken: existingDeviceID ? bearerToken(ctx.get('authorization')) : undefined })
     const origin = requestOrigin(ctx, dependencies.config)
     json(ctx, 201, {
       protocolVersion: NODE_PAIRING_PROTOCOL_VERSION,
@@ -1487,7 +1491,7 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
       nodeId: dependencies.pairings.nodeID,
       fingerprint: dependencies.pairings.fingerprint,
       scopes: [...DEFAULT_NODE_SCOPES],
-      features: ['bots', 'history'],
+      features: ['bots', 'history', 'workspace-agents', 'pair-renewal'],
     })
   })
 
@@ -1672,25 +1676,28 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
     const request = body(ctx)
     const username = typeof request.username === 'string' ? request.username : ''
     const password = typeof request.password === 'string' ? request.password : ''
-    json(ctx, 201, dependencies.auth.create(admin, username, password))
+    json(ctx, 201, dependencies.auth.create(admin, username, password, request.assignedProfiles))
   })
-  router.patch('/api/app/admin/users/:userID', (ctx) => {
+  router.patch('/api/app/admin/users/:userID', async (ctx) => {
     const admin = dependencies.auth.requireAdmin(ctx)
     const request = body(ctx)
     const userID = canonicalUUID(ctx.params.userID, 'user ID')
     const updated = dependencies.auth.updateUser(admin, userID, {
+      assignedProfiles: request.assignedProfiles,
       enabled: typeof request.enabled === 'boolean' ? request.enabled : undefined,
       password: typeof request.password === 'string' ? request.password : undefined,
     })
-    if (request.enabled === false || typeof request.password === 'string') {
+    await dependencies.onUserAccessChanged?.(userID)
+    if (request.enabled === false || typeof request.password === 'string' || request.assignedProfiles !== undefined) {
       bestEffortRemoveUserPush(ctx, dependencies, userID)
     }
     json(ctx, 200, updated)
   })
-  router.delete('/api/app/admin/users/:userID', (ctx) => {
+  router.delete('/api/app/admin/users/:userID', async (ctx) => {
     const admin = dependencies.auth.requireAdmin(ctx)
     const userID = canonicalUUID(ctx.params.userID, 'user ID')
     dependencies.auth.deleteUser(admin, userID)
+    await dependencies.onUserAccessChanged?.(userID)
     bestEffortRemoveUserPush(ctx, dependencies, userID)
     json(ctx, 200, { ok: true })
   })

+ 4 - 2
src/server/workspaceAssets.ts

@@ -90,6 +90,8 @@ export class WorkspaceAssets {
       agent.profile,
       message.conversationId,
       message.id,
+      'agent',
+      agent.remoteAgentId,
     )
     if (this.stopped) return
     const attachments = this.store
@@ -109,6 +111,7 @@ export class WorkspaceAssets {
     conversationId: string,
     messageId: string,
     sender: 'user' | 'agent' = 'agent',
+    remoteAgentId?: string,
   ): Promise<void> {
     if (this.stopped) return
     const paths = new Set<string>()
@@ -124,8 +127,7 @@ export class WorkspaceAssets {
         continue
       this.pending.add(`${owner}:${key}`)
       try {
-        const response = await this.nodes
-          .target(owner, nodeId)
+        const response = await (remoteAgentId ? this.nodes.targetForAgent(owner,{nodeId,remoteAgentId}) : this.nodes.target(owner,nodeId))
           .session.request('/api/files/download', {
             search: new URLSearchParams({ profile, path }),
             maxResponseBytes: 25 * 1024 * 1024,

+ 163 - 49
src/server/workspaceGateway.ts

@@ -2,23 +2,29 @@ import WebSocket from 'ws'
 import { randomUUID, randomBytes, createCipheriv, createDecipheriv } from 'node:crypto'
 import { readFileSync, writeFileSync } from 'node:fs'
 import { join } from 'node:path'
-import { UpstreamClient } from './upstream.js'
+import { UpstreamClient, CookieJar } from './upstream.js'
+import { nodeJSON, nodeServiceURL, parseWorkspacePairCode, verifyNode, WorkspacePairedChannel } from './workspacePairedNode.js'
 import { UpstreamServiceSession } from './localAuth.js'
-import { WorkspaceStore } from './workspaceStore.js'
+import { WorkspaceStore, agentInput, parse } from './workspaceStore.js'
 import { HttpError } from './errors.js'
 import type { ServerConfig } from './config.js'
-import type { WorkspaceSource } from '../shared/workspace.js'
+import type { WorkspaceSource, WorkspaceAgent, WorkspaceConversation } from '../shared/workspace.js'
 
 export interface WorkspaceNode {
   id: string
   name: string
   url: string
   secret: string
+  transport?: 'paired-web'
+  remoteNodeId?: string
+  fingerprint?: string
+  deviceId?: string
 }
 export interface GatewayTarget {
   url: URL
   client: UpstreamClient
-  session: UpstreamServiceSession
+  session: Pick<UpstreamServiceSession, 'request' | 'webSocketCredential'>
+  pairedToken?: string
 }
 export interface GatewayFrame {
   type: string
@@ -26,12 +32,19 @@ export interface GatewayFrame {
   payload?: Record<string, any>
 }
 export class WorkspaceNodes {
+  sourceAllowed: (owner: string, nodeId: string, profile: string) => boolean = () => true
+  requireSource(owner: string, agent: { nodeId: string; profile: string }): void {
+    if (!this.sourceAllowed(owner, agent.nodeId, agent.profile)) throw new HttpError(403, '基础 Agent 未分配给当前账号', 'agent_source_forbidden')
+  }
   private readonly key: Buffer
   private targets = new Map<string, GatewayTarget>()
+  private remoteCatalogs = new Map<string,{expires:number; value?:WorkspaceAgent[]; pending?:Promise<WorkspaceAgent[]>}>()
+  private remoteRefreshes = new Map<string,Promise<WorkspaceAgent>>()
   constructor(
     readonly store: WorkspaceStore,
     readonly config: ServerConfig,
     readonly local: GatewayTarget,
+    readonly localNodeID?: string,
   ) {
     const path = join(config.home, 'workspace-key.bin')
     try {
@@ -63,6 +76,16 @@ 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
+      }
       const credentials = this.open<{ username: string; password: string }>(node.secret)
       const url = new URL(node.url),
         client = new UpstreamClient(url)
@@ -71,56 +94,138 @@ export class WorkspaceNodes {
     }
     return target
   }
-  async add(
-    owner: string,
-    input: { name: string; url: string; username: string; password: string },
-  ): Promise<void> {
-    const url = new URL(input.url)
-    if (
-      !['http:', 'https:'].includes(url.protocol) ||
-      url.username ||
-      url.password ||
-      url.search ||
-      url.hash
-    )
-      throw new HttpError(400, '节点地址无效', 'invalid_node')
-    const client = new UpstreamClient(url),
-      session = new UpstreamServiceSession(client, () => ({
-        username: input.username,
-        password: input.password,
-      }))
-    try {
-      const status = await session.request('/api/status')
-      if (
-        status.status === 200 &&
-        JSON.parse(status.body.toString()).server_kind === 'yaoyao-web'
-      ) {
-        throw new HttpError(
-          400,
-          '请填写远端 Hermes 的 9119 地址,手机仍连接当前 Web 服务',
-          'native_gateway_required',
-        )
+  targetForAgent(owner: string, agent: { nodeId: string; remoteAgentId?: string }): GatewayTarget {
+    const base = this.target(owner, agent.nodeId)
+    if (!agent.remoteAgentId) return base
+    if (!base.pairedToken) throw new HttpError(409, '引用远端 Agent 需要扫码子节点', 'paired_node_required')
+    const key = `${owner}:${agent.nodeId}:agent:${agent.remoteAgentId}`
+    let target = this.targets.get(key)
+    if (!target) {
+      const token = base.pairedToken, url = new URL(`${base.url.href.replace(/\/$/, '')}/api/workspace-agents/${encodeURIComponent(agent.remoteAgentId)}/gateway`)
+      const client = new UpstreamClient(url, base.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('Remote Agent uses HTTP/SSE')},
+      }}
+      this.targets.set(key,target)
+    }
+    return target
+  }
+  async remoteAgents(owner: string, nodeId: string, fresh = false): Promise<WorkspaceAgent[]> {
+    const key=`${owner}:${nodeId}`, cached=this.remoteCatalogs.get(key)
+    if (!fresh && cached?.value && cached.expires>Date.now()) return cached.value
+    if (cached?.pending) return cached.pending
+    const state:{expires:number;value?:WorkspaceAgent[];pending?:Promise<WorkspaceAgent[]>}={expires:0}
+    const pending=(async()=>{
+      const target=this.target(owner,nodeId)
+      if (!target.pairedToken) throw new HttpError(409,'请先扫码添加远程子节点','paired_node_required')
+      const response=await target.session.request('/api/workspace-agents')
+      if (response.status!==200) throw new HttpError(409,'远端需升级并重新扫码授权,才能添加 Bot 模式 Agent','remote_agents_unavailable')
+      const payload=JSON.parse(response.body.toString())
+      const agents:Array<WorkspaceAgent>=Array.isArray(payload.agents) ? payload.agents.filter((a:WorkspaceAgent)=>!a.archived).map((a:WorkspaceAgent)=>{
+        if(typeof a.id!=='string'||!/^[0-9a-f-]{36}$/.test(a.id)) throw new HttpError(502,'远端 Agent 标识无效','invalid_remote_agent')
+        const fields=parse(agentInput,{name:a.name,avatar:a.avatar,instructions:a.instructions,nodeId:a.nodeId,profile:a.profile})
+        return {...a,...fields}
+      }) : []
+      state.value=agents;state.expires=Date.now()+3000;return agents
+    })()
+    state.pending=pending;this.remoteCatalogs.set(key,state)
+    try { return await pending } finally {state.pending=undefined}
+  }
+  private uniqueRemoteName(owner: string, remoteName: string, nodeId: string, excludeId?: string): string {
+    const names = new Set(this.store.list<WorkspaceAgent>(owner,'agent').filter(a=>a.id!==excludeId).map(a=>a.name.toLocaleLowerCase()))
+    let name = remoteName, index = 1
+    const node = this.store.require<WorkspaceNode>(owner,'node',nodeId)
+    const nodeName=node.name.replace(/[@\u0000-\u001f]/g,' ').trim().slice(0,20) || '远程'
+    while (names.has(name.toLocaleLowerCase())) {
+      name=`${remoteName.slice(0,60)} · ${nodeName}${index===1 ? '' : ' '+index}`;index++
+    }
+    return name
+  }
+  async importRemoteAgent(owner: string, nodeId: string, remoteAgentId: string): Promise<WorkspaceAgent> {
+    const remote = (await this.remoteAgents(owner,nodeId,true)).find(a=>a.id===remoteAgentId)
+    if (!remote) throw new HttpError(404,'远端 Agent 不存在或已归档','remote_agent_not_found')
+    const existing = this.store.list<WorkspaceAgent>(owner,'agent').find(a=>a.nodeId===nodeId && a.remoteAgentId===remoteAgentId)
+    if (existing) {
+      const current=existing.archived ? this.store.updateAgent(owner,existing.id,{archived:false}) : existing
+      return this.refreshRemoteAgent(owner,current)
+    }
+    const created = this.store.createAgent(owner,{name:this.uniqueRemoteName(owner,remote.name,nodeId),avatar:remote.avatar,instructions:remote.instructions,nodeId,profile:remote.profile})
+    created.remoteAgentId = remote.id
+    this.store.put(owner,'agent',created.id,created)
+    this.store.event(owner,'agent.changed',created)
+    return created
+  }
+  refreshRemoteAgent(owner: string, agent: WorkspaceAgent): Promise<WorkspaceAgent> {
+    const key=`${owner}:${agent.id}`, pending=this.remoteRefreshes.get(key)
+    if (pending) return pending
+    const next=this.readRemoteAgent(owner,agent).finally(()=>this.remoteRefreshes.delete(key))
+    this.remoteRefreshes.set(key,next);return next
+  }
+  private async readRemoteAgent(owner: string, agent: WorkspaceAgent): Promise<WorkspaceAgent> {
+    if (!agent.remoteAgentId) return agent
+    const remote = (await this.remoteAgents(owner,agent.nodeId)).find(a=>a.id===agent.remoteAgentId)
+    if (!remote) throw new HttpError(404,'引用的远端 Agent 不存在或已归档','remote_agent_unavailable')
+    agent=this.store.require<WorkspaceAgent>(owner,'agent',agent.id)
+    const latest = {...agent,name:this.uniqueRemoteName(owner,remote.name,agent.nodeId,agent.id),avatar:remote.avatar,instructions:remote.instructions,profile:remote.profile}
+    if (latest.name!==agent.name || latest.avatar!==agent.avatar || latest.instructions!==agent.instructions || latest.profile!==agent.profile) {
+      latest.revision++; latest.updatedAt=Date.now()
+      this.store.put(owner,'agent',agent.id,latest)
+      this.store.event(owner,'agent.changed',latest)
+      for (const c of this.store.list<WorkspaceConversation>(owner,'conversation').filter(c=>c.kind==='direct'&&c.memberIds[0]===agent.id)) {
+        Object.assign(c,{name:latest.name,avatar:latest.avatar});this.store.put(owner,'conversation',c.id,c)
       }
-      const response = await session.request('/api/profiles')
-      if (response.status !== 200)
-        throw new HttpError(400, '无法读取节点 Agent,请检查地址和凭据', 'node_unavailable')
-      const id = randomUUID()
-      this.store.put(owner, 'node', id, {
-        id,
-        name: input.name,
-        url: url.href,
-        secret: this.seal({ username: input.username, password: input.password }),
-      })
-      this.store.event(owner, 'nodes.changed', {})
-    } finally {
-      client.close()
+    }
+    return latest
+  }
+  private invalidateNode(owner: string, id: string): void {
+    this.remoteCatalogs.delete(`${owner}:${id}`)
+    for (const [key,target] of this.targets) if (key===`${owner}:${id}` || key.startsWith(`${owner}:${id}:agent:`)) {
+      target.client.close(); this.targets.delete(key)
     }
   }
+  async pair(owner: string, input: { qrPayload: string; name: string; nodeId?: string }): Promise<void> {
+    const code = parseWorkspacePairCode(input.qrPayload), fetchImpl = this.local.client.fetchImpl
+    if (code.nodeId === this.localNodeID) throw new HttpError(400, '不能把当前服务器添加为自己的子节点', 'self_node')
+    const capabilities=await verifyNode(fetchImpl, code.url, code.nodeId, code.fingerprint)
+    const replaced = input.nodeId ? this.store.require<WorkspaceNode>(owner,'node',input.nodeId) : undefined
+    if (replaced && !capabilities.features?.includes('pair-renewal'))
+      throw new HttpError(409,'请先升级远端,再更新扫码授权','node_upgrade_required')
+    if (replaced && (replaced.remoteNodeId !== code.nodeId || replaced.fingerprint !== code.fingerprint))
+      throw new HttpError(409,'请扫描同一个子节点的配对码','node_identity_mismatch')
+    if (!replaced && this.store.list<WorkspaceNode>(owner, 'node').some(n => n.remoteNodeId === code.nodeId))
+      throw new HttpError(409, '子节点已经连接,请在节点管理中修改地址', 'node_already_paired')
+    const claim = await nodeJSON(fetchImpl, new URL('api/pair/v1/claim', code.url), {
+      method: 'POST', headers: { 'Content-Type': 'application/json', ...(replaced ? {Authorization:`Bearer ${this.open<{token:string}>(replaced.secret).token}`} : {}) },
+      body: JSON.stringify({ pairingId: code.pairingId, secret: code.secret, deviceName: input.name || '夭夭 Bot 主节点', ...(replaced ? {existingDeviceId:replaced.deviceId} : {}) }),
+    })
+    if (claim.protocolVersion !== 1 || claim.serviceType !== 'yaoyao-web' || claim.nodeId !== code.nodeId || claim.fingerprint !== code.fingerprint
+      || (replaced && claim.deviceId !== replaced.deviceId)
+      || typeof claim.token !== 'string' || !claim.token || !/^[0-9a-f-]{36}$/.test(claim.deviceId ?? '')
+      || !Array.isArray(claim.scopes) || !['agents.read', 'sessions.execute', 'history.read'].every(scope => claim.scopes.includes(scope)))
+      throw new HttpError(409, '子节点授权或身份不匹配,请重新扫码并授权 Agent 与会话访问', 'invalid_node_claim')
+    const expected = new URL(`node/${claim.deviceId}`, code.url)
+    if (claim.serverUrl !== expected.href) throw new HttpError(409, '子节点配对地址与二维码不一致', 'node_identity_mismatch')
+    const id = replaced?.id ?? randomUUID()
+    this.invalidateNode(owner,id)
+    this.store.put(owner, 'node', id, { id, name: input.name.trim() || replaced?.name || code.url.hostname, url: code.url.href,
+      transport: 'paired-web', remoteNodeId: code.nodeId, fingerprint: code.fingerprint, deviceId: claim.deviceId, secret: this.seal({ token: claim.token }) })
+    this.store.event(owner, 'nodes.changed', {})
+  }
+  async update(owner: string, id: string, input: { name: string; url: string }): Promise<void> {
+    const node = this.store.require<WorkspaceNode>(owner, 'node', id)
+    if (node.transport !== 'paired-web' || !node.remoteNodeId || !node.fingerprint)
+      throw new HttpError(409, '旧节点请使用 15300 子节点二维码重新连接', 'node_pairing_required')
+    const url = nodeServiceURL(input.url)
+    await verifyNode(this.local.client.fetchImpl, url, node.remoteNodeId, node.fingerprint)
+    this.store.put(owner, 'node', id, { ...node, name: input.name, url: url.href })
+    this.invalidateNode(owner,id)
+    this.store.event(owner, 'nodes.changed', {})
+  }
   remove(owner: string, id: string): void {
     this.store.require(owner, 'node', id)
     this.store.remove(owner, 'node', id)
-    this.targets.get(`${owner}:${id}`)?.client.close()
-    this.targets.delete(`${owner}:${id}`)
+    this.invalidateNode(owner,id)
     this.store.event(owner, 'nodes.changed', {})
   }
   async sources(owner: string): Promise<{ sources: WorkspaceSource[]; errors: string[] }> {
@@ -135,7 +240,7 @@ export class WorkspaceNodes {
             const payload = JSON.parse(response.body.toString())
             for (const p of Array.isArray(payload.profiles) ? payload.profiles : []) {
               const profile = typeof p === 'string' ? p : p.name
-              if (typeof profile === 'string' && profile)
+              if (typeof profile === 'string' && profile && this.sourceAllowed(owner, nodeId, profile))
                 sources.push({
                   nodeId,
                   profile,
@@ -159,6 +264,7 @@ export class WorkspaceNodes {
 /** One server-owned transport per execution binding, never tied to a browser. */
 export class WorkspaceGateway {
   private socket?: WebSocket
+  private paired?: WorkspacePairedChannel
   private pending = new Map<
     string,
     { resolve(value: any): void; reject(error: Error): void; timer: ReturnType<typeof setTimeout> }
@@ -167,6 +273,12 @@ export class WorkspaceGateway {
   onDisconnect: () => void = () => {}
   constructor(readonly target: GatewayTarget) {}
   async connect(): Promise<void> {
+    if (this.target.pairedToken) {
+      this.paired = new WorkspacePairedChannel(this.target.url, this.target.pairedToken, this.target.client.fetchImpl,
+        frame => this.onEvent(frame), () => this.onDisconnect())
+      await this.paired.connect()
+      return
+    }
     const credential = await this.target.session.webSocketCredential(),
       url = new URL(this.target.url)
     url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
@@ -229,6 +341,7 @@ export class WorkspaceGateway {
     await handshake
   }
   rpc(method: string, params: Record<string, unknown>): Promise<any> {
+    if (this.paired) return this.paired.rpc(method, params)
     if (this.socket?.readyState !== WebSocket.OPEN)
       return Promise.reject(new Error('Hermes connection unavailable'))
     const id = randomUUID()
@@ -249,6 +362,7 @@ export class WorkspaceGateway {
   }
   close(): void {
     this.onDisconnect = () => {}
+    this.paired?.close()
     this.socket?.close()
   }
 }

+ 125 - 0
src/server/workspacePairedNode.ts

@@ -0,0 +1,125 @@
+import { randomUUID } from 'node:crypto'
+import { HttpError } from './errors.js'
+import { SSEParser } from '../shared/sse.js'
+
+export interface WorkspacePairCode { url: URL; nodeId: string; fingerprint: string; pairingId: string; secret: string }
+const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
+export function nodeServiceURL(value: string): URL {
+  let url: URL
+  try { url = new URL(value) } catch { throw new HttpError(400, '节点地址无效', 'invalid_node') }
+  if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash || !['', '/'].includes(url.pathname))
+    throw new HttpError(400, '请填写子节点的 Web 地址', 'invalid_node')
+  return url
+}
+export function parseWorkspacePairCode(payload: string): WorkspacePairCode {
+  let code: URL
+  try { code = new URL(payload) } catch { throw new HttpError(400, '无效的子节点二维码', 'invalid_pairing') }
+  const fields = [...code.searchParams.keys()]
+  if (code.protocol !== 'yaoyao:' || code.hostname !== 'pair' || code.searchParams.get('v') !== '1' || new Set(fields).size !== fields.length)
+    throw new HttpError(400, '请扫描子节点配对二维码,不能使用服务器登录二维码', 'invalid_pairing')
+  const nodeId = code.searchParams.get('node') ?? '', pairingId = code.searchParams.get('id') ?? '',
+    fingerprint = code.searchParams.get('fingerprint') ?? '', secret = code.searchParams.get('secret') ?? ''
+  if (!uuid.test(nodeId) || !uuid.test(pairingId) || fingerprint.length < 32 || fingerprint.length > 512 || secret.length < 32 || secret.length > 512)
+    throw new HttpError(400, '子节点二维码信息不完整', 'invalid_pairing')
+  return { url: nodeServiceURL(code.searchParams.get('url') ?? ''), nodeId, pairingId, fingerprint, secret }
+}
+export async function nodeJSON(fetchImpl: typeof fetch, url: URL, options: RequestInit = {}): Promise<any> {
+  const response = await fetchImpl(url, { ...options, redirect: 'error', signal: options.signal ?? AbortSignal.timeout(30_000) })
+  if (!response.ok) throw new HttpError(502, '子节点请求失败,请检查地址、二维码或授权状态', 'node_unavailable')
+  return response.status === 204 ? {} : response.json()
+}
+export async function verifyNode(fetchImpl: typeof fetch, url: URL, nodeId: string, fingerprint: string): Promise<any> {
+  const capability = await nodeJSON(fetchImpl, new URL('api/pair/v1/capabilities', url))
+  if (capability.protocolVersion !== 1 || capability.serviceType !== 'yaoyao-web' || capability.nodeId !== nodeId || capability.fingerprint !== fingerprint)
+    throw new HttpError(409, '子节点身份不匹配,请确认 IP 指向原来的 15300 Web 服务', 'node_identity_mismatch')
+  return capability
+}
+
+/** Delegated child-node transport. No browser cookie, login, or direct 9119 connection. */
+export class WorkspacePairedChannel {
+  private channel = ''
+  private stopped = false
+  private abort?: AbortController
+  private cursor = ''
+  constructor(private base: URL, private token: string, private fetchImpl: typeof fetch,
+    private onEvent: (frame: any) => void, private onDisconnect: () => void) {}
+  private url(path: string): URL { return new URL(`${this.base.href.replace(/\/$/, '')}/api/realtime/${path}`) }
+  private headers(extra: Record<string, string> = {}) { return { Authorization: `Bearer ${this.token}`, 'Content-Type': 'application/json', ...extra } }
+  async connect(): Promise<void> {
+    const created = await nodeJSON(this.fetchImpl, this.url('channels'), { method: 'POST', headers: this.headers(), body: JSON.stringify({ channel: 'chat' }) })
+    if (!uuid.test(created.id)) throw new Error('Invalid child channel')
+    this.channel = created.id
+    if (this.stopped) { this.close(); throw new Error('Child channel closed') }
+    await new Promise<void>((resolve, reject) => {
+      let ready = false
+      const timer = setTimeout(() => { this.close(); reject(new Error('子节点事件握手超时')) }, 20_000)
+      void this.read(() => { ready = true; clearTimeout(timer); resolve() }).catch(error => {
+        clearTimeout(timer)
+        if (!ready) reject(error)
+        if (!this.stopped) this.onDisconnect()
+      })
+    })
+  }
+  private async read(onReady: () => void): Promise<void> {
+    let failures = 0
+    while (!this.stopped) {
+      this.abort = new AbortController()
+      let reader: ReadableStreamDefaultReader<Uint8Array> | undefined
+      let watchdog: ReturnType<typeof setTimeout> | undefined
+      try {
+        const response = await this.fetchImpl(this.url(`channels/${this.channel}/events`), {
+          headers: this.headers({ Accept: 'text/event-stream', ...(this.cursor ? { 'Last-Event-ID': this.cursor } : {}) }),
+          redirect: 'error', signal: this.abort.signal,
+        })
+        if (!response.ok || !response.body || !response.headers.get('content-type')?.startsWith('text/event-stream'))
+          throw new HttpError(502, '子节点事件流不可用', 'node_stream_unavailable')
+        reader = response.body.getReader()
+        const parser = new SSEParser(), decoder = new TextDecoder()
+        while (!this.stopped) {
+          clearTimeout(watchdog)
+          watchdog = setTimeout(() => this.abort?.abort(), 45_000)
+          const part = await reader.read()
+          if (part.done) throw new Error('Child stream disconnected')
+          failures = 0
+          for (const entry of parser.feed(decoder.decode(part.value, { stream: true }))) {
+            if (entry.event === 'reset') throw new HttpError(409, '子节点事件需要重新同步', 'node_stream_reset')
+            if (entry.event !== 'frame' || (entry.id && entry.id === this.cursor)) continue
+            const frame = JSON.parse(entry.data)
+            if (frame.method === 'event' && frame.params) {
+              this.onEvent(frame.params)
+              if (frame.params.type === 'gateway.ready') onReady()
+            }
+            if (entry.id) this.cursor = entry.id
+          }
+        }
+      } catch (error) {
+        if (this.stopped) return
+        if (error instanceof HttpError || ++failures >= 3) throw error
+      } finally { clearTimeout(watchdog); await reader?.cancel().catch(() => {}); reader?.releaseLock() }
+      await new Promise(resolve => setTimeout(resolve, 500))
+    }
+  }
+  async rpc(method: string, params: Record<string, unknown>): Promise<any> {
+    if (!this.channel || this.stopped) throw new Error('Child channel unavailable')
+    const id = randomUUID()
+    let receipt: any
+    try {
+      receipt = await nodeJSON(this.fetchImpl, this.url(`channels/${this.channel}/commands`), {
+        method: 'POST', headers: this.headers({ 'Idempotency-Key': id }), body: JSON.stringify({ jsonrpc: '2.0', method, params }),
+      })
+    } catch {
+      receipt = await nodeJSON(this.fetchImpl, this.url(`commands/${id}`), { headers: this.headers() })
+    }
+    if (receipt.response?.error) throw new HttpError(502, receipt.response.error.message || '子节点拒绝请求', 'gateway_rejected')
+    if (receipt.state !== 'confirmed') throw new HttpError(502, '子节点提交结果尚未确认', 'node_submission_uncertain')
+    return receipt.response?.result
+  }
+  close(): void {
+    this.stopped = true
+    this.abort?.abort()
+    if (this.channel) {
+      const id = this.channel; this.channel = ''
+      void nodeJSON(this.fetchImpl, this.url(`channels/${id}`), { method: 'DELETE', headers: this.headers(), signal: AbortSignal.timeout(5_000) }).catch(() => {})
+    }
+  }
+}

+ 139 - 0
src/server/workspaceRemoteAgents.ts

@@ -0,0 +1,139 @@
+import type Koa from 'koa'
+import { Readable } from 'node:stream'
+import { HttpError } from './errors.js'
+import type { LocalAuthStore } from './localAuth.js'
+import type { NodePairingStore } from './pairing.js'
+import type { WorkspaceStore } from './workspaceStore.js'
+import type { WorkspaceNodes, GatewayTarget } from './workspaceGateway.js'
+import type { WorkspaceAgent } from '../shared/workspace.js'
+
+export interface AgentExportContext {
+  id: string
+  target: GatewayTarget
+  valid(): boolean
+  transform(frame: Record<string, any>): Record<string, any>
+}
+const allowedCommands = new Set(['session.create','session.resume','session.close','session.usage','session.interrupt','prompt.submit','session.steer','image.attach_bytes','file.attach','approval.respond','clarify.respond'])
+
+/** A paired device sees only Agents owned by the account that issued its QR grant. */
+export function remoteAgentExports(store: WorkspaceStore, nodes: WorkspaceNodes, pairings: NodePairingStore, auth: LocalAuthStore): Koa.Middleware {
+  return async (ctx,next) => {
+    const match = /^\/node\/([0-9a-f-]{36})\/api\/workspace-agents(?:\/([0-9a-f-]{36})(?:\/gateway(\/api\/.*))?)?$/.exec(ctx.path)
+    if (!match) return next()
+    const device=match[1]!, agentId=match[2], path=match[3], token=ctx.get('authorization').match(/^Bearer\s+(.+)$/i)?.[1] ?? ''
+    const owner=pairings.workspaceOwner(device,token,path ? 'sessions.execute' : 'agents.read')
+    if (!auth.isUserActive(owner)) throw new HttpError(403,'远端授权账号已停用','remote_owner_unavailable')
+    const valid = () => {
+      try { return pairings.workspaceOwner(device,token,'sessions.execute')===owner && auth.isUserActive(owner)
+        && (!agentId || store.get<WorkspaceAgent>(owner,'agent',agentId)?.archived === false) }
+      catch { return false }
+    }
+    ctx.set('Cache-Control','no-store')
+    if (!agentId) {
+      if (ctx.method!=='GET') throw new HttpError(405,'只允许读取远端 Agent','method_not_allowed')
+      const agents=store.list<WorkspaceAgent>(owner,'agent').filter(a=>!a.archived)
+      for(const value of agents) if(value.remoteAgentId) void nodes.refreshRemoteAgent(owner,value).catch(()=>{})
+      ctx.body={agents:agents.map(a=>store.agentSummary(a))};return
+    }
+    let agent=store.require<WorkspaceAgent>(owner,'agent',agentId)
+    if (agent.archived) throw new HttpError(410,'远端 Agent 已归档','remote_agent_archived')
+    if (agent.remoteAgentId) agent=await nodes.refreshRemoteAgent(owner,agent)
+    if (!path) {
+      if (ctx.method!=='GET') throw new HttpError(405,'远端配置只读','method_not_allowed')
+      ctx.body={agent:store.agentSummary(agent)};return
+    }
+    const hops=Number(ctx.get('x-yaoyao-agent-hops')||0)
+    if (!Number.isInteger(hops) || hops<0 || hops>=8) throw new HttpError(508,'远端 Agent 引用链过长或循环','agent_reference_loop')
+    const target=nodes.targetForAgent(owner,agent), scope=`${device}:${agentId}`
+    const bindingKey=(id:string)=>`${scope}:${id}`
+    const requireBinding=(id:string)=>{
+      const binding=store.get<{profile:string}>(owner,'agent-export-binding',bindingKey(id))
+      if (!binding || binding.profile!==agent.profile) throw new HttpError(403,'会话不属于这个 Agent 引用','agent_session_forbidden')
+    }
+    const transform=(frame:Record<string,any>)=>{
+      if (!allowedCommands.has(frame.method)) throw new HttpError(403,'不能通过引用修改远端配置','remote_agent_read_only')
+      const params={...frame.params}
+      if (frame.method==='session.create' || frame.method==='session.resume') {
+        if(frame.method==='session.resume') requireBinding(String(params.session_id))
+        params.profile=agent.profile
+        for (const key of ['model','provider','config','cwd','reasoning_effort','fast','messages']) delete params[key]
+      }
+      if ((frame.method==='prompt.submit'||frame.method==='session.steer') && !agent.remoteAgentId) {
+        if (typeof params.text!=='string') throw new HttpError(400,'消息内容无效','invalid_prompt')
+        params.text=`你是 ${agent.name}。远端 Agent 的角色与规则(版本 ${agent.revision}):\n${agent.instructions}\n\n${params.text}`
+      }
+      const id=ctx.get('idempotency-key')
+      if (id) store.put(owner,'agent-export-command',bindingKey(id),{method:frame.method,profile:agent.profile})
+      return {...frame,params}
+    }
+    const remember=(receipt:any)=>{
+      const id=ctx.get('idempotency-key') || /^\/api\/realtime\/commands\/([^/]+)$/.exec(path)?.[1]
+      const command=id ? store.get<{method:string}>(owner,'agent-export-command',bindingKey(id)) : undefined
+      if (!id || !command || !['session.create','session.resume'].includes(command.method)) return
+      const result=receipt?.state==='confirmed' ? receipt.response?.result : undefined
+      const stored=result?.stored_session_id ?? result?.session_key ?? result?.info?.stored_session_id ?? result?.session_id
+      if (typeof stored==='string' && stored.length<=256) {
+        store.put(owner,'agent-export-binding',bindingKey(stored),{profile:agent.profile})
+      }
+    }
+    if (path==='/api/profiles' && ctx.method==='GET') { ctx.body={profiles:[{name:agent.profile,display_name:agent.name}]};return }
+    if (path.startsWith('/api/realtime/')) {
+      const selectedChannel=/^\/api\/realtime\/channels\/([0-9a-f-]{36})/.exec(path)?.[1]
+      if (selectedChannel && !store.get(owner,'agent-export-channel',bindingKey(selectedChannel)))
+        throw new HttpError(403,'通道不属于这个 Agent 引用','agent_channel_forbidden')
+      const receiptID=/^\/api\/realtime\/commands\/([^/]+)$/.exec(path)?.[1]
+      if (receiptID && !store.get(owner,'agent-export-command',bindingKey(receiptID)))
+        throw new HttpError(403,'命令不属于这个 Agent 引用','agent_command_forbidden')
+      const rememberChannel=(body:any)=>{
+        if(ctx.method==='POST' && path==='/api/realtime/channels' && typeof body?.id==='string')
+          store.put(owner,'agent-export-channel',bindingKey(body.id),{createdAt:Date.now()})
+      }
+      if (!target.pairedToken) {
+        ctx.state.workspaceAgentExport={id:agentId,target,valid,transform} satisfies AgentExportContext
+        const previous=ctx.url
+        ctx.url=`/node/${device}${path}${ctx.search}`
+        try { await next(); rememberChannel(ctx.body); remember(ctx.body) } finally { ctx.url=previous }
+        return
+      }
+      const allowed=/^\/api\/realtime\/(capabilities|channels(?:\/[0-9a-f-]{36}(?:\/(events|commands))?)?|commands\/[A-Za-z0-9:_-]{1,200})$/.test(path)
+      if (!allowed) throw new HttpError(404,'Agent 实时路径不存在','not_found')
+      let body: any
+      if (ctx.method==='POST') {
+        let size=0;const parts:Buffer[]=[]
+        for await(const part of ctx.req) {size+=part.length;if(size>36*1024*1024) throw new HttpError(413,'请求过大','body_too_large');parts.push(Buffer.from(part))}
+        try {body=JSON.parse(Buffer.concat(parts).toString())} catch {throw new HttpError(400,'JSON 无效','invalid_json')}
+        if(path.endsWith('/commands')) body=transform(body)
+        else if(body.channel!=='chat') throw new HttpError(403,'Agent 仅支持聊天通道','invalid_channel')
+      }
+      const abort=new AbortController(), timer=setTimeout(()=>abort.abort(),30_000)
+      ctx.res.once('close',()=>abort.abort())
+      let response: Response
+      try {response=await target.client.fetchImpl(new URL(`${target.url.href.replace(/\/$/,'')}${path}${ctx.search}`),{
+        method:ctx.method,redirect:'error',signal:abort.signal,
+        headers:{Authorization:`Bearer ${target.pairedToken}`,'Content-Type':'application/json','Last-Event-ID':ctx.get('last-event-id'),'Idempotency-Key':ctx.get('idempotency-key'),'x-yaoyao-agent-hops':String(hops+1)},
+        ...(body!==undefined ? {body:JSON.stringify(body)} : {}),
+      })} finally {clearTimeout(timer)}
+      ctx.status=response.status
+      ctx.set('Content-Type',response.headers.get('content-type')||'application/json')
+      if(response.headers.get('content-type')?.startsWith('text/event-stream') && response.body) {
+        const interval=setInterval(()=>{if(!valid())abort.abort()},5000);interval.unref()
+        ctx.res.once('close',()=>clearInterval(interval))
+        const stream=Readable.fromWeb(response.body as any)
+        stream.once('close',()=>clearInterval(interval));stream.once('error',()=>clearInterval(interval))
+        ctx.body=stream
+      }
+      else if(response.status!==204) {ctx.body=await response.json();rememberChannel(ctx.body);remember(ctx.body)}
+      return
+    }
+    if (ctx.method!=='GET') throw new HttpError(405,'Agent 引用只允许读取文件与会话','method_not_allowed')
+    pairings.authorize(device,token,'history.read')
+    const history=/^\/api\/sessions\/([^/]+)\/messages$/.exec(path)
+    if (history) requireBinding(decodeURIComponent(history[1]!))
+    else if(path!=='/api/files/download') throw new HttpError(403,'路径不属于 Agent 引用','agent_path_forbidden')
+    const query=new URLSearchParams(ctx.querystring);query.set('profile',agent.profile)
+    const response=await target.session.request(path,{search:query,maxResponseBytes:25*1024*1024})
+    ctx.status=response.status
+    for(const name of ['content-type','content-disposition']) {const value=response.headers.get(name);if(value)ctx.set(name,value)}
+    ctx.body=response.body
+  }
+}

+ 38 - 20
src/server/workspaceRoutes.ts

@@ -64,7 +64,7 @@ export function workspaceRouter(
         'files',
         'voice',
         'context',
-        'nodes',
+        ...(auth.require(ctx).role === 'admin' ? ['nodes', 'pairedNodes', 'remoteAgentReferences', 'editableNodeAddress'] : []),
         'events',
       ],
     }
@@ -72,19 +72,33 @@ export function workspaceRouter(
   router.get('/api/app/agents/sources', async (ctx) => {
     ctx.body = await nodes.sources(owner(ctx))
   })
-  router.get('/api/app/agents', (ctx) => {
-    ctx.body = { agents: store.list<WorkspaceAgent>(owner(ctx), 'agent').map(agent => store.agentSummary(agent)) }
+  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) }
+  })
+  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
   })
   router.post('/api/app/agents', async (ctx) => {
     const user = owner(ctx),
-      input = parse(agentInput, body(ctx)),
-      sources = await nodes.sources(user)
+      input = parse(agentInput, body(ctx))
+    nodes.requireSource(user, input)
+    const sources = await nodes.sources(user)
     if (!sources.sources.some((s) => s.nodeId === input.nodeId && s.profile === input.profile))
       throw new HttpError(409, '基础 Agent 当前不可用', 'source_unavailable')
+    nodes.requireSource(user, input)
     ctx.body = { agent: store.agentSummary(store.createAgent(user, input)) }
     ctx.status = 201
   })
   router.patch('/api/app/agents/:id', (ctx) => {
+    nodes.requireSource(owner(ctx), store.require<WorkspaceAgent>(owner(ctx), 'agent', ctx.params.id))
     ctx.body = { agent: store.agentSummary(store.updateAgent(owner(ctx), ctx.params.id, body(ctx))) }
   })
   router.get('/api/app/conversations', (ctx) => {
@@ -104,7 +118,10 @@ export function workspaceRouter(
   })
   router.post('/api/app/conversations', (ctx) => {
     const user = owner(ctx),
-      conversation = store.createGroup(user, body(ctx))
+      input = body(ctx)
+    for (const id of Array.isArray(input.memberIds) ? input.memberIds : [])
+      nodes.requireSource(user, store.require<WorkspaceAgent>(user, 'agent', id))
+    const conversation = store.createGroup(user, input)
     try {
       push.setGroupSubscription(user, conversation.id, true, conversation.lastSeq)
     } catch {
@@ -159,7 +176,10 @@ export function workspaceRouter(
     }
   })
   router.patch('/api/app/conversations/:id', async (ctx) => {
-    const user = owner(ctx), conversation = store.updateConversation(user, ctx.params.id, body(ctx))
+    const user = owner(ctx), input = body(ctx)
+    for (const id of Array.isArray(input.memberIds) ? input.memberIds : [])
+      nodes.requireSource(user, store.require<WorkspaceAgent>(user, 'agent', id))
+    const conversation = store.updateConversation(user, ctx.params.id, input)
     if (conversation.archived) await runtime.stopConversation(user, conversation.id)
     runtime.wake()
     ctx.body = { conversation: store.require(user, 'conversation', conversation.id) }
@@ -231,6 +251,7 @@ export function workspaceRouter(
     ctx.body = { events, cursor: events.at(-1)?.seq ?? after }
   })
   router.get('/api/app/nodes', (ctx) => {
+    auth.requireAdmin(ctx)
     ctx.body = {
       nodes: store
         .list<WorkspaceNode>(owner(ctx), 'node')
@@ -238,22 +259,19 @@ export function workspaceRouter(
     }
   })
   router.post('/api/app/nodes', async (ctx) => {
-    const b = parse(
-      z
-        .object({
-          name: z.string().min(1).max(100),
-          url: z.string().url().max(2048),
-          username: z.string().max(256),
-          password: z.string().max(4096),
-        })
-        .strict(),
-      body(ctx),
-    )
-    await nodes.add(owner(ctx), b)
-    ctx.status = 201
+    auth.requireAdmin(ctx)
+    const input = parse(z.object({ qrPayload: z.string().min(1).max(8192), name: z.string().max(100).default(''), nodeId:z.string().uuid().optional() }).strict(), body(ctx))
+    await nodes.pair(owner(ctx), input)
+    ctx.status = 201; ctx.body = { ok: true }
+  })
+  router.patch('/api/app/nodes/:id', async (ctx) => {
+    auth.requireAdmin(ctx)
+    const input = parse(z.object({ name: z.string().min(1).max(100), url: z.string().url().max(2048) }).strict(), body(ctx))
+    await nodes.update(owner(ctx), ctx.params.id, input)
     ctx.body = { ok: true }
   })
   router.delete('/api/app/nodes/:id', (ctx) => {
+    auth.requireAdmin(ctx)
     nodes.remove(owner(ctx), ctx.params.id)
     ctx.body = { ok: true }
   })

+ 12 - 3
src/server/workspaceRuntime.ts

@@ -19,6 +19,7 @@ export { mentionedAgents } from './workspaceMentions.js'
 type Run = WorkspaceRun
 
 export interface WorkspaceBinding {
+  remoteAgentId?: string
   id: string
   nodeId: string
   profile: string
@@ -55,6 +56,8 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
   constructor(store: WorkspaceStore, readonly nodes: WorkspaceNodes, readonly uploads: UploadStore, userActive: (owner: string) => boolean = () => true) { super(store, userActive) }
   send(owner: string, conversationId: string, input: unknown): Run {
     const body = parse(sendInput, input)
+    for (const id of this.store.require<Conversation>(owner, 'conversation', conversationId).memberIds)
+      this.nodes.requireSource(owner, this.store.require<Agent>(owner, 'agent', id))
     const result = this.store.command(owner, body.requestId, { conversationId, ...body }, () => {
       const c = this.store.require<Conversation>(owner, 'conversation', conversationId)
       if (c.archived) throw new HttpError(409, '聊天已归档', 'conversation_archived')
@@ -133,8 +136,10 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
     run: Work,
     recovering = false,
   ): Promise<Message> {
+    this.nodes.requireSource(owner, agent)
+    if (agent.remoteAgentId) agent = await this.nodes.refreshRemoteAgent(owner,agent)
     const key = this.bindingKey(c.id, run.conversationTaskId, agent.id),
-      target = this.nodes.target(owner, agent.nodeId)
+      target = agent.remoteAgentId ? this.nodes.targetForAgent(owner,agent) : this.nodes.target(owner, agent.nodeId)
     const gateway = new WorkspaceGateway(target)
     let binding = this.store.get<WorkspaceBinding>(owner, 'binding', key)
     let message =
@@ -351,6 +356,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
     }
     try {
       await gateway.connect()
+      this.nodes.requireSource(owner, agent)
       const opened = binding?.storedId
         ? await gateway.rpc('session.resume', {
             profile: agent.profile,
@@ -379,6 +385,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
       binding = {
         id: key,
         nodeId: agent.nodeId,
+        remoteAgentId: agent.remoteAgentId,
         profile: agent.profile,
         storedId,
         runtimeId,
@@ -457,7 +464,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
         }
         const members = run.turnConfiguration!.members
         const rules = [
-          `你是 ${agent.name}。以下是用户为这个独立 Agent 配置的角色与规则(版本 ${agent.revision}):\n${agent.instructions}`,
+          `你是 ${agent.name}。以下是用户为这个独立 Agent 配置的角色与规则(版本 ${agent.revision}):\n${agent.remoteAgentId ? '配置由远端 Agent 管理。' : agent.instructions}`,
           c.kind === 'group' && Object.keys(c.memberRoles ?? {}).length
             ? `本群角色分工(仅在本群生效):\n${members.flatMap(member => {
                 const role = c.memberRoles?.[member.id]
@@ -480,6 +487,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
           admission.status = 'interrupted'; admission.error = '执行前成员已移除'; this.saveWork(owner, admission)
           throw new Error('执行前成员已移除')
         }
+        this.nodes.requireSource(owner, agent)
         if (admission.cancelRequested || admission.status === 'interrupted') throw new Error('运行已停止')
         admission.contextThroughSeq = run.contextThroughSeq ?? run.triggerSeq
         admission.submitted = true
@@ -542,6 +550,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
   async respond(owner: string, id: string, answer: string): Promise<void> {
     type Reply = WorkspaceInteraction & { answer?: string; responseState?: 'sending' | 'uncertain' | 'sent' }
     const interaction = this.store.require<Reply>(owner, 'interaction', id)
+    this.nodes.requireSource(owner, this.store.require<Agent>(owner, 'agent', interaction.agentId))
     if (interaction.resolved) {
       if (interaction.answer && interaction.answer !== answer) throw new HttpError(409, '该请求已使用其他答复完成', 'interaction_answer_conflict')
       return
@@ -586,7 +595,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
       const binding = this.store.get<WorkspaceBinding>(owner, 'binding', key)
       if (work.submitted) {
         if (!binding || binding.taskId !== work.id) throw new Error('无法确认待停止成员的会话身份')
-        const gateway = new WorkspaceGateway(this.nodes.target(owner, binding.nodeId))
+        const gateway = new WorkspaceGateway(binding.remoteAgentId ? this.nodes.targetForAgent(owner,binding) : this.nodes.target(owner, binding.nodeId))
         try {
           await gateway.connect()
           const opened = await gateway.rpc('session.resume', { profile: binding.profile, session_id: binding.storedId, omit_messages: true, close_on_disconnect: false })

+ 2 - 0
src/server/workspaceStore.ts

@@ -312,6 +312,8 @@ export class WorkspaceStore {
     if (patch.avatar !== undefined) patch.avatar = normalizeAvatar(patch.avatar)
     return this.atomic(() => {
       const agent = this.require<Agent>(owner, 'agent', id)
+      if (agent.remoteAgentId && Object.keys(patch).some(key => key !== 'archived'))
+        throw new HttpError(409, '引用 Agent 的配置由远端管理', 'remote_agent_read_only')
       if (
         patch.name &&
         this.list<Agent>(owner, 'agent').some(

+ 1 - 0
src/shared/workspace.ts

@@ -6,6 +6,7 @@ export interface WorkspaceAgent {
   instructions: string
   nodeId: string
   profile: string
+  remoteAgentId?: string
   archived: boolean
   revision: number
   createdAt: number

+ 5 - 1
tests/client/workspaceShell.test.ts

@@ -164,10 +164,14 @@ it('uses the reference Bot list header and keeps mode changes in settings', asyn
   await wrapper.get('[data-testid="close-settings"]').trigger('click')
   await rail.get('.sidebar-create-trigger').trigger('click')
   const items = document.querySelectorAll<HTMLButtonElement>('.workspace-create-menu [role="menuitem"]')
-  expect([...items].map(item => item.textContent?.trim())).toEqual(['新建 Bot', '新建群聊'])
+  expect([...items].map(item => item.textContent?.trim())).toEqual(['新建 Bot', '添加远程 Agent', '新建群聊'])
   items[0]?.click()
   await wrapper.vm.$nextTick()
   expect(wrapper.emitted('createAgent')).toHaveLength(1)
+  await rail.get('.sidebar-create-trigger').trigger('click')
+  document.querySelectorAll<HTMLButtonElement>('.workspace-create-menu [role="menuitem"]')[1]?.click()
+  await wrapper.vm.$nextTick()
+  expect(wrapper.emitted('createRemoteAgent')).toHaveLength(1)
   await rail.get('.sidebar-search-trigger').trigger('click')
   document.dispatchEvent(new CustomEvent('hermes-yaoyao:sidebar-search-close'))
   await wrapper.vm.$nextTick()

+ 47 - 2
tests/e2e/workspace-chat.spec.ts

@@ -58,7 +58,7 @@ test('an open Web chat list receives cross-client sessions and real title events
   const pageBSend = pageB.getByRole('button', { name: '发送消息', exact: true })
   await expect(pageBSend).toBeEnabled()
   await pageBSend.click()
-  await expect(pageB).toHaveURL(/\/chat\/[^/?]+/)
+  await expect(pageB).toHaveURL(/\/chat\/(?!draft-)[^/?]+(?:\?.*)?$/)
   const sessionId = decodeURIComponent(new URL(pageB.url()).pathname.split('/').at(-1) || '')
   expect(sessionId).not.toMatch(/^draft-/)
   await expect(pageB.locator('.message--assistant')).toContainText('会话独立保存')
@@ -130,7 +130,7 @@ test('created roles and editable teams share a durable chat list without plugin
   const plus = rail.getByRole('button', { name: '新建', exact: true })
   await expect(rail.locator('.sidebar-collapse')).toHaveCount(0)
   await plus.click()
-  await expect(page.getByRole('menu', { name: '新建聊天' }).getByRole('menuitem')).toHaveCount(2)
+  await expect(page.getByRole('menu', { name: '新建聊天' }).getByRole('menuitem')).toHaveText(['新建 Bot', '添加远程 Agent', '新建群聊'])
   await page.keyboard.press('Escape')
   await rail.getByRole('button', { name: '搜索', exact: true }).click()
   await expect(page.getByRole('dialog', { name: '搜索聊天' })).toBeVisible()
@@ -378,3 +378,48 @@ test('created roles and editable teams share a durable chat list without plugin
   await expect(rail.locator('.bot-list-header')).toBeVisible()
   expect(pluginCalls).toEqual([])
 })
+
+test('subaccount allocation and Bot-only navigation work through the browser', async ({ page, browser }, testInfo) => {
+  await login(page)
+  await page.locator('.desktop-sidebar').getByRole('button', { name: '设置与模式', exact: true }).click()
+  await page.getByRole('menuitem', { name: '进入设置', exact: true }).click()
+  const settings = page.getByRole('dialog', { name: '设置中心', exact: true })
+  await settings.getByRole('button', { name: '用户与权限', exact: true }).click()
+  const username = `child-${Date.now()}`
+  await settings.locator('input[name=managed-username]').fill(username)
+  await settings.locator('input[name=managed-temporary-password]').fill('temporary-pass')
+  await settings.getByRole('listbox', { name: '新子账号的基础 Agent' }).selectOption('default')
+  await settings.getByRole('button', { name: '创建用户', exact: true }).click()
+  await expect(settings.getByRole('listbox', { name: `分配给 ${username} 的基础 Agent`, exact: true })).toHaveValues(['default'])
+  await page.screenshot({ path: testInfo.outputPath('admin-agent-allocation.png'), fullPage: true })
+
+  const childContext = await browser.newContext({ viewport: { width: 1280, height: 800 } })
+  const child = await childContext.newPage()
+  await child.goto(new URL('/chat', page.url()).href)
+  await child.getByRole('textbox', { name: '账号', exact: true }).fill(username)
+  await child.getByRole('textbox', { name: '密码', exact: true }).fill('temporary-pass')
+  await child.getByRole('button', { name: '登录', exact: true }).click()
+  await child.getByLabel('当前密码', { exact: true }).fill('temporary-pass')
+  await child.getByLabel('新密码', { exact: true }).fill('permanent-pass')
+  await child.getByLabel('确认新密码', { exact: true }).fill('permanent-pass')
+  await child.getByRole('button', { name: '保存并进入夭夭', exact: true }).click()
+  await expect(child).toHaveURL(/\/conversations$/)
+  await expect(child.getByRole('heading', { name: '还没有聊天' })).toBeVisible()
+  await child.locator('.desktop-sidebar').getByRole('button', { name: '设置与模式', exact: true }).click()
+  await expect(child.getByRole('menu', { name: '设置与模式' }).getByRole('menuitem')).toHaveText(['进入设置'])
+  await child.keyboard.press('Escape')
+  await openCreate(child, '新建 Bot')
+  const dialog = child.getByRole('dialog')
+  await expect(dialog.getByLabel('基础 Agent').locator('option')).toHaveCount(1)
+  await expect(dialog.getByLabel('基础 Agent').locator('option')).toContainText('通用助手')
+  await dialog.getByLabel('基础 Agent').scrollIntoViewIfNeeded()
+  await child.screenshot({ path: testInfo.outputPath('subaccount-assigned-source.png'), fullPage: true })
+  await dialog.getByRole('textbox', { name: '名称', exact: true }).fill('子账号助手')
+  await dialog.getByRole('button', { name: '保存', exact: true }).click()
+  await expect(child.getByRole('heading', { name: '子账号助手', exact: true })).toBeVisible()
+  for (const path of ['/history', '/kanban', '/files', '/chat']) {
+    await child.goto(new URL(path, page.url()).href)
+    await expect(child).toHaveURL(/\/conversations$/)
+  }
+  await childContext.close()
+})

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

@@ -148,7 +148,9 @@ const upstream = createServer((req, res) => {
   }
   const history = /^\/api\/sessions\/([^/]+)\/messages$/.exec(url.pathname)
   if (history) {
-    send({ messages: sessions.get(history[1]!)?.messages || [] })
+    const messages = sessions.get(history[1]!)?.messages || []
+    send({ session_id: history[1], messages,
+      pagination: {offset: 0, limit: 100, returned: messages.length, total: messages.length, has_more: false} })
     return
   }
   if (url.pathname === '/api/files/download') {

+ 1 - 1
tests/server/agentManagementRoutes.test.ts

@@ -32,7 +32,7 @@ describe('Agent management admin routes', () => {
       .set('Origin', 'http://127.0.0.1:15300')
       .set('X-CSRF-Token', bootstrap.body.csrfToken)
       .send({ provider: 'opencode-free', model: 'free-a' })
-      .expect(403, /需要管理员权限/)
+      .expect(403, /子账号只能使用 Bot 模式/)
   })
 
   it('proxies profile-scoped model services and masked duplex voice settings through 9119', async () => {

+ 2 - 0
tests/server/authenticatedApplication.ts

@@ -25,6 +25,7 @@ const TEST_USER: LocalUser = {
 }
 
 class AuthenticatedTestStore extends LocalAuthStore {
+  override canUseSource() { return true }
   override currentFromCookieHeader(_cookie: string | undefined): LocalUser { return TEST_ADMIN }
   override current(_ctx: Koa.Context): LocalUser { return TEST_ADMIN }
   override require(_ctx: Koa.Context, _allowPasswordChange = false): LocalUser { return TEST_ADMIN }
@@ -32,6 +33,7 @@ class AuthenticatedTestStore extends LocalAuthStore {
 }
 
 class UserAuthenticatedTestStore extends LocalAuthStore {
+  override canUseSource() { return true }
   override currentFromCookieHeader(_cookie: string | undefined): LocalUser { return TEST_USER }
   override current(_ctx: Koa.Context): LocalUser { return TEST_USER }
   override require(_ctx: Koa.Context, _allowPasswordChange = false): LocalUser { return TEST_USER }

+ 62 - 0
tests/server/hermesBotRelay.test.ts

@@ -0,0 +1,62 @@
+// @vitest-environment node
+import { describe, it, expect, vi } from 'vitest'
+import { Readable } from 'node:stream'
+import { EventEmitter } from 'node:events'
+import { hermesBotRelay } from '../../src/server/hermesBotRelay'
+
+const id = '11111111-1111-4111-8111-111111111111'
+function fixture(path: string, method = 'GET', body?: object) {
+  const calls: {url: URL; options: RequestInit}[] = []
+  const node = { id, transport: 'paired-web', secret: 'sealed' }
+  const nodes: any = {
+    store: { require: (owner: string) => { if(owner !== 'owner') throw new Error('forbidden'); return node }, get: () => node },
+    target: vi.fn((owner: string) => {
+      if (owner !== 'owner') throw new Error('forbidden')
+      return {url: new URL('http://child:15300/node/'+id), pairedToken:'child-secret', client: {
+        fetchImpl: async (url: URL, options: RequestInit) => {
+          calls.push({url, options})
+          return Response.json(path.endsWith('capabilities') ? {protocolVersion:1, csrfToken:'child-csrf'} : {state:'confirmed'})
+        },
+      }}
+    }),
+  }
+  const auth: any = {require: () => ({id:'owner'}), isUserActive: () => true}
+  const ctx: any = {path: '/api/app/hermes-bot/nodes/'+id+path, method, search:'',
+    req: Readable.from(body ? [JSON.stringify(body)] : []), res: new EventEmitter(),
+    get: (name: string) => ({'idempotency-key':'command-one','last-event-id':'cursor-one'}[name] || ''),
+    is: () => true, set: vi.fn(),
+  }
+  return {nodes,auth,ctx,calls,run:()=>hermesBotRelay(nodes,auth,{issue:()=> 'primary-csrf'} as any)(ctx,async()=>{})}
+}
+describe('native Hermes Bot primary relay', () => {
+  it('keeps the primary CSRF identity and relays only to paired child 15300', async () => {
+    const t=fixture('/api/realtime/capabilities'); await t.run()
+    expect(t.ctx.body.csrfToken).toBe('primary-csrf')
+    expect(t.calls[0]!.url.port).toBe('15300')
+    expect(t.calls[0]!.url.pathname).toBe('/node/'+id+'/api/realtime/capabilities')
+    expect(t.calls[0]!.options.headers).toMatchObject({Authorization:'Bearer child-secret'})
+    expect(JSON.stringify(t.ctx.body)).not.toContain('child-secret')
+  })
+  it('uses the native child entry only for explicit native chat routes', async () => {
+    const t=fixture('/api/realtime/capabilities')
+    t.ctx.path=t.ctx.path.replace('/api/realtime','/native/api/realtime')
+    await t.run()
+    expect(t.calls[0]!.url.pathname).toBe('/node/'+id+'/api/hermes-bot/api/realtime/capabilities')
+  })
+  it('preserves receipt identity for prompt submission and forbids redirects', async () => {
+    const frame={method:'prompt.submit',params:{text:'hello'}}
+    const t=fixture('/api/realtime/channels/'+id+'/commands','POST',frame); await t.run()
+    expect(t.calls[0]!.options.redirect).toBe('error')
+    expect(t.calls[0]!.options.headers).toMatchObject({'Idempotency-Key':'command-one'})
+    expect(JSON.parse(Buffer.from(t.calls[0]!.options.body as Uint8Array).toString())).toEqual(frame)
+  })
+  it('rejects another primary account and non-paired legacy nodes before contacting upstream', async () => {
+    const t=fixture('/api/profiles'); t.auth.require=()=>({id:'other'})
+    await expect(t.run()).rejects.toThrow('forbidden'); expect(t.calls).toHaveLength(0)
+    t.auth.require=()=>({id:'owner'}); t.nodes.store.require=()=>({transport:'legacy'})
+    await expect(t.run()).rejects.toThrow('15300'); expect(t.calls).toHaveLength(0)
+  })
+  it.each(['/api/app/agents','/api/auth/login','/api/profiles/new'])('refuses unrelated or configuration route %s', async path => {
+    const t=fixture(path,'POST',{}); await expect(t.run()).rejects.toThrow('不支持'); expect(t.calls).toHaveLength(0)
+  })
+})

+ 7 - 7
tests/server/kanbanRoutes.test.ts

@@ -187,7 +187,7 @@ describe('Kanban proxy routes', () => {
     })
   })
 
-  it('allows authenticated users to read but rejects every user mutation alias', async () => {
+  it('rejects Bot-only users from every native Kanban read and mutation alias', async () => {
     const fixture = upstreamFixture()
     const runtime = createUserAuthenticatedApplication({ config: testConfig(), fetchImpl: fixture.fetchImpl })
     runtimes.push(runtime)
@@ -195,9 +195,9 @@ describe('Kanban proxy routes', () => {
     const csrf = await csrfToken(agent)
     fixture.calls.splice(0)
 
-    await agent.get('/api/app/kanban/board?board=main').set('Host', HOST).expect(200)
-    await agent.get('/api/kanban/v1/board?board=main').set('Host', HOST).expect(200)
-    expect(fixture.calls).toHaveLength(2)
+    await agent.get('/api/app/kanban/board?board=main').set('Host', HOST).expect(403)
+    await agent.get('/api/kanban/v1/board?board=main').set('Host', HOST).expect(403)
+    expect(fixture.calls).toHaveLength(0)
 
     const web = await agent.post('/api/app/kanban/tasks?board=main')
       .set('Host', HOST)
@@ -205,14 +205,14 @@ describe('Kanban proxy routes', () => {
       .set('X-CSRF-Token', csrf)
       .send({ title: 'forbidden' })
       .expect(403)
-    expect(web.body.code).toBe('admin_required')
+    expect(web.body.code).toBe('bot_mode_required')
 
     const native = await agent.patch('/api/kanban/v1/tasks/t_01234567?board=main')
       .set('Host', HOST)
       .send({ status: 'ready' })
       .expect(403)
-    expect(native.body.code).toBe('admin_required')
-    expect(fixture.calls).toHaveLength(2)
+    expect(native.body.code).toBe('bot_mode_required')
+    expect(fixture.calls).toHaveLength(0)
   })
 
   it('keeps CSRF on Web mutations while native admins can mutate without CSRF', async () => {

+ 7 - 1
tests/server/realtimeApi.test.ts

@@ -37,7 +37,7 @@ describe('native Hermes chat and history are separated on 15300', () => {
         if (path === '/auth/password-login') return new Response(JSON.stringify({ ok: true }), {
           headers: { 'content-type': 'application/json', 'set-cookie': 'hermes_service=session; Path=/; HttpOnly' },
         })
-        if (path === '/api/profiles') return Response.json({ profiles: [{ name: 'default' }] })
+        if (path === '/api/profiles') return Response.json({ profiles: [{ name: 'default', ui_meta: {'hermes-bots': {chat: 'native-canonical'}} }] })
         if (path === '/api/auth/ws-ticket') return Response.json({ ticket: 'fixture-ticket' })
         if (path === '/api/sessions/session-web') return Response.json(init?.method === 'PATCH' ? { ok: true } : { id: 'session-web', source: 'web' })
         return Response.json({ method: init?.method ?? 'GET' })
@@ -85,6 +85,12 @@ describe('native Hermes chat and history are separated on 15300', () => {
       canResume(profile: string, sessionID: string, jar?: unknown, owner?: string): Promise<boolean>
     }
     expect(await ownershipGate.canResume('default', 'session-web', undefined, owner)).toBe(false)
+    expect(await ownershipGate.canResume('default', 'native-canonical', undefined, owner)).toBe(false)
+    await agent.get('/api/app/hermes-bot/local/api/realtime/capabilities').set('Host', '127.0.0.1:15300').expect(200)
+    await request(runtime.app.callback()).get('/api/app/hermes-bot/local/api/realtime/capabilities').set('Host', '127.0.0.1:15300').expect(401)
+    await agent.post('/api/app/hermes-bot/local/api/profiles').set('Host', '127.0.0.1:15300')
+      .set('Origin', origin).set('X-CSRF-Token', csrf).send({}).expect(403)
+
     const deviceOwner = `device:${paired.device.id}`
     expect(await ownershipGate.canResume('default', 'session-web', undefined, deviceOwner)).toBe(false)
 

+ 14 - 1
tests/server/realtimeBroker.test.ts

@@ -25,7 +25,9 @@ async function fixture() {
     socket.on('message', raw => {
       const f = JSON.parse(raw.toString()); commands.push(f)
       if (f.method === 'prompt.submit' && dropPrompt) { socket.terminate(); return }
-      if (f.method === 'session.events.since') {
+      if (f.method === 'profiles.list') {
+        socket.send(JSON.stringify({id:f.id,result:{profiles:[{name:'default',ui_meta:{'hermes-bots':{chat:'stored-1'}}}]}}))
+      } else if (f.method === 'session.events.since') {
         socket.send(JSON.stringify({ id: f.id, result: { epoch: 'test', truncated: truncate,
           events: history.filter(e => e.seq > f.params.last_seen) } }))
       } else if (f.method === 'session.branch') {
@@ -63,6 +65,17 @@ describe('realtime broker', () => {
     expect(f.commands).toEqual([])
     expect(routes).toEqual([])
   })
+  it('allows only a native Bots configured chat without claiming arbitrary history', async () => {
+    const f = await fixture()
+    const principal = {...f.principal('native:user:alice:1'), nativeBot:true, canResume:async()=>false}
+    const channel = await f.broker.create(principal,'chat')
+    expect(await f.broker.command(channel,'native-canonical',resume)).toMatchObject({state:'confirmed'})
+    expect(await f.broker.command(channel,'wrong-profile',{...resume,params:{...resume.params,profile:'other'}}))
+      .toMatchObject({state:'rejected',response:{error:{code:'history_session_read_only'}}})
+    expect(await f.broker.command(channel,'arbitrary-history',{...resume,params:{...resume.params,session_id:'history-2'}}))
+      .toMatchObject({state:'rejected',response:{error:{code:'history_session_read_only'}}})
+    expect(f.commands.filter(c=>c.method==='session.resume')).toHaveLength(1)
+  })
   it('registers and resumes sessions only within one stable device owner', async () => {
     const f = await fixture()
     const owned = new Set<string>()

+ 99 - 0
tests/server/subaccountPermissions.test.ts

@@ -0,0 +1,99 @@
+// @vitest-environment node
+import { randomUUID } from 'node:crypto'
+import { mkdtempSync, rmSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import request from 'supertest'
+import { createApplication, type ApplicationRuntime } from '../../src/server/app'
+import { loadServerConfig } from '../../src/server/config'
+import { LocalAuthStore } from '../../src/server/localAuth'
+
+let runtime: ApplicationRuntime, home: string, admin: ReturnType<typeof request.agent>, child: ReturnType<typeof request.agent>
+let adminCSRF: string, childCSRF: string, childID: string, upstreamPaths: string[]
+const host = '127.0.0.1:15300'
+function call(client: ReturnType<typeof request.agent>, method: 'get' | 'post' | 'put' | 'patch' | 'delete', path: string, csrf = '') {
+  return client[method](path).set('Host', host).set('Origin', `http://${host}`).set('X-CSRF-Token', csrf)
+}
+async function signInChild(password = 'child-password') {
+  const boot = await call(child, 'get', '/api/app/bootstrap').expect(200)
+  const login = await call(child, 'post', '/api/app/login', boot.body.csrfToken).send({ username: 'child', password }).expect(200)
+  childCSRF = login.body.csrfToken
+}
+beforeEach(async () => {
+  home = mkdtempSync(join(tmpdir(), 'yaoyao-subaccount-')); upstreamPaths = []
+  const config = loadServerConfig({ HERMES_YAOYAO_HOME: home, HERMES_UPSTREAM_URL: 'http://127.0.0.1:1' })
+  runtime = createApplication({ config, fetchImpl: (async input => {
+    const path = new URL(String(input)).pathname; upstreamPaths.push(path)
+    if (path === '/api/status') return Response.json({ auth_required: true })
+    if (path === '/api/auth/me') return Response.json({ user_id: 'service' })
+    if (path === '/api/profiles') return Response.json({ profiles: [{ name: 'allowed' }, { name: 'private' }] })
+    return Response.json({ ok: true })
+  }) as typeof fetch })
+  vi.spyOn(runtime.workspaceRuntime, 'wake').mockImplementation(() => {})
+  admin = request.agent(runtime.app.callback()); child = request.agent(runtime.app.callback())
+  const boot = await call(admin, 'get', '/api/app/bootstrap').expect(200)
+  const setup = await call(admin, 'post', '/api/app/setup', boot.body.csrfToken).send({ username: 'owner', password: 'owner-password' }).expect(200)
+  adminCSRF = setup.body.csrfToken
+  const created = await call(admin, 'post', '/api/app/admin/users', adminCSRF).send({ username: 'child', password: 'temporary-password', assignedProfiles: ['allowed'] }).expect(201)
+  childID = created.body.id
+  await signInChild('temporary-password')
+  const changed = await call(child, 'put', '/api/app/account/credentials', childCSRF).send({ currentPassword: 'temporary-password', newPassword: 'child-password' }).expect(200)
+  childCSRF = changed.body.csrfToken
+})
+afterEach(() => { runtime.close(); rmSync(home, { recursive: true, force: true }); vi.restoreAllMocks() })
+
+describe('subaccount Bot-only security boundary with real login sessions', () => {
+  it('persists assignment, filters sources, hides native profiles and rejects unassigned/local-node spoofing', async () => {
+    expect((await call(child, 'get', '/api/app/bootstrap')).body.profiles).toEqual([])
+    expect((await call(child, 'get', '/api/profiles')).body.profiles).toEqual([])
+    const sources = await call(child, 'get', '/api/app/agents/sources').expect(200)
+    expect(sources.body.sources.map((s: any) => s.profile)).toEqual(['allowed'])
+    expect(new LocalAuthStore(home).canUseSource(childID, 'local', 'allowed')).toBe(true)
+    expect(new LocalAuthStore(home).canUseSource(childID, 'local', 'private')).toBe(false)
+    const caps = await call(child, 'get', '/api/app/capabilities').expect(200)
+    expect(caps.body.features).not.toContain('nodes')
+    await call(child, 'post', '/api/app/agents', childCSRF).send({ name: 'mine', profile: 'allowed' }).expect(201)
+    for (const input of [{ profile: 'private' }, { profile: 'allowed', nodeId: randomUUID() }, { profile: 'ALLOWED' }]) {
+      await call(child, 'post', '/api/app/agents', childCSRF).send({ name: 'forbidden', ...input }).expect(403)
+    }
+  })
+
+  it('denies native REST, realtime, relay, settings secrets and administration before any upstream request', async () => {
+    upstreamPaths.length = 0
+    for (const path of ['/Users/test/.hermes/cache/private.png', '/Users/test/.hermes/profiles/private/images/private.png', '/Users/test/Agents/private.txt', '/attachments/private.txt', '/api/sessions', '/api/profiles/sessions', '/api/profile-identities', '/api/files/download?path=/tmp/private', '/api/realtime/capabilities', '/api/app/hermes-bot/local/api/profiles', '/api/app/admin/users', '/api/app/system/push-config', '/api/app/voice/runtime', '/api/app/tts/settings', '/api/app/models', '/api/app/nodes', '/api/app/session-context/test']) {
+      await call(child, 'get', path).expect(403)
+    }
+    for (const path of ['/api/realtime/channels', '/api/app/nodes', '/api/app/agents/remote', '/api/app/agents/remote/', '/api/app/pairings', '/api/app/admin/users']) {
+      await call(child, 'post', path, childCSRF).send({ channel: 'chat' }).expect(403)
+    }
+    expect(upstreamPaths).toEqual([])
+  })
+
+  it('keeps agents, conversations, files, runs and interactions isolated by owner', async () => {
+    const a = await call(admin, 'post', '/api/app/agents', adminCSRF).send({ name: 'admin bot', profile: 'private' }).expect(201)
+    const c = (await call(admin, 'get', '/api/app/conversations')).body.conversations[0]
+    expect((await call(child, 'get', '/api/app/agents')).body.agents).toEqual([])
+    expect((await call(child, 'get', '/api/app/conversations')).body.conversations).toEqual([])
+    for (const path of [`/api/app/conversations/${c.id}`, `/api/app/conversations/${c.id}/messages`, `/api/app/files/${randomUUID()}/download`]) await call(child, 'get', path).expect(404)
+    await call(child, 'patch', `/api/app/agents/${a.body.agent.id}`, childCSRF).send({ name: 'stolen' }).expect(404)
+    await call(child, 'post', `/api/app/conversations/${c.id}/messages`, childCSRF).send({ requestId: randomUUID(), content: 'stolen' }).expect(404)
+    await call(child, 'post', `/api/app/runs/${randomUUID()}/reconcile`, childCSRF).send({}).expect(404)
+    await call(child, 'post', `/api/app/interactions/${randomUUID()}/respond`, childCSRF).send({ answer: 'approve' }).expect(404)
+  })
+
+  it('invalidates sessions and stops existing work when assignment is revoked, including old agent references', async () => {
+    const a = (await call(child, 'post', '/api/app/agents', childCSRF).send({ name: 'mine', profile: 'allowed' }).expect(201)).body.agent
+    const c = (await call(child, 'get', '/api/app/conversations')).body.conversations[0]
+    await call(child, 'post', `/api/app/conversations/${c.id}/messages`, childCSRF).send({ requestId: randomUUID(), content: 'allowed' }).expect(202)
+    const stop = vi.spyOn(runtime.workspaceRuntime, 'stopConversation')
+    await call(admin, 'patch', `/api/app/admin/users/${childID}`, adminCSRF).send({ assignedProfiles: [] }).expect(200)
+    expect(stop).toHaveBeenCalledWith(childID, c.id)
+    await call(child, 'get', '/api/app/agents').expect(401)
+    await signInChild()
+    expect((await call(child, 'get', '/api/app/agents/sources')).body.sources).toEqual([])
+    await call(child, 'post', `/api/app/conversations/${c.id}/messages`, childCSRF).send({ requestId: randomUUID(), content: 'revoked' }).expect(403)
+    await call(child, 'patch', `/api/app/agents/${a.id}`, childCSRF).send({ name: 'revoked' }).expect(403)
+    await call(child, 'post', '/api/app/conversations', childCSRF).send({ name: 'bypass', memberIds: [a.id], administratorId: a.id }).expect(403)
+  })
+})

+ 2 - 2
tests/server/workspace.test.ts

@@ -54,7 +54,7 @@ beforeEach(async () => {
       }),
     },
   }
-  nodes = { target: () => target } as unknown as WorkspaceNodes
+  nodes = { requireSource: () => {}, target: () => target } as unknown as WorkspaceNodes
   runtime = new WorkspaceRuntime(store, nodes, uploads)
   reply = (socket, p) =>
     setTimeout(
@@ -326,7 +326,7 @@ describe('Web-owned workspace', () => {
   it('persists a visible failure when a fixed member loses its source node',async()=>{
     const a=agent('不可用成员'),c=direct(a.id)
     runtime.close()
-    runtime=new WorkspaceRuntime(store,{target:()=>{throw new Error('基础节点不可用')}} as unknown as WorkspaceNodes,uploads)
+    runtime=new WorkspaceRuntime(store,{requireSource: () => {},target:()=>{throw new Error('基础节点不可用')}} as unknown as WorkspaceNodes,uploads)
     const run=runtime.send(owner,c.id,{requestId:randomUUID(),content:'执行任务'})
     await vi.waitFor(()=>expect(store.require<WorkspaceRun>(owner,'run',run.id).status).toBe('failed'))
     expect(store.messages(owner,c.id).at(-1)).toMatchObject({role:'system',status:'failed',content:'执行失败:基础节点不可用'})

+ 107 - 0
tests/server/workspacePairedNodes.test.ts

@@ -0,0 +1,107 @@
+// @vitest-environment node
+import { afterEach, describe, expect, it } from 'vitest'
+import { mkdtempSync, rmSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { WorkspaceNodes, WorkspaceGateway, type WorkspaceNode } from '../../src/server/workspaceGateway'
+import { parseWorkspacePairCode } from '../../src/server/workspacePairedNode'
+import { WorkspaceStore } from '../../src/server/workspaceStore'
+import { loadServerConfig } from '../../src/server/config'
+import { UpstreamClient } from '../../src/server/upstream'
+import { UpstreamServiceSession } from '../../src/server/localAuth'
+
+const remote = '11111111-1111-4111-8111-111111111111', device = '22222222-2222-4222-8222-222222222222',
+  pairing = '33333333-3333-4333-8333-333333333333', channel = '44444444-4444-4444-8444-444444444444', fingerprint = 'f'.repeat(64)
+const homes: string[] = [], stores: WorkspaceStore[] = [], clients: UpstreamClient[] = [], nodeStores: WorkspaceNodes[] = []
+afterEach(() => { nodeStores.forEach(n => n.close()); clients.forEach(c => c.close()); stores.forEach(s => s.close()); homes.forEach(h => rmSync(h, { recursive: true, force: true })); homes.length = stores.length = clients.length = nodeStores.length = 0 })
+function code() { const url = new URL('yaoyao://pair'); for (const [k,v] of Object.entries({v:'1', url:'http://192.168.1.10:15300/', node:remote, id:pairing, fingerprint, secret:'s'.repeat(64)})) url.searchParams.set(k,v); return url.href }
+function setup(localID?: string) {
+  const home = mkdtempSync(join(tmpdir(), 'workspace-child-')); homes.push(home)
+  const config = loadServerConfig({ HERMES_YAOYAO_HOME: home }), store = new WorkspaceStore(home); stores.push(store)
+  const seen: Array<{url: URL; headers: Headers; body: any}> = []
+  let mismatch = false, lostCommandResponse = false, remoteRules = 'remote rules v1'
+  const fetchImpl = (async (input: any, init: RequestInit = {}) => {
+    const url = new URL(String(input)), headers = new Headers(init.headers), body = init.body ? JSON.parse(String(init.body)) : undefined
+    seen.push({url,headers,body})
+    if (url.pathname === '/api/pair/v1/capabilities') return Response.json({protocolVersion:1,serviceType:'yaoyao-web',nodeId:remote,fingerprint:mismatch ? 'x'.repeat(64) : fingerprint})
+    if (url.pathname === '/api/pair/v1/claim') return Response.json({protocolVersion:1,serviceType:'yaoyao-web',nodeId:remote,fingerprint,deviceId:device,token:'delegated-token',scopes:['agents.read','history.read','sessions.execute'],serverUrl:`http://192.168.1.10:15300/node/${device}`})
+    expect(headers.get('authorization')).toBe('Bearer delegated-token')
+    expect(url.pathname).toContain(`/node/${device}/api/`)
+    if (url.pathname.endsWith('/api/workspace-agents')) return Response.json({agents:[{id:'66666666-6666-4666-8666-666666666666',name:'远端现成 Agent',avatar:'',instructions:remoteRules,nodeId:'local',profile:'remote-profile',archived:false,revision:1,createdAt:1,updatedAt:1}]})
+    if (url.pathname.endsWith('/profiles')) return Response.json({profiles:[{name:'remote-profile',display_name:'远端基础 Agent'}]})
+    if (url.pathname.endsWith('/channels')) return Response.json({id:channel})
+    if (url.pathname.endsWith('/events')) return new Response(new ReadableStream({start(controller) {
+      controller.enqueue(new TextEncoder().encode('id: epoch:1\nevent: frame\ndata: '+JSON.stringify({method:'event',params:{type:'gateway.ready'}})+'\n\n'))
+      init.signal?.addEventListener('abort', () => { try { controller.close() } catch {} })
+    }}), {headers:{'Content-Type':'text/event-stream'}})
+    if (url.pathname.endsWith('/commands') && lostCommandResponse) throw new Error('response lost after admission')
+    if (url.pathname.endsWith('/commands') || url.pathname.includes('/api/realtime/commands/')) return Response.json({state:'confirmed',response:{result:{session_id:'runtime-child',stored_session_id:'stored-child'}}})
+    return new Response(null,{status:204})
+  }) as typeof fetch
+  const client = new UpstreamClient(config.upstream, fetchImpl); clients.push(client)
+  const nodes = new WorkspaceNodes(store,config,{url:config.upstream,client,session:new UpstreamServiceSession(client,()=>undefined)},localID); nodeStores.push(nodes)
+  return {home,store,nodes,seen,mismatch:()=>{mismatch=true},loseResponse:()=>{lostCommandResponse=true},changeRemoteRules:()=>{remoteRules='remote rules v2'}}
+}
+describe('workspace 15300 child nodes', () => {
+  it('rejects login codes and duplicate identity fields', () => {
+    expect(()=>parseWorkspacePairCode(code().replace('://pair','://login'))).toThrow()
+    expect(()=>parseWorkspacePairCode(code()+'&node='+remote)).toThrow()
+  })
+  it('pairs only under the owner, encrypts credentials, preserves agent binding when changing IP, and executes via HTTP/SSE', async () => {
+    const t=setup(); await t.nodes.pair('parent-user',{qrPayload:code(),name:'远端'})
+    const node=t.store.list<WorkspaceNode>('parent-user','node')[0]!
+    expect(node.transport).toBe('paired-web'); expect(node.url).toContain(':15300')
+    expect(node.secret).not.toContain('delegated-token'); expect(t.store.list('other-user','node')).toEqual([])
+    expect(()=>t.nodes.target('other-user',node.id)).toThrow()
+    const agent=t.store.createAgent('parent-user',{name:'策划',profile:'remote-profile',nodeId:node.id,instructions:''})
+    await t.nodes.update('parent-user',node.id,{name:'新名字',url:'http://192.168.1.20:15300'})
+    expect(t.store.require<any>('parent-user','agent',agent.id).nodeId).toBe(node.id)
+    const target=t.nodes.target('parent-user',node.id)
+    expect(target.url.hostname).toBe('192.168.1.20')
+    const profiles=await target.session.request('/api/profiles'); expect(profiles.status).toBe(200)
+    const gateway=new WorkspaceGateway(target)
+    await gateway.connect()
+    expect(await gateway.rpc('session.create',{profile:'remote-profile'})).toMatchObject({stored_session_id:'stored-child'})
+    gateway.close()
+    const command=t.seen.find(r=>r.url.pathname.endsWith('/commands'))!
+    expect(command.headers.get('idempotency-key')).toBeTruthy()
+    expect(command.body.method).toBe('session.create')
+    expect(t.seen.every(r=>r.url.port==='15300')).toBe(true)
+    expect(t.seen.some(r=>r.url.pathname.includes('/auth/password-login'))).toBe(false)
+  })
+  it('recovers a lost command response by receipt without resubmitting the command', async () => {
+    const t=setup(); await t.nodes.pair('owner',{qrPayload:code(),name:'child'})
+    const id=t.store.list<WorkspaceNode>('owner','node')[0]!.id
+    const gateway=new WorkspaceGateway(t.nodes.target('owner',id)); await gateway.connect(); t.loseResponse()
+    expect(await gateway.rpc('session.create',{profile:'remote-profile'})).toMatchObject({stored_session_id:'stored-child'})
+    gateway.close()
+    expect(t.seen.filter(r=>r.url.pathname.endsWith('/commands'))).toHaveLength(1)
+    expect(t.seen.filter(r=>r.url.pathname.includes('/api/realtime/commands/'))).toHaveLength(1)
+  })
+  it('rejects self-pairing before redeeming a code', async () => {
+    const t=setup(remote)
+    await expect(t.nodes.pair('owner',{qrPayload:code(),name:'self'})).rejects.toThrow('自己')
+    expect(t.seen).toEqual([])
+  })
+  it('imports a read-only reference without duplicates and refreshes remote configuration', async () => {
+    const t=setup();await t.nodes.pair('owner',{qrPayload:code(),name:'child'})
+    const node=t.store.list<WorkspaceNode>('owner','node')[0]!, remote='66666666-6666-4666-8666-666666666666'
+    const agent=await t.nodes.importRemoteAgent('owner',node.id,remote)
+    expect(agent.remoteAgentId).toBe(remote);expect(agent.nodeId).toBe(node.id)
+    expect(()=>t.store.updateAgent('owner',agent.id,{instructions:'local edit'})).toThrow('远端')
+    expect((await t.nodes.importRemoteAgent('owner',node.id,remote)).id).toBe(agent.id)
+    t.changeRemoteRules();await t.nodes.remoteAgents('owner',node.id,true)
+    expect((await t.nodes.refreshRemoteAgent('owner',agent)).instructions).toBe('remote rules v2')
+    t.store.updateAgent('owner',agent.id,{archived:true})
+    expect((await t.nodes.importRemoteAgent('owner',node.id,remote)).archived).toBe(false)
+    expect(t.store.list('owner','agent')).toHaveLength(1)
+    expect(t.nodes.targetForAgent('owner',agent).url.pathname).toContain(`/api/workspace-agents/${remote}/gateway`)
+  })
+  it('checks identity at the new address before sending the saved bearer and leaves state unchanged on mismatch', async () => {
+    const t=setup(); await t.nodes.pair('owner',{qrPayload:code(),name:'远端'})
+    const node=t.store.list<WorkspaceNode>('owner','node')[0]!; t.seen.length=0; t.mismatch()
+    await expect(t.nodes.update('owner',node.id,{name:'错误目标',url:'http://192.168.1.99:15300'})).rejects.toThrow('身份')
+    expect(t.store.require<WorkspaceNode>('owner','node',node.id).url).toBe(node.url)
+    expect(t.seen).toHaveLength(1); expect(t.seen[0]!.headers.has('authorization')).toBe(false)
+  })
+})

+ 105 - 0
tests/server/workspaceRemoteAgents.test.ts

@@ -0,0 +1,105 @@
+// @vitest-environment node
+import { mkdtempSync, rmSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { randomUUID } from 'node:crypto'
+import { WebSocketServer } from 'ws'
+import request from 'supertest'
+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
+import { createApplication, type ApplicationRuntime } from '../../src/server/app'
+import { loadServerConfig } from '../../src/server/config'
+import { LocalAuthStore } from '../../src/server/localAuth'
+let runtime: ApplicationRuntime, home:string, ws:WebSocketServer, frames:any[], forwarded:any[]
+class Auth extends LocalAuthStore { override isUserActive(id:string) { return ['owner','other'].includes(id) } }
+beforeEach(async()=>{
+  home=mkdtempSync(join(tmpdir(),'remote-agent-export-'));frames=[];forwarded=[]
+  ws=new WebSocketServer({port:0,host:'127.0.0.1'});await new Promise<void>(resolve=>ws.once('listening',resolve))
+  ws.on('connection',socket=>{
+    socket.send(JSON.stringify({method:'event',params:{type:'gateway.ready'}}))
+    socket.on('message',raw=>{
+      const frame=JSON.parse(String(raw));frames.push(frame)
+      const result=['session.create','session.resume'].includes(frame.method) ? {session_id:randomUUID(),stored_session_id:frame.params.session_id ?? randomUUID(),info:{profile_name:frame.params.profile}} : {ok:true}
+      socket.send(JSON.stringify({id:frame.id,result}))
+    })
+  })
+  const config=loadServerConfig({HERMES_YAOYAO_HOME:home,HERMES_YAOYAO_UPSTREAM:`http://127.0.0.1:${(ws.address() as any).port}`})
+  runtime=createApplication({config,auth:new Auth(home),fetchImpl:(async(input,init={})=>{
+    const url=new URL(String(input))
+    if(url.hostname==='nested.test') {
+      forwarded.push({path:url.pathname,headers:new Headers(init.headers),body:init.body?JSON.parse(String(init.body)):undefined})
+      return Response.json(url.pathname.endsWith('/channels') ? {id:'55555555-5555-4555-8555-555555555555'} : {state:'confirmed',response:{result:{session_id:'runtime',stored_session_id:'stored'}}},{status:url.pathname.endsWith('/channels')?201:200})
+    }
+    if(url.pathname==='/api/auth/ws-ticket')return Response.json({ticket:'test-ticket'})
+    return Response.json({messages:[],profiles:[{name:'remote-profile'}]})
+  }) as typeof fetch})
+})
+afterEach(async()=>{
+  runtime.close();for(const client of ws.clients)client.terminate()
+  await new Promise<void>(resolve=>ws.close(()=>resolve()));rmSync(home,{recursive:true,force:true})
+})
+function pair(owner?:string) {
+  const code=runtime.pairings.create('delegated-native-cookie=test',undefined,owner)
+  return runtime.pairings.claim({pairingID:code.id,secret:code.secret,deviceName:'parent'})
+}
+function call(grant:ReturnType<typeof pair>,method:'get'|'post'|'patch',path:string) {
+  return request(runtime.app.callback())[method](`/node/${grant.device.id}/api/workspace-agents${path}`)
+    .set('Host','127.0.0.1:15300').set('Authorization',`Bearer ${grant.token}`)
+}
+describe('referenced remote Bot Agents',()=>{
+  it('lists only the QR issuing account and requires renewed authorization for legacy grants',async()=>{
+    const agent=runtime.workspace.createAgent('owner',{name:'我的 Agent',profile:'remote-profile'})
+    runtime.workspace.createAgent('other',{name:'其他账号私有 Agent',profile:'remote-profile'})
+    const grant=pair('owner')
+    expect((await call(grant,'get','').expect(200)).body.agents.map((a:any)=>a.id)).toEqual([agent.id])
+    await call(pair(),'get','').expect(403)
+    await call(grant,'patch',`/${agent.id}`).send({instructions:'overwrite'}).expect(405)
+    runtime.pairings.revoke(grant.device.id)
+    await call(grant,'get','').expect(401)
+  })
+  it('keeps the same delegated identity when renewing the QR grant',()=>{
+    const old=pair()
+    const qr=runtime.pairings.create('new-cookie=test',undefined,'owner')
+    const renewed=runtime.pairings.claim({pairingID:qr.id,secret:qr.secret,deviceName:'parent',existingDeviceID:old.device.id,existingToken:old.token})
+    expect(renewed.device.id).toBe(old.device.id);expect(renewed.token).toBe(old.token)
+    expect(runtime.pairings.workspaceOwner(old.device.id,old.token,'agents.read')).toBe('owner')
+  })
+  it('executes using the remote profile and live remote rules, and isolates other Agent channels/history',async()=>{
+    const agent=runtime.workspace.createAgent('owner',{name:'远端策划',profile:'remote-profile',instructions:'REMOTE RULE V1'})
+    const other=runtime.workspace.createAgent('owner',{name:'另一 Agent',profile:'other-profile'})
+    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
+    async function rpc(method:string,params:any) {
+      return call(grant,'post',`${base}/channels/${channel}/commands`).set('Idempotency-Key',randomUUID()).send({jsonrpc:'2.0',method,params})
+    }
+    const opened=await rpc('session.create',{profile:'wrong-profile',model:'overwrite',cwd:'/different',fast:true,reasoning_effort:'high',messages:[{role:'system',content:'override'}]})
+    expect(opened.status).toBe(200)
+    const result=opened.body.response.result
+    expect(frames.at(-1).params.profile).toBe('remote-profile');expect(frames.at(-1).params.model).toBeUndefined();expect(frames.at(-1).params.cwd).toBe('');expect(frames.at(-1).params.fast).toBeUndefined();expect(frames.at(-1).params.messages).toBeUndefined()
+    expect((await rpc('prompt.submit',{session_id:result.session_id,text:'question'})).status).toBe(200)
+    expect(frames.at(-1).params.text).toContain('REMOTE RULE V1')
+    runtime.workspace.updateAgent('owner',agent.id,{instructions:'REMOTE RULE V2'})
+    expect((await rpc('prompt.submit',{session_id:result.session_id,text:'next question'})).status).toBe(200)
+    expect(frames.at(-1).params.text).toContain('REMOTE RULE V2');expect(frames.at(-1).params.text).not.toContain('REMOTE RULE V1')
+    expect((await rpc('profiles.configure',{profile:'remote-profile'})).status).toBe(403)
+    await call(grant,'get',`/${other.id}/gateway/api/realtime/channels/${channel}/events`).expect(403)
+    await call(grant,'get',`/${agent.id}/gateway/api/sessions/foreign/messages`).expect(403)
+    await call(grant,'get',`/${agent.id}/gateway/api/sessions/${result.stored_session_id}/messages`).expect(200)
+    const next=(await call(grant,'post',`${base}/channels`).send({channel:'chat'}).expect(201)).body.id
+    await call(grant,'post',`${base}/channels/${next}/commands`).set('Idempotency-Key',randomUUID())
+      .send({jsonrpc:'2.0',method:'session.resume',params:{session_id:result.stored_session_id,profile:'wrong'}}).expect(200)
+    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()=>{
+    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')
+  })
+})

+ 24 - 2
tests/server/workspaceRoutes.test.ts

@@ -30,6 +30,7 @@ class TestAuth extends LocalAuthStore {
   override requireAdmin(ctx: Koa.Context) {
     return this.require(ctx)
   }
+  override canUseSource() { return true }
   override currentFromCookieHeader() {
     return first
   }
@@ -61,11 +62,15 @@ beforeEach(async () => {
       const path = new URL(String(input)).pathname
       upstream.push(path)
       const body =
-        path === '/api/status'
+        path === '/api/pair/v1/capabilities'
+          ? {protocolVersion:1,serviceType:'yaoyao-web',nodeId:'11111111-1111-4111-8111-111111111111',fingerprint:'f'.repeat(64)}
+          : path === '/api/pair/v1/claim'
+          ? {protocolVersion:1,serviceType:'yaoyao-web',nodeId:'11111111-1111-4111-8111-111111111111',fingerprint:'f'.repeat(64),deviceId:'22222222-2222-4222-8222-222222222222',token:'delegated-token',scopes:['agents.read','sessions.execute','history.read'],serverUrl:'http://child.test:15300/node/22222222-2222-4222-8222-222222222222'}
+          : path === '/api/status'
           ? { auth_required: true }
           : path === '/api/auth/me'
             ? { user_id: 'upstream' }
-            : path === '/api/profiles'
+            : path.endsWith('/api/profiles')
               ? { profiles: [{ name: 'default', display_name: '基础 Agent' }] }
               : { ok: true }
       return new Response(JSON.stringify(body), {
@@ -86,6 +91,23 @@ afterEach(() => {
   rmSync(home, { recursive: true, force: true })
 })
 describe('application workspace HTTP contract', () => {
+  it('accepts only child pairing, hides credentials, and scopes address edits to the owner', async () => {
+    const code = new URL('yaoyao://pair')
+    for (const [key,value] of Object.entries({v:'1',url:'http://child.test:15300',node:'11111111-1111-4111-8111-111111111111',id:'33333333-3333-4333-8333-333333333333',fingerprint:'f'.repeat(64),secret:'s'.repeat(64)})) code.searchParams.set(key,value)
+    await req('post','/api/app/nodes').send({name:'legacy',url:'http://child.test:9119',username:'user',password:'password'}).expect(400)
+    await req('post','/api/app/nodes').send({name:'child',qrPayload:code.href.replace('://pair','://login')}).expect(400)
+    await req('post','/api/app/nodes').send({name:'child',qrPayload:code.href}).expect(201)
+    const listing=await req('get','/api/app/nodes').expect(200), node=listing.body.nodes[0]
+    expect(node.transport).toBe('paired-web'); expect(JSON.stringify(listing.body)).not.toContain('delegated-token'); expect(node.secret).toBeUndefined()
+    expect((await req('get','/api/app/nodes','second').expect(200)).body.nodes).toEqual([])
+    await req('patch',`/api/app/nodes/${node.id}`,'second').send({name:'other',url:'http://new-ip.test:15300'}).expect(404)
+    await req('patch',`/api/app/nodes/${node.id}`).send({name:'new name',url:'http://new-ip.test:15300'}).expect(200)
+    const updated=(await req('get','/api/app/nodes').expect(200)).body.nodes[0]
+    expect(updated.id).toBe(node.id); expect(updated.url).toBe('http://new-ip.test:15300/')
+    const sources=(await req('get','/api/app/agents/sources').expect(200)).body.sources
+    expect(sources.some((s:any)=>s.nodeId===node.id && s.profile==='default')).toBe(true)
+  })
+
   it('archives native-chat uploads through the standard file API with user ownership', async () => {
     const assets = new WorkspaceAssets(runtime.workspace, runtime.workspaceRuntime.nodes, home)
     await assets.archiveText(