From 17d82506a4719a719a8742dca3c183e2402589e8 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 18 Oct 2026 12:09:24 -0700 Subject: [PATCH 1/4] fix(grants): keep later native posts inside pod after broker call Co-authored-by: kai Agent-Model: codex/gpt-6-sol/medium --- .../approvalActionService.decider.test.js | 12 ++ .../nativeRuntimeService.webSearch.test.js | 156 ++++++++++++++++++ backend/services/agentMessageService.ts | 4 +- backend/services/approvalActionService.ts | 5 +- backend/services/nativeRuntimeService.ts | 43 +++-- 5 files changed, 204 insertions(+), 16 deletions(-) diff --git a/backend/__tests__/unit/services/approvalActionService.decider.test.js b/backend/__tests__/unit/services/approvalActionService.decider.test.js index 21868946e4..0d3f23d052 100644 --- a/backend/__tests__/unit/services/approvalActionService.decider.test.js +++ b/backend/__tests__/unit/services/approvalActionService.decider.test.js @@ -138,6 +138,18 @@ describe('proposeAction — decider derivation', () => { ); }); + test('a native broker-marked proposal suppresses outbound relay on its card', async () => { + podLookup(HUMAN_POD_CREATOR); + installLookup({ installedBy: HUMAN_INSTALLER }); + + const result = await propose({ noOutboundRelay: true }); + + expect(result.ok).toBe(true); + expect(mockPostMessage).toHaveBeenCalledWith(expect.objectContaining({ + messageType: 'card', noOutboundRelay: true, + })); + }); + test('a proposal whose card never posted is moot and is NOT projected — no phantom ask', async () => { podLookup(HUMAN_POD_CREATOR); installLookup({ installedBy: null }); diff --git a/backend/__tests__/unit/services/nativeRuntimeService.webSearch.test.js b/backend/__tests__/unit/services/nativeRuntimeService.webSearch.test.js index 650606d9b4..f243716cbb 100644 --- a/backend/__tests__/unit/services/nativeRuntimeService.webSearch.test.js +++ b/backend/__tests__/unit/services/nativeRuntimeService.webSearch.test.js @@ -20,6 +20,10 @@ jest.mock('../../../models/User', () => ({ })); jest.mock('../../../services/agentTypingService', () => ({ emitAgentTypingStart: jest.fn(), emitAgentTypingStop: jest.fn() })); jest.mock('../../../services/agentMessageService', () => ({ postMessage: jest.fn().mockResolvedValue({ success: true }) })); +const mockProposeAction = jest.fn(); +jest.mock('../../../services/approvalActionService', () => ({ + proposeAction: (...args) => mockProposeAction(...args), +})); jest.mock('../../../services/messageClaimService', () => ({ claim: jest.fn().mockResolvedValue({ claimed: true, expiresAt: new Date() }), release: jest.fn().mockResolvedValue({ released: true }), @@ -114,6 +118,7 @@ describe('native turn with commonly_web_search', () => { mockBrokerDispatch.mockImplementation(async ({ name }) => (name === 'linear_list_issues' ? { callId: 'tc-1', outcome: 'ok', content: { issues: [] } } : null)); + mockProposeAction.mockResolvedValue({ ok: true, approvalId: 'approval-1' }); }); test('results reach the model marked untrusted; the run keeps the count and the addresses only', async () => { @@ -191,6 +196,157 @@ describe('native turn with commonly_web_search', () => { expect(mockSearchWeb).toHaveBeenCalledTimes(1); }); + describe('after a granted broker call', () => { + test('an unbridged post stays in the pod without narrowing the next tool offer', async () => { + axios.post + .mockResolvedValueOnce(toolCalls(call('c1', 'linear_list_issues', {}))) + .mockResolvedValueOnce(toolCalls(call('c2', 'commonly_post_message', { content: 'issue result' }))) + .mockResolvedValueOnce(finalReply); + + await runAgent(install, mention); + + expect(offeredIn(1)).toContain('linear_list_issues'); + expect(AgentMessageService.postMessage).toHaveBeenCalledWith(expect.objectContaining({ + content: 'issue result', noOutboundRelay: true, + })); + }); + + test('only posts after the broker call receive its outbound marker', async () => { + axios.post + .mockResolvedValueOnce(toolCalls( + call('c1', 'commonly_post_message', { content: 'before' }), + call('c2', 'linear_list_issues', {}), + call('c3', 'commonly_post_message', { content: 'after' }), + )) + .mockResolvedValueOnce(finalReply); + + await runAgent(install, mention); + + expect(AgentMessageService.postMessage.mock.calls.map(([args]) => [args.content, args.noOutboundRelay])) + .toEqual([['before', false], ['after', true]]); + }); + + test('re-reads the bridge before each later post, including two in one response', async () => { + mockBridged.mockResolvedValueOnce(false).mockResolvedValueOnce(false).mockResolvedValue(true); + axios.post + .mockResolvedValueOnce(toolCalls( + call('c1', 'linear_list_issues', {}), + call('c2', 'commonly_post_message', { content: 'first' }), + call('c3', 'commonly_post_message', { content: 'second' }), + )) + .mockResolvedValueOnce(finalReply); + + await runAgent(install, mention); + + expect(AgentMessageService.postMessage).toHaveBeenCalledTimes(1); + expect(AgentMessageService.postMessage.mock.calls[0][0]).toMatchObject({ + content: 'first', noOutboundRelay: true, + }); + expect(toolMessages(1)[2].content).toMatch(/turn that called a granted tool cannot post/); + // Initial offer, each of two posts, then the next model offer. + expect(mockBridged).toHaveBeenCalledTimes(4); + }); + + test('a bridge added only before the final-text fallback prevents its post', async () => { + mockBridged.mockResolvedValueOnce(false).mockResolvedValueOnce(false).mockResolvedValue(true); + axios.post + .mockResolvedValueOnce(toolCalls(call('c1', 'linear_list_issues', {}))) + .mockResolvedValueOnce(finalReply); + + const result = await runAgent(install, mention); + + expect(AgentMessageService.postMessage).not.toHaveBeenCalled(); + expect(result.finalMessage).toBeUndefined(); + expect(savedRun.errorKind).toBe('guardrail_blocked'); + expect(mockBridged).toHaveBeenCalledTimes(3); + }); + + test('an unbridged final-text fallback still carries noOutboundRelay', async () => { + axios.post + .mockResolvedValueOnce(toolCalls(call('c1', 'linear_list_issues', {}))) + .mockResolvedValueOnce(finalReply); + + await runAgent(install, mention); + + expect(offeredIn(1)).toContain('linear_list_issues'); + expect(AgentMessageService.postMessage).toHaveBeenCalledWith(expect.objectContaining({ + content: 'Here is what shipped.', noOutboundRelay: true, + })); + }); + + test('a hosted offer also stays in the pod', async () => { + const priorEngine = process.env.HOSTED_ENGINE; + process.env.HOSTED_ENGINE = 'native'; + const offerInstall = { + ...install, + installedBy: null, + config: { ...install.config, tools: [...install.config.tools, 'commonly_offer_hosted_agent'] }, + }; + axios.post + .mockResolvedValueOnce(toolCalls(call('c1', 'linear_list_issues', {}), + call('c2', 'commonly_offer_hosted_agent', { content: 'Run it here.' }))) + .mockResolvedValueOnce(finalReply); + + try { + await runAgent(offerInstall, mention); + + expect(AgentMessageService.postMessage).toHaveBeenCalledWith(expect.objectContaining({ + content: 'Run it here.', noOutboundRelay: true, + })); + } finally { + if (priorEngine === undefined) delete process.env.HOSTED_ENGINE; + else process.env.HOSTED_ENGINE = priorEngine; + } + }); + + test('an approval card receives the outbound marker', async () => { + const proposalInstall = { + ...install, + config: { ...install.config, tools: [...install.config.tools, 'commonly_propose_action'] }, + }; + axios.post + .mockResolvedValueOnce(toolCalls(call('c1', 'linear_list_issues', {}), + call('c2', 'commonly_propose_action', { + actionType: 'create_pod', params: { name: 'Research' }, summary: 'Create a research pod', + }))) + .mockResolvedValueOnce(finalReply); + + await runAgent(proposalInstall, mention); + + expect(mockProposeAction).toHaveBeenCalledWith(expect.objectContaining({ noOutboundRelay: true })); + }); + + test.each([ + ['commonly_offer_hosted_agent', { content: 'Run it here.' }], + ['commonly_propose_action', { + actionType: 'create_pod', params: { name: 'Research' }, summary: 'Create a research pod', + }], + ])('a newly bridged pod refuses a later %s post', async (postTool, args) => { + const priorEngine = process.env.HOSTED_ENGINE; + process.env.HOSTED_ENGINE = 'native'; + mockBridged.mockResolvedValueOnce(false).mockResolvedValue(true); + const postingInstall = { + ...install, + installedBy: null, + config: { ...install.config, tools: [...install.config.tools, postTool] }, + }; + axios.post + .mockResolvedValueOnce(toolCalls(call('c1', 'linear_list_issues', {}), call('c2', postTool, args))) + .mockResolvedValueOnce(finalReply); + + try { + await runAgent(postingInstall, mention); + + expect(AgentMessageService.postMessage).not.toHaveBeenCalled(); + expect(mockProposeAction).not.toHaveBeenCalled(); + expect(toolMessages(1)[1].content).toMatch(/turn that called a granted tool cannot post/); + } finally { + if (priorEngine === undefined) delete process.env.HOSTED_ENGINE; + else process.env.HOSTED_ENGINE = priorEngine; + } + }); + }); + test('stops at three searches a turn and records three', async () => { axios.post .mockResolvedValueOnce(toolCalls( diff --git a/backend/services/agentMessageService.ts b/backend/services/agentMessageService.ts index 40dfd8a5a3..4018204356 100644 --- a/backend/services/agentMessageService.ts +++ b/backend/services/agentMessageService.ts @@ -179,8 +179,8 @@ interface PostMessageOptions { installationConfig?: unknown; // Server-set, never model-set: the post must not leave Commonly through a // channel relay (Telegram, Slack). A native turn that has read web results - // sets it (TASK-267a, Rhea on #2244), and the relay dispatcher refuses such - // a message whatever bridges exist by the time it is stored. + // or called a granted tool sets it (TASK-267a/#2244, TASK-234), and the relay + // dispatcher refuses such a message whatever bridges exist when it is stored. noOutboundRelay?: boolean; // Server-set, never model-set: the message the posting run is answering, // the native runtime's trigger. Read by the answer rule (agentAnswerService) diff --git a/backend/services/approvalActionService.ts b/backend/services/approvalActionService.ts index b6cc319233..907e0511c7 100644 --- a/backend/services/approvalActionService.ts +++ b/backend/services/approvalActionService.ts @@ -111,6 +111,8 @@ interface ProposeOptions { params: Record; summary: string; installationConfig?: unknown; + /** A native turn that called a grant keeps its later card inside the pod. */ + noOutboundRelay?: boolean; /** Broker calls bind approval to the Integration owner, not pod creator. */ ownerUserId?: string; agentUserId?: string; @@ -374,7 +376,7 @@ export const proposeActionForRuntime = async (input: { export const proposeAction = async (options: ProposeOptions): Promise => { const { podId, agentName, instanceId, displayName, actionType, params, summary, installationConfig, - ownerUserId: requestedOwnerUserId, agentUserId, toolCall, + ownerUserId: requestedOwnerUserId, agentUserId, toolCall, noOutboundRelay, } = options; if (!KNOWN_ACTION_TYPES.has(actionType as ApprovalActionType)) { @@ -468,6 +470,7 @@ export const proposeAction = async (options: ProposeOptions): Promise = new Set([ + 'commonly_post_message', 'commonly_offer_hosted_agent', 'commonly_propose_action', +]); export const toolsForConfig = ( cfg: { tools?: unknown } | null | undefined, @@ -549,6 +554,8 @@ interface DispatchContext { sandbox?: TurnSandbox; /** Set when a web search has returned in this turn (TASK-267a). */ webTainted?: boolean; + /** A granted tool has returned in this turn; affects outbound posts only. */ + brokerCallSeen?: boolean; /** Whether the pod is bridged to an external chat, read just before dispatch. */ podBridged?: boolean; /** Search requests this turn has sent. */ @@ -646,8 +653,12 @@ async function dispatchTool( error: 'web_tainted_tool_denied', }; } - if (ctx.webTainted && ctx.podBridged && name === 'commonly_post_message') { - return { content: { ok: false, error: WEB_TAINTED_BRIDGED_REFUSAL }, error: 'web_tainted_bridged_post_denied' }; + if ((ctx.webTainted || ctx.brokerCallSeen) && ctx.podBridged && POSTING_NATIVE_TOOLS.has(name)) { + const refusal = ctx.webTainted ? WEB_TAINTED_BRIDGED_REFUSAL : BROKER_TAINTED_BRIDGED_REFUSAL; + return { + content: { ok: false, error: refusal }, + error: ctx.webTainted ? 'web_tainted_bridged_post_denied' : 'broker_tainted_bridged_post_denied', + }; } const args = (rawArgs && typeof rawArgs === 'object' ? rawArgs : {}) as Record; @@ -709,9 +720,9 @@ async function dispatchTool( displayName: ctx.displayName, installationConfig: ctx.installationConfig, metadata: { source: 'native-runtime' }, - // Web results are in this turn: the post stays in the pod and never - // reaches a channel relay, whatever bridges exist when it is stored. - noOutboundRelay: Boolean(ctx.webTainted), + // External results seen this turn stay in the pod even if a channel + // bridge appears between this check and message storage. + noOutboundRelay: Boolean(ctx.webTainted || ctx.brokerCallSeen), answeringMessageId: ctx.answeringMessageId, }); return { content: { ok: true, posted: true, result } }; @@ -756,7 +767,7 @@ async function dispatchTool( installationConfig: ctx.installationConfig, metadata: { source: 'native-runtime' }, payload: { kind: 'starts', starts: [{ id: 'run-hosted', label: HOSTED_OFFER_LABEL, href }] }, - noOutboundRelay: Boolean(ctx.webTainted), + noOutboundRelay: Boolean(ctx.webTainted || ctx.brokerCallSeen), answeringMessageId: ctx.answeringMessageId, }); return { content: { ok: true, posted: true, result } }; @@ -810,6 +821,7 @@ async function dispatchTool( ? args.params : {}) as Record, summary: String(args.summary || ''), installationConfig: ctx.installationConfig, + noOutboundRelay: Boolean(ctx.webTainted || ctx.brokerCallSeen), }); if (!result.ok) { return { content: { ok: false, error: result.error }, error: 'propose_failed' }; @@ -1924,6 +1936,9 @@ export async function runAgent( }); const elapsed = Date.now() - toolStart; if (brokerCall) { + // A bridge can appear after this call. Keep its result inside the + // pod on every later post without narrowing the turn's tools. + dispatchCtx.brokerCallSeen = true; // A broker call records its callId and outcome and nothing else: // the ToolCall row the broker writes is the trail for what was // asked and returned, and an `AgentRun` must not become a second @@ -1946,8 +1961,9 @@ export async function runAgent( // Re-read the policy before dispatch; a stale model call is refused. dispatchCtx.inPodOnly = await inPodOnlyNow(); // A bridge can be added mid-turn: re-read it before a lookup, and - // before any post once the web is in the turn. - if (WEB_LOOKUP_TOOLS.has(toolName) || (dispatchCtx.webTainted && toolName === 'commonly_post_message')) { + // before every later post after a web or broker result. + if (WEB_LOOKUP_TOOLS.has(toolName) + || ((dispatchCtx.webTainted || dispatchCtx.brokerCallSeen) && POSTING_NATIVE_TOOLS.has(toolName))) { dispatchCtx.podBridged = await podBridgedNow(); } // A tool the narrowing removed goes to dispatchTool, which refuses it @@ -2036,12 +2052,13 @@ export async function runAgent( run.totalTokens += Number(usage.total_tokens || 0); const textOut = typeof msg.content === 'string' ? msg.content.trim() : ''; - // The fallback post is a post like any other: after a search, not into a - // bridged pod (Rhea on #2244). - const fallbackBlocked = Boolean(textOut && !postedViaTool && dispatchCtx.webTainted && await podBridgedNow()); + // The fallback post is a post like any other: after an external result, + // re-read the bridge before letting it through (Rhea on #2244). + const fallbackBlocked = Boolean(textOut && !postedViaTool + && (dispatchCtx.webTainted || dispatchCtx.brokerCallSeen) && await podBridgedNow()); if (fallbackBlocked) { run.errorKind = 'guardrail_blocked'; - run.errorMessage = WEB_TAINTED_BRIDGED_REFUSAL; + run.errorMessage = dispatchCtx.webTainted ? WEB_TAINTED_BRIDGED_REFUSAL : BROKER_TAINTED_BRIDGED_REFUSAL; } if (textOut && !postedViaTool && !fallbackBlocked) { // Fallback: the LLM wrote content WITHOUT calling commonly_post_message. @@ -2057,7 +2074,7 @@ export async function runAgent( displayName, installationConfig: cfg, metadata: { source: 'native-runtime', fallback: true }, - noOutboundRelay: Boolean(dispatchCtx.webTainted), + noOutboundRelay: Boolean(dispatchCtx.webTainted || dispatchCtx.brokerCallSeen), answeringMessageId: dispatchCtx.answeringMessageId, }); // The message service suppresses a bare NO_REPLY (and other From d2c04f113f4dd12faa063f9a0edf1aa76d18464a Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Thu, 22 Oct 2026 03:53:35 -0700 Subject: [PATCH 2/4] fix(native): keep failed broker turns inside pod Co-authored-by: kai Agent-Model: codex/gpt-6-sol/medium --- .../nativeRuntimeService.webSearch.test.js | 21 +++++++++++++++++++ backend/services/nativeRuntimeService.ts | 5 +++-- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/backend/__tests__/unit/services/nativeRuntimeService.webSearch.test.js b/backend/__tests__/unit/services/nativeRuntimeService.webSearch.test.js index f243716cbb..b025e97ad2 100644 --- a/backend/__tests__/unit/services/nativeRuntimeService.webSearch.test.js +++ b/backend/__tests__/unit/services/nativeRuntimeService.webSearch.test.js @@ -17,6 +17,9 @@ jest.mock('../../../models/Pod', () => ({ jest.mock('../../../models/AgentRun', () => ({ create: jest.fn(), countDocuments: jest.fn() })); jest.mock('../../../models/User', () => ({ findOne: jest.fn(() => ({ select: () => ({ lean: () => Promise.resolve({ _id: '507f1f77bcf86cd799439077' }) }) })), + findById: jest.fn(() => ({ + select: () => ({ lean: () => Promise.resolve({ entitlements: { pro: false }, isBot: false }) }), + })), })); jest.mock('../../../services/agentTypingService', () => ({ emitAgentTypingStart: jest.fn(), emitAgentTypingStop: jest.fn() })); jest.mock('../../../services/agentMessageService', () => ({ postMessage: jest.fn().mockResolvedValue({ success: true }) })); @@ -316,6 +319,24 @@ describe('native turn with commonly_web_search', () => { expect(mockProposeAction).toHaveBeenCalledWith(expect.objectContaining({ noOutboundRelay: true })); }); + test('a failed hosted turn after a broker call keeps its fixed line inside the pod', async () => { + const hostedInstall = { + ...install, + config: { runtime: { runtimeType: 'hosted' }, hosted: { engine: 'native', provisionedAt: new Date() } }, + }; + axios.post + .mockResolvedValueOnce(toolCalls(call('c1', 'linear_list_issues', {}))) + .mockResolvedValueOnce({ status: 502, headers: {}, data: { error: 'upstream unavailable' } }); + + const result = await runAgent(hostedInstall, mention); + + expect(result.status).toBe('failed'); + expect(AgentMessageService.postMessage).toHaveBeenCalledWith(expect.objectContaining({ + metadata: expect.objectContaining({ runFailed: true }), + noOutboundRelay: true, + })); + }); + test.each([ ['commonly_offer_hosted_agent', { content: 'Run it here.' }], ['commonly_propose_action', { diff --git a/backend/services/nativeRuntimeService.ts b/backend/services/nativeRuntimeService.ts index d7d1b591fc..0cebf736de 100644 --- a/backend/services/nativeRuntimeService.ts +++ b/backend/services/nativeRuntimeService.ts @@ -2107,7 +2107,8 @@ export async function runAgent( // never reaches this. The per-user spend ceiling still counts the run. if (hostedNative && run.status === 'failed' && !postedViaTool && !finalMessage) { run.metered = false; - if (wasDirectlyAddressed(trigger) && !(dispatchCtx.webTainted && await podBridgedNow())) { + if (wasDirectlyAddressed(trigger) + && !((dispatchCtx.webTainted || dispatchCtx.brokerCallSeen) && await podBridgedNow())) { try { const AgentMessageService = require('./agentMessageService'); await AgentMessageService.postMessage({ @@ -2119,7 +2120,7 @@ export async function runAgent( displayName, installationConfig: cfg, metadata: { source: 'native-runtime', runFailed: true }, - noOutboundRelay: Boolean(dispatchCtx.webTainted), + noOutboundRelay: Boolean(dispatchCtx.webTainted || dispatchCtx.brokerCallSeen), answeringMessageId: dispatchCtx.answeringMessageId, }); } catch (postErr) { From 946856a3280bde242e88ee54f71f6f939656cf08 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Thu, 22 Oct 2026 04:02:57 -0700 Subject: [PATCH 3/4] fix(native): bind broker approval cards to relay marker Co-authored-by: kai Agent-Model: codex/gpt-6-sol/medium --- .../services/grantBrokerProjectionService.test.js | 2 ++ .../services/nativeRuntimeService.webSearch.test.js | 13 +++++++++++++ .../unit/services/toolBrokerService.hosted.test.js | 2 ++ backend/services/grantBrokerProjectionService.ts | 4 +++- backend/services/nativeRuntimeService.ts | 1 + backend/services/toolBrokerService.ts | 3 +++ 6 files changed, 24 insertions(+), 1 deletion(-) diff --git a/backend/__tests__/unit/services/grantBrokerProjectionService.test.js b/backend/__tests__/unit/services/grantBrokerProjectionService.test.js index 94620936a9..cb5aa1bdcf 100644 --- a/backend/__tests__/unit/services/grantBrokerProjectionService.test.js +++ b/backend/__tests__/unit/services/grantBrokerProjectionService.test.js @@ -202,6 +202,7 @@ describe('dispatchHostedBrokerTool', () => { agentUserId: SEAT, agentName: 'kai', instanceId: 'default', + noOutboundRelay: true, }); expect(mockCallTool).toHaveBeenCalledWith({ @@ -212,6 +213,7 @@ describe('dispatchHostedBrokerTool', () => { // Set only here: a hosted turn has no shell, web or file tools to confine, // so the seat-confinement refusal does not apply to it (Wren, TASK-175). hostedTurn: true, + noOutboundRelay: true, tool: 'github.list_issues', args: { grantId: 'grant-someone-else', agentUserId: OTHER_SEAT, state: 'open' }, }); diff --git a/backend/__tests__/unit/services/nativeRuntimeService.webSearch.test.js b/backend/__tests__/unit/services/nativeRuntimeService.webSearch.test.js index b025e97ad2..b2e282e0f7 100644 --- a/backend/__tests__/unit/services/nativeRuntimeService.webSearch.test.js +++ b/backend/__tests__/unit/services/nativeRuntimeService.webSearch.test.js @@ -229,6 +229,19 @@ describe('native turn with commonly_web_search', () => { .toEqual([['before', false], ['after', true]]); }); + test('a second broker call carries the outbound marker needed by a parked card', async () => { + axios.post + .mockResolvedValueOnce(toolCalls( + call('c1', 'linear_list_issues', {}), + call('c2', 'linear_list_issues', {}), + )) + .mockResolvedValueOnce(finalReply); + + await runAgent(install, mention); + + expect(mockBrokerDispatch.mock.calls.map(([args]) => args.noOutboundRelay)).toEqual([false, true]); + }); + test('re-reads the bridge before each later post, including two in one response', async () => { mockBridged.mockResolvedValueOnce(false).mockResolvedValueOnce(false).mockResolvedValue(true); axios.post diff --git a/backend/__tests__/unit/services/toolBrokerService.hosted.test.js b/backend/__tests__/unit/services/toolBrokerService.hosted.test.js index ef9f867377..cdf0518342 100644 --- a/backend/__tests__/unit/services/toolBrokerService.hosted.test.js +++ b/backend/__tests__/unit/services/toolBrokerService.hosted.test.js @@ -763,6 +763,7 @@ describe('a hosted-MCP grant through the broker', () => { agentUserId: 'agent-a', agentName: 'openclaw', instanceId: 'aria', + noOutboundRelay: true, tool: 'linear.create_issue', args: { title: 'x' }, })).rejects.toMatchObject({ code: 'approval_required' }); @@ -773,6 +774,7 @@ describe('a hosted-MCP grant through the broker', () => { expect.objectContaining({ agentName: 'openclaw' }), ); expect(mockProposeAction).toHaveBeenCalledTimes(1); + expect(mockProposeAction).toHaveBeenCalledWith(expect.objectContaining({ noOutboundRelay: true })); // A parked write is assessed by tools/list but never sends tools/call. expect(toolCalls()).toHaveLength(0); expect(mockToolCall.create).toHaveBeenCalledWith(expect.objectContaining({ outcome: 'pending_approval' })); diff --git a/backend/services/grantBrokerProjectionService.ts b/backend/services/grantBrokerProjectionService.ts index 476b9d2367..0d2f1ce148 100644 --- a/backend/services/grantBrokerProjectionService.ts +++ b/backend/services/grantBrokerProjectionService.ts @@ -241,13 +241,14 @@ export interface HostedBrokerCall { * JSON for the same outcome. */ export const dispatchHostedBrokerTool = async ( - { projection, name, args, agentUserId, agentName, instanceId }: { + { projection, name, args, agentUserId, agentName, instanceId, noOutboundRelay }: { projection: HostedBrokerProjection; name: string; args: unknown; agentUserId: string; agentName?: string; instanceId?: string; + noOutboundRelay?: boolean; }, ): Promise => { const mapped = projection.dispatch.get(name); @@ -263,6 +264,7 @@ export const dispatchHostedBrokerTool = async ( instanceId, tool: mapped.tool, args, + noOutboundRelay, // A hosted turn has no shell, web or file tools: there is nothing for a // sandbox to confine, so the seat-confinement refusal does not apply // (Wren, TASK-175 74882). This is the only caller that sets it. diff --git a/backend/services/nativeRuntimeService.ts b/backend/services/nativeRuntimeService.ts index 0cebf736de..3dd57a00e3 100644 --- a/backend/services/nativeRuntimeService.ts +++ b/backend/services/nativeRuntimeService.ts @@ -1933,6 +1933,7 @@ export async function runAgent( agentUserId: seatUserId, agentName, instanceId, + noOutboundRelay: Boolean(dispatchCtx.webTainted || dispatchCtx.brokerCallSeen), }); const elapsed = Date.now() - toolStart; if (brokerCall) { diff --git a/backend/services/toolBrokerService.ts b/backend/services/toolBrokerService.ts index 949dabcf4c..52c10bd5ca 100644 --- a/backend/services/toolBrokerService.ts +++ b/backend/services/toolBrokerService.ts @@ -97,6 +97,8 @@ export interface BrokerCallInput { * one this flag must not answer. */ hostedTurn?: boolean; + /** Server-set native turn marker. A parked approval card must stay in the pod. */ + noOutboundRelay?: boolean; } export interface BrokerCallResult { @@ -1198,6 +1200,7 @@ export const callTool = async (input: BrokerCallInput): Promise).grantId || input.grantId), callId, From b0e813c74616a921837210c181c5646c12b94f5c Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Thu, 22 Oct 2026 04:15:48 -0700 Subject: [PATCH 4/4] test: include relay flag in hosted broker call expectation Co-authored-by: kai Agent-Model: codex/gpt-6-sol/medium --- .../unit/services/nativeRuntimeService.hostedBroker.test.js | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/__tests__/unit/services/nativeRuntimeService.hostedBroker.test.js b/backend/__tests__/unit/services/nativeRuntimeService.hostedBroker.test.js index 8b5e3c690d..dde65c9ad8 100644 --- a/backend/__tests__/unit/services/nativeRuntimeService.hostedBroker.test.js +++ b/backend/__tests__/unit/services/nativeRuntimeService.hostedBroker.test.js @@ -228,6 +228,7 @@ describe('a hosted run in a granted pod', () => { // A hosted turn has no shell, web or file tools, so the // seat-confinement refusal is out of scope for it (Wren, TASK-175). hostedTurn: true, + noOutboundRelay: false, tool: 'github.list_issues', args: { state: 'open' }, });