diff --git a/CHANGELOG.md b/CHANGELOG.md index 88a0fa50..a5787ef0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,9 @@ ### Fixed +- #314:招聘/编辑能力面板在绑定任一 MCP 连接器时给出明确警告——当前全部内置引擎都会在第一回合以 `qoder.mcp_binding_unsupported` 拒绝员工级 MCP,绑定仍可保存,但不再静默让操作员以为该能力已经可用。 +- #243:Agent Host 登记顺序改为从详尽的 `HOST_DEFINITIONS` 派生,新增引擎时不能再静默漏掉。 + - #325: The employee card now keeps its per-task and per-day budget declarations visible after turns instead of replacing the declaration with latest-turn usage. Usage remains available in the reports surfaces, where over-budget percentages stay truthful while the visual meter fill is bounded to its track. - #297:创建数字员工默认先展示 Agent 与基础信息;Agent 草案、头像、权限、能力、记忆、运行模式和预算改为按需展开,保留安全默认值、原有字段校验与 hire 请求 payload。 diff --git a/apps/desktop/renderer/src/app.css b/apps/desktop/renderer/src/app.css index fffad33c..d398c7b9 100644 --- a/apps/desktop/renderer/src/app.css +++ b/apps/desktop/renderer/src/app.css @@ -3422,6 +3422,7 @@ .owb-hire-mcp__tools { display: flex; flex-wrap: wrap; gap: 4px; padding: 0 4px; } .owb-hire-mcp__tools button { border: 1px solid var(--ui-border); border-radius: 999px; background: transparent; color: var(--ui-foreground-muted); padding: 3px 7px; font: 9px var(--ui-font-mono, monospace); cursor: pointer; } .owb-hire-mcp__tools button.is-selected { border-color: color-mix(in srgb, var(--ui-success) 58%, var(--ui-border)); background: color-mix(in srgb, var(--ui-success) 15%, transparent); color: var(--ui-success); } +.owb-hire-mcp-unsupported { margin: 0; padding: 8px 10px; border: 1px solid color-mix(in srgb, var(--ui-warning, #d97706) 45%, var(--ui-border)); border-radius: 8px; background: color-mix(in srgb, var(--ui-warning, #d97706) 12%, var(--ui-surface)); color: var(--ui-foreground); font-size: 11px; line-height: 1.45; } .owb-hire-capability-note { margin: 0; color: var(--ui-foreground-muted); font-size: 10px; line-height: 1.4; } .owb-tag--mcp { border-color: color-mix(in srgb, var(--ui-info, var(--ui-primary)) 46%, var(--ui-border)); color: var(--ui-info, var(--ui-primary)); } diff --git a/apps/desktop/renderer/src/org/PermissionsEditor.tsx b/apps/desktop/renderer/src/org/PermissionsEditor.tsx index d14ed4d6..5e763c44 100644 --- a/apps/desktop/renderer/src/org/PermissionsEditor.tsx +++ b/apps/desktop/renderer/src/org/PermissionsEditor.tsx @@ -231,6 +231,11 @@ export function CapabilityPicker({ + {(permissions.mcpServers ?? []).length > 0 ? ( +

+ {t("hire.mcpUnsupportedHint")} +

+ ) : null}

{t("hire.capabilityPermissionHint")}

); diff --git a/apps/desktop/renderer/test/capability-picker.test.tsx b/apps/desktop/renderer/test/capability-picker.test.tsx new file mode 100644 index 00000000..5fb1e97f --- /dev/null +++ b/apps/desktop/renderer/test/capability-picker.test.tsx @@ -0,0 +1,43 @@ +import { useState } from "react"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import type { HirePermissions } from "@roleweave/shared"; +import { CapabilityPicker } from "../src/org/PermissionsEditor"; + +const EMPTY: HirePermissions = { + tools: ["Read"], + rules: [{ scope: "position", resource: "./knowledge/**", actions: ["read"] }], + skills: [], + mcpServers: [], +}; + +function Harness({ initial = EMPTY }: { initial?: HirePermissions }) { + const [permissions, setPermissions] = useState(initial); + return ; +} + +describe("CapabilityPicker MCP hire warning (#314)", () => { + it("does not warn until an MCP connector is bound", () => { + render(); + expect(screen.queryByRole("status")).toBeNull(); + }); + + it("warns that bundled engines reject employee MCP as soon as a server is bound", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: /工作区网盘/ })); + expect(screen.getByRole("status")).toHaveTextContent("qoder.mcp_binding_unsupported"); + expect(screen.getByRole("status")).toHaveTextContent("第一回合"); + }); + + it("also warns when editing an employee that already has a binding", () => { + render( + , + ); + expect(screen.getByRole("status")).toHaveTextContent("qoder.mcp_binding_unsupported"); + }); +}); diff --git a/apps/server/src/agent-registry.ts b/apps/server/src/agent-registry.ts index 10a90562..27636131 100644 --- a/apps/server/src/agent-registry.ts +++ b/apps/server/src/agent-registry.ts @@ -90,8 +90,6 @@ interface HostDefinition { readonly capabilities: readonly string[]; } -const HOST_ORDER = ["qoder", "claude-code", "claude-local", "codex", "codex-local", "workbuddy"] as const satisfies readonly AgentHostId[]; - /** * Capabilities describe the control-plane contract, not provider account * state. No executable path, token, or raw CLI output is ever part of this @@ -124,6 +122,10 @@ const HOST_DEFINITIONS: Readonly> = { }, }; +/** Derived from HOST_DEFINITIONS so adding an AgentHostId is a compile error + * until the catalog entry exists; a handwritten subset would stay legal. */ +const HOST_ORDER = Object.keys(HOST_DEFINITIONS) as AgentHostId[]; + const LOCAL_PROBE_FAILURES = new Set([ "timed_out", "unsupported_version", @@ -135,7 +137,7 @@ function isRecord(value: unknown): value is Record { } function isKnownHostId(value: unknown): value is AgentHostId { - return typeof value === "string" && (HOST_ORDER as readonly string[]).includes(value); + return typeof value === "string" && Object.hasOwn(HOST_DEFINITIONS, value); } function isBoolean(value: unknown): value is boolean { diff --git a/apps/server/test/agent-registry.test.ts b/apps/server/test/agent-registry.test.ts index c7f6d7d9..e6186460 100644 --- a/apps/server/test/agent-registry.test.ts +++ b/apps/server/test/agent-registry.test.ts @@ -32,6 +32,21 @@ function health(overrides: Record = {}) { }; } +test("inherited Object keys are not treated as Agent Host ids", () => { + for (const inherited of ["constructor", "toString", "hasOwnProperty"]) { + assert.deepEqual(getAgentHostCapabilities({ id: inherited, engine: inherited }), []); + assert.equal(agentHostSupportsEmployeeMcp(inherited), false); + } +}); + +test("Host order is derived from the exhaustive catalog, not a handwritten subset", () => { + const hosts = listRegisteredAgentHosts(health()); + assert.deepEqual( + hosts.map((host) => host.id).sort(), + ["claude-code", "claude-local", "codex", "codex-local", "qoder", "workbuddy"], + ); +}); + test("registers every contracted Host from a health snapshot", () => { const hosts = listRegisteredAgentHosts(health()); diff --git a/packages/ui/src/locales/en.ts b/packages/ui/src/locales/en.ts index 8e5fe6ab..92e87684 100644 --- a/packages/ui/src/locales/en.ts +++ b/packages/ui/src/locales/en.ts @@ -542,6 +542,7 @@ export const enCatalog: Record = { "hire.mcpBound": "Bound", "hire.mcpAvailable": "Available", "hire.mcpToolsAria": "MCP tool permissions", + "hire.mcpUnsupportedHint": "Every bundled engine currently rejects employee-level MCP bindings on the first turn (qoder.mcp_binding_unsupported). The grant can be saved, but the employee's first task will fail immediately until an engine supports MCP.", "hire.capabilityPermissionHint": "Capability binding and resource access are separate; MCP tools can be turned off individually.", "hire.actionRead": "Read", "hire.actionCreate": "Create", diff --git a/packages/ui/src/locales/zh.ts b/packages/ui/src/locales/zh.ts index 5a487312..89a77221 100644 --- a/packages/ui/src/locales/zh.ts +++ b/packages/ui/src/locales/zh.ts @@ -545,6 +545,7 @@ export const zhCatalog: Record = { "hire.mcpBound": "已绑定", "hire.mcpAvailable": "可选", "hire.mcpToolsAria": "MCP 工具权限", + "hire.mcpUnsupportedHint": "当前全部内置引擎都会在第一回合拒绝员工级 MCP 绑定(qoder.mcp_binding_unsupported)。绑定可以保存,但该员工的首次任务会立刻失败,直到引擎支持 MCP。", "hire.capabilityPermissionHint": "能力绑定和资源权限分开控制;MCP 工具可以逐个关闭。", "hire.actionRead": "查", "hire.actionCreate": "新建",