diff --git a/docs/memory/architecture.md b/docs/memory/architecture.md index 3cd7d970..264bd53f 100644 --- a/docs/memory/architecture.md +++ b/docs/memory/architecture.md @@ -263,7 +263,7 @@ FastMCP, Streamable HTTP transport, port 8080. API-key auth via `Authorization: | `loops.ts` (3) | `run_agent_loop`, `get_loop_status`, `stop_loop` | Sequential bounded task execution (#740) | | `memory.ts` (1) | `write_user_memory` | Per-user memory blob; user email resolved server-side from execution_id (MEM-001, #888) | | `voip.ts` (1) | `call_user` | Outbound phone call via Twilio Media Streams; server-gated + rate-limited (VOIP-001, #1056) | -| `operator_queue.ts` (2) | `list_operator_queue`, `get_operator_queue_item` | Read-only Operating Room queue, broad or `agent_name`-scoped; agent-scoped keys gated to `{self} ∪ permitted` in the MCP layer (OPS-001, #1101) | +| `operator_queue.ts` (3) | `list_operator_queue`, `get_operator_queue_item`, `respond_to_operator_queue` | Read the Operating Room queue (broad or `agent_name`-scoped) and **resolve** a pending item — answer / approve / deny via `POST /{id}/respond`. The respond tool resolves the item's `agent_name`, then applies the same MCP-layer gate before writing (non-`pending` → structured error). Agent-scoped keys gated to `{self} ∪ permitted`. `cancel` deferred. (OPS-001, #1101 read / #1104 respond) | ### Vector Log Aggregator (`config/vector.yaml`) diff --git a/src/mcp-server/src/client.ts b/src/mcp-server/src/client.ts index 877e6cb0..d741f7e0 100644 --- a/src/mcp-server/src/client.ts +++ b/src/mcp-server/src/client.ts @@ -1200,6 +1200,23 @@ export class TrinityClient { ); } + /** + * Respond to (resolve) a pending operator-queue item (OPS-001, #1104). + * Proxies POST /api/operator-queue/{id}/respond. The backend 400s if the + * item is not in a respondable (`pending`) state — surfaced as a thrown + * Error the tool layer catches and returns as a structured `{ error }`. + */ + async respondToOperatorQueueItem( + itemId: string, + body: { response: string; response_text?: string }, + ): Promise { + return this.request( + "POST", + `/api/operator-queue/${encodeURIComponent(itemId)}/respond`, + body, + ); + } + // ============================================================================ // Outbound File Sharing (FILES-001) // ============================================================================ diff --git a/src/mcp-server/src/operator_queue.test.ts b/src/mcp-server/src/operator_queue.test.ts index c2aa7c88..bf951916 100644 --- a/src/mcp-server/src/operator_queue.test.ts +++ b/src/mcp-server/src/operator_queue.test.ts @@ -12,7 +12,11 @@ import { describe, it } from "node:test"; import { strict as assert } from "node:assert"; -import { filterQueueItemsForAgentScope } from "./tools/operator_queue.js"; +import { + filterQueueItemsForAgentScope, + createOperatorQueueTools, +} from "./tools/operator_queue.js"; +import type { TrinityClient } from "./client.js"; type Item = { id: string; agent_name: string }; @@ -58,3 +62,103 @@ describe("#1101 filterQueueItemsForAgentScope", () => { assert.deepEqual(out.map((i) => i.id), ["a", "c", "d"]); }); }); + +// --------------------------------------------------------------------------- +// #1104 — respond_to_operator_queue access gate + proxy behavior. +// Builds the tool with requireApiKey=false so getClient() returns our fake +// client directly. The crux being pinned: an agent-scoped key may NOT resolve +// a non-permitted agent's item, and on denial the write is never attempted. +// --------------------------------------------------------------------------- + +function makeRespondTool(fake: Partial) { + const tools = createOperatorQueueTools(fake as unknown as TrinityClient, false); + return tools.respondToOperatorQueue; +} + +const agentCtx = (agentName: string) => ({ + session: { scope: "agent", agentName } as any, +}); + +describe("#1104 respond_to_operator_queue", () => { + it("denies an agent-scoped key resolving a non-permitted agent's item — and never writes", async () => { + let responded = false; + const tool = makeRespondTool({ + getOperatorQueueItem: async () => ({ agent_name: "stranger" }) as any, + getPermittedAgents: async () => [], + respondToOperatorQueueItem: async () => { + responded = true; + return {} as any; + }, + }); + + const out = JSON.parse( + await tool.execute( + { item_id: "x", response: "approve" }, + agentCtx("self"), + ), + ); + + assert.equal(out.error, "Access denied"); + assert.equal(responded, false, "respond must not be called when access is denied"); + }); + + it("allows an agent to resolve its own item and proxies the response", async () => { + const calls: Array<{ id: string; body: any }> = []; + const tool = makeRespondTool({ + getOperatorQueueItem: async () => ({ agent_name: "self" }) as any, + getPermittedAgents: async () => [], + respondToOperatorQueueItem: async (id: string, body: any) => { + calls.push({ id, body }); + return { id, status: "responded", agent_name: "self" } as any; + }, + }); + + const out = JSON.parse( + await tool.execute( + { item_id: "item1", response: "approve", response_text: "ok" }, + agentCtx("self"), + ), + ); + + assert.equal(out.status, "responded"); + assert.deepEqual(calls, [ + { id: "item1", body: { response: "approve", response_text: "ok" } }, + ]); + }); + + it("allows resolving a permitted (non-self) agent's item", async () => { + let responded = false; + const tool = makeRespondTool({ + getOperatorQueueItem: async () => ({ agent_name: "friend" }) as any, + getPermittedAgents: async () => ["friend"], + respondToOperatorQueueItem: async () => { + responded = true; + return { status: "responded" } as any; + }, + }); + + const out = JSON.parse( + await tool.execute({ item_id: "y", response: "deny" }, agentCtx("self")), + ); + + assert.equal(out.status, "responded"); + assert.equal(responded, true); + }); + + it("surfaces a backend 400 (non-pending item) as a structured error, not a throw", async () => { + const tool = makeRespondTool({ + getOperatorQueueItem: async () => ({ agent_name: "self" }) as any, + getPermittedAgents: async () => [], + respondToOperatorQueueItem: async () => { + throw new Error("API error (400): Cannot respond to item with status 'responded'"); + }, + }); + + const out = JSON.parse( + await tool.execute({ item_id: "z", response: "approve" }, agentCtx("self")), + ); + + assert.match(out.error, /400/); + assert.match(out.error, /Cannot respond/); + }); +}); diff --git a/src/mcp-server/src/server.ts b/src/mcp-server/src/server.ts index 14cf4e0f..ee2baf27 100644 --- a/src/mcp-server/src/server.ts +++ b/src/mcp-server/src/server.ts @@ -220,7 +220,7 @@ export async function createServer(config: ServerConfig = {}) { createMemoryTools(client, requireApiKey), // MEM-001 write path (#888) createLoopTools(client, requireApiKey), // Sequential agent loops (#740) createVoipTools(client, requireApiKey), // VoIP telephony — call_user (VOIP-001, #1056) - createOperatorQueueTools(client, requireApiKey), // Operator queue read surface (OPS-001, #1101) + createOperatorQueueTools(client, requireApiKey), // Operator queue read + respond (OPS-001, #1101/#1104) ]; for (const group of toolGroups) { addAllTools(group); diff --git a/src/mcp-server/src/tools/operator_queue.ts b/src/mcp-server/src/tools/operator_queue.ts index 79eefc5f..8a5c2011 100644 --- a/src/mcp-server/src/tools/operator_queue.ts +++ b/src/mcp-server/src/tools/operator_queue.ts @@ -1,16 +1,19 @@ /** - * Operator Queue read tools (OPS-001, #1101) + * Operator Queue tools (OPS-001, #1101 read + #1104 respond) * - * Two MCP tools exposing the Operating Room queue over MCP (read-only v1): - * - list_operator_queue — broad listing, or scoped via the agent_name filter - * - get_operator_queue_item — a single item by id + * MCP tools exposing the Operating Room queue over MCP: + * - list_operator_queue — broad listing, or scoped via the agent_name filter + * - get_operator_queue_item — a single item by id + * - respond_to_operator_queue — resolve a pending item (answer / approve / deny) * * Access control crux: the backend resolves an agent-scoped MCP key to its * OWNER and filters by the owner's accessible agents — it does NOT apply * agent_permissions (architecture §5). So agent-to-agent gating lives HERE, * mirroring executions.ts (`checkAgentAccess`) and agents.ts (`list_agents` - * post-filter). Write actions (respond / cancel) are intentionally deferred to - * a follow-up — this surface is read-only. + * post-filter). The write tool resolves the item's `agent_name` first, then + * runs the SAME `checkAgentAccess` gate before proxying the response — an + * agent-scoped key may resolve items for {self} ∪ permitted only. (#1104 v1 + * exposes `respond` only; `cancel` is deferred — wider blast radius.) */ import { z } from "zod"; @@ -240,5 +243,76 @@ export function createOperatorQueueTools( return JSON.stringify(item, null, 2); }, }, + + // ======================================================================== + // respond_to_operator_queue (#1104) + // ======================================================================== + respondToOperatorQueue: { + name: "respond_to_operator_queue", + description: + "Respond to (resolve) a pending Operating Room (operator queue) item — " + + "answer a question, or approve/deny an approval request. `response` is " + + "the decision value (e.g. the chosen approval option, or the answer); " + + "`response_text` is optional freeform context. Only items in the " + + "'pending' state can be resolved — responding to an already-resolved, " + + "expired, or cancelled item returns a structured error. Access control: " + + "agent-scoped keys may only resolve items belonging to themselves or " + + "agents they have explicit permission for.", + parameters: z.object({ + item_id: z.string().min(1).describe("Operator queue item id to resolve."), + response: z + .string() + .min(1) + .describe( + "The response/decision value — for an approval item the chosen option (e.g. 'approve'/'deny'); for a question, the answer.", + ), + response_text: z + .string() + .optional() + .describe("Optional freeform text accompanying the response."), + }), + execute: async ( + params: { item_id: string; response: string; response_text?: string }, + context?: { session?: McpAuthContext }, + ) => { + const authContext = context?.session; + const apiClient = getClient(authContext); + + // Resolve the item's agent_name first so the permission check has a + // target (the caller only supplies an id). A read here also surfaces a + // 404 cleanly before any write attempt. + let item: { agent_name: string }; + try { + item = await apiClient.getOperatorQueueItem(params.item_id); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + console.error(`[respond_to_operator_queue] lookup error: ${msg}`); + return JSON.stringify({ error: msg }, null, 2); + } + + // Same MCP-layer agent_permissions gate as the read tools — the backend + // resolved the item under the KEY OWNER's access, so re-check it against + // the calling agent's permits before allowing a write. + const access = await checkAgentAccess(apiClient, authContext, item.agent_name); + if (!access.allowed) { + console.log(`[respond_to_operator_queue] Access denied: ${access.reason}`); + return JSON.stringify({ error: "Access denied", reason: access.reason }, null, 2); + } + + try { + const updated = await apiClient.respondToOperatorQueueItem(params.item_id, { + response: params.response, + response_text: params.response_text, + }); + return JSON.stringify(updated, null, 2); + } catch (error) { + // Backend 400s on a non-pending item (already responded / expired / + // cancelled) — surface as a structured error, not a thrown exception. + const msg = error instanceof Error ? error.message : String(error); + console.error(`[respond_to_operator_queue] error: ${msg}`); + return JSON.stringify({ error: msg }, null, 2); + } + }, + }, }; }