Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,7 @@ describe('dispatchHostedBrokerTool', () => {
agentUserId: SEAT,
agentName: 'kai',
instanceId: 'default',
noOutboundRelay: true,
});

expect(mockCallTool).toHaveBeenCalledWith({
Expand All @@ -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' },
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
});
Expand Down
190 changes: 190 additions & 0 deletions backend/__tests__/unit/services/nativeRuntimeService.webSearch.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,16 @@ 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 }) }));
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 }),
Expand Down Expand Up @@ -114,6 +121,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 () => {
Expand Down Expand Up @@ -191,6 +199,188 @@ 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('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
.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('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', {
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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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' });
Expand All @@ -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' }));
Expand Down
4 changes: 2 additions & 2 deletions backend/services/agentMessageService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
5 changes: 4 additions & 1 deletion backend/services/approvalActionService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,8 @@ interface ProposeOptions {
params: Record<string, unknown>;
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;
Expand Down Expand Up @@ -374,7 +376,7 @@ export const proposeActionForRuntime = async (input: {
export const proposeAction = async (options: ProposeOptions): Promise<ProposeResult> => {
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)) {
Expand Down Expand Up @@ -468,6 +470,7 @@ export const proposeAction = async (options: ProposeOptions): Promise<ProposeRes
content: `[approval needed] ${trimmedSummary}`,
metadata: { source: 'approval-card', approvalId: String(row._id) },
installationConfig,
noOutboundRelay,
// A card is not a summary. `postMessage` otherwise runs every message
// through `persistSummaryFromAgentMessage`, and once propose-action opened
// an HTTP entry point CodeQL flagged the resulting flow into that path's
Expand Down
4 changes: 3 additions & 1 deletion backend/services/grantBrokerProjectionService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<HostedBrokerCall | null> => {
const mapped = projection.dispatch.get(name);
Expand All @@ -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.
Expand Down
Loading
Loading