Procházet zdrojové kódy

支持 Bot Agent 按授权自主创建成员和团队任务

LSamien před 1 měsícem
rodič
revize
ac62d07db7

+ 68 - 0
docs/bot-agent-teams.md

@@ -0,0 +1,68 @@
+# Bot Agent 自主组建团队
+
+分支:`feat/bot-agent-teams`。配套 iOS 使用同名分支;该功能没有修改基础 Hermes Profile。
+
+## 使用
+
+在 Bot 模式创建或编辑 Agent,打开「允许组建团队」并保存。例如给它发送:
+
+> 帮我完成项目调研,需要哪些成员就先查看已有 Agent,缺少时再创建。组建团队、启动任务,并告诉我到哪个团队查看结果。
+
+Agent 可以查看可用基础 Agent、复用已有成员、创建持久成员和团队、启动独立团队任务、读取进展和结果。新团队自动加入发起者并将其设为管理员,随后使用已有的 @分派和管理员复核机制。成员、团队和任务写入当前账号的 `workspace.sqlite3`,发布既有变更事件供 Web/iOS 同步。
+
+启动任务返回「已启动,尚未完成」。工作在新团队的任务内继续,结果在那里显示;发起者可使用查询工具读取结果。并未新增回到原单聊的自动回传机制,也不会把队列完成当成用户目标已经达成。
+
+## 运行条件
+
+- Web 服务和发起者的 Hermes 必须在同一机器/网络命名空间,以回环地址连接。远程节点和远端引用 Agent 可作为成员,不能开启发起组队权限。
+- 发起者的基础 Profile 需安装并启用既有 `yaoyao-bot-bridge` 工具桥,能力响应必须包含 `version: 1`、`ready: true`、`in_process: true`、`native_tools: true`。插件由 Hermes Bot 提供;按其安装流程部署并重新加载,Named Profile 需要各自安装、启用。
+- 服务端在首次授予权限及每轮执行前检查条件。不满足时返回具体错误,普通 Agent 对话仍不查询工具桥。
+- 先部署 Web 分支,再使用配套 iOS 分支。现有记录缺少 `canManageTeam` 时按关闭处理;新建成员默认关闭。
+
+## 工具与权限
+
+| 工具 | 作用 |
+| --- | --- |
+| `workspace_list_sources` | 当前账号获准的基础 Profile |
+| `workspace_list_agents` | 当前账号可使用的已有成员 |
+| `workspace_list_teams` | 发起者担任管理员的团队 |
+| `workspace_create_agent` | 创建成员,禁止授予组队权限 |
+| `workspace_create_team` | 创建管理员协作模式的团队 |
+| `workspace_start_team_task` | 在发起者管理的团队启动任务 |
+| `workspace_get_team_task` | 读取状态、最近 20 条消息和待处理问题 |
+
+所有变更工具要求 `requestId`(UUID);重试同一操作必须复用它。服务端持久保存回执;相同编号用于不同参数返回冲突,重复请求不会重复创建。原生工具调用 ID 也在本轮去重。
+
+账号和调用 Agent 从正在运行的服务端轮次取得,模型参数不能指定 owner、token 或调用身份。创建成员只能引用当前账号获准的基础 Profile;子账号的分配规则保持有效。只有团队管理员可以启动或读取该团队的任务,禁止从当前团队任务递归启动自身任务。
+
+每轮使用独立随机凭据、独立原生工具 ID 和回环监听端口,凭据仅传给经过认证的工具桥绑定接口。权限、账号、Profile、成员归属、归档和停止状态在每次调用时校验,异步操作在写入前再次校验。绑定保留 Agent 的配置版本;关闭后重新开启权限也不能使旧轮次重新生效。完成、取消、连接结束或服务关闭时销毁监听器并解绑;最长 30 分钟,运行时每 5 分钟续租。断线恢复只核对原执行,不重新提交已有工具副作用;下一轮重新授权。
+
+自动组队的范围:每队 2–8 人,最多管理 20 个有效团队,账号最多保留 100 个有效 Agent,默认协作 6 轮、可设置 1–12 轮,每轮最多 128 次团队工具调用。运行并发沿用现有调度器。手工编辑团队仍走原有界面和接口。
+
+## 验证
+
+```sh
+npm run typecheck
+npm test
+npm run test:e2e:team-tools
+```
+
+`tests/server/workspaceTeamTools.test.ts` 验证创建、持久去重、账号隔离、Profile 撤销、配置版本失效、停止、回环认证、不同轮次的工具 ID/凭据隔离、过期和取消绑定竞态。
+
+真实 Hermes 协议验收使用隔离测试实例(不调用付费模型、不访问用户会话):
+
+1. 使用 Hermes 的 Python,运行 Hermes Bot 仓库内 `integrations/hermes-bots-bridge/tests/hermes_wire_fixture.py /path/to/hermes-agent`。
+2. 将它打印的回环 URL 设为 `HERMES_TEAM_WIRE_URL`,运行 `npm test -- tests/live/workspaceTeamTools.live.test.ts`。
+
+此测试必须校验实例的专用 fixture 标记,实际经过 HTTP 登录、WebSocket 会话、插件原生工具注册、回环工具调用、持久成员/团队/任务和权限撤销。未设置 URL 时跳过该项;模型决策使用确定脚本,因此不代表真实模型在任意任务下的自主组队质量。
+
+### 2026-09-08 分支验收
+
+- 类型检查及生产构建通过。
+- 完整 Vitest:98 个文件、649 项通过;上述需显式指定实例的用例默认跳过,并已单独执行通过。
+- 真实 Hermes 隔离实例通过创建、重试去重、团队任务、撤销和两个发起者同时调用的验证,实际注册的原生函数名称互不冲突。
+- Playwright:新开关保存/重载/撤销 1 项、原有成员/团队及子账号权限回归 2 项通过。检查了桌面浅色与 375px 手机宽度深色截图。
+- iOS:24 项 WorkspaceChatTests 通过;开关保存往返在普通显示与深色大字体下均通过模拟器验收。
+- 首次全量并发运行有 1 项既有语音配置用例失败;聚焦重跑及最终全量运行均通过,未修改语音功能。
+
+所有结果来自分支代码和隔离数据。没有部署正式 Web、调用付费模型、安装实体手机或合并主分支。

+ 2 - 1
package.json

@@ -23,7 +23,8 @@
     "brand:verify": "node scripts/verify-brand.mjs",
     "release:verify": "node scripts/verify-release.mjs",
     "verify:lifecycle": "node scripts/verify-lifecycle.mjs",
-    "test:e2e:workspace": "npm run build && playwright test -c playwright.workspace.config.ts"
+    "test:e2e:workspace": "npm run build && playwright test -c playwright.workspace.config.ts",
+    "test:e2e:team-tools": "npm run build && playwright test -c playwright.team-tools.config.ts"
   },
   "dependencies": {
     "@koa/bodyparser": "^5.0.0",

+ 10 - 0
playwright.team-tools.config.ts

@@ -0,0 +1,10 @@
+import { defineConfig, devices } from '@playwright/test'
+export default defineConfig({
+  testDir: './tests/e2e', testMatch: 'team-tools.spec.ts', workers: 1, timeout: 45_000, reporter: 'list',
+  use: { baseURL: 'http://127.0.0.1:18808', trace: 'retain-on-failure', screenshot: 'only-on-failure' },
+  projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
+  webServer: {
+    command: 'WORKSPACE_FIXTURE_TEAM_TOOLS=1 WORKSPACE_FIXTURE_PORT=18808 WORKSPACE_FIXTURE_UPSTREAM_PORT=19128 node --import tsx tests/fixtures/workspace-server.ts',
+    url: 'http://127.0.0.1:18808/healthz', reuseExistingServer: false, timeout: 20_000,
+  },
+})

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

@@ -134,6 +134,7 @@ const form = reactive({
   name: '',
   avatar: '',
   instructions: '',
+  canManageTeam: false,
   source: '',
   memberIds: [] as string[],
   memberRoles: {} as Record<string, WorkspaceMemberRole>,
@@ -296,6 +297,7 @@ async function openDialog(kind: NonNullable<typeof dialog.value>) {
     name: '',
     avatar: kind === 'agent' ? encodeAgentAvatar(randomAgentIdentity('new-agent', 'Agent')) : '',
     instructions: '',
+    canManageTeam: false,
     source: '',
     memberIds: [],
     memberRoles: {},
@@ -352,7 +354,7 @@ async function save() {
   busy.value = true
   error.value = ''
   try {
-    const fields = { name: form.name, ...(isAgentDialog.value ? { avatar: form.avatar } : {}), instructions: form.instructions }
+    const fields = { name: form.name, ...(isAgentDialog.value ? { avatar: form.avatar, canManageTeam: form.canManageTeam } : {}), instructions: form.instructions }
     if (dialog.value === 'agent') {
       const [nodeId, profile] = JSON.parse(form.source)
       const result = await apiRequest<{ agent: Agent }>('/api/app/agents', {
@@ -648,6 +650,10 @@ onBeforeUnmount(() => {
             "
           />
         </label>
+        <label v-if="isAgentDialog && !isRemoteAgent" class="team-management-permission">
+          <span><input v-model="form.canManageTeam" type="checkbox" aria-label="允许组建团队" aria-describedby="team-management-help" />允许组建团队</span>
+          <small id="team-management-help">允许自主创建成员、组建团队并启动任务。仅使用当前账号获准的基础 Agent,新成员默认不获得此权限。发起者需使用与 Web 同机且已启用工具桥的基础 Agent。</small>
+        </label>
         <button v-if="!isAgentDialog && !auth.isBotOnly" type="button" @click="remotePickerOpen = true">添加远程 Agent</button>
         <fieldset v-if="selectedPreset" class="preset-role-mapping">
           <legend>角色分配</legend>
@@ -759,6 +765,16 @@ onBeforeUnmount(() => {
   margin: 15px 0;
   font-size: 13px;
 }
+.team-management-permission > span {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  min-height: 44px;
+}
+.team-management-permission small {
+  color: var(--text-muted);
+  line-height: 1.6;
+}
 .editor input:not([type='checkbox']),
 .editor textarea,
 .editor select,

+ 3 - 0
src/server/app.ts

@@ -206,6 +206,9 @@ export function createApplication(options: ApplicationOptions = {}): Application
   const workspaceNodes = new WorkspaceNodes(workspace, config, { url: config.upstream, client: upstream, session: upstreamSession }, pairings.nodeID)
   workspaceNodes.sourceAllowed = (owner, nodeId, profile) => auth.canUseSource(owner, nodeId, profile)
   const workspaceRuntime = new WorkspaceRuntime(workspace, workspaceNodes, uploads, owner => auth.isUserActive(owner))
+  workspaceRuntime.onTeamCreated = (owner, team) => {
+    try { push.setGroupSubscription(owner, team.id, true, team.lastSeq) } catch { /* Optional notifications do not undo a team. */ }
+  }
   const workspaceAssets = new WorkspaceAssets(workspace, workspaceNodes, config.home)
   workspaceRuntime.onMessage = (owner, message) => workspaceAssets.archive(owner, message)
   workspaceRuntime.onNotify = (owner, c, run, message, interaction) => {

+ 8 - 3
src/server/workspaceRoutes.ts

@@ -62,6 +62,7 @@ export function workspaceRouter(
         'conversations',
         'conversationTasks',
         'editableGroups',
+        'agentTeamManagement',
         'files',
         'voice',
         'context',
@@ -95,12 +96,16 @@ export function workspaceRouter(
     if (!sources.sources.some((s) => s.nodeId === input.nodeId && s.profile === input.profile))
       throw new HttpError(409, '基础 Agent 当前不可用', 'source_unavailable')
     nodes.requireSource(user, input)
+    if (input.canManageTeam) await runtime.teamTools.requireAvailable(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.patch('/api/app/agents/:id', async (ctx) => {
+    const user = owner(ctx), agent = store.require<WorkspaceAgent>(user, 'agent', ctx.params.id), input = body(ctx)
+    nodes.requireSource(user, agent)
+    if (input.canManageTeam === true && agent.canManageTeam !== true) await runtime.teamTools.requireAvailable(user, agent)
+    nodes.requireSource(user, agent)
+    ctx.body = { agent: store.agentSummary(store.updateAgent(user, ctx.params.id, input)) }
   })
   router.get('/api/app/conversations', (ctx) => {
     const user = owner(ctx)

+ 30 - 1
src/server/workspaceRuntime.ts

@@ -16,6 +16,8 @@ import type {
 
 import { WorkspaceScheduler, type Work, NO_REPLY, HOST_FALLBACK } from './workspaceScheduler.js'
 import { mentionedAgents } from './workspaceMentions.js'
+import { WorkspaceTeamTools, TEAM_TOOL_RULES } from './workspaceTeamTools.js'
+import { createWorkspaceToolLease, type WorkspaceToolLease } from './workspaceToolLease.js'
 export { mentionedAgents } from './workspaceMentions.js'
 type Run = WorkspaceRun
 
@@ -54,7 +56,12 @@ export const sendInput = z
   .refine((b) => b.content.trim() || b.fileIds.length, '请输入消息或添加附件')
 export class WorkspaceRuntime extends WorkspaceScheduler {
   private live = new Map<string, LiveTurn>()
-  constructor(store: WorkspaceStore, readonly nodes: WorkspaceNodes, readonly uploads: UploadStore, userActive: (owner: string) => boolean = () => true) { super(store, userActive) }
+  readonly teamTools: WorkspaceTeamTools
+  onTeamCreated: (owner: string, team: Conversation) => void = () => {}
+  constructor(store: WorkspaceStore, readonly nodes: WorkspaceNodes, readonly uploads: UploadStore, userActive: (owner: string) => boolean = () => true) {
+    super(store, userActive)
+    this.teamTools = new WorkspaceTeamTools(store, nodes, this)
+  }
   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)
@@ -180,6 +187,8 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
       runtimeId = '',
       lastFlush = 0
     let flushTimer: ReturnType<typeof setTimeout> | undefined
+    const toolController = new AbortController()
+    let toolLease: WorkspaceToolLease | undefined
     let resolveTurn!: (m: Message) => void, rejectTurn!: (e: Error) => void
     const completion = new Promise<Message>((resolve, reject) => {
       resolveTurn = resolve
@@ -190,6 +199,8 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
     const finish = (error?: Error) => {
       if (settled) return
       settled = true
+      toolController.abort()
+      void toolLease?.dispose()
       clearTimeout(flushTimer)
       flushTimer = undefined
       this.live.delete(key)
@@ -466,6 +477,23 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
       } else {
         if (opened.running) throw new HttpError(409, '上游会话仍在运行', 'session_busy')
         this.nodes.requireSource(owner, agent)
+        // Only explicit grants mount the bridge; ordinary Bot turns never query plugins.
+        if (agent.canManageTeam === true) {
+          const granted = this.getWork(owner, run.id)
+          granted.teamManagementRevision = agent.revision
+          this.saveWork(owner, granted)
+          await this.teamTools.requireAvailable(owner, agent)
+          toolLease = await createWorkspaceToolLease({
+            target, profile: agent.profile, workId: run.id, signal: toolController.signal,
+            session: () => ({ runtimeId, storedId: binding!.storedId }),
+            assertActive: () => { if (settled || this.closing) throw new Error('运行已停止'); this.teamTools.assertTurn(owner, run.id) },
+            catalog: () => this.teamTools.catalog(owner, run.id),
+            call: (toolId, args) => this.teamTools.call(owner, run.id, toolId, args),
+            onFailure: error => { if (!settled) void gateway.rpc('session.interrupt', { session_id: runtimeId }).catch(() => {}).finally(() => finish(error)) },
+          })
+          await toolLease.bind()
+          if (settled) return await completion
+        }
         await applyWorkingDirectory((method, params) => gateway.rpc(method, params), runtimeId, cwd, undefined, opened.info?.cwd)
         const trigger = this.store.require<Message>(owner, 'message', run.messageId)
         const attachmentRefs: string[] = []
@@ -500,6 +528,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
           run.requiredReply ? '你必须公开处理本次消息,直接回答、委派或澄清;禁止静默。管理员可按依赖一次 @一人,也可同时 @多人并行执行,整批结束后系统统一交回复核。' : run.replyMode === 'automatic' ? `你按自动参与配置收到消息。若与职责无关,禁止调用工具、禁止 @,完整答复只能是 ${NO_REPLY}。有关时正常回答。` : '',
           `本轮用户指定成员:${this.store.require<Run>(owner, 'run', run.runId).mentionIds.map(id => members.find(a => a.id === id)).filter(Boolean).map(a => '@' + a!.name).join('、') || '未指定'}`,
           '角色规则不赋予额外工具权限;仍遵守基础 Hermes 的工具和安全约束。',
+          toolLease ? TEAM_TOOL_RULES : '',
           `[yaoyao-run:${run.runId}:${resultMessage.id}]`,
         ]
           .filter(Boolean)

+ 2 - 0
src/server/workspaceScheduler.ts

@@ -23,6 +23,8 @@ export interface Work {
   status: Run['status']
   currentMessageId?: string
   submitted?: boolean
+  /** Revision captured when this turn receives a team-management grant. */
+  teamManagementRevision?: number
   cancelRequested?: boolean
   planned?: boolean
   silent?: boolean

+ 2 - 0
src/server/workspaceStore.ts

@@ -35,6 +35,7 @@ export const agentInput = z
     name,
     avatar: avatar.default(''),
     instructions: z.string().max(24_000).default(''),
+    canManageTeam: z.boolean().default(false),
     nodeId: z.string().default('local'),
     profile: z.string().min(1).max(256),
   })
@@ -44,6 +45,7 @@ export const agentPatch = z
     name: name.optional(),
     avatar: avatar.optional(),
     instructions: z.string().max(24_000).optional(),
+    canManageTeam: z.boolean().optional(),
     archived: z.boolean().optional(),
   })
   .strict()

+ 173 - 0
src/server/workspaceTeamTools.ts

@@ -0,0 +1,173 @@
+import { randomUUID } from 'node:crypto'
+import { z } from 'zod'
+import { HttpError } from './errors.js'
+import { agentInput, parse, type WorkspaceStore } from './workspaceStore.js'
+import type { WorkspaceNodes } from './workspaceGateway.js'
+import type { WorkspaceRuntime } from './workspaceRuntime.js'
+import type { Work } from './workspaceScheduler.js'
+import type { WorkspaceAgent as Agent, WorkspaceConversation as Conversation, WorkspaceRun as Run, WorkspaceInteraction } from '../shared/workspace.js'
+import { requireTeamToolBridge } from './workspaceToolLease.js'
+
+const uuid = z.string().uuid()
+const empty = z.object({}).strict()
+const createAgent = agentInput.omit({ canManageTeam: true }).extend({ requestId: uuid }).strict()
+const createTeam = z.object({
+  requestId: uuid,
+  name: z.string().trim().min(1).max(100),
+  memberIds: z.array(uuid).min(1).max(8),
+  instructions: z.string().max(24_000).default(''),
+  maxReplyRounds: z.number().int().min(1).max(12).default(6),
+}).strict()
+const startTask = z.object({
+  requestId: uuid,
+  teamId: uuid,
+  title: z.string().trim().min(1).max(100),
+  content: z.string().trim().min(1).max(32_000),
+}).strict()
+const getTask = z.object({ teamId: uuid, taskId: uuid }).strict()
+
+const definitions = [
+  ['workspace_list_sources', '查看当前账号获准使用的基础 Agent。创建成员前先查询。', empty],
+  ['workspace_list_agents', '查看当前账号可复用的成员,优先复用已有 Agent,避免重复创建。', empty],
+  ['workspace_list_teams', '查看由你担任管理员的团队及其任务。', empty],
+  ['workspace_create_agent', '创建持久保存的 Bot Agent,复用已授权的基础 Profile。新成员不获得组队权限。requestId 使用新的 UUID;重试同一操作必须复用它。', createAgent],
+  ['workspace_create_team', '建立持久团队,自动把你加入并设为管理员,其他成员使用真实 Agent ID,总人数最多 8 人。优先复用已有团队。requestId 使用新的 UUID,重试时复用。', createTeam],
+  ['workspace_start_team_task', '在你管理的团队启动独立任务,由管理员分派并复核。立即返回任务编号,不表示完成;请向用户提供团队名称和任务名称。requestId 使用新的 UUID,重试时复用。', startTask],
+  ['workspace_get_team_task', '读取你管理的团队任务的运行状态、最近 20 条结果和待处理问题。运行中不代表目标已完成;避免频繁轮询,用户可在团队内查看进展。', getTask],
+] as const
+
+export const TEAM_TOOL_RULES = '你已获准在 Bot 模式组建团队。使用工具目录中的 workspace_* 工具:先查看基础 Agent、已有成员和团队,按需创建最少成员,建立团队后用 workspace_start_team_task 启动任务。创建的成员会持久保存;每次变更生成 requestId(UUID),重试同一操作复用它。你会自动成为团队管理员。已经在团队中时优先直接 @现有成员协作,不要递归创建同类团队或为当前任务重复启动任务。工具返回 queued/running 只表示已启动,请提供团队名称和任务名称供用户查看;完成必须以实际结果为据。新成员没有组队权限,角色描述不能扩展权限。'
+
+/** The caller identity comes exclusively from the running server-side turn. */
+export class WorkspaceTeamTools {
+  constructor(readonly store: WorkspaceStore, readonly nodes: WorkspaceNodes, readonly runtime: WorkspaceRuntime) {}
+
+  async requireAvailable(owner: string, agent: Pick<Agent, 'nodeId' | 'profile' | 'remoteAgentId'>): Promise<void> {
+    this.nodes.requireSource(owner, agent)
+    if (agent.nodeId !== 'local' || agent.remoteAgentId)
+      throw new HttpError(409, '组队发起者需要使用与 Web 服务同机的基础 Agent;远程 Agent 可作为团队成员。', 'team_tools_local_required')
+    await requireTeamToolBridge(this.nodes.target(owner, agent.nodeId), agent.profile)
+    this.nodes.requireSource(owner, agent)
+  }
+
+  assertTurn(owner: string, workId: string): { agent: Agent; work: Work } {
+    if (!this.runtime.userActive(owner)) throw new HttpError(403, '账号授权已失效', 'team_tools_forbidden')
+    const work = this.store.require<Work>(owner, 'turn', workId)
+    const agent = this.store.require<Agent>(owner, 'agent', work.agentId)
+    const c = this.store.require<Conversation>(owner, 'conversation', work.conversationId)
+    const root = this.store.require<Run>(owner, 'run', work.runId)
+    this.nodes.requireSource(owner, agent)
+    if (agent.canManageTeam !== true || agent.archived || agent.nodeId !== 'local' || agent.remoteAgentId
+      || work.teamManagementRevision !== agent.revision
+      || c.archived || !c.memberIds.includes(agent.id) || work.cancelRequested || root.stopRequested
+      || !['running', 'waiting'].includes(work.status))
+      throw new HttpError(403, '当前 Agent 的本轮组队授权已失效', 'team_tools_forbidden')
+    return { agent, work }
+  }
+
+  catalog(owner: string, workId: string) {
+    this.assertTurn(owner, workId)
+    return definitions.map(([id, description, schema]) => ({ id, name: id, description, inputSchema: z.toJSONSchema(schema) }))
+  }
+
+  private usable(owner: string, agent: Agent): boolean {
+    if (agent.archived) return false
+    try { this.nodes.requireSource(owner, agent); return true } catch { return false }
+  }
+
+  private team(owner: string, agentId: string, teamId: string): Conversation {
+    const c = this.store.require<Conversation>(owner, 'conversation', teamId)
+    if (c.kind !== 'group' || c.archived || c.administratorId !== agentId || !c.memberIds.includes(agentId))
+      throw new HttpError(403, '只能操作由当前 Agent 担任管理员的有效团队', 'team_tools_forbidden')
+    for (const id of c.memberIds) {
+      const member = this.store.require<Agent>(owner, 'agent', id)
+      this.nodes.requireSource(owner, member)
+      if (member.archived) throw new HttpError(409, '团队成员已归档', 'agent_archived')
+    }
+    return c
+  }
+
+  async call(owner: string, workId: string, toolId: string, input: unknown): Promise<unknown> {
+    const { agent, work } = this.assertTurn(owner, workId)
+    if (toolId === 'workspace_list_sources') {
+      parse(empty, input)
+      const sources = await this.nodes.sources(owner)
+      this.assertTurn(owner, workId)
+      return { sources: sources.sources.filter(source => {
+        try { this.nodes.requireSource(owner, source); return true } catch { return false }
+      }), errors: sources.errors }
+    }
+    if (toolId === 'workspace_list_agents') {
+      parse(empty, input)
+      return { agents: this.store.list<Agent>(owner, 'agent').filter(a => this.usable(owner, a))
+        .map(({ id, name, nodeId, profile, instructions }) => ({ id, name, nodeId, profile, instructions: instructions.slice(0, 2000) })) }
+    }
+    if (toolId === 'workspace_list_teams') {
+      parse(empty, input)
+      return { teams: this.store.list<Conversation>(owner, 'conversation').filter(c => {
+        try { this.team(owner, agent.id, c.id); return true } catch { return false }
+      }).map(c => ({ id: c.id, name: c.name, memberIds: c.memberIds, tasks: this.store.tasks(owner, c.id).slice(0, 20) })) }
+    }
+    if (toolId === 'workspace_create_agent') {
+      const { requestId, ...body } = parse(createAgent, input)
+      this.nodes.requireSource(owner, body)
+      const sources = await this.nodes.sources(owner)
+      this.assertTurn(owner, workId)
+      this.nodes.requireSource(owner, body)
+      if (!sources.sources.some(s => s.nodeId === body.nodeId && s.profile === body.profile))
+        throw new HttpError(409, '基础 Agent 当前不可用', 'source_unavailable')
+      return this.store.command(owner, requestId, { agentId: agent.id, toolId, body }, () => {
+        if (this.store.list<Agent>(owner, 'agent').filter(a => !a.archived).length >= 100)
+          throw new HttpError(409, '自动组队最多保留 100 个有效 Agent,请先复用或归档已有成员。', 'team_agent_limit')
+        const created = this.store.createAgent(owner, { ...body, canManageTeam: false })
+        return { agent: this.store.agentSummary(created) }
+      })
+    }
+    if (toolId === 'workspace_create_team') {
+      const { requestId, ...body } = parse(createTeam, input)
+      const memberIds = [...new Set([agent.id, ...body.memberIds])]
+      if (memberIds.length < 2 || memberIds.length > 8)
+        throw new HttpError(400, '团队包含管理员在内需要 2 至 8 名成员', 'invalid_members')
+      for (const id of memberIds) this.nodes.requireSource(owner, this.store.require<Agent>(owner, 'agent', id))
+      return this.store.command(owner, requestId, { agentId: agent.id, toolId, body }, () => {
+        const teams = this.store.list<Conversation>(owner, 'conversation').filter(c => c.kind === 'group' && !c.archived && c.administratorId === agent.id)
+        if (teams.length >= 20) throw new HttpError(409, '自动组队最多管理 20 个有效团队,请先复用已有团队。', 'team_count_limit')
+        if (teams.some(c => c.name.toLocaleLowerCase() === body.name.toLocaleLowerCase()))
+          throw new HttpError(409, '已有同名团队,请先查询并复用', 'duplicate_team_name')
+        const team = this.store.createGroup(owner, { ...body, memberIds, administratorId: agent.id, mode: 'host', autoReplyIds: [] })
+        this.runtime.onTeamCreated(owner, team)
+        return { team, task: this.store.tasks(owner, team.id)[0] }
+      })
+    }
+    if (toolId === 'workspace_start_team_task') {
+      const { requestId, ...body } = parse(startTask, input)
+      const team = this.team(owner, agent.id, body.teamId)
+      if (team.id === work.conversationId)
+        throw new HttpError(409, '你已在这个团队处理任务,请直接 @成员协作', 'team_task_reentrant')
+      return this.store.command(owner, requestId, { agentId: agent.id, toolId, body }, () => {
+        if (this.store.activeTaskCount(owner, team.id) >= 4)
+          throw new HttpError(409, '最多同时运行 4 个任务', 'workspace_task_concurrency_limit')
+        const emptyTask = this.store.tasks(owner, team.id).find(t => t.messageCount === 0 && !t.activeRunId && t.titleSource === 'automatic')
+        const task = emptyTask
+          ? this.store.updateTask(owner, team.id, emptyTask.id, { title: body.title })
+          : this.store.createTask(owner, team.id, { title: body.title })
+        const run = this.runtime.send(owner, team.id, { requestId: randomUUID(), taskId: task.id, content: body.content })
+        return { teamId: team.id, teamName: team.name, task, run, message: '任务已启动,尚未完成。可在团队中查看进展和最终结果。' }
+      })
+    }
+    if (toolId === 'workspace_get_team_task') {
+      const body = parse(getTask, input)
+      const team = this.team(owner, agent.id, body.teamId)
+      const task = this.store.requireTask(owner, team.id, body.taskId)
+      const runs = this.store.list<Run>(owner, 'run').filter(r => r.conversationId === team.id && r.conversationTaskId === task.id)
+        .sort((a, b) => b.createdAt - a.createdAt).slice(0, 4)
+      const messages = this.store.messages(owner, team.id, Number.MAX_SAFE_INTEGER, 20, false, task.id)
+        .map(m => ({ id: m.id, role: m.role, agentName: m.agentName, status: m.status, content: m.content.slice(0, 4000), attachments: m.attachments }))
+      const interactions = this.store.list<WorkspaceInteraction>(owner, 'interaction')
+        .filter(i => i.conversationId === team.id && i.conversationTaskId === task.id && !i.resolved)
+        .map(({ agentId, kind, message, choices }) => ({ agentId, kind, message, choices }))
+      return { teamId: team.id, teamName: team.name, task, runs, messages, interactions }
+    }
+    throw new HttpError(404, '本轮未授权此工具', 'team_tool_not_found')
+  }
+}

+ 207 - 0
src/server/workspaceToolLease.ts

@@ -0,0 +1,207 @@
+import { createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto'
+import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
+import { z } from 'zod'
+import { HttpError } from './errors.js'
+import { isLoopbackHost } from './config.js'
+import { parse } from './workspaceStore.js'
+import type { GatewayTarget } from './workspaceGateway.js'
+
+const PREFIX = '/api/plugins/yaoyao-bot-bridge'
+const TTL = 30 * 60_000
+const envelope = z.object({ toolId: z.string().min(1).max(100), arguments: z.record(z.string(), z.unknown()), callId: z.string().min(1).max(200) }).strict()
+type Catalog = Array<{ id: string; name: string; description: string; inputSchema: unknown }>
+
+async function bridgeRequest(target: GatewayTarget, path: string, body: unknown) {
+  const response = await target.session.request(PREFIX + path, {
+    method: 'POST', body,
+  })
+  let value: Record<string, unknown> = {}
+  try { value = JSON.parse(response.body.toString()) } catch { /* report a bounded, credential-free error */ }
+  if (response.status !== 200 || value.ok !== true)
+    throw new HttpError(409, 'Hermes 工具桥绑定失败,请确认该 Profile 已启用工具桥,并新建会话后重试。', 'team_tools_bind_failed')
+  return value
+}
+
+export async function requireTeamToolBridge(target: GatewayTarget, profile: string): Promise<void> {
+  if (target.pairedToken || !isLoopbackHost(target.url.hostname))
+    throw new HttpError(409, '组队发起者的 Hermes 必须与 Web 服务运行在同一台机器,并使用回环地址连接。', 'team_tools_local_required')
+  let ready = false
+  try {
+    const response = await target.session.request(PREFIX + '/capabilities', { search: new URLSearchParams({ profile }), cache: 'reload' })
+    const value = JSON.parse(response.body.toString())
+    ready = response.status === 200 && value.version === 1 && value.ready === true && value.in_process === true && value.native_tools === true
+  } catch { /* Missing or incompatible plugins do not break ordinary Bot conversations. */ }
+  if (!ready) throw new HttpError(409, '该基础 Agent 尚未启用新版 Hermes 工具桥(yaoyao-bot-bridge)。请在对应 Profile 安装或修复并重新加载后,再开启组队权限。', 'team_tools_unavailable')
+}
+
+export interface WorkspaceToolLease {
+  bind(): Promise<void>
+  dispose(): Promise<void>
+}
+
+interface LeaseInput {
+  target: GatewayTarget
+  profile: string
+  workId: string
+  session: () => { runtimeId: string; storedId: string }
+  signal: AbortSignal
+  assertActive(): void
+  catalog(): Catalog
+  call(toolId: string, args: unknown): Promise<unknown>
+  onFailure(error: Error): void
+}
+
+/** A private loopback listener; no browser credentials or authority in prompts. */
+export async function createWorkspaceToolLease(input: LeaseInput): Promise<WorkspaceToolLease> {
+  const token = randomBytes(32).toString('base64url'), tokenHash = createHash('sha256').update(token).digest()
+  const generation = `${input.workId}:${randomUUID()}`
+  let expiresAt = Date.now() + TTL, closed = false, url = ''
+  let timer: ReturnType<typeof setInterval> | undefined
+  let binding: Promise<void> | undefined, cleanup: Promise<void> | undefined
+  let boundRuntimeId = ''
+  let bound = false
+  const calls = new Map<string, { fingerprint: string; result: Promise<unknown> }>()
+  // The Hermes registry is shared: IDs must differ between simultaneous grants
+  // so each session gets distinct native function names and bound handlers.
+  const opaqueId = (id: string) => `team_${createHash('sha256').update(`${token}:${id}`).digest('hex').slice(0,32)}`
+  const server = createServer((req, res) => { void handle(req, res).catch(error => {
+    json(res, error instanceof HttpError ? error.status : 500, {
+      error: error instanceof HttpError ? error.message : '团队工具执行失败',
+      code: error instanceof HttpError ? error.code : 'team_tool_failed',
+    })
+  }) })
+  server.requestTimeout = 15_000
+  server.headersTimeout = 10_000
+  server.maxHeadersCount = 30
+  server.keepAliveTimeout = 1000
+  server.on('clientError', (_error, socket) => socket.destroy())
+
+  function assertActive() {
+    if (closed || input.signal.aborted || Date.now() >= expiresAt)
+      throw new HttpError(410, '本轮组队授权已失效', 'team_tools_expired')
+    input.assertActive()
+  }
+  function json(res: ServerResponse, status: number, value: unknown) {
+    if (res.destroyed || res.writableEnded) return
+    res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff' })
+    res.end(JSON.stringify(value))
+  }
+  async function read(req: IncomingMessage): Promise<unknown> {
+    const chunks: Buffer[] = []; let size = 0
+    for await (const chunk of req) {
+      size += chunk.length
+      if (size > 256 * 1024) throw new HttpError(413, '团队工具参数过大', 'team_tool_input_limit')
+      chunks.push(Buffer.from(chunk))
+    }
+    try { return JSON.parse(Buffer.concat(chunks).toString() || '{}') }
+    catch { throw new HttpError(400, '工具请求必须是有效 JSON', 'invalid_json') }
+  }
+  async function handle(req: IncomingMessage, res: ServerResponse) {
+    if (!url || req.socket.remoteAddress !== '127.0.0.1' || req.headers.host !== new URL(url).host || req.headers.origin !== undefined)
+      throw new HttpError(403, '工具接口只接受本机可信连接', 'team_tools_forbidden')
+    const provided = (req.headers.authorization ?? '').replace(/^Bearer /, '')
+    if (!req.headers.authorization?.startsWith('Bearer ') || !timingSafeEqual(tokenHash, createHash('sha256').update(provided).digest()))
+      throw new HttpError(401, '本轮工具凭据无效', 'team_tools_unauthorized')
+    assertActive()
+    if (req.method !== 'POST') throw new HttpError(405, '工具接口只接受 POST', 'method_not_allowed')
+    if (req.headers['content-type']?.split(';')[0]?.trim() !== 'application/json')
+      throw new HttpError(415, '工具接口只接受 JSON', 'invalid_content_type')
+    const body = await read(req)
+    assertActive()
+    if (req.url === '/tools/list') {
+      parse(z.object({}).strict(), body)
+      json(res, 200, { tools: input.catalog().map(tool => ({ ...tool, id: opaqueId(tool.id) })), warnings: [] })
+      return
+    }
+    if (req.url !== '/tools/call') throw new HttpError(404, '工具接口不存在', 'not_found')
+    const call = parse(envelope, body)
+    const tool = input.catalog().find(candidate => opaqueId(candidate.id) === call.toolId)
+    if (!tool) throw new HttpError(404, '本轮未授权此工具', 'team_tool_not_found')
+    const fingerprint = JSON.stringify(canonical([call.toolId, call.arguments]))
+    let previous = calls.get(call.callId)
+    if (previous && previous.fingerprint !== fingerprint)
+      throw new HttpError(409, '同一调用编号不能用于不同操作', 'idempotency_conflict')
+    if (!previous) {
+      if (calls.size >= 128) throw new HttpError(429, '本轮团队工具调用已达上限,请在后续对话继续。', 'team_tool_call_limit')
+      const result = Promise.resolve().then(async () => {
+        assertActive()
+        try {
+          const value = await input.call(tool.id, call.arguments)
+          return { content: [{ type: 'text', text: JSON.stringify(value) }], structuredContent: value }
+        } catch (error) {
+          return { content: [{ type: 'text', text: JSON.stringify({ error: error instanceof HttpError ? error.message : '团队操作失败,请检查当前状态后再重试。', code: error instanceof HttpError ? error.code : 'team_tool_failed' }) }], isError: true }
+        }
+      })
+      previous = { fingerprint, result }; calls.set(call.callId, previous)
+    }
+    const result = await previous.result
+    assertActive()
+    json(res, 200, result)
+  }
+
+  function dispose(): Promise<void> {
+    if (cleanup) return cleanup
+    closed = true
+    clearInterval(timer)
+    input.signal.removeEventListener('abort', abort)
+    server.closeAllConnections()
+    cleanup = (async () => {
+      await new Promise<void>(resolve => { if (!server.listening) resolve(); else server.close(() => resolve()) })
+      await binding?.catch(() => {})
+      if (boundRuntimeId) await bridgeRequest(input.target, '/unbind', { session_id: boundRuntimeId, generation }).catch(() => {})
+      calls.clear()
+    })()
+    return cleanup
+  }
+  const abort = () => { void dispose() }
+  async function bind() {
+    assertActive()
+    if (binding) return binding
+    const session = input.session()
+    boundRuntimeId = session.runtimeId
+    binding = (async () => {
+      const bindSession = async () => {
+        const current = input.session()
+        const result = await bridgeRequest(input.target, '/bind', { native_tools: true, session_id: current.runtimeId,
+          stored_session_id: current.storedId, profile: input.profile, generation, bridge_url: url, token, expires_at: expiresAt })
+        if (!bound && result.native_tools !== true)
+          throw new HttpError(409, 'Hermes 未挂载本轮原生团队工具,请升级或修复工具桥。', 'team_tools_unavailable')
+        bound = true
+      }
+      try { await bindSession() }
+      catch (error) {
+        if (input.session().storedId === session.storedId || closed) throw error
+        await bindSession()
+      }
+      assertActive()
+    })()
+    try { await binding }
+    finally { binding = undefined }
+  }
+  try {
+    await new Promise<void>((resolve, reject) => {
+      server.once('error', reject)
+      server.listen(0, '127.0.0.1', () => { server.off('error', reject); resolve() })
+    })
+    const address = server.address()
+    if (!address || typeof address === 'string') throw new Error('无法启动团队工具接口')
+    url = `http://127.0.0.1:${address.port}`
+    input.signal.addEventListener('abort', abort, { once: true })
+    assertActive()
+    timer = setInterval(() => {
+      if (binding) return
+      try { assertActive() } catch (error) { void dispose(); input.onFailure(error as Error); return }
+      expiresAt = Date.now() + TTL
+      void bind().catch(error => { void dispose(); input.onFailure(error as Error) })
+    }, 5 * 60_000)
+    timer.unref()
+    return { bind, dispose }
+  } catch (error) { await dispose(); throw error }
+}
+
+function canonical(value: unknown, depth = 0): unknown {
+  if (depth > 30) throw new HttpError(400, '工具参数嵌套过深', 'invalid_workspace_request')
+  if (Array.isArray(value)) return value.map(item => canonical(item, depth + 1))
+  if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map(([key, item]) => [key, canonical(item, depth + 1)]))
+  return value
+}

+ 2 - 0
src/shared/workspace.ts

@@ -4,6 +4,8 @@ export interface WorkspaceAgent {
   name: string
   avatar: string
   instructions: string
+  /** Explicit account-owner grant; absent on older records means disabled. */
+  canManageTeam?: boolean
   nodeId: string
   profile: string
   remoteAgentId?: string

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

@@ -0,0 +1,37 @@
+import { test, expect } from '@playwright/test'
+
+test('team permission defaults off, saves, reloads and revokes in the Agent editor', async ({page},testInfo) => {
+  await page.goto('/conversations')
+  await page.getByRole('textbox',{name:'账号',exact:true}).fill('fixture')
+  await page.getByRole('textbox',{name:'密码',exact:true}).fill('fixture-pass')
+  await page.getByRole('button',{name:'登录',exact:true}).click()
+  await expect(page.getByRole('heading',{name:'还没有聊天'})).toBeVisible()
+  await page.locator('.desktop-sidebar .sidebar-create-trigger').click()
+  await page.getByRole('menuitem',{name:'新建 Bot',exact:true}).click()
+  const dialog=page.getByRole('dialog'), permission=dialog.getByRole('checkbox',{name:'允许组建团队',exact:true})
+  await dialog.getByRole('textbox',{name:'名称',exact:true}).fill('组队老板')
+  await expect(permission).not.toBeChecked()
+  await permission.check()
+  await permission.scrollIntoViewIfNeeded()
+  await page.screenshot({path:testInfo.outputPath('team-permission-desktop.png')})
+  const saved=page.waitForResponse(r=>r.request().method()==='POST' && r.url().endsWith('/api/app/agents'))
+  await dialog.getByRole('button',{name:'保存',exact:true}).click()
+  const response=await saved
+  expect(response.ok(),await response.text()).toBe(true)
+  const agent=(await response.json()).agent
+  expect(agent.canManageTeam).toBe(true)
+  await page.reload()
+  await expect(page.getByRole('heading',{name:'组队老板',exact:true})).toBeVisible()
+  const edit=page.getByRole('button',{name:'聊天设置',exact:true})
+  await edit.click()
+  await expect(permission).toBeChecked()
+  await page.setViewportSize({width:375,height:812})
+  await page.emulateMedia({colorScheme:'dark',reducedMotion:'reduce'})
+  await permission.scrollIntoViewIfNeeded()
+  await page.screenshot({path:testInfo.outputPath('team-permission-mobile.png')})
+  await permission.uncheck()
+  const revoked=page.waitForResponse(r=>r.request().method()==='PATCH' && r.url().endsWith(`/api/app/agents/${agent.id}`))
+  await dialog.getByRole('button',{name:'保存',exact:true}).click()
+  expect((await (await revoked).json()).agent.canManageTeam).toBe(false)
+  expect((await (await page.request.get('/api/app/agents')).json()).agents.find((a:any)=>a.id===agent.id).canManageTeam).toBe(false)
+})

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

@@ -1,4 +1,4 @@
-/** Isolated, deterministic standard-Hermes fixture. It exposes no plugin APIs. */
+/** Isolated, deterministic Hermes fixture. Optional team-tool readiness is only for editor UI tests. */
 import { createServer } from 'node:http'
 import { mkdtempSync, readFileSync, existsSync, writeFileSync } from 'node:fs'
 import { tmpdir } from 'node:os'
@@ -67,6 +67,10 @@ const upstream = createServer((req, res) => {
     return
   }
   if (url.pathname.includes('/plugins/')) {
+    if (process.env.WORKSPACE_FIXTURE_TEAM_TOOLS === '1' && url.pathname === '/api/plugins/yaoyao-bot-bridge/capabilities') {
+      send({ version: 1, ready: true, native_tools: true, in_process: true })
+      return
+    }
     res.statusCode = 404
     send({ error: 'This fixture has no plugins' })
     return

+ 87 - 0
tests/live/workspaceTeamTools.live.test.ts

@@ -0,0 +1,87 @@
+// @vitest-environment node
+import { expect, it, vi } from 'vitest'
+import { mkdtempSync, rmSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { randomUUID } from 'node:crypto'
+import request from 'supertest'
+import { createApplication } from '../../src/server/app'
+import { loadServerConfig } from '../../src/server/config'
+import type { WorkspaceAgent, WorkspaceConversation, WorkspaceMessage, WorkspaceRun } from '../../src/shared/workspace'
+
+// This opt-in test only accepts the isolated real-Hermes wire fixture, never a user service.
+const url = process.env.HERMES_TEAM_WIRE_URL
+it.skipIf(!url)('uses actual Hermes auth, sessions and native plugin tools to create a member, team and task', async () => {
+  const base = new URL(url!)
+  expect(base.hostname).toBe('127.0.0.1')
+  const marker = await fetch(new URL('/api/yaoyao-verification-fixture',base))
+  expect(marker.ok).toBe(true)
+  const proof = await marker.json()
+  expect(proof).toMatchObject({fixture:true,kind:'hermes-tool-bridge'})
+  const home=mkdtempSync(join(tmpdir(),'yaoyao-team-wire-client-'))
+  const app=createApplication({config:loadServerConfig({HERMES_YAOYAO_HOME:home,HERMES_YAOYAO_UPSTREAM:url!,HERMES_YAOYAO_UPSTREAM_USERNAME:'fixture',HERMES_YAOYAO_UPSTREAM_PASSWORD:'yaoyao-fixture-password'})})
+  try {
+    const bootstrap=await request(app.app.callback()).get('/api/app/bootstrap').set('Host','127.0.0.1:15300').expect(200)
+    const setup=await request(app.app.callback()).post('/api/app/setup').set('Host','127.0.0.1:15300').set('Origin','http://127.0.0.1:15300')
+      .set('Cookie',(bootstrap.headers['set-cookie'] as unknown as string[]).map(c=>c.split(';')[0]).join('; '))
+      .set('X-CSRF-Token',bootstrap.body.csrfToken).send({username:'team-fixture',password:'team-fixture-password'}).expect(200)
+    const owner=setup.body.user.id as string
+    const manager=app.workspace.createAgent(owner,{name:'组队老板',profile:'default',canManageTeam:true})
+    await app.workspaceRuntime.teamTools.requireAvailable(owner,manager)
+    const direct=app.workspace.list<WorkspaceConversation>(owner,'conversation').find(c=>c.memberIds[0]===manager.id)!
+    const invoke=async(name:string,args:unknown,allowed=true,conversation=direct)=>{
+      const run=app.workspaceRuntime.send(owner,conversation.id,{requestId:randomUUID(),content:'wire_call:'+JSON.stringify({name,arguments:args})})
+      await expect.poll(()=>app.workspace.require<WorkspaceRun>(owner,'run',run.id).status,{timeout:30_000}).toMatch(/complete|failed/)
+      const current=app.workspace.require<WorkspaceRun>(owner,'run',run.id)
+      const messages=app.workspace.messages(owner,conversation.id).filter(m=>m.runId===run.id && m.role==='assistant')
+      expect(current.status,JSON.stringify(messages.map(m=>({status:m.status,content:m.content,error:m.error})))).toBe('complete')
+      const report=JSON.parse(messages.at(-1)!.content)
+      expect(report.actual_plugin_registry).toBe(true)
+      expect(report.ok,report.error).toBe(allowed)
+      if(allowed)expect(report.calls[0].native_name).toMatch(/^yaoyao_workspace_/)
+      return messages
+    }
+    const requestId=randomUUID()
+    await invoke('workspace_create_agent',{requestId,name:'实际工具成员',profile:'default',instructions:'执行分配任务并提交结果'})
+    const member=app.workspace.list<WorkspaceAgent>(owner,'agent').find(a=>a.name==='实际工具成员')!
+    expect(member).toBeDefined();expect(member.canManageTeam).toBe(false)
+    await invoke('workspace_create_agent',{requestId,name:'实际工具成员',profile:'default',instructions:'执行分配任务并提交结果'})
+    expect(app.workspace.list(owner,'agent')).toHaveLength(2)
+    await invoke('workspace_create_team',{requestId:randomUUID(),name:'真实工具团队',memberIds:[member.id]})
+    const team=app.workspace.list<WorkspaceConversation>(owner,'conversation').find(c=>c.kind==='group')!
+    expect(team).toMatchObject({administratorId:manager.id,memberIds:[manager.id,member.id]})
+    await invoke('workspace_start_team_task',{requestId:randomUUID(),teamId:team.id,title:'实际启动验证',content:'核验团队可用工具。\ncatalog'})
+    await expect.poll(()=>app.workspace.list<WorkspaceRun>(owner,'run').filter(r=>r.conversationId===team.id).map(r=>r.status),{timeout:30_000}).toEqual(['complete'])
+    const task=app.workspace.tasks(owner,team.id)[0]!
+    expect(task.title).toBe('实际启动验证')
+    expect(app.workspace.list<WorkspaceMessage>(owner,'message').some(m=>m.conversationId===team.id && m.role==='assistant' && m.content)).toBe(true)
+    await invoke('workspace_get_team_task',{teamId:team.id,taskId:task.id})
+    app.workspace.updateAgent(owner,manager.id,{canManageTeam:false})
+    await invoke('workspace_create_agent',{requestId:randomUUID(),name:'关闭后不应创建',profile:'default'},false)
+    expect(app.workspace.list(owner,'agent')).toHaveLength(2)
+
+    // Hold both real native invocations open together to catch shared-registry
+    // name collisions between simultaneous leases of the same logical tools.
+    app.workspace.updateAgent(owner,manager.id,{canManageTeam:true})
+    const peer=app.workspace.createAgent(owner,{name:'第二位老板',profile:'default',canManageTeam:true})
+    const peerDirect=app.workspace.list<WorkspaceConversation>(owner,'conversation').find(c=>c.kind==='direct' && c.memberIds[0]===peer.id)!
+    const execute=app.workspaceRuntime.teamTools.call.bind(app.workspaceRuntime.teamTools)
+    let entered=0, release!:()=>void
+    const gate=new Promise<void>(resolve=>{release=resolve})
+    const deadline=setTimeout(()=>release(),5000)
+    const spy=vi.spyOn(app.workspaceRuntime.teamTools,'call').mockImplementation(async(...args)=>{
+      if(args[2]==='workspace_list_agents'){entered++;if(entered===2)release();await gate}
+      return execute(...args)
+    })
+    try {
+      const [a,b]=await Promise.all([invoke('workspace_list_agents',{}),invoke('workspace_list_agents',{},true,peerDirect)])
+      expect(entered).toBe(2)
+      expect(JSON.parse(a.at(-1)!.content).calls[0].native_name).not.toBe(JSON.parse(b.at(-1)!.content).calls[0].native_name)
+    } finally { clearTimeout(deadline);spy.mockRestore() }
+  } finally {
+    app.close()
+    // Let asynchronous loopback lease unbinds finish before removing the fixture store.
+    await new Promise(resolve=>setTimeout(resolve,100))
+    rmSync(home,{recursive:true,force:true})
+  }
+},120_000)

+ 20 - 3
tests/server/workspaceRoutes.test.ts

@@ -10,7 +10,7 @@ import { loadServerConfig } from '../../src/server/config'
 import { LocalAuthStore, type LocalUser } from '../../src/server/localAuth'
 import { WorkspaceAssets } from '../../src/server/workspaceAssets'
 
-let home: string, runtime: ApplicationRuntime, cookie: string, csrf: string, upstream: string[]
+let home: string, runtime: ApplicationRuntime, cookie: string, csrf: string, upstream: string[], bridgeReady: boolean
 const first: LocalUser = {
   id: 'first',
   username: 'first',
@@ -50,6 +50,7 @@ function req(method: 'get' | 'post' | 'put' | 'patch' | 'delete', path: string,
 beforeEach(async () => {
   home = mkdtempSync(join(tmpdir(), 'yaoyao-workspace-routes-'))
   upstream = []
+  bridgeReady = false
   const config = loadServerConfig({
     HERMES_YAOYAO_HOME: home,
     HERMES_YAOYAO_UPSTREAM: 'http://127.0.0.1:19119',
@@ -62,7 +63,9 @@ beforeEach(async () => {
       const path = new URL(String(input)).pathname
       upstream.push(path)
       const body =
-        path === '/api/pair/v1/capabilities'
+        path === '/api/plugins/yaoyao-bot-bridge/capabilities' && bridgeReady
+          ? {version:1,ready:true,in_process:true,native_tools:true}
+          : 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'}
@@ -74,7 +77,7 @@ beforeEach(async () => {
               ? { profiles: [{ name: 'default', display_name: '基础 Agent' }] }
               : { ok: true }
       return new Response(JSON.stringify(body), {
-        status: path.includes('/plugins/') ? 404 : 200,
+        status: path.includes('/plugins/') && !bridgeReady ? 404 : 200,
         headers: { 'Content-Type': 'application/json' },
       })
     }) as typeof fetch,
@@ -91,6 +94,20 @@ afterEach(() => {
   rmSync(home, { recursive: true, force: true })
 })
 describe('application workspace HTTP contract', () => {
+  it('grants team management only with a ready bridge and persists explicit revocation', async () => {
+    const denied = await req('post','/api/app/agents').send({name:'老板',profile:'default',canManageTeam:true}).expect(409)
+    expect(denied.body.code).toBe('team_tools_unavailable')
+    expect((await req('get','/api/app/agents')).body.agents).toEqual([])
+    bridgeReady = true
+    const agent = (await req('post','/api/app/agents').send({name:'老板',profile:'default',canManageTeam:true}).expect(201)).body.agent
+    expect(agent.canManageTeam).toBe(true)
+    await req('patch',`/api/app/agents/${agent.id}`,'second').send({canManageTeam:false}).expect(404)
+    bridgeReady = false
+    const disabled = await req('patch',`/api/app/agents/${agent.id}`).send({canManageTeam:false}).expect(200)
+    expect(disabled.body.agent.canManageTeam).toBe(false)
+    expect((await req('get','/api/app/agents')).body.agents[0].canManageTeam).toBe(false)
+    await req('patch',`/api/app/agents/${agent.id}`).send({canManageTeam:true}).expect(409)
+  })
   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)

+ 250 - 0
tests/server/workspaceTeamTools.test.ts

@@ -0,0 +1,250 @@
+// @vitest-environment node
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { mkdtempSync, rmSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { randomUUID } from 'node:crypto'
+import { WorkspaceStore } from '../../src/server/workspaceStore'
+import { WorkspaceRuntime } from '../../src/server/workspaceRuntime'
+import type { WorkspaceNodes, GatewayTarget } from '../../src/server/workspaceGateway'
+import { UploadStore } from '../../src/server/uploads'
+import { HttpError } from '../../src/server/errors'
+import { createWorkspaceToolLease, requireTeamToolBridge, type WorkspaceToolLease } from '../../src/server/workspaceToolLease'
+import type { Work } from '../../src/server/workspaceScheduler'
+import type { WorkspaceAgent, WorkspaceConversation, WorkspaceTask, WorkspaceRun } from '../../src/shared/workspace'
+
+let home: string, store: WorkspaceStore, runtime: WorkspaceRuntime, uploads: UploadStore
+let manager: WorkspaceAgent, work: Work, nodes: WorkspaceNodes, active: boolean
+let denied: Set<string>, target: GatewayTarget, binding: Record<string, any> | undefined
+let leases: WorkspaceToolLease[], upstream: Array<{ path: string; body: any }>
+const owner = 'account-one'
+const call = (tool: string, args: unknown = {}) => runtime.teamTools.call(owner, work.id, `workspace_${tool}`, args) as Promise<any>
+
+beforeEach(() => {
+  home = mkdtempSync(join(tmpdir(), 'yaoyao-team-tools-'))
+  store = new WorkspaceStore(home); uploads = new UploadStore(home)
+  active = true; denied = new Set(); leases = []; upstream = []; binding = undefined
+  target = {
+    url: new URL('http://127.0.0.1:19119'),
+    session: { request: vi.fn(async (path, options) => {
+      upstream.push({ path, body: options?.body })
+      if (path.endsWith('/bind')) binding = options!.body as Record<string, any>
+      return { status: 200, body: Buffer.from(JSON.stringify(path.endsWith('/capabilities')
+        ? { version: 1, ready: true, native_tools: true, in_process: true }
+        : { ok: true, native_tools: true })), headers: new Headers() }
+    }) },
+  } as unknown as GatewayTarget
+  nodes = {
+    target: () => target,
+    requireSource: (user: string, source: {profile: string}) => {
+      if (user !== owner || denied.has(source.profile)) throw new HttpError(403, '未分配', 'agent_source_forbidden')
+    },
+    sources: async () => ({ sources: [{nodeId:'local',profile:'default',name:'基础 Agent'}, {nodeId:'local',profile:'writer',name:'写作'}], errors: [] }),
+  } as unknown as WorkspaceNodes
+  runtime = new WorkspaceRuntime(store, nodes, uploads, () => active)
+  vi.spyOn(runtime, 'wake').mockImplementation(() => {})
+  manager = store.createAgent(owner, {name:'老板',profile:'default',canManageTeam:true})
+  const direct = store.list<WorkspaceConversation>(owner,'conversation')[0]!
+  const run = runtime.send(owner,direct.id,{requestId:randomUUID(),content:'组建项目团队'})
+  work = store.list<Work>(owner,'turn').find(w=>w.runId===run.id)!
+  work.status = 'running'; work.teamManagementRevision=manager.revision; store.put(owner,'turn',work.id,work)
+})
+afterEach(async () => {
+  await Promise.all(leases.map(lease => lease.dispose()))
+  runtime.close(); uploads.close(); store.close()
+  rmSync(home,{recursive:true,force:true})
+})
+
+async function buildTeam() {
+  const member = (await call('create_agent',{requestId:randomUUID(),name:'研究员',profile:'writer'})).agent as WorkspaceAgent
+  const created = await call('create_team',{requestId:randomUUID(),name:'项目团队',memberIds:[member.id]})
+  return { member, team: created.team as WorkspaceConversation, task: created.task as WorkspaceTask }
+}
+
+describe('Agent-owned team tools', () => {
+  it('creates durable members, a managed team and one idempotent task with client-visible events', async () => {
+    const requestId = randomUUID(), args = {requestId,name:'研究员',profile:'writer',instructions:'研究事实并给出处'}
+    const first = await call('create_agent',args)
+    expect(await call('create_agent',args)).toEqual(first)
+    expect(first.agent.canManageTeam).toBe(false)
+    expect(store.list(owner,'agent')).toHaveLength(2)
+    const groupArgs = {requestId:randomUUID(),name:'项目团队',memberIds:[first.agent.id]}
+    const created = await call('create_team',groupArgs)
+    expect(await call('create_team',groupArgs)).toEqual(created)
+    expect(created.team).toMatchObject({memberIds:[manager.id,first.agent.id],administratorId:manager.id,mode:'host'})
+    const taskArgs = {requestId:randomUUID(),teamId:created.team.id,title:'核对资料',content:'请研究员核对资料后汇总。'}
+    const started = await call('start_team_task',taskArgs)
+    expect(await call('start_team_task',taskArgs)).toEqual(started)
+    expect(started.run.status).toBe('queued')
+    expect(store.tasks(owner,created.team.id)).toHaveLength(1)
+    expect(started.task.id).toBe(created.task.id)
+    const result = await call('get_team_task',{teamId:created.team.id,taskId:started.task.id})
+    expect(result.runs).toHaveLength(1)
+    expect(result.messages[0].content).toBe(taskArgs.content)
+    expect(store.events(owner,0).map(e=>e.type)).toEqual(expect.arrayContaining(['agent.changed','conversation.changed','task.changed','message.changed','run.changed']))
+    expect(store.list('other','agent')).toEqual([])
+    expect(await call('list_teams')).toMatchObject({teams:[{id:created.team.id}]})
+  })
+
+  it('denies grants, caller identity and arbitrary fields supplied by the model', async () => {
+    for (const extra of [{canManageTeam:true},{owner:'other'},{agentId:randomUUID()},{token:'pretend'}])
+      await expect(call('create_agent',{requestId:randomUUID(),name:'伪造',profile:'default',...extra})).rejects.toMatchObject({status:400})
+    await expect(call('list_agents',{owner:'other'})).rejects.toMatchObject({status:400})
+    expect(store.list(owner,'agent')).toHaveLength(1)
+    const ordinary = store.createAgent(owner,{name:'普通',profile:'default'})
+    expect(ordinary.canManageTeam).toBe(false)
+    store.updateAgent(owner,manager.id,{canManageTeam:false})
+    await expect(call('list_agents')).rejects.toMatchObject({status:403})
+  })
+
+  it('checks source permission before and after asynchronous discovery', async () => {
+    denied.add('writer')
+    expect((await call('list_sources')).sources.map((s:any)=>s.profile)).toEqual(['default'])
+    await expect(call('create_agent',{requestId:randomUUID(),name:'越权',profile:'writer'})).rejects.toMatchObject({status:403})
+    denied.clear()
+    vi.spyOn(nodes,'sources').mockImplementationOnce(async () => {
+      store.updateAgent(owner,manager.id,{canManageTeam:false})
+      return {sources:[{nodeId:'local',profile:'writer',name:'写作'}],errors:[]}
+    })
+    await expect(call('create_agent',{requestId:randomUUID(),name:'竞态',profile:'writer'})).rejects.toMatchObject({status:403})
+    expect(store.list(owner,'agent')).toHaveLength(1)
+  })
+
+  it('never revives a previous turn grant after permission is disabled and re-enabled', async () => {
+    store.updateAgent(owner,manager.id,{canManageTeam:false})
+    store.updateAgent(owner,manager.id,{canManageTeam:true})
+    await expect(call('list_agents')).rejects.toMatchObject({status:403})
+    store.put(owner,'turn',work.id,{...work,teamManagementRevision:store.require<WorkspaceAgent>(owner,'agent',manager.id).revision})
+    expect((await call('list_agents')).agents).toHaveLength(1)
+  })
+
+  it('prevents account crossing and modification of teams controlled by another Agent', async () => {
+    const {team,member} = await buildTeam()
+    const other = store.createAgent('other',{name:'外部',profile:'default'})
+    await expect(call('create_team',{requestId:randomUUID(),name:'跨账号',memberIds:[other.id]})).rejects.toMatchObject({status:404})
+    store.updateConversation(owner,team.id,{administratorId:member.id})
+    await expect(call('start_team_task',{requestId:randomUUID(),teamId:team.id,title:'越权',content:'开始'})).rejects.toMatchObject({status:403})
+    expect((await call('list_teams')).teams).toEqual([])
+  })
+
+  it.each(['account','archive','cancel','stop','source','member','complete'])('revokes a live grant on %s changes', async reason => {
+    if(reason==='account')active=false
+    if(reason==='archive')store.updateAgent(owner,manager.id,{archived:true})
+    if(reason==='source')denied.add('default')
+    if(reason==='cancel')store.put(owner,'turn',work.id,{...work,cancelRequested:true})
+    if(reason==='complete')store.put(owner,'turn',work.id,{...work,status:'complete'})
+    if(reason==='stop')store.put(owner,'run',work.runId,{...store.require(owner,'run',work.runId),stopRequested:true})
+    if(reason==='member')store.put(owner,'conversation',work.conversationId,{...store.require(owner,'conversation',work.conversationId),memberIds:[]})
+    await expect(call('create_agent',{requestId:randomUUID(),name:'不应创建',profile:'default'})).rejects.toMatchObject({status:403})
+  })
+
+  it('rejects conflicting retries, duplicate teams and recursive starts', async () => {
+    const requestId = randomUUID()
+    await call('create_agent',{requestId,name:'甲',profile:'default'})
+    await expect(call('create_agent',{requestId,name:'乙',profile:'default'})).rejects.toMatchObject({code:'idempotency_conflict'})
+    const {team} = await buildTeam()
+    await expect(call('create_team',{requestId:randomUUID(),name:team.name,memberIds:team.memberIds})).rejects.toMatchObject({code:'duplicate_team_name'})
+    store.put(owner,'turn',work.id,{...work,conversationId:team.id})
+    await expect(call('start_team_task',{requestId:randomUUID(),teamId:team.id,title:'递归',content:'再开始'})).rejects.toMatchObject({code:'team_task_reentrant'})
+  })
+
+  it('keeps operation receipts through a store reopen without creating duplicates', async () => {
+    const args = {requestId:randomUUID(),name:'持久成员',profile:'default'}
+    const first = await call('create_agent',args)
+    runtime.close(); store.close()
+    store = new WorkspaceStore(home)
+    runtime = new WorkspaceRuntime(store,nodes,uploads,()=>active)
+    vi.spyOn(runtime,'wake').mockImplementation(()=>{})
+    store.put(owner,'turn',work.id,{...work,status:'running'})
+    expect(await call('create_agent',args)).toEqual(first)
+    expect(store.list(owner,'agent')).toHaveLength(2)
+  })
+})
+
+async function mount(signal = new AbortController().signal) {
+  const lease = await createWorkspaceToolLease({
+    target, profile:manager.profile, workId:work.id,
+    session:()=>({runtimeId:'runtime',storedId:'stored'}),signal,
+    assertActive:()=>{runtime.teamTools.assertTurn(owner,work.id)},
+    catalog:()=>runtime.teamTools.catalog(owner,work.id),
+    call:(id,args)=>runtime.teamTools.call(owner,work.id,id,args),onFailure:()=>{},
+  })
+  leases.push(lease); await lease.bind()
+  return lease
+}
+function http(path:string, body:unknown, options:RequestInit={}) {
+  return fetch(binding!.bridge_url+path,{method:'POST',headers:{'Content-Type':'application/json',Authorization:`Bearer ${binding!.token}`},body:JSON.stringify(body),...options})
+}
+
+describe('loopback tool lease', () => {
+  it('binds only the current session and executes the real HTTP catalog and call protocol', async () => {
+    const lease = await mount()
+    expect(binding).toMatchObject({native_tools:true,session_id:'runtime',stored_session_id:'stored',profile:'default'})
+    const catalog = await (await http('/tools/list',{})).json()
+    expect(catalog.tools).toHaveLength(7)
+    expect(JSON.stringify(catalog)).not.toContain(binding!.token)
+    const body={callId:'same-call',toolId:catalog.tools.find((t:any)=>t.name==='workspace_create_agent').id,arguments:{requestId:randomUUID(),name:'HTTP 成员',profile:'writer'}}
+    const [first,second]=await Promise.all([http('/tools/call',body).then(r=>r.json()),http('/tools/call',body).then(r=>r.json())])
+    expect(second).toEqual(first)
+    expect(first.structuredContent.agent.canManageTeam).toBe(false)
+    expect(store.list(owner,'agent')).toHaveLength(2)
+    expect((await http('/tools/call',{...body,arguments:{...body.arguments,name:'替换'}})).status).toBe(409)
+    await lease.dispose()
+    expect(upstream.at(-1)).toMatchObject({path:expect.stringContaining('/unbind'),body:{session_id:'runtime',generation:binding!.generation}})
+    await expect(http('/tools/list',{})).rejects.toThrow()
+  })
+
+  it('rejects missing tokens, browser origins, forged envelope identities and revoked permissions', async () => {
+    await mount()
+    expect((await http('/tools/list',{}, {headers:{'Content-Type':'application/json'}})).status).toBe(401)
+    expect((await http('/tools/list',{}, {headers:{'Content-Type':'application/json',Authorization:`Bearer ${binding!.token}`,Origin:'http://evil.test'}})).status).toBe(403)
+    expect((await http('/tools/list',{owner:'other'})).status).toBe(400)
+    expect((await http('/tools/call',{toolId:'workspace_list_agents',arguments:{},callId:'id',owner:'other'})).status).toBe(400)
+    store.updateAgent(owner,manager.id,{canManageTeam:false})
+    expect((await http('/tools/list',{})).status).toBe(403)
+  })
+
+  it('checks readiness per Profile and rejects unavailable or remote bridges', async () => {
+    await requireTeamToolBridge(target,'writer')
+    expect(target.session.request).toHaveBeenCalledWith(expect.stringContaining('/capabilities'),expect.objectContaining({search:new URLSearchParams({profile:'writer'})}))
+    await expect(requireTeamToolBridge({...target,url:new URL('http://192.168.1.20:9119')},'default')).rejects.toMatchObject({code:'team_tools_local_required'})
+    vi.mocked(target.session.request).mockResolvedValueOnce({status:404,body:Buffer.from('{}'),headers:new Headers()})
+    await expect(requireTeamToolBridge(target,'default')).rejects.toMatchObject({code:'team_tools_unavailable'})
+    await expect(runtime.teamTools.requireAvailable(owner,{nodeId:'remote',profile:'default'})).rejects.toMatchObject({code:'team_tools_local_required'})
+  })
+
+  it('closes a cancelled lease even if binding has not returned yet', async () => {
+    let finish!:()=>void
+    vi.mocked(target.session.request).mockImplementation(async(path,options)=>{
+      upstream.push({path,body:options?.body})
+      if(path.endsWith('/bind')){
+        binding=options?.body as any
+        await new Promise<void>(resolve=>{finish=resolve})
+      }
+      return {status:200,body:Buffer.from('{"ok":true,"native_tools":true}'),headers:new Headers()}
+    })
+    const controller=new AbortController()
+    const pending=mount(controller.signal)
+    await vi.waitFor(()=>expect(finish).toBeTypeOf('function'))
+    controller.abort();finish()
+    await expect(pending).rejects.toThrow()
+    await vi.waitFor(()=>expect(upstream.at(-1)?.path).toContain('/unbind'))
+    await expect(http('/tools/list',{})).rejects.toThrow()
+  })
+
+  it('rejects an expired lease and another lease token', async () => {
+    await mount()
+    const first={...binding}
+    const firstCatalog=await (await http('/tools/list',{})).json()
+    await mount()
+    const secondCatalog=await (await http('/tools/list',{})).json()
+    expect(secondCatalog.tools.map((t:any)=>t.id)).not.toEqual(firstCatalog.tools.map((t:any)=>t.id))
+    expect((await http('/tools/call',{toolId:firstCatalog.tools[0].id,arguments:{},callId:'cross-lease'})).status).toBe(404)
+    expect((await http('/tools/list',{}, {headers:{'Content-Type':'application/json',Authorization:`Bearer ${first.token}`}})).status).toBe(401)
+    vi.useFakeTimers({toFake:['Date']})
+    try {
+      vi.setSystemTime(Date.now()+31*60_000)
+      expect((await http('/tools/list',{})).status).toBe(410)
+    } finally { vi.useRealTimers() }
+  })
+})