From d3494a8d9c4a2a96fd9cad9572cd67f921ab34ff Mon Sep 17 00:00:00 2001 From: kunal-10-cloud Date: Thu, 5 Mar 2026 11:01:43 +0530 Subject: [PATCH 01/24] feat(core): implement structured SubagentProgress emission in BrowserAgentInvocation --- .../browser/browserAgentInvocation.test.ts | 151 +++++++++++++- .../agents/browser/browserAgentInvocation.ts | 187 ++++++++++++++++-- 2 files changed, 321 insertions(+), 17 deletions(-) diff --git a/packages/core/src/agents/browser/browserAgentInvocation.test.ts b/packages/core/src/agents/browser/browserAgentInvocation.test.ts index b58a9c409e8..7d060c486ca 100644 --- a/packages/core/src/agents/browser/browserAgentInvocation.test.ts +++ b/packages/core/src/agents/browser/browserAgentInvocation.test.ts @@ -9,9 +9,15 @@ import { BrowserAgentInvocation } from './browserAgentInvocation.js'; import { makeFakeConfig } from '../../test-utils/config.js'; import type { Config } from '../../config/config.js'; import type { MessageBus } from '../../confirmation-bus/message-bus.js'; -import type { AgentInputs } from '../types.js'; +import { + type AgentInputs, + type SubagentProgress, + AgentTerminateMode, +} from '../types.js'; +import { LocalAgentExecutor } from '../local-executor.js'; +import { createBrowserAgentDefinition } from './browserAgentFactory.js'; -// Mock dependencies before imports +// Mock dependencies vi.mock('../../utils/debugLogger.js', () => ({ debugLogger: { log: vi.fn(), @@ -19,6 +25,17 @@ vi.mock('../../utils/debugLogger.js', () => ({ }, })); +vi.mock('../local-executor.js', () => ({ + LocalAgentExecutor: { + create: vi.fn(), + }, +})); + +vi.mock('./browserAgentFactory.js', () => ({ + createBrowserAgentDefinition: vi.fn(), + cleanupBrowserAgent: vi.fn(), +})); + describe('BrowserAgentInvocation', () => { let mockConfig: Config; let mockMessageBus: MessageBus; @@ -123,6 +140,136 @@ describe('BrowserAgentInvocation', () => { }); }); + describe('execute', () => { + it('should emit SubagentProgress objects and return result', async () => { + const updateOutput = vi.fn(); + const mockExecutor = { + run: vi.fn().mockResolvedValue({ + terminate_reason: AgentTerminateMode.GOAL, + result: 'Success', + }), + }; + + vi.mocked(createBrowserAgentDefinition).mockResolvedValue({ + definition: { + name: 'browser_agent', + toolConfig: { tools: [] }, + } as unknown as LocalAgentExecutor['definition'], + browserManager: {} as unknown as NonNullable< + Awaited> + >['browserManager'], + }); + + vi.mocked(LocalAgentExecutor.create).mockResolvedValue( + mockExecutor as unknown as LocalAgentExecutor, + ); + + const invocation = new BrowserAgentInvocation( + mockConfig, + mockParams, + mockMessageBus, + ); + + const result = await invocation.execute( + new AbortController().signal, + updateOutput, + ); + + expect(result.llmContent).toBeDefined(); + expect(updateOutput).toHaveBeenCalled(); + + // Verify that emitted objects are SubagentProgress + const calls = updateOutput.mock.calls; + expect(calls[0][0]).toMatchObject({ + isSubagentProgress: true, + state: 'running', + }); + + const lastCall = calls[calls.length - 1][0]; + expect(lastCall).toMatchObject({ + isSubagentProgress: true, + state: 'completed', + }); + }); + + it('should handle activity events and update progress', async () => { + const updateOutput = vi.fn(); + let activityCallback: (activity: SubagentActivityEvent) => void; + + vi.mocked(LocalAgentExecutor.create).mockImplementation( + async (_def, _config, onActivity) => { + activityCallback = onActivity; + return { + run: vi.fn().mockResolvedValue({ + terminate_reason: AgentTerminateMode.GOAL, + result: 'Success', + }), + } as unknown as LocalAgentExecutor; + }, + ); + + vi.mocked(createBrowserAgentDefinition).mockResolvedValue({ + definition: { + name: 'browser_agent', + } as unknown as LocalAgentExecutor['definition'], + browserManager: {} as unknown as NonNullable< + Awaited> + >['browserManager'], + }); + + const invocation = new BrowserAgentInvocation( + mockConfig, + mockParams, + mockMessageBus, + ); + + await invocation.execute(new AbortController().signal, updateOutput); + + // Trigger thoughts + activityCallback({ + isSubagentActivityEvent: true, + agentName: 'browser_agent', + type: 'THOUGHT_CHUNK', + data: { text: 'Thinking...' }, + }); + + // Verify progress update with thought + const lastProgress = updateOutput.mock.calls[ + updateOutput.mock.calls.length - 1 + ][0] as SubagentProgress; + expect(lastProgress.recentActivity).toContainEqual( + expect.objectContaining({ + type: 'thought', + content: 'Thinking...', + status: 'running', + }), + ); + }); + + it('should emit error state on failure', async () => { + const updateOutput = vi.fn(); + vi.mocked(createBrowserAgentDefinition).mockRejectedValue( + new Error('Launch failed'), + ); + + const invocation = new BrowserAgentInvocation( + mockConfig, + mockParams, + mockMessageBus, + ); + + const result = await invocation.execute( + new AbortController().signal, + updateOutput, + ); + + expect(result.error).toBeDefined(); + const lastCall = + updateOutput.mock.calls[updateOutput.mock.calls.length - 1][0]; + expect(lastCall.state).toBe('error'); + }); + }); + describe('toolLocations', () => { it('should return empty array by default', () => { const invocation = new BrowserAgentInvocation( diff --git a/packages/core/src/agents/browser/browserAgentInvocation.ts b/packages/core/src/agents/browser/browserAgentInvocation.ts index 9df543300e5..5565ace72d2 100644 --- a/packages/core/src/agents/browser/browserAgentInvocation.ts +++ b/packages/core/src/agents/browser/browserAgentInvocation.ts @@ -14,6 +14,7 @@ * The MCP tools are only available in the browser agent's isolated registry. */ +import { randomUUID } from 'node:crypto'; import type { Config } from '../../config/config.js'; import { LocalAgentExecutor } from '../local-executor.js'; import { @@ -22,7 +23,12 @@ import { type ToolLiveOutput, } from '../../tools/tools.js'; import { ToolErrorType } from '../../tools/tool-error.js'; -import type { AgentInputs, SubagentActivityEvent } from '../types.js'; +import { + type AgentInputs, + type SubagentActivityEvent, + type SubagentProgress, + type SubagentActivityItem, +} from '../types.js'; import type { MessageBus } from '../../confirmation-bus/message-bus.js'; import { createBrowserAgentDefinition, @@ -31,6 +37,7 @@ import { const INPUT_PREVIEW_MAX_LENGTH = 50; const DESCRIPTION_MAX_LENGTH = 200; +const MAX_RECENT_ACTIVITY = 20; /** * Browser agent invocation with async tool setup. @@ -88,15 +95,40 @@ export class BrowserAgentInvocation extends BaseToolInvocation< updateOutput?: (output: ToolLiveOutput) => void, ): Promise { let browserManager; + let recentActivity: SubagentActivityItem[] = []; try { if (updateOutput) { - updateOutput('🌐 Starting browser agent...\n'); + // Send initial state + const initialProgress: SubagentProgress = { + isSubagentProgress: true, + agentName: this['_toolName'] ?? 'browser_agent', + recentActivity: [], + state: 'running', + }; + updateOutput(initialProgress); } // Create definition with MCP tools + // Note: printOutput is used for low-level connection logs before agent starts const printOutput = updateOutput - ? (msg: string) => updateOutput(`🌐 ${msg}\n`) + ? (msg: string) => { + recentActivity.push({ + id: randomUUID(), + type: 'thought', + content: msg, + status: 'completed', + }); + if (recentActivity.length > MAX_RECENT_ACTIVITY) { + recentActivity = recentActivity.slice(-MAX_RECENT_ACTIVITY); + } + updateOutput({ + isSubagentProgress: true, + agentName: this['_toolName'] ?? 'browser_agent', + recentActivity: [...recentActivity], + state: 'running', + } as SubagentProgress); + } : undefined; const result = await createBrowserAgentDefinition( @@ -107,21 +139,116 @@ export class BrowserAgentInvocation extends BaseToolInvocation< const { definition } = result; browserManager = result.browserManager; - if (updateOutput) { - updateOutput( - `🌐 Browser connected. Tools: ${definition.toolConfig?.tools.length ?? 0}\n`, - ); - } - // Create activity callback for streaming output const onActivity = (activity: SubagentActivityEvent): void => { if (!updateOutput) return; - if ( - activity.type === 'THOUGHT_CHUNK' && - typeof activity.data['text'] === 'string' - ) { - updateOutput(`🌐💭 ${activity.data['text']}`); + let updated = false; + + switch (activity.type) { + case 'THOUGHT_CHUNK': { + const text = String(activity.data['text']); + const lastItem = recentActivity[recentActivity.length - 1]; + if ( + lastItem && + lastItem.type === 'thought' && + lastItem.status === 'running' + ) { + lastItem.content += text; + } else { + recentActivity.push({ + id: randomUUID(), + type: 'thought', + content: text, + status: 'running', + }); + } + updated = true; + break; + } + case 'TOOL_CALL_START': { + const name = String(activity.data['name']); + const displayName = activity.data['displayName'] + ? String(activity.data['displayName']) + : undefined; + const description = activity.data['description'] + ? String(activity.data['description']) + : undefined; + const args = JSON.stringify(activity.data['args']); + recentActivity.push({ + id: randomUUID(), + type: 'tool_call', + content: name, + displayName, + description, + args, + status: 'running', + }); + updated = true; + break; + } + case 'TOOL_CALL_END': { + const name = String(activity.data['name']); + // Find the last running tool call with this name + for (let i = recentActivity.length - 1; i >= 0; i--) { + if ( + recentActivity[i].type === 'tool_call' && + recentActivity[i].content === name && + recentActivity[i].status === 'running' + ) { + recentActivity[i].status = 'completed'; + updated = true; + break; + } + } + break; + } + case 'ERROR': { + const error = String(activity.data['error']); + const isCancellation = error === 'Request cancelled.'; + const toolName = activity.data['name'] + ? String(activity.data['name']) + : undefined; + + if (toolName && isCancellation) { + for (let i = recentActivity.length - 1; i >= 0; i--) { + if ( + recentActivity[i].type === 'tool_call' && + recentActivity[i].content === toolName && + recentActivity[i].status === 'running' + ) { + recentActivity[i].status = 'cancelled'; + updated = true; + break; + } + } + } + + recentActivity.push({ + id: randomUUID(), + type: 'thought', + content: isCancellation ? error : `Error: ${error}`, + status: isCancellation ? 'cancelled' : 'error', + }); + updated = true; + break; + } + default: + break; + } + + if (updated) { + if (recentActivity.length > MAX_RECENT_ACTIVITY) { + recentActivity = recentActivity.slice(-MAX_RECENT_ACTIVITY); + } + + const progress: SubagentProgress = { + isSubagentProgress: true, + agentName: this['_toolName'] ?? 'browser_agent', + recentActivity: [...recentActivity], + state: 'running', + }; + updateOutput(progress); } }; @@ -148,6 +275,15 @@ Result: ${output.result} `; + if (updateOutput) { + updateOutput({ + isSubagentProgress: true, + agentName: this['_toolName'] ?? 'browser_agent', + recentActivity: [...recentActivity], + state: 'completed', + } as SubagentProgress); + } + return { llmContent: [{ text: resultContent }], returnDisplay: displayContent, @@ -155,10 +291,31 @@ ${output.result} } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); + const isAbort = + (error instanceof Error && error.name === 'AbortError') || + errorMessage.includes('Aborted'); + + // Mark any running items as error/cancelled + for (const item of recentActivity) { + if (item.status === 'running') { + item.status = isAbort ? 'cancelled' : 'error'; + } + } + + const progress: SubagentProgress = { + isSubagentProgress: true, + agentName: this['_toolName'] ?? 'browser_agent', + recentActivity: [...recentActivity], + state: isAbort ? 'cancelled' : 'error', + }; + + if (updateOutput) { + updateOutput(progress); + } return { llmContent: `Browser agent failed. Error: ${errorMessage}`, - returnDisplay: `Browser Agent Failed\nError: ${errorMessage}`, + returnDisplay: progress, error: { message: errorMessage, type: ToolErrorType.EXECUTION_FAILED, From 71cf7fce81416d214358a6323a2d67852a31355e Mon Sep 17 00:00:00 2001 From: kunal-10-cloud Date: Thu, 5 Mar 2026 11:02:36 +0530 Subject: [PATCH 02/24] feat(core): implement structured SubagentProgress emission in BrowserAgentInvocation --- .../agents/browser/browserAgentInvocation.test.ts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/packages/core/src/agents/browser/browserAgentInvocation.test.ts b/packages/core/src/agents/browser/browserAgentInvocation.test.ts index 7d060c486ca..78eb8a9a1e9 100644 --- a/packages/core/src/agents/browser/browserAgentInvocation.test.ts +++ b/packages/core/src/agents/browser/browserAgentInvocation.test.ts @@ -12,6 +12,7 @@ import type { MessageBus } from '../../confirmation-bus/message-bus.js'; import { type AgentInputs, type SubagentProgress, + type SubagentActivityEvent, AgentTerminateMode, } from '../types.js'; import { LocalAgentExecutor } from '../local-executor.js'; @@ -154,14 +155,14 @@ describe('BrowserAgentInvocation', () => { definition: { name: 'browser_agent', toolConfig: { tools: [] }, - } as unknown as LocalAgentExecutor['definition'], + } as unknown as LocalAgentExecutor['definition'], browserManager: {} as unknown as NonNullable< Awaited> >['browserManager'], }); vi.mocked(LocalAgentExecutor.create).mockResolvedValue( - mockExecutor as unknown as LocalAgentExecutor, + mockExecutor as unknown as LocalAgentExecutor, ); const invocation = new BrowserAgentInvocation( @@ -194,7 +195,9 @@ describe('BrowserAgentInvocation', () => { it('should handle activity events and update progress', async () => { const updateOutput = vi.fn(); - let activityCallback: (activity: SubagentActivityEvent) => void; + let activityCallback: + | ((activity: SubagentActivityEvent) => void) + | undefined; vi.mocked(LocalAgentExecutor.create).mockImplementation( async (_def, _config, onActivity) => { @@ -204,14 +207,14 @@ describe('BrowserAgentInvocation', () => { terminate_reason: AgentTerminateMode.GOAL, result: 'Success', }), - } as unknown as LocalAgentExecutor; + } as unknown as LocalAgentExecutor; }, ); vi.mocked(createBrowserAgentDefinition).mockResolvedValue({ definition: { name: 'browser_agent', - } as unknown as LocalAgentExecutor['definition'], + } as unknown as LocalAgentExecutor['definition'], browserManager: {} as unknown as NonNullable< Awaited> >['browserManager'], @@ -226,7 +229,7 @@ describe('BrowserAgentInvocation', () => { await invocation.execute(new AbortController().signal, updateOutput); // Trigger thoughts - activityCallback({ + activityCallback!({ isSubagentActivityEvent: true, agentName: 'browser_agent', type: 'THOUGHT_CHUNK', From 03784f1acc221894146eab9f21b76b52112aa00d Mon Sep 17 00:00:00 2001 From: kunal-10-cloud Date: Thu, 5 Mar 2026 11:03:07 +0530 Subject: [PATCH 03/24] feat(core): implement structured SubagentProgress emission in BrowserAgentInvocation --- .../src/agents/browser/browserAgentInvocation.test.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/core/src/agents/browser/browserAgentInvocation.test.ts b/packages/core/src/agents/browser/browserAgentInvocation.test.ts index 78eb8a9a1e9..d74128c6266 100644 --- a/packages/core/src/agents/browser/browserAgentInvocation.test.ts +++ b/packages/core/src/agents/browser/browserAgentInvocation.test.ts @@ -17,6 +17,7 @@ import { } from '../types.js'; import { LocalAgentExecutor } from '../local-executor.js'; import { createBrowserAgentDefinition } from './browserAgentFactory.js'; +import type { z } from 'zod'; // Mock dependencies vi.mock('../../utils/debugLogger.js', () => ({ @@ -155,14 +156,14 @@ describe('BrowserAgentInvocation', () => { definition: { name: 'browser_agent', toolConfig: { tools: [] }, - } as unknown as LocalAgentExecutor['definition'], + } as unknown as LocalAgentExecutor['definition'], browserManager: {} as unknown as NonNullable< Awaited> >['browserManager'], }); vi.mocked(LocalAgentExecutor.create).mockResolvedValue( - mockExecutor as unknown as LocalAgentExecutor, + mockExecutor as unknown as LocalAgentExecutor, ); const invocation = new BrowserAgentInvocation( @@ -207,14 +208,14 @@ describe('BrowserAgentInvocation', () => { terminate_reason: AgentTerminateMode.GOAL, result: 'Success', }), - } as unknown as LocalAgentExecutor; + } as unknown as LocalAgentExecutor; }, ); vi.mocked(createBrowserAgentDefinition).mockResolvedValue({ definition: { name: 'browser_agent', - } as unknown as LocalAgentExecutor['definition'], + } as unknown as LocalAgentExecutor['definition'], browserManager: {} as unknown as NonNullable< Awaited> >['browserManager'], From 9a9cbf2f2f35b11b717e236e51c85c3b79185e92 Mon Sep 17 00:00:00 2001 From: kunal-10-cloud Date: Thu, 5 Mar 2026 12:00:37 +0530 Subject: [PATCH 04/24] fix(core): sanitize sensitive tool arguments and correct error status tracking --- .../browser/browserAgentInvocation.test.ts | 154 ++++++++++++++++++ .../agents/browser/browserAgentInvocation.ts | 49 +++++- 2 files changed, 200 insertions(+), 3 deletions(-) diff --git a/packages/core/src/agents/browser/browserAgentInvocation.test.ts b/packages/core/src/agents/browser/browserAgentInvocation.test.ts index d74128c6266..df058c422a6 100644 --- a/packages/core/src/agents/browser/browserAgentInvocation.test.ts +++ b/packages/core/src/agents/browser/browserAgentInvocation.test.ts @@ -250,6 +250,160 @@ describe('BrowserAgentInvocation', () => { ); }); + it('should sanitize sensitive data in tool arguments', async () => { + const updateOutput = vi.fn(); + let activityCallback: + | ((activity: SubagentActivityEvent) => void) + | undefined; + + vi.mocked(LocalAgentExecutor.create).mockImplementation( + async (_def, _config, onActivity) => { + activityCallback = onActivity; + return { + run: vi.fn().mockResolvedValue({ + terminate_reason: AgentTerminateMode.GOAL, + result: 'Success', + }), + } as unknown as LocalAgentExecutor; + }, + ); + + vi.mocked(createBrowserAgentDefinition).mockResolvedValue({ + definition: { + name: 'browser_agent', + } as unknown as LocalAgentExecutor['definition'], + browserManager: {} as unknown as NonNullable< + Awaited> + >['browserManager'], + }); + + const invocation = new BrowserAgentInvocation( + mockConfig, + mockParams, + mockMessageBus, + ); + + await invocation.execute(new AbortController().signal, updateOutput); + + activityCallback!({ + isSubagentActivityEvent: true, + agentName: 'browser_agent', + type: 'TOOL_CALL_START', + data: { + name: 'fill', + args: { + username: 'testuser', + password: 'supersecretpassword', + nested: { + apiKey: 'my-api-key', + publicData: 'hello', + }, + }, + }, + }); + + const lastProgress = updateOutput.mock.calls[ + updateOutput.mock.calls.length - 1 + ][0] as SubagentProgress; + + const toolCall = lastProgress.recentActivity.find( + (item) => item.type === 'tool_call', + ); + expect(toolCall).toBeDefined(); + + const argsObj = JSON.parse(toolCall!.args!); + expect(argsObj.username).toBe('testuser'); + expect(argsObj.password).toBe('[REDACTED]'); + expect(argsObj.nested.apiKey).toBe('[REDACTED]'); + expect(argsObj.nested.publicData).toBe('hello'); + }); + + it('should correctly set error and cancelled status for tools', async () => { + const updateOutput = vi.fn(); + let activityCallback: + | ((activity: SubagentActivityEvent) => void) + | undefined; + + vi.mocked(LocalAgentExecutor.create).mockImplementation( + async (_def, _config, onActivity) => { + activityCallback = onActivity; + return { + run: vi.fn().mockResolvedValue({ + terminate_reason: AgentTerminateMode.GOAL, + result: 'Success', + }), + } as unknown as LocalAgentExecutor; + }, + ); + + vi.mocked(createBrowserAgentDefinition).mockResolvedValue({ + definition: { + name: 'browser_agent', + } as unknown as LocalAgentExecutor['definition'], + browserManager: {} as unknown as NonNullable< + Awaited> + >['browserManager'], + }); + + const invocation = new BrowserAgentInvocation( + mockConfig, + mockParams, + mockMessageBus, + ); + + await invocation.execute(new AbortController().signal, updateOutput); + + // Start tool 1 + activityCallback!({ + isSubagentActivityEvent: true, + agentName: 'browser_agent', + type: 'TOOL_CALL_START', + data: { name: 'tool1', args: {} }, + }); + + // Error for tool 1 + activityCallback!({ + isSubagentActivityEvent: true, + agentName: 'browser_agent', + type: 'ERROR', + data: { name: 'tool1', error: 'Some error' }, + }); + + let lastProgress = updateOutput.mock.calls[ + updateOutput.mock.calls.length - 1 + ][0] as SubagentProgress; + + const toolCall1 = lastProgress.recentActivity.find( + (item) => item.type === 'tool_call' && item.content === 'tool1', + ); + expect(toolCall1?.status).toBe('error'); + + // Start tool 2 + activityCallback!({ + isSubagentActivityEvent: true, + agentName: 'browser_agent', + type: 'TOOL_CALL_START', + data: { name: 'tool2', args: {} }, + }); + + // Cancellation for tool 2 + activityCallback!({ + isSubagentActivityEvent: true, + agentName: 'browser_agent', + type: 'ERROR', + data: { name: 'tool2', error: 'Request cancelled.' }, + }); + + lastProgress = updateOutput.mock.calls[ + updateOutput.mock.calls.length - 1 + ][0] as SubagentProgress; + + const toolCall2 = lastProgress.recentActivity.find( + (item) => item.type === 'tool_call' && item.content === 'tool2', + ); + expect(toolCall2?.status).toBe('cancelled'); + }); + it('should emit error state on failure', async () => { const updateOutput = vi.fn(); vi.mocked(createBrowserAgentDefinition).mockRejectedValue( diff --git a/packages/core/src/agents/browser/browserAgentInvocation.ts b/packages/core/src/agents/browser/browserAgentInvocation.ts index 5565ace72d2..ea112216853 100644 --- a/packages/core/src/agents/browser/browserAgentInvocation.ts +++ b/packages/core/src/agents/browser/browserAgentInvocation.ts @@ -39,6 +39,45 @@ const INPUT_PREVIEW_MAX_LENGTH = 50; const DESCRIPTION_MAX_LENGTH = 200; const MAX_RECENT_ACTIVITY = 20; +/** + * Sanitizes tool arguments by redacting sensitive fields. + */ +function sanitizeToolArgs(args: unknown): unknown { + if (typeof args !== 'object' || args === null) { + return args; + } + + if (Array.isArray(args)) { + return args.map(sanitizeToolArgs); + } + + const sensitiveKeys = [ + 'password', + 'apikey', + 'token', + 'secret', + 'credential', + 'auth', + 'passphrase', + 'privatekey', + ]; + + const sanitized: Record = {}; + + for (const [key, value] of Object.entries(args)) { + const isSensitive = sensitiveKeys.some((sensitiveKey) => + key.toLowerCase().includes(sensitiveKey.toLowerCase()), + ); + if (isSensitive) { + sanitized[key] = '[REDACTED]'; + } else { + sanitized[key] = sanitizeToolArgs(value); + } + } + + return sanitized; +} + /** * Browser agent invocation with async tool setup. * @@ -174,7 +213,9 @@ export class BrowserAgentInvocation extends BaseToolInvocation< const description = activity.data['description'] ? String(activity.data['description']) : undefined; - const args = JSON.stringify(activity.data['args']); + const args = JSON.stringify( + sanitizeToolArgs(activity.data['args']), + ); recentActivity.push({ id: randomUUID(), type: 'tool_call', @@ -210,14 +251,16 @@ export class BrowserAgentInvocation extends BaseToolInvocation< ? String(activity.data['name']) : undefined; - if (toolName && isCancellation) { + if (toolName) { for (let i = recentActivity.length - 1; i >= 0; i--) { if ( recentActivity[i].type === 'tool_call' && recentActivity[i].content === toolName && recentActivity[i].status === 'running' ) { - recentActivity[i].status = 'cancelled'; + recentActivity[i].status = isCancellation + ? 'cancelled' + : 'error'; updated = true; break; } From a8dd3e9802d51e1856b3dc19cad046afe745dc92 Mon Sep 17 00:00:00 2001 From: kunal-10-cloud Date: Thu, 5 Mar 2026 12:36:16 +0530 Subject: [PATCH 05/24] fix(core): expand sensitive key detection, sanitize error messages, and mark all running tools on global errors --- .../browser/browserAgentInvocation.test.ts | 191 ++++++++++++++++++ .../agents/browser/browserAgentInvocation.ts | 78 +++++-- 2 files changed, 250 insertions(+), 19 deletions(-) diff --git a/packages/core/src/agents/browser/browserAgentInvocation.test.ts b/packages/core/src/agents/browser/browserAgentInvocation.test.ts index df058c422a6..7d19bde4c7d 100644 --- a/packages/core/src/agents/browser/browserAgentInvocation.test.ts +++ b/packages/core/src/agents/browser/browserAgentInvocation.test.ts @@ -404,6 +404,197 @@ describe('BrowserAgentInvocation', () => { expect(toolCall2?.status).toBe('cancelled'); }); + it('should redact snake_case and kebab-case sensitive keys', async () => { + const updateOutput = vi.fn(); + let activityCallback: + | ((activity: SubagentActivityEvent) => void) + | undefined; + + vi.mocked(LocalAgentExecutor.create).mockImplementation( + async (_def, _config, onActivity) => { + activityCallback = onActivity; + return { + run: vi.fn().mockResolvedValue({ + terminate_reason: AgentTerminateMode.GOAL, + result: 'Success', + }), + } as unknown as LocalAgentExecutor; + }, + ); + + vi.mocked(createBrowserAgentDefinition).mockResolvedValue({ + definition: { + name: 'browser_agent', + } as unknown as LocalAgentExecutor['definition'], + browserManager: {} as unknown as NonNullable< + Awaited> + >['browserManager'], + }); + + const invocation = new BrowserAgentInvocation( + mockConfig, + mockParams, + mockMessageBus, + ); + + await invocation.execute(new AbortController().signal, updateOutput); + + activityCallback!({ + isSubagentActivityEvent: true, + agentName: 'browser_agent', + type: 'TOOL_CALL_START', + data: { + name: 'configure', + args: { + api_key: 'sk-12345', + 'api-key': 'ak-67890', + private_key: 'pk-abc', + pwd: 'mypassword', + hostname: 'example.com', + }, + }, + }); + + const lastProgress = updateOutput.mock.calls[ + updateOutput.mock.calls.length - 1 + ][0] as SubagentProgress; + const toolCall = lastProgress.recentActivity.find( + (item) => item.type === 'tool_call', + ); + const argsObj = JSON.parse(toolCall!.args!); + + expect(argsObj.api_key).toBe('[REDACTED]'); + expect(argsObj['api-key']).toBe('[REDACTED]'); + expect(argsObj.private_key).toBe('[REDACTED]'); + expect(argsObj.pwd).toBe('[REDACTED]'); + expect(argsObj.hostname).toBe('example.com'); + }); + + it('should sanitize sensitive patterns in error messages', async () => { + const updateOutput = vi.fn(); + let activityCallback: + | ((activity: SubagentActivityEvent) => void) + | undefined; + + vi.mocked(LocalAgentExecutor.create).mockImplementation( + async (_def, _config, onActivity) => { + activityCallback = onActivity; + return { + run: vi.fn().mockResolvedValue({ + terminate_reason: AgentTerminateMode.GOAL, + result: 'Success', + }), + } as unknown as LocalAgentExecutor; + }, + ); + + vi.mocked(createBrowserAgentDefinition).mockResolvedValue({ + definition: { + name: 'browser_agent', + } as unknown as LocalAgentExecutor['definition'], + browserManager: {} as unknown as NonNullable< + Awaited> + >['browserManager'], + }); + + const invocation = new BrowserAgentInvocation( + mockConfig, + mockParams, + mockMessageBus, + ); + + await invocation.execute(new AbortController().signal, updateOutput); + + activityCallback!({ + isSubagentActivityEvent: true, + agentName: 'browser_agent', + type: 'ERROR', + data: { error: 'Failed with api_key=sk-12345 and token=abc123' }, + }); + + const lastProgress = updateOutput.mock.calls[ + updateOutput.mock.calls.length - 1 + ][0] as SubagentProgress; + const errorThought = lastProgress.recentActivity.find( + (item) => item.type === 'thought' && item.status === 'error', + ); + expect(errorThought).toBeDefined(); + expect(errorThought!.content).not.toContain('sk-12345'); + expect(errorThought!.content).not.toContain('abc123'); + expect(errorThought!.content).toContain('[REDACTED]'); + }); + + it('should mark all running tools as error when no toolName in ERROR', async () => { + const updateOutput = vi.fn(); + let activityCallback: + | ((activity: SubagentActivityEvent) => void) + | undefined; + + vi.mocked(LocalAgentExecutor.create).mockImplementation( + async (_def, _config, onActivity) => { + activityCallback = onActivity; + return { + run: vi.fn().mockResolvedValue({ + terminate_reason: AgentTerminateMode.GOAL, + result: 'Success', + }), + } as unknown as LocalAgentExecutor; + }, + ); + + vi.mocked(createBrowserAgentDefinition).mockResolvedValue({ + definition: { + name: 'browser_agent', + } as unknown as LocalAgentExecutor['definition'], + browserManager: {} as unknown as NonNullable< + Awaited> + >['browserManager'], + }); + + const invocation = new BrowserAgentInvocation( + mockConfig, + mockParams, + mockMessageBus, + ); + + await invocation.execute(new AbortController().signal, updateOutput); + + // Start two tools + activityCallback!({ + isSubagentActivityEvent: true, + agentName: 'browser_agent', + type: 'TOOL_CALL_START', + data: { name: 'toolA', args: {} }, + }); + activityCallback!({ + isSubagentActivityEvent: true, + agentName: 'browser_agent', + type: 'TOOL_CALL_START', + data: { name: 'toolB', args: {} }, + }); + + // Global error (no toolName) + activityCallback!({ + isSubagentActivityEvent: true, + agentName: 'browser_agent', + type: 'ERROR', + data: { error: 'Connection lost' }, + }); + + const lastProgress = updateOutput.mock.calls[ + updateOutput.mock.calls.length - 1 + ][0] as SubagentProgress; + + const toolA = lastProgress.recentActivity.find( + (item) => item.type === 'tool_call' && item.content === 'toolA', + ); + const toolB = lastProgress.recentActivity.find( + (item) => item.type === 'tool_call' && item.content === 'toolB', + ); + expect(toolA?.status).toBe('error'); + expect(toolB?.status).toBe('error'); + }); + it('should emit error state on failure', async () => { const updateOutput = vi.fn(); vi.mocked(createBrowserAgentDefinition).mockRejectedValue( diff --git a/packages/core/src/agents/browser/browserAgentInvocation.ts b/packages/core/src/agents/browser/browserAgentInvocation.ts index ea112216853..8204bd1b5a8 100644 --- a/packages/core/src/agents/browser/browserAgentInvocation.ts +++ b/packages/core/src/agents/browser/browserAgentInvocation.ts @@ -40,7 +40,27 @@ const DESCRIPTION_MAX_LENGTH = 200; const MAX_RECENT_ACTIVITY = 20; /** - * Sanitizes tool arguments by redacting sensitive fields. + * Sensitive key patterns used for redaction. + */ +const SENSITIVE_KEY_PATTERNS = [ + 'password', + 'pwd', + 'apikey', + 'api_key', + 'api-key', + 'token', + 'secret', + 'credential', + 'auth', + 'passphrase', + 'privatekey', + 'private_key', + 'private-key', +]; + +/** + * Sanitizes tool arguments by recursively redacting sensitive fields. + * Supports nested objects and arrays. */ function sanitizeToolArgs(args: unknown): unknown { if (typeof args !== 'object' || args === null) { @@ -51,22 +71,12 @@ function sanitizeToolArgs(args: unknown): unknown { return args.map(sanitizeToolArgs); } - const sensitiveKeys = [ - 'password', - 'apikey', - 'token', - 'secret', - 'credential', - 'auth', - 'passphrase', - 'privatekey', - ]; - const sanitized: Record = {}; for (const [key, value] of Object.entries(args)) { - const isSensitive = sensitiveKeys.some((sensitiveKey) => - key.toLowerCase().includes(sensitiveKey.toLowerCase()), + const keyNormalized = key.toLowerCase().replace(/[-_]/g, ''); + const isSensitive = SENSITIVE_KEY_PATTERNS.some((pattern) => + keyNormalized.includes(pattern.replace(/[-_]/g, '')), ); if (isSensitive) { sanitized[key] = '[REDACTED]'; @@ -78,6 +88,24 @@ function sanitizeToolArgs(args: unknown): unknown { return sanitized; } +/** + * Sanitizes error messages by redacting potential sensitive data patterns. + */ +function sanitizeErrorMessage(message: string): string { + return message + .replace( + /api[_-]?key[s]?[:=]\s*['"]?[a-zA-Z0-9_-]+['"]?/gi, + 'api_key=[REDACTED]', + ) + .replace(/token[:=]\s*['"]?[a-zA-Z0-9_-]+['"]?/gi, 'token=[REDACTED]') + .replace(/password[:=]\s*['"]?[^\s'"]+['"]?/gi, 'password=[REDACTED]') + .replace(/secret[:=]\s*['"]?[a-zA-Z0-9_-]+['"]?/gi, 'secret=[REDACTED]') + .replace( + /\/[a-zA-Z0-9_\-/]*\/[a-zA-Z0-9_-]*\.(key|pem|p12|pfx)/gi, + '/path/to/[REDACTED].key', + ); +} + /** * Browser agent invocation with async tool setup. * @@ -250,28 +278,40 @@ export class BrowserAgentInvocation extends BaseToolInvocation< const toolName = activity.data['name'] ? String(activity.data['name']) : undefined; + const newStatus = isCancellation ? 'cancelled' : 'error'; if (toolName) { + // Mark the specific tool as error/cancelled for (let i = recentActivity.length - 1; i >= 0; i--) { if ( recentActivity[i].type === 'tool_call' && recentActivity[i].content === toolName && recentActivity[i].status === 'running' ) { - recentActivity[i].status = isCancellation - ? 'cancelled' - : 'error'; + recentActivity[i].status = newStatus; updated = true; break; } } + } else { + // No specific tool — mark ALL running tool_call items + for (const item of recentActivity) { + if (item.type === 'tool_call' && item.status === 'running') { + item.status = newStatus; + updated = true; + } + } } + // Sanitize the error message before emitting + const sanitizedError = sanitizeErrorMessage(error); recentActivity.push({ id: randomUUID(), type: 'thought', - content: isCancellation ? error : `Error: ${error}`, - status: isCancellation ? 'cancelled' : 'error', + content: isCancellation + ? sanitizedError + : `Error: ${sanitizedError}`, + status: newStatus, }); updated = true; break; From 5fafcc10dbce4e0a7914df7f1ebca6243c57c7e9 Mon Sep 17 00:00:00 2001 From: kunal-10-cloud Date: Thu, 5 Mar 2026 12:52:44 +0530 Subject: [PATCH 06/24] fix(core): widen regex patterns, sanitize thought content, and redact catch block errors --- .../browser/browserAgentInvocation.test.ts | 81 +++++++++++++++++++ .../agents/browser/browserAgentInvocation.ts | 38 ++++++--- 2 files changed, 109 insertions(+), 10 deletions(-) diff --git a/packages/core/src/agents/browser/browserAgentInvocation.test.ts b/packages/core/src/agents/browser/browserAgentInvocation.test.ts index 7d19bde4c7d..387b9085198 100644 --- a/packages/core/src/agents/browser/browserAgentInvocation.test.ts +++ b/packages/core/src/agents/browser/browserAgentInvocation.test.ts @@ -617,6 +617,87 @@ describe('BrowserAgentInvocation', () => { updateOutput.mock.calls[updateOutput.mock.calls.length - 1][0]; expect(lastCall.state).toBe('error'); }); + + it('should sanitize sensitive data in LLM thought content', async () => { + const updateOutput = vi.fn(); + let activityCallback: + | ((activity: SubagentActivityEvent) => void) + | undefined; + + vi.mocked(LocalAgentExecutor.create).mockImplementation( + async (_def, _config, onActivity) => { + activityCallback = onActivity; + return { + run: vi.fn().mockResolvedValue({ + terminate_reason: AgentTerminateMode.GOAL, + result: 'Success', + }), + } as unknown as LocalAgentExecutor; + }, + ); + + vi.mocked(createBrowserAgentDefinition).mockResolvedValue({ + definition: { + name: 'browser_agent', + } as unknown as LocalAgentExecutor['definition'], + browserManager: {} as unknown as NonNullable< + Awaited> + >['browserManager'], + }); + + const invocation = new BrowserAgentInvocation( + mockConfig, + mockParams, + mockMessageBus, + ); + + await invocation.execute(new AbortController().signal, updateOutput); + + activityCallback!({ + isSubagentActivityEvent: true, + agentName: 'browser_agent', + type: 'THOUGHT_CHUNK', + data: { + text: 'Using token=eyJhbGciOi.payload.signature to authenticate', + }, + }); + + const lastProgress = updateOutput.mock.calls[ + updateOutput.mock.calls.length - 1 + ][0] as SubagentProgress; + const thought = lastProgress.recentActivity.find( + (item) => item.type === 'thought' && item.status === 'running', + ); + expect(thought).toBeDefined(); + expect(thought!.content).not.toContain('eyJhbGciOi'); + expect(thought!.content).toContain('[REDACTED]'); + }); + + it('should sanitize error messages in catch block', async () => { + const updateOutput = vi.fn(); + vi.mocked(createBrowserAgentDefinition).mockRejectedValue( + new Error( + 'Connection failed with api_key=sk-secret123 and token=jwt.token.here', + ), + ); + + const invocation = new BrowserAgentInvocation( + mockConfig, + mockParams, + mockMessageBus, + ); + + const result = await invocation.execute( + new AbortController().signal, + updateOutput, + ); + + expect(result.error).toBeDefined(); + const errorMsg = result.error!.message; + expect(errorMsg).not.toContain('sk-secret123'); + expect(errorMsg).not.toContain('jwt.token.here'); + expect(errorMsg).toContain('[REDACTED]'); + }); }); describe('toolLocations', () => { diff --git a/packages/core/src/agents/browser/browserAgentInvocation.ts b/packages/core/src/agents/browser/browserAgentInvocation.ts index 8204bd1b5a8..85b082bd03c 100644 --- a/packages/core/src/agents/browser/browserAgentInvocation.ts +++ b/packages/core/src/agents/browser/browserAgentInvocation.ts @@ -90,22 +90,34 @@ function sanitizeToolArgs(args: unknown): unknown { /** * Sanitizes error messages by redacting potential sensitive data patterns. + * Uses [^\s'"]+ to catch JWTs, tokens with dots/slashes, and other complex values. */ function sanitizeErrorMessage(message: string): string { return message + .replace(/api[_-]?key[s]?[:=]\s*['"]?[^\s'"]+['"]?/gi, 'api_key=[REDACTED]') + .replace(/token[:=]\s*['"]?[^\s'"]+['"]?/gi, 'token=[REDACTED]') + .replace(/password[:=]\s*['"]?[^\s'"]+['"]?/gi, 'password=[REDACTED]') + .replace(/secret[:=]\s*['"]?[^\s'"]+['"]?/gi, 'secret=[REDACTED]') + .replace(/credential[:=]\s*['"]?[^\s'"]+['"]?/gi, 'credential=[REDACTED]') + .replace(/auth[:=]\s*['"]?[^\s'"]+['"]?/gi, 'auth=[REDACTED]') + .replace(/passphrase[:=]\s*['"]?[^\s'"]+['"]?/gi, 'passphrase=[REDACTED]') .replace( - /api[_-]?key[s]?[:=]\s*['"]?[a-zA-Z0-9_-]+['"]?/gi, - 'api_key=[REDACTED]', + /private[_-]?key[:=]\s*['"]?[^\s'"]+['"]?/gi, + 'private_key=[REDACTED]', ) - .replace(/token[:=]\s*['"]?[a-zA-Z0-9_-]+['"]?/gi, 'token=[REDACTED]') - .replace(/password[:=]\s*['"]?[^\s'"]+['"]?/gi, 'password=[REDACTED]') - .replace(/secret[:=]\s*['"]?[a-zA-Z0-9_-]+['"]?/gi, 'secret=[REDACTED]') .replace( /\/[a-zA-Z0-9_\-/]*\/[a-zA-Z0-9_-]*\.(key|pem|p12|pfx)/gi, '/path/to/[REDACTED].key', ); } +/** + * Sanitizes LLM thought content by redacting sensitive data patterns. + */ +function sanitizeThoughtContent(text: string): string { + return sanitizeErrorMessage(text); +} + /** * Browser agent invocation with async tool setup. * @@ -215,18 +227,19 @@ export class BrowserAgentInvocation extends BaseToolInvocation< switch (activity.type) { case 'THOUGHT_CHUNK': { const text = String(activity.data['text']); + const sanitizedText = sanitizeThoughtContent(text); const lastItem = recentActivity[recentActivity.length - 1]; if ( lastItem && lastItem.type === 'thought' && lastItem.status === 'running' ) { - lastItem.content += text; + lastItem.content += sanitizedText; } else { recentActivity.push({ id: randomUUID(), type: 'thought', - content: text, + content: sanitizedText, status: 'running', }); } @@ -372,11 +385,12 @@ ${output.result} returnDisplay: displayContent, }; } catch (error) { - const errorMessage = + const rawErrorMessage = error instanceof Error ? error.message : String(error); const isAbort = (error instanceof Error && error.name === 'AbortError') || - errorMessage.includes('Aborted'); + rawErrorMessage.includes('Aborted'); + const errorMessage = sanitizeErrorMessage(rawErrorMessage); // Mark any running items as error/cancelled for (const item of recentActivity) { @@ -396,8 +410,12 @@ ${output.result} updateOutput(progress); } + const llmContent = isAbort + ? 'Browser agent execution was aborted.' + : `Browser agent failed. Error: ${errorMessage}`; + return { - llmContent: `Browser agent failed. Error: ${errorMessage}`, + llmContent, returnDisplay: progress, error: { message: errorMessage, From 688b2a6f4200d603f5227d6d8dc07d63ff053b73 Mon Sep 17 00:00:00 2001 From: Aditya Bijalwan Date: Thu, 5 Mar 2026 13:05:22 +0530 Subject: [PATCH 07/24] Update packages/core/src/agents/browser/browserAgentInvocation.ts Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- packages/core/src/agents/browser/browserAgentInvocation.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/agents/browser/browserAgentInvocation.ts b/packages/core/src/agents/browser/browserAgentInvocation.ts index 85b082bd03c..f9e3fc87727 100644 --- a/packages/core/src/agents/browser/browserAgentInvocation.ts +++ b/packages/core/src/agents/browser/browserAgentInvocation.ts @@ -415,7 +415,7 @@ ${output.result} : `Browser agent failed. Error: ${errorMessage}`; return { - llmContent, + llmContent: [{ text: llmContent }], returnDisplay: progress, error: { message: errorMessage, From bfa8a0878815e26219a107ee2a987fd971b0db7a Mon Sep 17 00:00:00 2001 From: kunal-10-cloud Date: Thu, 5 Mar 2026 13:08:29 +0530 Subject: [PATCH 08/24] fix(core): use non-greedy regex delimiters to prevent over-redaction in sanitizeErrorMessage --- .../agents/browser/browserAgentInvocation.ts | 25 +++++++++++++------ 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/packages/core/src/agents/browser/browserAgentInvocation.ts b/packages/core/src/agents/browser/browserAgentInvocation.ts index f9e3fc87727..b0b81bd64b7 100644 --- a/packages/core/src/agents/browser/browserAgentInvocation.ts +++ b/packages/core/src/agents/browser/browserAgentInvocation.ts @@ -94,15 +94,24 @@ function sanitizeToolArgs(args: unknown): unknown { */ function sanitizeErrorMessage(message: string): string { return message - .replace(/api[_-]?key[s]?[:=]\s*['"]?[^\s'"]+['"]?/gi, 'api_key=[REDACTED]') - .replace(/token[:=]\s*['"]?[^\s'"]+['"]?/gi, 'token=[REDACTED]') - .replace(/password[:=]\s*['"]?[^\s'"]+['"]?/gi, 'password=[REDACTED]') - .replace(/secret[:=]\s*['"]?[^\s'"]+['"]?/gi, 'secret=[REDACTED]') - .replace(/credential[:=]\s*['"]?[^\s'"]+['"]?/gi, 'credential=[REDACTED]') - .replace(/auth[:=]\s*['"]?[^\s'"]+['"]?/gi, 'auth=[REDACTED]') - .replace(/passphrase[:=]\s*['"]?[^\s'"]+['"]?/gi, 'passphrase=[REDACTED]') .replace( - /private[_-]?key[:=]\s*['"]?[^\s'"]+['"]?/gi, + /api[_-]?key[s]?[:=]\s*['"]?[^\s,'"&;]+['"]?/gi, + 'api_key=[REDACTED]', + ) + .replace(/token[:=]\s*['"]?[^\s,'"&;]+['"]?/gi, 'token=[REDACTED]') + .replace(/password[:=]\s*['"]?[^\s,'"&;]+['"]?/gi, 'password=[REDACTED]') + .replace(/secret[:=]\s*['"]?[^\s,'"&;]+['"]?/gi, 'secret=[REDACTED]') + .replace( + /credential[:=]\s*['"]?[^\s,'"&;]+['"]?/gi, + 'credential=[REDACTED]', + ) + .replace(/auth[:=]\s*['"]?[^\s,'"&;]+['"]?/gi, 'auth=[REDACTED]') + .replace( + /passphrase[:=]\s*['"]?[^\s,'"&;]+['"]?/gi, + 'passphrase=[REDACTED]', + ) + .replace( + /private[_-]?key[:=]\s*['"]?[^\s,'"&;]+['"]?/gi, 'private_key=[REDACTED]', ) .replace( From a2d631015e4f15f675e6acb61327a0a39821f725 Mon Sep 17 00:00:00 2001 From: kunal-10-cloud Date: Thu, 5 Mar 2026 13:36:47 +0530 Subject: [PATCH 09/24] fix(core): comprehensive credential sanitization across tool args, descriptions, displayNames, and error messages --- .../browser/browserAgentInvocation.test.ts | 20 ++++++++++++++++++- .../agents/browser/browserAgentInvocation.ts | 7 +++++-- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/packages/core/src/agents/browser/browserAgentInvocation.test.ts b/packages/core/src/agents/browser/browserAgentInvocation.test.ts index 387b9085198..034849dd3d1 100644 --- a/packages/core/src/agents/browser/browserAgentInvocation.test.ts +++ b/packages/core/src/agents/browser/browserAgentInvocation.test.ts @@ -404,7 +404,7 @@ describe('BrowserAgentInvocation', () => { expect(toolCall2?.status).toBe('cancelled'); }); - it('should redact snake_case and kebab-case sensitive keys', async () => { + it('should redact sensitive keys and recursively sanitize string values in tool arguments', async () => { const updateOutput = vi.fn(); let activityCallback: | ((activity: SubagentActivityEvent) => void) @@ -445,12 +445,17 @@ describe('BrowserAgentInvocation', () => { type: 'TOOL_CALL_START', data: { name: 'configure', + displayName: 'Configure api_key=superSecret', + description: 'Setting up with token=jwt.token.abc', args: { api_key: 'sk-12345', 'api-key': 'ak-67890', private_key: 'pk-abc', pwd: 'mypassword', hostname: 'example.com', + nestedConfig: { + regularUrl: 'https://api.com?apikey=secret_in_url&other=val', + }, }, }, }); @@ -461,13 +466,26 @@ describe('BrowserAgentInvocation', () => { const toolCall = lastProgress.recentActivity.find( (item) => item.type === 'tool_call', ); + + // Check display name and description + expect(toolCall!.displayName).toContain('[REDACTED]'); + expect(toolCall!.displayName).not.toContain('superSecret'); + expect(toolCall!.description).toContain('[REDACTED]'); + expect(toolCall!.description).not.toContain('jwt.token.abc'); + const argsObj = JSON.parse(toolCall!.args!); + // Check key-based redaction expect(argsObj.api_key).toBe('[REDACTED]'); expect(argsObj['api-key']).toBe('[REDACTED]'); expect(argsObj.private_key).toBe('[REDACTED]'); expect(argsObj.pwd).toBe('[REDACTED]'); expect(argsObj.hostname).toBe('example.com'); + + // Check value-based redaction (string scanning) + expect(argsObj.nestedConfig.regularUrl).toContain('[REDACTED]'); + expect(argsObj.nestedConfig.regularUrl).not.toContain('secret_in_url'); + expect(argsObj.nestedConfig.regularUrl).toContain('other=val'); }); it('should sanitize sensitive patterns in error messages', async () => { diff --git a/packages/core/src/agents/browser/browserAgentInvocation.ts b/packages/core/src/agents/browser/browserAgentInvocation.ts index b0b81bd64b7..38ddf06a358 100644 --- a/packages/core/src/agents/browser/browserAgentInvocation.ts +++ b/packages/core/src/agents/browser/browserAgentInvocation.ts @@ -80,6 +80,8 @@ function sanitizeToolArgs(args: unknown): unknown { ); if (isSensitive) { sanitized[key] = '[REDACTED]'; + } else if (typeof value === 'string') { + sanitized[key] = sanitizeErrorMessage(value); } else { sanitized[key] = sanitizeToolArgs(value); } @@ -100,6 +102,7 @@ function sanitizeErrorMessage(message: string): string { ) .replace(/token[:=]\s*['"]?[^\s,'"&;]+['"]?/gi, 'token=[REDACTED]') .replace(/password[:=]\s*['"]?[^\s,'"&;]+['"]?/gi, 'password=[REDACTED]') + .replace(/pwd[:=]\s*['"]?[^\s,'"&;]+['"]?/gi, 'pwd=[REDACTED]') .replace(/secret[:=]\s*['"]?[^\s,'"&;]+['"]?/gi, 'secret=[REDACTED]') .replace( /credential[:=]\s*['"]?[^\s,'"&;]+['"]?/gi, @@ -258,10 +261,10 @@ export class BrowserAgentInvocation extends BaseToolInvocation< case 'TOOL_CALL_START': { const name = String(activity.data['name']); const displayName = activity.data['displayName'] - ? String(activity.data['displayName']) + ? sanitizeErrorMessage(String(activity.data['displayName'])) : undefined; const description = activity.data['description'] - ? String(activity.data['description']) + ? sanitizeErrorMessage(String(activity.data['description'])) : undefined; const args = JSON.stringify( sanitizeToolArgs(activity.data['args']), From 730a932b62e9dcf5f545af3211ed781d5a8126d5 Mon Sep 17 00:00:00 2001 From: kunal-10-cloud Date: Thu, 5 Mar 2026 20:29:37 +0530 Subject: [PATCH 10/24] feat(core): fix race conditions and enhance security in browser agent progress emission --- .../browser/browserAgentInvocation.test.ts | 257 +++++++++++++++--- .../agents/browser/browserAgentInvocation.ts | 56 ++-- .../core/src/agents/local-executor.test.ts | 28 +- packages/core/src/agents/local-executor.ts | 8 + 4 files changed, 279 insertions(+), 70 deletions(-) diff --git a/packages/core/src/agents/browser/browserAgentInvocation.test.ts b/packages/core/src/agents/browser/browserAgentInvocation.test.ts index 034849dd3d1..cf1b1d43968 100644 --- a/packages/core/src/agents/browser/browserAgentInvocation.test.ts +++ b/packages/core/src/agents/browser/browserAgentInvocation.test.ts @@ -64,7 +64,7 @@ describe('BrowserAgentInvocation', () => { publish: vi.fn().mockResolvedValue(undefined), subscribe: vi.fn(), unsubscribe: vi.fn(), - } as unknown as MessageBus; + } as unknown as LocalAgentExecutor; mockParams = { task: 'Navigate to example.com and click the button', @@ -153,17 +153,18 @@ describe('BrowserAgentInvocation', () => { }; vi.mocked(createBrowserAgentDefinition).mockResolvedValue({ + // @ts-expect-error - Partial mock for testing definition: { name: 'browser_agent', toolConfig: { tools: [] }, - } as unknown as LocalAgentExecutor['definition'], - browserManager: {} as unknown as NonNullable< - Awaited> - >['browserManager'], + }, + // @ts-expect-error - Partial mock for testing + browserManager: {}, }); vi.mocked(LocalAgentExecutor.create).mockResolvedValue( - mockExecutor as unknown as LocalAgentExecutor, + // @ts-expect-error - Partial mock for testing + mockExecutor, ); const invocation = new BrowserAgentInvocation( @@ -208,17 +209,18 @@ describe('BrowserAgentInvocation', () => { terminate_reason: AgentTerminateMode.GOAL, result: 'Success', }), - } as unknown as LocalAgentExecutor; + // @ts-expect-error - Partial mock for testing + }; }, ); vi.mocked(createBrowserAgentDefinition).mockResolvedValue({ + // @ts-expect-error - Partial mock for testing definition: { name: 'browser_agent', - } as unknown as LocalAgentExecutor['definition'], - browserManager: {} as unknown as NonNullable< - Awaited> - >['browserManager'], + }, + // @ts-expect-error - Partial mock for testing + browserManager: {}, }); const invocation = new BrowserAgentInvocation( @@ -264,17 +266,16 @@ describe('BrowserAgentInvocation', () => { terminate_reason: AgentTerminateMode.GOAL, result: 'Success', }), - } as unknown as LocalAgentExecutor; + // @ts-expect-error - Partial mock for testing + }; }, ); vi.mocked(createBrowserAgentDefinition).mockResolvedValue({ definition: { name: 'browser_agent', - } as unknown as LocalAgentExecutor['definition'], - browserManager: {} as unknown as NonNullable< - Awaited> - >['browserManager'], + } as unknown as LocalAgentExecutor, + browserManager: {} as unknown as LocalAgentExecutor, }); const invocation = new BrowserAgentInvocation( @@ -339,10 +340,8 @@ describe('BrowserAgentInvocation', () => { vi.mocked(createBrowserAgentDefinition).mockResolvedValue({ definition: { name: 'browser_agent', - } as unknown as LocalAgentExecutor['definition'], - browserManager: {} as unknown as NonNullable< - Awaited> - >['browserManager'], + } as unknown as LocalAgentExecutor, + browserManager: {} as unknown as LocalAgentExecutor, }); const invocation = new BrowserAgentInvocation( @@ -425,10 +424,8 @@ describe('BrowserAgentInvocation', () => { vi.mocked(createBrowserAgentDefinition).mockResolvedValue({ definition: { name: 'browser_agent', - } as unknown as LocalAgentExecutor['definition'], - browserManager: {} as unknown as NonNullable< - Awaited> - >['browserManager'], + } as unknown as LocalAgentExecutor, + browserManager: {} as unknown as LocalAgentExecutor, }); const invocation = new BrowserAgentInvocation( @@ -509,10 +506,8 @@ describe('BrowserAgentInvocation', () => { vi.mocked(createBrowserAgentDefinition).mockResolvedValue({ definition: { name: 'browser_agent', - } as unknown as LocalAgentExecutor['definition'], - browserManager: {} as unknown as NonNullable< - Awaited> - >['browserManager'], + } as unknown as LocalAgentExecutor, + browserManager: {} as unknown as LocalAgentExecutor, }); const invocation = new BrowserAgentInvocation( @@ -563,10 +558,8 @@ describe('BrowserAgentInvocation', () => { vi.mocked(createBrowserAgentDefinition).mockResolvedValue({ definition: { name: 'browser_agent', - } as unknown as LocalAgentExecutor['definition'], - browserManager: {} as unknown as NonNullable< - Awaited> - >['browserManager'], + } as unknown as LocalAgentExecutor, + browserManager: {} as unknown as LocalAgentExecutor, }); const invocation = new BrowserAgentInvocation( @@ -657,10 +650,8 @@ describe('BrowserAgentInvocation', () => { vi.mocked(createBrowserAgentDefinition).mockResolvedValue({ definition: { name: 'browser_agent', - } as unknown as LocalAgentExecutor['definition'], - browserManager: {} as unknown as NonNullable< - Awaited> - >['browserManager'], + } as unknown as LocalAgentExecutor, + browserManager: {} as unknown as LocalAgentExecutor, }); const invocation = new BrowserAgentInvocation( @@ -716,6 +707,200 @@ describe('BrowserAgentInvocation', () => { expect(errorMsg).not.toContain('jwt.token.here'); expect(errorMsg).toContain('[REDACTED]'); }); + + it('should handle concurrent tool calls correctly using callId', async () => { + const updateOutput = vi.fn(); + let activityCallback: + | ((activity: SubagentActivityEvent) => void) + | undefined; + + vi.mocked(LocalAgentExecutor.create).mockImplementation( + async (_def, _config, onActivity) => { + activityCallback = onActivity; + return { + run: vi.fn().mockResolvedValue({ + terminate_reason: AgentTerminateMode.GOAL, + result: 'Success', + }), + } as unknown as LocalAgentExecutor; + }, + ); + + vi.mocked(createBrowserAgentDefinition).mockResolvedValue({ + definition: { + name: 'browser_agent', + } as unknown as LocalAgentExecutor, + browserManager: {} as unknown as LocalAgentExecutor, + }); + + const invocation = new BrowserAgentInvocation( + mockConfig, + mockParams, + mockMessageBus, + ); + + await invocation.execute(new AbortController().signal, updateOutput); + + // Start tool instance 1 + activityCallback!({ + isSubagentActivityEvent: true, + agentName: 'browser_agent', + type: 'TOOL_CALL_START', + data: { name: 'readFile', args: { path: 'file1.txt' }, callId: 'id1' }, + }); + + // Start tool instance 2 (same name, different callId) + activityCallback!({ + isSubagentActivityEvent: true, + agentName: 'browser_agent', + type: 'TOOL_CALL_START', + data: { name: 'readFile', args: { path: 'file2.txt' }, callId: 'id2' }, + }); + + // End tool instance 2 first + activityCallback!({ + isSubagentActivityEvent: true, + agentName: 'browser_agent', + type: 'TOOL_CALL_END', + data: { name: 'readFile', id: 'id2' }, + }); + + let lastProgress = updateOutput.mock.calls[ + updateOutput.mock.calls.length - 1 + ][0] as SubagentProgress; + + const item1 = lastProgress.recentActivity.find((i) => i.id === 'id1'); + const item2 = lastProgress.recentActivity.find((i) => i.id === 'id2'); + + expect(item1?.status).toBe('running'); + expect(item2?.status).toBe('completed'); + + // End tool instance 1 + activityCallback!({ + isSubagentActivityEvent: true, + agentName: 'browser_agent', + type: 'TOOL_CALL_END', + data: { name: 'readFile', id: 'id1' }, + }); + + lastProgress = updateOutput.mock.calls[ + updateOutput.mock.calls.length - 1 + ][0] as SubagentProgress; + + const updatedItem1 = lastProgress.recentActivity.find( + (i) => i.id === 'id1', + ); + expect(updatedItem1?.status).toBe('completed'); + }); + + it('should redact secrets with spaces in unquoted values', async () => { + const updateOutput = vi.fn(); + let activityCallback: + | ((activity: SubagentActivityEvent) => void) + | undefined; + + vi.mocked(LocalAgentExecutor.create).mockImplementation( + async (_def, _config, onActivity) => { + activityCallback = onActivity; + return { + run: vi.fn().mockResolvedValue({ + terminate_reason: AgentTerminateMode.GOAL, + result: 'Success', + }), + } as unknown as LocalAgentExecutor; + }, + ); + + vi.mocked(createBrowserAgentDefinition).mockResolvedValue({ + definition: { + name: 'browser_agent', + } as unknown as LocalAgentExecutor, + browserManager: {} as unknown as LocalAgentExecutor, + }); + + const invocation = new BrowserAgentInvocation( + mockConfig, + mockParams, + mockMessageBus, + ); + + await invocation.execute(new AbortController().signal, updateOutput); + + activityCallback!({ + isSubagentActivityEvent: true, + agentName: 'browser_agent', + type: 'ERROR', + data: { + error: 'Failed with api_key=my secret value here and more text', + }, + }); + + const lastProgress = updateOutput.mock.calls[ + updateOutput.mock.calls.length - 1 + ][0] as SubagentProgress; + const errorThought = lastProgress.recentActivity.find( + (item) => item.type === 'thought' && item.status === 'error', + ); + expect(errorThought!.content).toContain('api_key=[REDACTED]'); + expect(errorThought!.content).not.toContain('my secret value'); + }); + + it('should handle URL-encoded sensitive keys in tool arguments', async () => { + const updateOutput = vi.fn(); + let activityCallback: + | ((activity: SubagentActivityEvent) => void) + | undefined; + + vi.mocked(LocalAgentExecutor.create).mockImplementation( + async (_def, _config, onActivity) => { + activityCallback = onActivity; + return { + run: vi.fn().mockResolvedValue({ + terminate_reason: AgentTerminateMode.GOAL, + result: 'Success', + }), + } as unknown as LocalAgentExecutor; + }, + ); + + vi.mocked(createBrowserAgentDefinition).mockResolvedValue({ + definition: { + name: 'browser_agent', + } as unknown as LocalAgentExecutor, + browserManager: {} as unknown as LocalAgentExecutor, + }); + + const invocation = new BrowserAgentInvocation( + mockConfig, + mockParams, + mockMessageBus, + ); + + await invocation.execute(new AbortController().signal, updateOutput); + + activityCallback!({ + isSubagentActivityEvent: true, + agentName: 'browser_agent', + type: 'TOOL_CALL_START', + data: { + name: 'testTool', + args: { + 'api%5fkey': 'secret-value', + 'auth%2dtoken': 'token-value', + }, + }, + }); + + const lastProgress = updateOutput.mock.calls[ + updateOutput.mock.calls.length - 1 + ][0] as SubagentProgress; + const toolCall = lastProgress.recentActivity.find( + (item) => item.type === 'tool_call', + ); + const argsObj = JSON.parse(toolCall!.args!); + expect(argsObj['api%5fkey']).toBe('[REDACTED]'); + expect(argsObj['auth%2dtoken']).toBe('[REDACTED]'); + }); }); describe('toolLocations', () => { diff --git a/packages/core/src/agents/browser/browserAgentInvocation.ts b/packages/core/src/agents/browser/browserAgentInvocation.ts index 38ddf06a358..b1f1ba1f6b8 100644 --- a/packages/core/src/agents/browser/browserAgentInvocation.ts +++ b/packages/core/src/agents/browser/browserAgentInvocation.ts @@ -74,7 +74,14 @@ function sanitizeToolArgs(args: unknown): unknown { const sanitized: Record = {}; for (const [key, value] of Object.entries(args)) { - const keyNormalized = key.toLowerCase().replace(/[-_]/g, ''); + // Decode key to handle URL-encoded sensitive keys (e.g., api%5fkey) + let decodedKey = key; + try { + decodedKey = decodeURIComponent(key); + } catch { + // Ignore decoding errors + } + const keyNormalized = decodedKey.toLowerCase().replace(/[-_]/g, ''); const isSensitive = SENSITIVE_KEY_PATTERNS.some((pattern) => keyNormalized.includes(pattern.replace(/[-_]/g, '')), ); @@ -97,24 +104,18 @@ function sanitizeToolArgs(args: unknown): unknown { function sanitizeErrorMessage(message: string): string { return message .replace( - /api[_-]?key[s]?[:=]\s*['"]?[^\s,'"&;]+['"]?/gi, + /api[_-]?key[s]?[:=]\s*['"]?[^,'"&;]+['"]?/gi, 'api_key=[REDACTED]', ) - .replace(/token[:=]\s*['"]?[^\s,'"&;]+['"]?/gi, 'token=[REDACTED]') - .replace(/password[:=]\s*['"]?[^\s,'"&;]+['"]?/gi, 'password=[REDACTED]') - .replace(/pwd[:=]\s*['"]?[^\s,'"&;]+['"]?/gi, 'pwd=[REDACTED]') - .replace(/secret[:=]\s*['"]?[^\s,'"&;]+['"]?/gi, 'secret=[REDACTED]') + .replace(/token[:=]\s*['"]?[^,'"&;]+['"]?/gi, 'token=[REDACTED]') + .replace(/password[:=]\s*['"]?[^,'"&;]+['"]?/gi, 'password=[REDACTED]') + .replace(/pwd[:=]\s*['"]?[^,'"&;]+['"]?/gi, 'pwd=[REDACTED]') + .replace(/secret[:=]\s*['"]?[^,'"&;]+['"]?/gi, 'secret=[REDACTED]') + .replace(/credential[:=]\s*['"]?[^,'"&;]+['"]?/gi, 'credential=[REDACTED]') + .replace(/auth[:=]\s*['"]?[^,'"&;]+['"]?/gi, 'auth=[REDACTED]') + .replace(/passphrase[:=]\s*['"]?[^,'"&;]+['"]?/gi, 'passphrase=[REDACTED]') .replace( - /credential[:=]\s*['"]?[^\s,'"&;]+['"]?/gi, - 'credential=[REDACTED]', - ) - .replace(/auth[:=]\s*['"]?[^\s,'"&;]+['"]?/gi, 'auth=[REDACTED]') - .replace( - /passphrase[:=]\s*['"]?[^\s,'"&;]+['"]?/gi, - 'passphrase=[REDACTED]', - ) - .replace( - /private[_-]?key[:=]\s*['"]?[^\s,'"&;]+['"]?/gi, + /private[_-]?key[:=]\s*['"]?[^,'"&;]+['"]?/gi, 'private_key=[REDACTED]', ) .replace( @@ -269,8 +270,11 @@ export class BrowserAgentInvocation extends BaseToolInvocation< const args = JSON.stringify( sanitizeToolArgs(activity.data['args']), ); + const callId = activity.data['callId'] + ? String(activity.data['callId']) + : randomUUID(); recentActivity.push({ - id: randomUUID(), + id: callId, type: 'tool_call', content: name, displayName, @@ -282,12 +286,17 @@ export class BrowserAgentInvocation extends BaseToolInvocation< break; } case 'TOOL_CALL_END': { + const callId = activity.data['id'] + ? String(activity.data['id']) + : undefined; const name = String(activity.data['name']); - // Find the last running tool call with this name + // Find the tool call by ID or the last running tool call with this name for (let i = recentActivity.length - 1; i >= 0; i--) { if ( recentActivity[i].type === 'tool_call' && - recentActivity[i].content === name && + (callId + ? recentActivity[i].id === callId + : recentActivity[i].content === name) && recentActivity[i].status === 'running' ) { recentActivity[i].status = 'completed'; @@ -303,14 +312,19 @@ export class BrowserAgentInvocation extends BaseToolInvocation< const toolName = activity.data['name'] ? String(activity.data['name']) : undefined; + const callId = activity.data['callId'] + ? String(activity.data['callId']) + : undefined; const newStatus = isCancellation ? 'cancelled' : 'error'; - if (toolName) { + if (callId || toolName) { // Mark the specific tool as error/cancelled for (let i = recentActivity.length - 1; i >= 0; i--) { if ( recentActivity[i].type === 'tool_call' && - recentActivity[i].content === toolName && + (callId + ? recentActivity[i].id === callId + : recentActivity[i].content === toolName) && recentActivity[i].status === 'running' ) { recentActivity[i].status = newStatus; diff --git a/packages/core/src/agents/local-executor.test.ts b/packages/core/src/agents/local-executor.test.ts index 50eb30da760..5948afac52a 100644 --- a/packages/core/src/agents/local-executor.test.ts +++ b/packages/core/src/agents/local-executor.test.ts @@ -930,11 +930,11 @@ describe('LocalAgentExecutor', () => { expect(activities).toContainEqual( expect.objectContaining({ type: 'ERROR', - data: { + data: expect.objectContaining({ context: 'tool_call', name: TASK_COMPLETE_TOOL_NAME, error: expectedError, - }, + }), }), ); @@ -1216,11 +1216,11 @@ describe('LocalAgentExecutor', () => { expect(activities).toContainEqual( expect.objectContaining({ type: 'ERROR', - data: { + data: expect.objectContaining({ context: 'tool_call', name: TASK_COMPLETE_TOOL_NAME, error: expect.stringContaining('Output validation failed'), - }, + }), }), ); @@ -1341,11 +1341,11 @@ describe('LocalAgentExecutor', () => { expect(activities).toContainEqual( expect.objectContaining({ type: 'ERROR', - data: { + data: expect.objectContaining({ context: 'tool_call', name: LS_TOOL_NAME, error: toolErrorMessage, - }, + }), }), ); @@ -1702,15 +1702,17 @@ describe('LocalAgentExecutor', () => { expect(activities).toContainEqual( expect.objectContaining({ type: 'THOUGHT_CHUNK', - data: { + data: expect.objectContaining({ text: 'Execution limit reached (MAX_TURNS). Attempting one final recovery turn with a grace period.', - }, + }), }), ); expect(activities).toContainEqual( expect.objectContaining({ type: 'THOUGHT_CHUNK', - data: { text: 'Graceful recovery succeeded.' }, + data: expect.objectContaining({ + text: 'Graceful recovery succeeded.', + }), }), ); }); @@ -1787,9 +1789,9 @@ describe('LocalAgentExecutor', () => { expect(activities).toContainEqual( expect.objectContaining({ type: 'THOUGHT_CHUNK', - data: { + data: expect.objectContaining({ text: 'Execution limit reached (ERROR_NO_COMPLETE_TASK_CALL). Attempting one final recovery turn with a grace period.', - }, + }), }), ); }); @@ -1885,9 +1887,9 @@ describe('LocalAgentExecutor', () => { expect(activities).toContainEqual( expect.objectContaining({ type: 'THOUGHT_CHUNK', - data: { + data: expect.objectContaining({ text: 'Execution limit reached (TIMEOUT). Attempting one final recovery turn with a grace period.', - }, + }), }), ); }); diff --git a/packages/core/src/agents/local-executor.ts b/packages/core/src/agents/local-executor.ts index 7bbecdac7c3..fd450c5efa8 100644 --- a/packages/core/src/agents/local-executor.ts +++ b/packages/core/src/agents/local-executor.ts @@ -902,6 +902,7 @@ export class LocalAgentExecutor { displayName, description, args, + callId, }); if (toolName === TASK_COMPLETE_TOOL_NAME) { @@ -969,6 +970,7 @@ export class LocalAgentExecutor { }); this.emitActivity('TOOL_CALL_END', { name: toolName, + id: callId, output: 'Output submitted and task completed.', }); } else { @@ -985,6 +987,7 @@ export class LocalAgentExecutor { this.emitActivity('ERROR', { context: 'tool_call', name: toolName, + callId, error, }); } @@ -1009,6 +1012,7 @@ export class LocalAgentExecutor { }); this.emitActivity('TOOL_CALL_END', { name: toolName, + id: callId, output: 'Result submitted and task completed.', }); } else { @@ -1026,6 +1030,7 @@ export class LocalAgentExecutor { this.emitActivity('ERROR', { context: 'tool_call', name: toolName, + callId, error, }); } @@ -1086,18 +1091,21 @@ export class LocalAgentExecutor { if (call.status === 'success') { this.emitActivity('TOOL_CALL_END', { name: toolName, + id: call.request.callId, output: call.response.resultDisplay, }); } else if (call.status === 'error') { this.emitActivity('ERROR', { context: 'tool_call', name: toolName, + callId: call.request.callId, error: call.response.error?.message || 'Unknown error', }); } else if (call.status === 'cancelled') { this.emitActivity('ERROR', { context: 'tool_call', name: toolName, + callId: call.request.callId, error: 'Request cancelled.', }); aborted = true; From e5134453d543be83fe00a55cc175fa438c1bc2fd Mon Sep 17 00:00:00 2001 From: kunal-10-cloud Date: Thu, 5 Mar 2026 21:04:24 +0530 Subject: [PATCH 11/24] fix(core): resolve regex lint errors in browser agent invocation --- .../agents/browser/browserAgentInvocation.ts | 40 +++++++++++++------ 1 file changed, 27 insertions(+), 13 deletions(-) diff --git a/packages/core/src/agents/browser/browserAgentInvocation.ts b/packages/core/src/agents/browser/browserAgentInvocation.ts index b1f1ba1f6b8..700c4c73203 100644 --- a/packages/core/src/agents/browser/browserAgentInvocation.ts +++ b/packages/core/src/agents/browser/browserAgentInvocation.ts @@ -63,6 +63,9 @@ const SENSITIVE_KEY_PATTERNS = [ * Supports nested objects and arrays. */ function sanitizeToolArgs(args: unknown): unknown { + if (typeof args === 'string') { + return sanitizeErrorMessage(args); + } if (typeof args !== 'object' || args === null) { return args; } @@ -87,8 +90,6 @@ function sanitizeToolArgs(args: unknown): unknown { ); if (isSensitive) { sanitized[key] = '[REDACTED]'; - } else if (typeof value === 'string') { - sanitized[key] = sanitizeErrorMessage(value); } else { sanitized[key] = sanitizeToolArgs(value); } @@ -102,21 +103,34 @@ function sanitizeToolArgs(args: unknown): unknown { * Uses [^\s'"]+ to catch JWTs, tokens with dots/slashes, and other complex values. */ function sanitizeErrorMessage(message: string): string { + const valuePattern = `(?:"[^"]*"|'[^']*'|[^&;]*)`; return message .replace( - /api[_-]?key[s]?[:=]\s*['"]?[^,'"&;]+['"]?/gi, - 'api_key=[REDACTED]', + new RegExp(`(api[_-]?key[s]?[:=]\\s*)${valuePattern}`, 'gi'), + '$1[REDACTED]', + ) + .replace(new RegExp(`(token[:=]\\s*)${valuePattern}`, 'gi'), '$1[REDACTED]') + .replace( + new RegExp(`(password[:=]\\s*)${valuePattern}`, 'gi'), + '$1[REDACTED]', + ) + .replace(new RegExp(`(pwd[:=]\\s*)${valuePattern}`, 'gi'), '$1[REDACTED]') + .replace( + new RegExp(`(secret[:=]\\s*)${valuePattern}`, 'gi'), + '$1[REDACTED]', + ) + .replace( + new RegExp(`(credential[:=]\\s*)${valuePattern}`, 'gi'), + '$1[REDACTED]', + ) + .replace(new RegExp(`(auth[:=]\\s*)${valuePattern}`, 'gi'), '$1[REDACTED]') + .replace( + new RegExp(`(passphrase[:=]\\s*)${valuePattern}`, 'gi'), + '$1[REDACTED]', ) - .replace(/token[:=]\s*['"]?[^,'"&;]+['"]?/gi, 'token=[REDACTED]') - .replace(/password[:=]\s*['"]?[^,'"&;]+['"]?/gi, 'password=[REDACTED]') - .replace(/pwd[:=]\s*['"]?[^,'"&;]+['"]?/gi, 'pwd=[REDACTED]') - .replace(/secret[:=]\s*['"]?[^,'"&;]+['"]?/gi, 'secret=[REDACTED]') - .replace(/credential[:=]\s*['"]?[^,'"&;]+['"]?/gi, 'credential=[REDACTED]') - .replace(/auth[:=]\s*['"]?[^,'"&;]+['"]?/gi, 'auth=[REDACTED]') - .replace(/passphrase[:=]\s*['"]?[^,'"&;]+['"]?/gi, 'passphrase=[REDACTED]') .replace( - /private[_-]?key[:=]\s*['"]?[^,'"&;]+['"]?/gi, - 'private_key=[REDACTED]', + new RegExp(`(private[_-]?key[:=]\\s*)${valuePattern}`, 'gi'), + '$1[REDACTED]', ) .replace( /\/[a-zA-Z0-9_\-/]*\/[a-zA-Z0-9_-]*\.(key|pem|p12|pfx)/gi, From 690f2c89c8944c49e5d063e35ee92c8d125f30df Mon Sep 17 00:00:00 2001 From: kunal-10-cloud Date: Thu, 5 Mar 2026 21:52:02 +0530 Subject: [PATCH 12/24] security: strengthen error message sanitization in browser agent --- .../browser/browserAgentInvocation.test.ts | 127 +++++++++++++++++- .../agents/browser/browserAgentInvocation.ts | 53 ++++---- 2 files changed, 151 insertions(+), 29 deletions(-) diff --git a/packages/core/src/agents/browser/browserAgentInvocation.test.ts b/packages/core/src/agents/browser/browserAgentInvocation.test.ts index cf1b1d43968..501a4c4e84f 100644 --- a/packages/core/src/agents/browser/browserAgentInvocation.test.ts +++ b/packages/core/src/agents/browser/browserAgentInvocation.test.ts @@ -482,7 +482,9 @@ describe('BrowserAgentInvocation', () => { // Check value-based redaction (string scanning) expect(argsObj.nestedConfig.regularUrl).toContain('[REDACTED]'); expect(argsObj.nestedConfig.regularUrl).not.toContain('secret_in_url'); - expect(argsObj.nestedConfig.regularUrl).toContain('other=val'); + // Note: Full query string is redacted because we no longer assume & is a delimiter + // as part of strengthening redaction robustness for tokens that might contain &. + expect(argsObj.nestedConfig.regularUrl).not.toContain('other=val'); }); it('should sanitize sensitive patterns in error messages', async () => { @@ -901,6 +903,129 @@ describe('BrowserAgentInvocation', () => { expect(argsObj['api%5fkey']).toBe('[REDACTED]'); expect(argsObj['auth%2dtoken']).toBe('[REDACTED]'); }); + + it('should redact JSON-style keys and space-separated values', async () => { + const updateOutput = vi.fn(); + let activityCallback: + | ((activity: SubagentActivityEvent) => void) + | undefined; + + vi.mocked(LocalAgentExecutor.create).mockImplementation( + async (_def, _config, onActivity) => { + activityCallback = onActivity; + return { + run: vi.fn().mockResolvedValue({ + terminate_reason: AgentTerminateMode.GOAL, + result: 'Success', + }), + } as unknown as LocalAgentExecutor; + }, + ); + + vi.mocked(createBrowserAgentDefinition).mockResolvedValue({ + definition: { + name: 'browser_agent', + } as unknown as LocalAgentExecutor, + browserManager: {} as unknown as LocalAgentExecutor, + }); + + const invocation = new BrowserAgentInvocation( + mockConfig, + mockParams, + mockMessageBus, + ); + + await invocation.execute(new AbortController().signal, updateOutput); + + // JSON-style keys + activityCallback!({ + isSubagentActivityEvent: true, + agentName: 'browser_agent', + type: 'ERROR', + data: { + error: 'Error: {"api_key": "secret123", "other": "val"}', + }, + }); + + // Space-separated tokens + activityCallback!({ + isSubagentActivityEvent: true, + agentName: 'browser_agent', + type: 'ERROR', + data: { + error: + 'Connection failed: token eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...', + }, + }); + + // Bearer token + activityCallback!({ + isSubagentActivityEvent: true, + agentName: 'browser_agent', + type: 'ERROR', + data: { + error: 'Unauthorized: Bearer sk_test_51Mz...', + }, + }); + + // Partial redaction with delimiters + activityCallback!({ + isSubagentActivityEvent: true, + agentName: 'browser_agent', + type: 'ERROR', + data: { + error: 'Failed with api_key=foo&bar;token=baz', + }, + }); + + const progressResults = updateOutput.mock.calls.map( + (c) => c[0] as SubagentProgress, + ); + + const jsonError = progressResults.find((p) => + p.recentActivity.some((a) => + a.content.includes('"api_key": [REDACTED]'), + ), + ); + expect(jsonError).toBeDefined(); + expect( + jsonError?.recentActivity.some((a) => a.content.includes('secret123')), + ).toBe(false); + + const tokenError = progressResults.find((p) => + p.recentActivity.some((a) => a.content.includes('token [REDACTED]')), + ); + expect(tokenError).toBeDefined(); + expect( + tokenError?.recentActivity.some((a) => + a.content.includes('eyJhbGciOi'), + ), + ).toBe(false); + + const bearerError = progressResults.find((p) => + p.recentActivity.some((a) => a.content.includes('Bearer [REDACTED]')), + ); + expect(bearerError).toBeDefined(); + expect( + bearerError?.recentActivity.some((a) => + a.content.includes('sk_test_51Mz'), + ), + ).toBe(false); + + const delimiterError = progressResults.find((p) => + p.recentActivity.some((a) => a.content.includes('api_key=[REDACTED]')), + ); + expect(delimiterError).toBeDefined(); + expect( + delimiterError?.recentActivity.some((a) => a.content.includes('foo')), + ).toBe(false); + expect( + delimiterError?.recentActivity.some((a) => a.content.includes('bar')), + ).toBe(false); + expect( + delimiterError?.recentActivity.some((a) => a.content.includes('baz')), + ).toBe(false); + }); }); describe('toolLocations', () => { diff --git a/packages/core/src/agents/browser/browserAgentInvocation.ts b/packages/core/src/agents/browser/browserAgentInvocation.ts index 700c4c73203..cbcb2721a09 100644 --- a/packages/core/src/agents/browser/browserAgentInvocation.ts +++ b/packages/core/src/agents/browser/browserAgentInvocation.ts @@ -52,6 +52,11 @@ const SENSITIVE_KEY_PATTERNS = [ 'secret', 'credential', 'auth', + 'authorization', + 'access_token', + 'refresh_token', + 'session_id', + 'cookie', 'passphrase', 'privatekey', 'private_key', @@ -103,35 +108,27 @@ function sanitizeToolArgs(args: unknown): unknown { * Uses [^\s'"]+ to catch JWTs, tokens with dots/slashes, and other complex values. */ function sanitizeErrorMessage(message: string): string { - const valuePattern = `(?:"[^"]*"|'[^']*'|[^&;]*)`; + if (!message) return message; + + const keyMatch = SENSITIVE_KEY_PATTERNS.join('|').replace(/[-_]/g, '[_-]?'); + const valuePattern = `(?:"[^"]*"|'[^']*'|[^\\s]+)`; + + // 1. Handle space-separated tokens/auth (e.g., "token eyJ...", "Bearer eyJ...", "Authorization: Bearer eyJ...") + // We handle this first to avoid partial redaction of "Authorization: Bearer" by the key-value regex + const spaceSeparated = new RegExp( + `\\b((?:token|bearer|authorization(?:\\s*:\\s*bearer)?)\\s+)${valuePattern}`, + 'gi', + ); + + // 2. Handle key with delimiter (:, =) and optional quotes around key/value + const keyWithDelimiter = new RegExp( + `(("?(${keyMatch})"?)\\s*[:=]\\s*)${valuePattern}`, + 'gi', + ); + return message - .replace( - new RegExp(`(api[_-]?key[s]?[:=]\\s*)${valuePattern}`, 'gi'), - '$1[REDACTED]', - ) - .replace(new RegExp(`(token[:=]\\s*)${valuePattern}`, 'gi'), '$1[REDACTED]') - .replace( - new RegExp(`(password[:=]\\s*)${valuePattern}`, 'gi'), - '$1[REDACTED]', - ) - .replace(new RegExp(`(pwd[:=]\\s*)${valuePattern}`, 'gi'), '$1[REDACTED]') - .replace( - new RegExp(`(secret[:=]\\s*)${valuePattern}`, 'gi'), - '$1[REDACTED]', - ) - .replace( - new RegExp(`(credential[:=]\\s*)${valuePattern}`, 'gi'), - '$1[REDACTED]', - ) - .replace(new RegExp(`(auth[:=]\\s*)${valuePattern}`, 'gi'), '$1[REDACTED]') - .replace( - new RegExp(`(passphrase[:=]\\s*)${valuePattern}`, 'gi'), - '$1[REDACTED]', - ) - .replace( - new RegExp(`(private[_-]?key[:=]\\s*)${valuePattern}`, 'gi'), - '$1[REDACTED]', - ) + .replace(spaceSeparated, '$1[REDACTED]') + .replace(keyWithDelimiter, '$1[REDACTED]') .replace( /\/[a-zA-Z0-9_\-/]*\/[a-zA-Z0-9_-]*\.(key|pem|p12|pfx)/gi, '/path/to/[REDACTED].key', From 73d0d71fc6d3ae66e1864b7b702b191bdc37103d Mon Sep 17 00:00:00 2001 From: Aditya Bijalwan Date: Thu, 5 Mar 2026 22:11:49 +0530 Subject: [PATCH 13/24] Update packages/core/src/agents/browser/browserAgentInvocation.test.ts Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- packages/core/src/agents/browser/browserAgentInvocation.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/agents/browser/browserAgentInvocation.test.ts b/packages/core/src/agents/browser/browserAgentInvocation.test.ts index 501a4c4e84f..3a782c4090c 100644 --- a/packages/core/src/agents/browser/browserAgentInvocation.test.ts +++ b/packages/core/src/agents/browser/browserAgentInvocation.test.ts @@ -64,7 +64,7 @@ describe('BrowserAgentInvocation', () => { publish: vi.fn().mockResolvedValue(undefined), subscribe: vi.fn(), unsubscribe: vi.fn(), - } as unknown as LocalAgentExecutor; + } as unknown as MessageBus; mockParams = { task: 'Navigate to example.com and click the button', From 4a6ac7ac8651a3b4142bcf6e9f9d47a7075668e9 Mon Sep 17 00:00:00 2001 From: kunal-10-cloud Date: Sat, 7 Mar 2026 20:41:11 +0530 Subject: [PATCH 14/24] fix(core): strengthen browser agent error sanitization regex --- .../browser/browserAgentInvocation.test.ts | 56 +++++++++++++++++++ .../agents/browser/browserAgentInvocation.ts | 49 ++++++++++------ 2 files changed, 88 insertions(+), 17 deletions(-) diff --git a/packages/core/src/agents/browser/browserAgentInvocation.test.ts b/packages/core/src/agents/browser/browserAgentInvocation.test.ts index 3a782c4090c..ff8a1251d59 100644 --- a/packages/core/src/agents/browser/browserAgentInvocation.test.ts +++ b/packages/core/src/agents/browser/browserAgentInvocation.test.ts @@ -1026,6 +1026,62 @@ describe('BrowserAgentInvocation', () => { delimiterError?.recentActivity.some((a) => a.content.includes('baz')), ).toBe(false); }); + + it('should redact inline PEM key content', async () => { + const updateOutput = vi.fn(); + let activityCallback: + | ((activity: SubagentActivityEvent) => void) + | undefined; + + vi.mocked(LocalAgentExecutor.create).mockImplementation( + async (_def, _config, onActivity) => { + activityCallback = onActivity; + return { + run: vi.fn().mockResolvedValue({ + terminate_reason: AgentTerminateMode.GOAL, + result: 'Success', + }), + } as unknown as LocalAgentExecutor; + }, + ); + + vi.mocked(createBrowserAgentDefinition).mockResolvedValue({ + definition: { + name: 'browser_agent', + } as unknown as LocalAgentExecutor, + browserManager: {} as unknown as LocalAgentExecutor, + }); + + const invocation = new BrowserAgentInvocation( + mockConfig, + mockParams, + mockMessageBus, + ); + + await invocation.execute(new AbortController().signal, updateOutput); + + activityCallback!({ + isSubagentActivityEvent: true, + agentName: 'browser_agent', + type: 'ERROR', + data: { + error: + 'Failed to authenticate:\n-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEA12345...\n-----END RSA PRIVATE KEY-----\nPlease check your credentials.', + }, + }); + + const lastProgress = updateOutput.mock.calls[ + updateOutput.mock.calls.length - 1 + ][0] as SubagentProgress; + const errorThought = lastProgress.recentActivity.find( + (item) => item.type === 'thought' && item.status === 'error', + ); + + expect(errorThought).toBeDefined(); + expect(errorThought!.content).toContain('[REDACTED_PEM]'); + expect(errorThought!.content).not.toContain('-----BEGIN'); + expect(errorThought!.content).not.toContain('MIIEowIBAAKCAQEA12345...'); + }); }); describe('toolLocations', () => { diff --git a/packages/core/src/agents/browser/browserAgentInvocation.ts b/packages/core/src/agents/browser/browserAgentInvocation.ts index cbcb2721a09..b3b19a42ab7 100644 --- a/packages/core/src/agents/browser/browserAgentInvocation.ts +++ b/packages/core/src/agents/browser/browserAgentInvocation.ts @@ -110,29 +110,43 @@ function sanitizeToolArgs(args: unknown): unknown { function sanitizeErrorMessage(message: string): string { if (!message) return message; - const keyMatch = SENSITIVE_KEY_PATTERNS.join('|').replace(/[-_]/g, '[_-]?'); - const valuePattern = `(?:"[^"]*"|'[^']*'|[^\\s]+)`; + let sanitized = message; - // 1. Handle space-separated tokens/auth (e.g., "token eyJ...", "Bearer eyJ...", "Authorization: Bearer eyJ...") - // We handle this first to avoid partial redaction of "Authorization: Bearer" by the key-value regex - const spaceSeparated = new RegExp( - `\\b((?:token|bearer|authorization(?:\\s*:\\s*bearer)?)\\s+)${valuePattern}`, - 'gi', + // 1. Redact inline PEM content + sanitized = sanitized.replace( + /-----BEGIN\s+[\w\s]+-----[\s\S]*?-----END\s+[\w\s]+-----/g, + '[REDACTED_PEM]', ); - // 2. Handle key with delimiter (:, =) and optional quotes around key/value + const unquotedValue = `[^\\s,;}\\]]+(?:\\s+(?![a-zA-Z0-9_.-]+(?:=|:))[^\\s=:<>,;}\\]]+)*`; + const valuePattern = `(?:"[^"]*"|'[^']*'|${unquotedValue})`; + + // 2. Handle key with delimiter + const urlSafeKeyPatternStr = SENSITIVE_KEY_PATTERNS.map((p) => + p.replace(/[-_]/g, '(?:[-_]|%2D|%5F|%2d|%5f)?'), + ).join('|'); + const keyWithDelimiter = new RegExp( - `(("?(${keyMatch})"?)\\s*[:=]\\s*)${valuePattern}`, + `((""|"|')?(${urlSafeKeyPatternStr})\\2\\s*(?:[:=]|%3A|%3D)\\s*)${valuePattern}`, 'gi', ); + sanitized = sanitized.replace(keyWithDelimiter, '$1[REDACTED]'); - return message - .replace(spaceSeparated, '$1[REDACTED]') - .replace(keyWithDelimiter, '$1[REDACTED]') - .replace( - /\/[a-zA-Z0-9_\-/]*\/[a-zA-Z0-9_-]*\.(key|pem|p12|pfx)/gi, - '/path/to/[REDACTED].key', - ); + // 3. Handle space-separated tokens/auth + const tokenValuePattern = `[A-Za-z0-9._\\-/+=]{8,}`; + const spaceSeparated = new RegExp( + `\\b((?:token|bearer|authorization(?:\\s*:\\s*bearer)?)\\s+)(${tokenValuePattern})`, + 'gi', + ); + sanitized = sanitized.replace(spaceSeparated, '$1[REDACTED]'); + + // 4. Handle file path redaction + sanitized = sanitized.replace( + /([\\/][a-zA-Z0-9_\-/]*[\\/][a-zA-Z0-9_-]*\.(?:key|pem|p12|pfx))/gi, + '/path/to/[REDACTED].key', + ); + + return sanitized; } /** @@ -216,10 +230,11 @@ export class BrowserAgentInvocation extends BaseToolInvocation< // Note: printOutput is used for low-level connection logs before agent starts const printOutput = updateOutput ? (msg: string) => { + const sanitizedMsg = sanitizeThoughtContent(msg); recentActivity.push({ id: randomUUID(), type: 'thought', - content: msg, + content: sanitizedMsg, status: 'completed', }); if (recentActivity.length > MAX_RECENT_ACTIVITY) { From e4577aed9cdffe8efe3b4f4a52ed3cbbfbe6ed1e Mon Sep 17 00:00:00 2001 From: Aditya Bijalwan Date: Sat, 7 Mar 2026 22:47:00 +0530 Subject: [PATCH 15/24] Update packages/core/src/agents/browser/browserAgentInvocation.ts Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- packages/core/src/agents/browser/browserAgentInvocation.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/agents/browser/browserAgentInvocation.ts b/packages/core/src/agents/browser/browserAgentInvocation.ts index b3b19a42ab7..68ebc8efe0a 100644 --- a/packages/core/src/agents/browser/browserAgentInvocation.ts +++ b/packages/core/src/agents/browser/browserAgentInvocation.ts @@ -142,7 +142,7 @@ function sanitizeErrorMessage(message: string): string { // 4. Handle file path redaction sanitized = sanitized.replace( - /([\\/][a-zA-Z0-9_\-/]*[\\/][a-zA-Z0-9_-]*\.(?:key|pem|p12|pfx))/gi, + /((?:[\/][a-zA-Z0-9_-]+)*[\/][a-zA-Z0-9_-]*\.(?:key|pem|p12|pfx))/gi, '/path/to/[REDACTED].key', ); From 57122573cc1393a5f35bbc8b1951fed6963422d1 Mon Sep 17 00:00:00 2001 From: kunal-10-cloud Date: Sat, 7 Mar 2026 23:04:43 +0530 Subject: [PATCH 16/24] fix(core): remove unnecessary escape in regex literal --- .../core/src/agents/browser/browserAgentInvocation.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/core/src/agents/browser/browserAgentInvocation.ts b/packages/core/src/agents/browser/browserAgentInvocation.ts index 68ebc8efe0a..4583b576960 100644 --- a/packages/core/src/agents/browser/browserAgentInvocation.ts +++ b/packages/core/src/agents/browser/browserAgentInvocation.ts @@ -142,7 +142,7 @@ function sanitizeErrorMessage(message: string): string { // 4. Handle file path redaction sanitized = sanitized.replace( - /((?:[\/][a-zA-Z0-9_-]+)*[\/][a-zA-Z0-9_-]*\.(?:key|pem|p12|pfx))/gi, + /((?:[/\\][a-zA-Z0-9_-]+)*[/\\][a-zA-Z0-9_-]*\.(?:key|pem|p12|pfx))/gi, '/path/to/[REDACTED].key', ); @@ -266,19 +266,20 @@ export class BrowserAgentInvocation extends BaseToolInvocation< switch (activity.type) { case 'THOUGHT_CHUNK': { const text = String(activity.data['text']); - const sanitizedText = sanitizeThoughtContent(text); const lastItem = recentActivity[recentActivity.length - 1]; if ( lastItem && lastItem.type === 'thought' && lastItem.status === 'running' ) { - lastItem.content += sanitizedText; + lastItem.content = sanitizeThoughtContent( + lastItem.content + text, + ); } else { recentActivity.push({ id: randomUUID(), type: 'thought', - content: sanitizedText, + content: sanitizeThoughtContent(text), status: 'running', }); } From 6c153f6a3fddfeceb1f5a2f9c5211ca3981e7744 Mon Sep 17 00:00:00 2001 From: Aditya Bijalwan Date: Sat, 7 Mar 2026 23:10:51 +0530 Subject: [PATCH 17/24] Update packages/core/src/agents/browser/browserAgentInvocation.ts Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- packages/core/src/agents/browser/browserAgentInvocation.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/core/src/agents/browser/browserAgentInvocation.ts b/packages/core/src/agents/browser/browserAgentInvocation.ts index 4583b576960..e020bfb5a48 100644 --- a/packages/core/src/agents/browser/browserAgentInvocation.ts +++ b/packages/core/src/agents/browser/browserAgentInvocation.ts @@ -135,10 +135,9 @@ function sanitizeErrorMessage(message: string): string { // 3. Handle space-separated tokens/auth const tokenValuePattern = `[A-Za-z0-9._\\-/+=]{8,}`; const spaceSeparated = new RegExp( - `\\b((?:token|bearer|authorization(?:\\s*:\\s*bearer)?)\\s+)(${tokenValuePattern})`, + `\\b((?:token|bearer|authorization(?:\\s*:\\s*bearer)?|password|pwd|apikey|api[-_]?key|secret)\\s+)(${tokenValuePattern})`, 'gi', ); - sanitized = sanitized.replace(spaceSeparated, '$1[REDACTED]'); // 4. Handle file path redaction sanitized = sanitized.replace( From 72cadf5d30d76c20952c2e903c0663aac2803ba8 Mon Sep 17 00:00:00 2001 From: Aditya Bijalwan Date: Sat, 7 Mar 2026 23:11:02 +0530 Subject: [PATCH 18/24] Update packages/core/src/agents/browser/browserAgentInvocation.ts Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- packages/core/src/agents/browser/browserAgentInvocation.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/agents/browser/browserAgentInvocation.ts b/packages/core/src/agents/browser/browserAgentInvocation.ts index e020bfb5a48..b1c4aa70c12 100644 --- a/packages/core/src/agents/browser/browserAgentInvocation.ts +++ b/packages/core/src/agents/browser/browserAgentInvocation.ts @@ -118,7 +118,7 @@ function sanitizeErrorMessage(message: string): string { '[REDACTED_PEM]', ); - const unquotedValue = `[^\\s,;}\\]]+(?:\\s+(?![a-zA-Z0-9_.-]+(?:=|:))[^\\s=:<>,;}\\]]+)*`; + const unquotedValue = `[^\\s}\\]]+(?:\\s+(?![a-zA-Z0-9_.-]+(?:=|:))[^\\s=:<>}\\]]+)*`; const valuePattern = `(?:"[^"]*"|'[^']*'|${unquotedValue})`; // 2. Handle key with delimiter From cf11934dc68d159970a25e70ede4924537c3e7af Mon Sep 17 00:00:00 2001 From: kunal-10-cloud Date: Sat, 7 Mar 2026 23:15:04 +0530 Subject: [PATCH 19/24] fix(core): expand sensitive key coverage and support CLI-style delimiters --- .../agents/browser/browserAgentInvocation.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/packages/core/src/agents/browser/browserAgentInvocation.ts b/packages/core/src/agents/browser/browserAgentInvocation.ts index b1c4aa70c12..c0367bf2a85 100644 --- a/packages/core/src/agents/browser/browserAgentInvocation.ts +++ b/packages/core/src/agents/browser/browserAgentInvocation.ts @@ -54,6 +54,7 @@ const SENSITIVE_KEY_PATTERNS = [ 'auth', 'authorization', 'access_token', + 'access_key', 'refresh_token', 'session_id', 'cookie', @@ -61,6 +62,9 @@ const SENSITIVE_KEY_PATTERNS = [ 'privatekey', 'private_key', 'private-key', + 'secret_key', + 'client_secret', + 'client_id', ]; /** @@ -121,21 +125,27 @@ function sanitizeErrorMessage(message: string): string { const unquotedValue = `[^\\s}\\]]+(?:\\s+(?![a-zA-Z0-9_.-]+(?:=|:))[^\\s=:<>}\\]]+)*`; const valuePattern = `(?:"[^"]*"|'[^']*'|${unquotedValue})`; - // 2. Handle key with delimiter + // 2. Handle key-value pairs with delimiters (=, :, space, CLI-style --flag) const urlSafeKeyPatternStr = SENSITIVE_KEY_PATTERNS.map((p) => p.replace(/[-_]/g, '(?:[-_]|%2D|%5F|%2d|%5f)?'), ).join('|'); const keyWithDelimiter = new RegExp( - `((""|"|')?(${urlSafeKeyPatternStr})\\2\\s*(?:[:=]|%3A|%3D)\\s*)${valuePattern}`, + `((?:--)?("|')?(${urlSafeKeyPatternStr})\\2\\s*(?:[:=]|%3A|%3D)\\s*)${valuePattern}`, 'gi', ); sanitized = sanitized.replace(keyWithDelimiter, '$1[REDACTED]'); - // 3. Handle space-separated tokens/auth + // 3. Handle space-separated sensitive keywords (e.g. "password mypass", "--api-key secret") const tokenValuePattern = `[A-Za-z0-9._\\-/+=]{8,}`; + const spaceKeywords = [ + ...SENSITIVE_KEY_PATTERNS.map((p) => + p.replace(/[-_]/g, '(?:[-_]|%2D|%5F|%2d|%5f)?'), + ), + 'bearer', + ]; const spaceSeparated = new RegExp( - `\\b((?:token|bearer|authorization(?:\\s*:\\s*bearer)?|password|pwd|apikey|api[-_]?key|secret)\\s+)(${tokenValuePattern})`, + `\\b((?:--)?(?:${spaceKeywords.join('|')})(?:\\s*:\\s*bearer)?\\s+)(${tokenValuePattern})`, 'gi', ); From a825d67f88672a20a6826f2683cf9cbb7c15bc26 Mon Sep 17 00:00:00 2001 From: Aditya Bijalwan Date: Sat, 7 Mar 2026 23:22:18 +0530 Subject: [PATCH 20/24] Update packages/core/src/agents/browser/browserAgentInvocation.ts Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- packages/core/src/agents/browser/browserAgentInvocation.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/core/src/agents/browser/browserAgentInvocation.ts b/packages/core/src/agents/browser/browserAgentInvocation.ts index c0367bf2a85..bfc0fcc9b05 100644 --- a/packages/core/src/agents/browser/browserAgentInvocation.ts +++ b/packages/core/src/agents/browser/browserAgentInvocation.ts @@ -148,6 +148,7 @@ function sanitizeErrorMessage(message: string): string { `\\b((?:--)?(?:${spaceKeywords.join('|')})(?:\\s*:\\s*bearer)?\\s+)(${tokenValuePattern})`, 'gi', ); + sanitized = sanitized.replace(spaceSeparated, '$1[REDACTED]'); // 4. Handle file path redaction sanitized = sanitized.replace( From af9bf0984501fbda86abd6d90c80546b1c36916f Mon Sep 17 00:00:00 2001 From: kunal-10-cloud Date: Sat, 7 Mar 2026 23:35:05 +0530 Subject: [PATCH 21/24] fix(core): strictly rely on callId for tool tracking during progression --- .../agents/browser/browserAgentInvocation.ts | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/packages/core/src/agents/browser/browserAgentInvocation.ts b/packages/core/src/agents/browser/browserAgentInvocation.ts index bfc0fcc9b05..816ddaa5bd9 100644 --- a/packages/core/src/agents/browser/browserAgentInvocation.ts +++ b/packages/core/src/agents/browser/browserAgentInvocation.ts @@ -326,14 +326,13 @@ export class BrowserAgentInvocation extends BaseToolInvocation< const callId = activity.data['id'] ? String(activity.data['id']) : undefined; - const name = String(activity.data['name']); - // Find the tool call by ID or the last running tool call with this name + // Find the tool call by ID + // Find the tool call by ID for (let i = recentActivity.length - 1; i >= 0; i--) { if ( recentActivity[i].type === 'tool_call' && - (callId - ? recentActivity[i].id === callId - : recentActivity[i].content === name) && + callId != null && + recentActivity[i].id === callId && recentActivity[i].status === 'running' ) { recentActivity[i].status = 'completed'; @@ -346,22 +345,17 @@ export class BrowserAgentInvocation extends BaseToolInvocation< case 'ERROR': { const error = String(activity.data['error']); const isCancellation = error === 'Request cancelled.'; - const toolName = activity.data['name'] - ? String(activity.data['name']) - : undefined; const callId = activity.data['callId'] ? String(activity.data['callId']) : undefined; const newStatus = isCancellation ? 'cancelled' : 'error'; - if (callId || toolName) { + if (callId) { // Mark the specific tool as error/cancelled for (let i = recentActivity.length - 1; i >= 0; i--) { if ( recentActivity[i].type === 'tool_call' && - (callId - ? recentActivity[i].id === callId - : recentActivity[i].content === toolName) && + recentActivity[i].id === callId && recentActivity[i].status === 'running' ) { recentActivity[i].status = newStatus; From 1720cec57dc4d78c6558f165251694b88d29d5b7 Mon Sep 17 00:00:00 2001 From: Aditya Bijalwan Date: Sat, 7 Mar 2026 23:51:54 +0530 Subject: [PATCH 22/24] Update packages/core/src/agents/browser/browserAgentInvocation.ts Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- packages/core/src/agents/browser/browserAgentInvocation.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/agents/browser/browserAgentInvocation.ts b/packages/core/src/agents/browser/browserAgentInvocation.ts index 816ddaa5bd9..4213e045de3 100644 --- a/packages/core/src/agents/browser/browserAgentInvocation.ts +++ b/packages/core/src/agents/browser/browserAgentInvocation.ts @@ -122,7 +122,7 @@ function sanitizeErrorMessage(message: string): string { '[REDACTED_PEM]', ); - const unquotedValue = `[^\\s}\\]]+(?:\\s+(?![a-zA-Z0-9_.-]+(?:=|:))[^\\s=:<>}\\]]+)*`; + const unquotedValue = `[^\s]+(?:\s+(?![a-zA-Z0-9_.-]+(?:=|:))[^\s=:<>]+)*`; const valuePattern = `(?:"[^"]*"|'[^']*'|${unquotedValue})`; // 2. Handle key-value pairs with delimiters (=, :, space, CLI-style --flag) From 29a39ca80709294826dee25ede8ce341ddbaeb97 Mon Sep 17 00:00:00 2001 From: Aditya Bijalwan Date: Sun, 8 Mar 2026 00:02:17 +0530 Subject: [PATCH 23/24] Update packages/core/src/agents/browser/browserAgentInvocation.ts Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- packages/core/src/agents/browser/browserAgentInvocation.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/agents/browser/browserAgentInvocation.ts b/packages/core/src/agents/browser/browserAgentInvocation.ts index 4213e045de3..3bdb4fa2d56 100644 --- a/packages/core/src/agents/browser/browserAgentInvocation.ts +++ b/packages/core/src/agents/browser/browserAgentInvocation.ts @@ -122,7 +122,7 @@ function sanitizeErrorMessage(message: string): string { '[REDACTED_PEM]', ); - const unquotedValue = `[^\s]+(?:\s+(?![a-zA-Z0-9_.-]+(?:=|:))[^\s=:<>]+)*`; + const unquotedValue = `[^\\s]+(?:\\s+(?![a-zA-Z0-9_.-]+(?:=|:))[^\\s=:<>]+)*`; const valuePattern = `(?:"[^"]*"|'[^']*'|${unquotedValue})`; // 2. Handle key-value pairs with delimiters (=, :, space, CLI-style --flag) From 988befc9f8f719762d2caef175075e0d3ae934c9 Mon Sep 17 00:00:00 2001 From: kunal-10-cloud Date: Sun, 8 Mar 2026 04:42:21 +0530 Subject: [PATCH 24/24] test(core): rewrite browser agent tests with canonical typing and add progress emission tests --- .../browser/browserAgentInvocation.test.ts | 1022 +++++------------ 1 file changed, 276 insertions(+), 746 deletions(-) diff --git a/packages/core/src/agents/browser/browserAgentInvocation.test.ts b/packages/core/src/agents/browser/browserAgentInvocation.test.ts index ff8a1251d59..daf53094792 100644 --- a/packages/core/src/agents/browser/browserAgentInvocation.test.ts +++ b/packages/core/src/agents/browser/browserAgentInvocation.test.ts @@ -13,13 +13,9 @@ import { type AgentInputs, type SubagentProgress, type SubagentActivityEvent, - AgentTerminateMode, } from '../types.js'; -import { LocalAgentExecutor } from '../local-executor.js'; -import { createBrowserAgentDefinition } from './browserAgentFactory.js'; -import type { z } from 'zod'; -// Mock dependencies +// Mock dependencies before imports vi.mock('../../utils/debugLogger.js', () => ({ debugLogger: { log: vi.fn(), @@ -27,16 +23,23 @@ vi.mock('../../utils/debugLogger.js', () => ({ }, })); +vi.mock('./browserAgentFactory.js', () => ({ + createBrowserAgentDefinition: vi.fn(), + cleanupBrowserAgent: vi.fn(), +})); + vi.mock('../local-executor.js', () => ({ LocalAgentExecutor: { create: vi.fn(), }, })); -vi.mock('./browserAgentFactory.js', () => ({ - createBrowserAgentDefinition: vi.fn(), - cleanupBrowserAgent: vi.fn(), -})); +import { + createBrowserAgentDefinition, + cleanupBrowserAgent, +} from './browserAgentFactory.js'; +import { LocalAgentExecutor } from '../local-executor.js'; +import type { ToolLiveOutput } from '../../tools/tools.js'; describe('BrowserAgentInvocation', () => { let mockConfig: Config; @@ -142,207 +145,131 @@ describe('BrowserAgentInvocation', () => { }); }); - describe('execute', () => { - it('should emit SubagentProgress objects and return result', async () => { - const updateOutput = vi.fn(); - const mockExecutor = { - run: vi.fn().mockResolvedValue({ - terminate_reason: AgentTerminateMode.GOAL, - result: 'Success', - }), - }; - - vi.mocked(createBrowserAgentDefinition).mockResolvedValue({ - // @ts-expect-error - Partial mock for testing - definition: { - name: 'browser_agent', - toolConfig: { tools: [] }, - }, - // @ts-expect-error - Partial mock for testing - browserManager: {}, - }); - - vi.mocked(LocalAgentExecutor.create).mockResolvedValue( - // @ts-expect-error - Partial mock for testing - mockExecutor, - ); - + describe('toolLocations', () => { + it('should return empty array by default', () => { const invocation = new BrowserAgentInvocation( mockConfig, mockParams, mockMessageBus, ); - const result = await invocation.execute( - new AbortController().signal, - updateOutput, - ); - - expect(result.llmContent).toBeDefined(); - expect(updateOutput).toHaveBeenCalled(); - - // Verify that emitted objects are SubagentProgress - const calls = updateOutput.mock.calls; - expect(calls[0][0]).toMatchObject({ - isSubagentProgress: true, - state: 'running', - }); + const locations = invocation.toolLocations(); - const lastCall = calls[calls.length - 1][0]; - expect(lastCall).toMatchObject({ - isSubagentProgress: true, - state: 'completed', - }); + expect(locations).toEqual([]); }); + }); - it('should handle activity events and update progress', async () => { - const updateOutput = vi.fn(); - let activityCallback: - | ((activity: SubagentActivityEvent) => void) - | undefined; - - vi.mocked(LocalAgentExecutor.create).mockImplementation( - async (_def, _config, onActivity) => { - activityCallback = onActivity; - return { - run: vi.fn().mockResolvedValue({ - terminate_reason: AgentTerminateMode.GOAL, - result: 'Success', - }), - // @ts-expect-error - Partial mock for testing - }; - }, - ); + describe('execute', () => { + let mockExecutor: { run: ReturnType }; + beforeEach(() => { vi.mocked(createBrowserAgentDefinition).mockResolvedValue({ - // @ts-expect-error - Partial mock for testing definition: { name: 'browser_agent', + description: 'mock definition', + kind: 'local', + inputConfig: {} as never, + outputConfig: {} as never, + processOutput: () => '', + modelConfig: { model: 'test' }, + runConfig: {}, + promptConfig: { query: '', systemPrompt: '' }, + toolConfig: { tools: ['analyze_screenshot', 'click'] }, }, - // @ts-expect-error - Partial mock for testing - browserManager: {}, + browserManager: {} as never, }); + mockExecutor = { + run: vi.fn().mockResolvedValue({ + result: JSON.stringify({ success: true }), + terminate_reason: 'GOAL', + }), + }; + + vi.mocked(LocalAgentExecutor.create).mockResolvedValue( + mockExecutor as never, + ); + vi.mocked(cleanupBrowserAgent).mockClear(); + }); + + it('should return result text and call cleanup on success', async () => { const invocation = new BrowserAgentInvocation( mockConfig, mockParams, mockMessageBus, ); - await invocation.execute(new AbortController().signal, updateOutput); + const controller = new AbortController(); + const updateOutput: (output: ToolLiveOutput) => void = vi.fn(); - // Trigger thoughts - activityCallback!({ - isSubagentActivityEvent: true, - agentName: 'browser_agent', - type: 'THOUGHT_CHUNK', - data: { text: 'Thinking...' }, - }); + const result = await invocation.execute(controller.signal, updateOutput); - // Verify progress update with thought - const lastProgress = updateOutput.mock.calls[ - updateOutput.mock.calls.length - 1 - ][0] as SubagentProgress; - expect(lastProgress.recentActivity).toContainEqual( - expect.objectContaining({ - type: 'thought', - content: 'Thinking...', - status: 'running', - }), + expect(Array.isArray(result.llmContent)).toBe(true); + expect((result.llmContent as Array<{ text: string }>)[0].text).toContain( + 'Browser agent finished', ); + expect(cleanupBrowserAgent).toHaveBeenCalled(); }); - it('should sanitize sensitive data in tool arguments', async () => { - const updateOutput = vi.fn(); - let activityCallback: - | ((activity: SubagentActivityEvent) => void) - | undefined; - - vi.mocked(LocalAgentExecutor.create).mockImplementation( - async (_def, _config, onActivity) => { - activityCallback = onActivity; - return { - run: vi.fn().mockResolvedValue({ - terminate_reason: AgentTerminateMode.GOAL, - result: 'Success', - }), - // @ts-expect-error - Partial mock for testing - }; - }, - ); - - vi.mocked(createBrowserAgentDefinition).mockResolvedValue({ - definition: { - name: 'browser_agent', - } as unknown as LocalAgentExecutor, - browserManager: {} as unknown as LocalAgentExecutor, - }); - + it('should work without updateOutput (fire-and-forget)', async () => { const invocation = new BrowserAgentInvocation( mockConfig, mockParams, mockMessageBus, ); - await invocation.execute(new AbortController().signal, updateOutput); - - activityCallback!({ - isSubagentActivityEvent: true, - agentName: 'browser_agent', - type: 'TOOL_CALL_START', - data: { - name: 'fill', - args: { - username: 'testuser', - password: 'supersecretpassword', - nested: { - apiKey: 'my-api-key', - publicData: 'hello', - }, - }, - }, - }); + const controller = new AbortController(); + // Should not throw even with no updateOutput + await expect( + invocation.execute(controller.signal), + ).resolves.toBeDefined(); + }); - const lastProgress = updateOutput.mock.calls[ - updateOutput.mock.calls.length - 1 - ][0] as SubagentProgress; + it('should return error result when executor throws', async () => { + mockExecutor.run.mockRejectedValue(new Error('Unexpected crash')); - const toolCall = lastProgress.recentActivity.find( - (item) => item.type === 'tool_call', + const invocation = new BrowserAgentInvocation( + mockConfig, + mockParams, + mockMessageBus, ); - expect(toolCall).toBeDefined(); - const argsObj = JSON.parse(toolCall!.args!); - expect(argsObj.username).toBe('testuser'); - expect(argsObj.password).toBe('[REDACTED]'); - expect(argsObj.nested.apiKey).toBe('[REDACTED]'); - expect(argsObj.nested.publicData).toBe('hello'); + const controller = new AbortController(); + const result = await invocation.execute(controller.signal); + + expect(result.error).toBeDefined(); + expect(cleanupBrowserAgent).toHaveBeenCalled(); }); - it('should correctly set error and cancelled status for tools', async () => { - const updateOutput = vi.fn(); - let activityCallback: - | ((activity: SubagentActivityEvent) => void) - | undefined; + // ─── Structured SubagentProgress emission tests ─────────────────────── + + /** + * Helper: sets up LocalAgentExecutor.create to capture the onActivity + * callback so tests can fire synthetic activity events. + */ + function setupActivityCapture(): { + capturedOnActivity: () => SubagentActivityEvent | undefined; + fireActivity: (event: SubagentActivityEvent) => void; + } { + let onActivityFn: ((e: SubagentActivityEvent) => void) | undefined; vi.mocked(LocalAgentExecutor.create).mockImplementation( async (_def, _config, onActivity) => { - activityCallback = onActivity; - return { - run: vi.fn().mockResolvedValue({ - terminate_reason: AgentTerminateMode.GOAL, - result: 'Success', - }), - } as unknown as LocalAgentExecutor; + onActivityFn = onActivity; + return mockExecutor as never; }, ); - vi.mocked(createBrowserAgentDefinition).mockResolvedValue({ - definition: { - name: 'browser_agent', - } as unknown as LocalAgentExecutor, - browserManager: {} as unknown as LocalAgentExecutor, - }); + return { + capturedOnActivity: () => undefined, + fireActivity: (event: SubagentActivityEvent) => { + onActivityFn?.(event); + }, + }; + } + + it('should emit initial SubagentProgress with running state', async () => { + const updateOutput = vi.fn(); const invocation = new BrowserAgentInvocation( mockConfig, @@ -352,81 +279,14 @@ describe('BrowserAgentInvocation', () => { await invocation.execute(new AbortController().signal, updateOutput); - // Start tool 1 - activityCallback!({ - isSubagentActivityEvent: true, - agentName: 'browser_agent', - type: 'TOOL_CALL_START', - data: { name: 'tool1', args: {} }, - }); - - // Error for tool 1 - activityCallback!({ - isSubagentActivityEvent: true, - agentName: 'browser_agent', - type: 'ERROR', - data: { name: 'tool1', error: 'Some error' }, - }); - - let lastProgress = updateOutput.mock.calls[ - updateOutput.mock.calls.length - 1 - ][0] as SubagentProgress; - - const toolCall1 = lastProgress.recentActivity.find( - (item) => item.type === 'tool_call' && item.content === 'tool1', - ); - expect(toolCall1?.status).toBe('error'); - - // Start tool 2 - activityCallback!({ - isSubagentActivityEvent: true, - agentName: 'browser_agent', - type: 'TOOL_CALL_START', - data: { name: 'tool2', args: {} }, - }); - - // Cancellation for tool 2 - activityCallback!({ - isSubagentActivityEvent: true, - agentName: 'browser_agent', - type: 'ERROR', - data: { name: 'tool2', error: 'Request cancelled.' }, - }); - - lastProgress = updateOutput.mock.calls[ - updateOutput.mock.calls.length - 1 - ][0] as SubagentProgress; - - const toolCall2 = lastProgress.recentActivity.find( - (item) => item.type === 'tool_call' && item.content === 'tool2', - ); - expect(toolCall2?.status).toBe('cancelled'); + const firstCall = updateOutput.mock.calls[0]?.[0] as SubagentProgress; + expect(firstCall.isSubagentProgress).toBe(true); + expect(firstCall.state).toBe('running'); + expect(firstCall.recentActivity).toEqual([]); }); - it('should redact sensitive keys and recursively sanitize string values in tool arguments', async () => { + it('should emit completed SubagentProgress on success', async () => { const updateOutput = vi.fn(); - let activityCallback: - | ((activity: SubagentActivityEvent) => void) - | undefined; - - vi.mocked(LocalAgentExecutor.create).mockImplementation( - async (_def, _config, onActivity) => { - activityCallback = onActivity; - return { - run: vi.fn().mockResolvedValue({ - terminate_reason: AgentTerminateMode.GOAL, - result: 'Success', - }), - } as unknown as LocalAgentExecutor; - }, - ); - - vi.mocked(createBrowserAgentDefinition).mockResolvedValue({ - definition: { - name: 'browser_agent', - } as unknown as LocalAgentExecutor, - browserManager: {} as unknown as LocalAgentExecutor, - }); const invocation = new BrowserAgentInvocation( mockConfig, @@ -436,81 +296,16 @@ describe('BrowserAgentInvocation', () => { await invocation.execute(new AbortController().signal, updateOutput); - activityCallback!({ - isSubagentActivityEvent: true, - agentName: 'browser_agent', - type: 'TOOL_CALL_START', - data: { - name: 'configure', - displayName: 'Configure api_key=superSecret', - description: 'Setting up with token=jwt.token.abc', - args: { - api_key: 'sk-12345', - 'api-key': 'ak-67890', - private_key: 'pk-abc', - pwd: 'mypassword', - hostname: 'example.com', - nestedConfig: { - regularUrl: 'https://api.com?apikey=secret_in_url&other=val', - }, - }, - }, - }); - - const lastProgress = updateOutput.mock.calls[ + const lastCall = updateOutput.mock.calls[ updateOutput.mock.calls.length - 1 - ][0] as SubagentProgress; - const toolCall = lastProgress.recentActivity.find( - (item) => item.type === 'tool_call', - ); - - // Check display name and description - expect(toolCall!.displayName).toContain('[REDACTED]'); - expect(toolCall!.displayName).not.toContain('superSecret'); - expect(toolCall!.description).toContain('[REDACTED]'); - expect(toolCall!.description).not.toContain('jwt.token.abc'); - - const argsObj = JSON.parse(toolCall!.args!); - - // Check key-based redaction - expect(argsObj.api_key).toBe('[REDACTED]'); - expect(argsObj['api-key']).toBe('[REDACTED]'); - expect(argsObj.private_key).toBe('[REDACTED]'); - expect(argsObj.pwd).toBe('[REDACTED]'); - expect(argsObj.hostname).toBe('example.com'); - - // Check value-based redaction (string scanning) - expect(argsObj.nestedConfig.regularUrl).toContain('[REDACTED]'); - expect(argsObj.nestedConfig.regularUrl).not.toContain('secret_in_url'); - // Note: Full query string is redacted because we no longer assume & is a delimiter - // as part of strengthening redaction robustness for tokens that might contain &. - expect(argsObj.nestedConfig.regularUrl).not.toContain('other=val'); + ]?.[0] as SubagentProgress; + expect(lastCall.isSubagentProgress).toBe(true); + expect(lastCall.state).toBe('completed'); }); - it('should sanitize sensitive patterns in error messages', async () => { + it('should handle THOUGHT_CHUNK and emit structured progress', async () => { + const { fireActivity } = setupActivityCapture(); const updateOutput = vi.fn(); - let activityCallback: - | ((activity: SubagentActivityEvent) => void) - | undefined; - - vi.mocked(LocalAgentExecutor.create).mockImplementation( - async (_def, _config, onActivity) => { - activityCallback = onActivity; - return { - run: vi.fn().mockResolvedValue({ - terminate_reason: AgentTerminateMode.GOAL, - result: 'Success', - }), - } as unknown as LocalAgentExecutor; - }, - ); - - vi.mocked(createBrowserAgentDefinition).mockResolvedValue({ - definition: { - name: 'browser_agent', - } as unknown as LocalAgentExecutor, - browserManager: {} as unknown as LocalAgentExecutor, - }); const invocation = new BrowserAgentInvocation( mockConfig, @@ -518,51 +313,41 @@ describe('BrowserAgentInvocation', () => { mockMessageBus, ); - await invocation.execute(new AbortController().signal, updateOutput); + const executePromise = invocation.execute( + new AbortController().signal, + updateOutput, + ); + + // Allow createBrowserAgentDefinition to resolve and onActivity to be registered + await Promise.resolve(); + await Promise.resolve(); - activityCallback!({ + fireActivity({ isSubagentActivityEvent: true, agentName: 'browser_agent', - type: 'ERROR', - data: { error: 'Failed with api_key=sk-12345 and token=abc123' }, + type: 'THOUGHT_CHUNK', + data: { text: 'Navigating to the page...' }, }); - const lastProgress = updateOutput.mock.calls[ - updateOutput.mock.calls.length - 1 - ][0] as SubagentProgress; - const errorThought = lastProgress.recentActivity.find( - (item) => item.type === 'thought' && item.status === 'error', - ); - expect(errorThought).toBeDefined(); - expect(errorThought!.content).not.toContain('sk-12345'); - expect(errorThought!.content).not.toContain('abc123'); - expect(errorThought!.content).toContain('[REDACTED]'); - }); + await executePromise; - it('should mark all running tools as error when no toolName in ERROR', async () => { - const updateOutput = vi.fn(); - let activityCallback: - | ((activity: SubagentActivityEvent) => void) - | undefined; + const progressCalls = updateOutput.mock.calls + .map((c) => c[0] as SubagentProgress) + .filter((p) => p.isSubagentProgress); - vi.mocked(LocalAgentExecutor.create).mockImplementation( - async (_def, _config, onActivity) => { - activityCallback = onActivity; - return { - run: vi.fn().mockResolvedValue({ - terminate_reason: AgentTerminateMode.GOAL, - result: 'Success', - }), - } as unknown as LocalAgentExecutor; - }, + const thoughtProgress = progressCalls.find((p) => + p.recentActivity.some( + (a) => + a.type === 'thought' && + a.content.includes('Navigating to the page...'), + ), ); + expect(thoughtProgress).toBeDefined(); + }); - vi.mocked(createBrowserAgentDefinition).mockResolvedValue({ - definition: { - name: 'browser_agent', - } as unknown as LocalAgentExecutor, - browserManager: {} as unknown as LocalAgentExecutor, - }); + it('should handle TOOL_CALL_START and TOOL_CALL_END with callId tracking', async () => { + const { fireActivity } = setupActivityCapture(); + const updateOutput = vi.fn(); const invocation = new BrowserAgentInvocation( mockConfig, @@ -570,49 +355,50 @@ describe('BrowserAgentInvocation', () => { mockMessageBus, ); - await invocation.execute(new AbortController().signal, updateOutput); + const executePromise = invocation.execute( + new AbortController().signal, + updateOutput, + ); - // Start two tools - activityCallback!({ - isSubagentActivityEvent: true, - agentName: 'browser_agent', - type: 'TOOL_CALL_START', - data: { name: 'toolA', args: {} }, - }); - activityCallback!({ + await Promise.resolve(); + await Promise.resolve(); + + fireActivity({ isSubagentActivityEvent: true, agentName: 'browser_agent', type: 'TOOL_CALL_START', - data: { name: 'toolB', args: {} }, + data: { + name: 'navigate_browser', + callId: 'call-1', + args: { url: 'https://example.com' }, + }, }); - // Global error (no toolName) - activityCallback!({ + fireActivity({ isSubagentActivityEvent: true, agentName: 'browser_agent', - type: 'ERROR', - data: { error: 'Connection lost' }, + type: 'TOOL_CALL_END', + data: { name: 'navigate_browser', id: 'call-1' }, }); - const lastProgress = updateOutput.mock.calls[ - updateOutput.mock.calls.length - 1 - ][0] as SubagentProgress; + await executePromise; - const toolA = lastProgress.recentActivity.find( - (item) => item.type === 'tool_call' && item.content === 'toolA', - ); - const toolB = lastProgress.recentActivity.find( - (item) => item.type === 'tool_call' && item.content === 'toolB', + const progressCalls = updateOutput.mock.calls + .map((c) => c[0] as SubagentProgress) + .filter((p) => p.isSubagentProgress); + + // After TOOL_CALL_END, the tool should be completed + const finalProgress = progressCalls[progressCalls.length - 1]; + const toolItem = finalProgress?.recentActivity.find( + (a) => a.type === 'tool_call' && a.content === 'navigate_browser', ); - expect(toolA?.status).toBe('error'); - expect(toolB?.status).toBe('error'); + expect(toolItem).toBeDefined(); + expect(toolItem?.status).toBe('completed'); }); - it('should emit error state on failure', async () => { + it('should sanitize sensitive data in tool call args', async () => { + const { fireActivity } = setupActivityCapture(); const updateOutput = vi.fn(); - vi.mocked(createBrowserAgentDefinition).mockRejectedValue( - new Error('Launch failed'), - ); const invocation = new BrowserAgentInvocation( mockConfig, @@ -620,120 +406,43 @@ describe('BrowserAgentInvocation', () => { mockMessageBus, ); - const result = await invocation.execute( + const executePromise = invocation.execute( new AbortController().signal, updateOutput, ); - expect(result.error).toBeDefined(); - const lastCall = - updateOutput.mock.calls[updateOutput.mock.calls.length - 1][0]; - expect(lastCall.state).toBe('error'); - }); + await Promise.resolve(); + await Promise.resolve(); - it('should sanitize sensitive data in LLM thought content', async () => { - const updateOutput = vi.fn(); - let activityCallback: - | ((activity: SubagentActivityEvent) => void) - | undefined; - - vi.mocked(LocalAgentExecutor.create).mockImplementation( - async (_def, _config, onActivity) => { - activityCallback = onActivity; - return { - run: vi.fn().mockResolvedValue({ - terminate_reason: AgentTerminateMode.GOAL, - result: 'Success', - }), - } as unknown as LocalAgentExecutor; - }, - ); - - vi.mocked(createBrowserAgentDefinition).mockResolvedValue({ - definition: { - name: 'browser_agent', - } as unknown as LocalAgentExecutor, - browserManager: {} as unknown as LocalAgentExecutor, - }); - - const invocation = new BrowserAgentInvocation( - mockConfig, - mockParams, - mockMessageBus, - ); - - await invocation.execute(new AbortController().signal, updateOutput); - - activityCallback!({ + fireActivity({ isSubagentActivityEvent: true, agentName: 'browser_agent', - type: 'THOUGHT_CHUNK', + type: 'TOOL_CALL_START', data: { - text: 'Using token=eyJhbGciOi.payload.signature to authenticate', + name: 'fill_form', + callId: 'call-2', + args: { password: 'supersecret123', url: 'https://example.com' }, }, }); - const lastProgress = updateOutput.mock.calls[ - updateOutput.mock.calls.length - 1 - ][0] as SubagentProgress; - const thought = lastProgress.recentActivity.find( - (item) => item.type === 'thought' && item.status === 'running', - ); - expect(thought).toBeDefined(); - expect(thought!.content).not.toContain('eyJhbGciOi'); - expect(thought!.content).toContain('[REDACTED]'); - }); - - it('should sanitize error messages in catch block', async () => { - const updateOutput = vi.fn(); - vi.mocked(createBrowserAgentDefinition).mockRejectedValue( - new Error( - 'Connection failed with api_key=sk-secret123 and token=jwt.token.here', - ), - ); + await executePromise; - const invocation = new BrowserAgentInvocation( - mockConfig, - mockParams, - mockMessageBus, - ); + const progressCalls = updateOutput.mock.calls + .map((c) => c[0] as SubagentProgress) + .filter((p) => p.isSubagentProgress); - const result = await invocation.execute( - new AbortController().signal, - updateOutput, - ); + const toolItem = progressCalls + .flatMap((p) => p.recentActivity) + .find((a) => a.type === 'tool_call' && a.content === 'fill_form'); - expect(result.error).toBeDefined(); - const errorMsg = result.error!.message; - expect(errorMsg).not.toContain('sk-secret123'); - expect(errorMsg).not.toContain('jwt.token.here'); - expect(errorMsg).toContain('[REDACTED]'); + expect(toolItem).toBeDefined(); + expect(toolItem?.args).not.toContain('supersecret123'); + expect(toolItem?.args).toContain('[REDACTED]'); }); - it('should handle concurrent tool calls correctly using callId', async () => { + it('should handle ERROR event with callId and mark tool as errored', async () => { + const { fireActivity } = setupActivityCapture(); const updateOutput = vi.fn(); - let activityCallback: - | ((activity: SubagentActivityEvent) => void) - | undefined; - - vi.mocked(LocalAgentExecutor.create).mockImplementation( - async (_def, _config, onActivity) => { - activityCallback = onActivity; - return { - run: vi.fn().mockResolvedValue({ - terminate_reason: AgentTerminateMode.GOAL, - result: 'Success', - }), - } as unknown as LocalAgentExecutor; - }, - ); - - vi.mocked(createBrowserAgentDefinition).mockResolvedValue({ - definition: { - name: 'browser_agent', - } as unknown as LocalAgentExecutor, - browserManager: {} as unknown as LocalAgentExecutor, - }); const invocation = new BrowserAgentInvocation( mockConfig, @@ -741,84 +450,44 @@ describe('BrowserAgentInvocation', () => { mockMessageBus, ); - await invocation.execute(new AbortController().signal, updateOutput); + const executePromise = invocation.execute( + new AbortController().signal, + updateOutput, + ); - // Start tool instance 1 - activityCallback!({ - isSubagentActivityEvent: true, - agentName: 'browser_agent', - type: 'TOOL_CALL_START', - data: { name: 'readFile', args: { path: 'file1.txt' }, callId: 'id1' }, - }); + await Promise.resolve(); + await Promise.resolve(); - // Start tool instance 2 (same name, different callId) - activityCallback!({ + fireActivity({ isSubagentActivityEvent: true, agentName: 'browser_agent', type: 'TOOL_CALL_START', - data: { name: 'readFile', args: { path: 'file2.txt' }, callId: 'id2' }, + data: { name: 'click_element', callId: 'call-3', args: {} }, }); - // End tool instance 2 first - activityCallback!({ + fireActivity({ isSubagentActivityEvent: true, agentName: 'browser_agent', - type: 'TOOL_CALL_END', - data: { name: 'readFile', id: 'id2' }, + type: 'ERROR', + data: { error: 'Element not found', callId: 'call-3' }, }); - let lastProgress = updateOutput.mock.calls[ - updateOutput.mock.calls.length - 1 - ][0] as SubagentProgress; - - const item1 = lastProgress.recentActivity.find((i) => i.id === 'id1'); - const item2 = lastProgress.recentActivity.find((i) => i.id === 'id2'); + await executePromise; - expect(item1?.status).toBe('running'); - expect(item2?.status).toBe('completed'); + const progressCalls = updateOutput.mock.calls + .map((c) => c[0] as SubagentProgress) + .filter((p) => p.isSubagentProgress); - // End tool instance 1 - activityCallback!({ - isSubagentActivityEvent: true, - agentName: 'browser_agent', - type: 'TOOL_CALL_END', - data: { name: 'readFile', id: 'id1' }, - }); - - lastProgress = updateOutput.mock.calls[ - updateOutput.mock.calls.length - 1 - ][0] as SubagentProgress; - - const updatedItem1 = lastProgress.recentActivity.find( - (i) => i.id === 'id1', + const allItems = progressCalls.flatMap((p) => p.recentActivity); + const toolItem = allItems.find( + (a) => a.type === 'tool_call' && a.content === 'click_element', ); - expect(updatedItem1?.status).toBe('completed'); + expect(toolItem?.status).toBe('error'); }); - it('should redact secrets with spaces in unquoted values', async () => { + it('should sanitize sensitive data in ERROR event messages', async () => { + const { fireActivity } = setupActivityCapture(); const updateOutput = vi.fn(); - let activityCallback: - | ((activity: SubagentActivityEvent) => void) - | undefined; - - vi.mocked(LocalAgentExecutor.create).mockImplementation( - async (_def, _config, onActivity) => { - activityCallback = onActivity; - return { - run: vi.fn().mockResolvedValue({ - terminate_reason: AgentTerminateMode.GOAL, - result: 'Success', - }), - } as unknown as LocalAgentExecutor; - }, - ); - - vi.mocked(createBrowserAgentDefinition).mockResolvedValue({ - definition: { - name: 'browser_agent', - } as unknown as LocalAgentExecutor, - browserManager: {} as unknown as LocalAgentExecutor, - }); const invocation = new BrowserAgentInvocation( mockConfig, @@ -826,51 +495,39 @@ describe('BrowserAgentInvocation', () => { mockMessageBus, ); - await invocation.execute(new AbortController().signal, updateOutput); + const executePromise = invocation.execute( + new AbortController().signal, + updateOutput, + ); + + await Promise.resolve(); + await Promise.resolve(); - activityCallback!({ + fireActivity({ isSubagentActivityEvent: true, agentName: 'browser_agent', type: 'ERROR', - data: { - error: 'Failed with api_key=my secret value here and more text', - }, + data: { error: 'Auth failed: api_key=sk-secret-abc1234567890' }, }); - const lastProgress = updateOutput.mock.calls[ - updateOutput.mock.calls.length - 1 - ][0] as SubagentProgress; - const errorThought = lastProgress.recentActivity.find( - (item) => item.type === 'thought' && item.status === 'error', - ); - expect(errorThought!.content).toContain('api_key=[REDACTED]'); - expect(errorThought!.content).not.toContain('my secret value'); - }); + await executePromise; - it('should handle URL-encoded sensitive keys in tool arguments', async () => { - const updateOutput = vi.fn(); - let activityCallback: - | ((activity: SubagentActivityEvent) => void) - | undefined; + const progressCalls = updateOutput.mock.calls + .map((c) => c[0] as SubagentProgress) + .filter((p) => p.isSubagentProgress); - vi.mocked(LocalAgentExecutor.create).mockImplementation( - async (_def, _config, onActivity) => { - activityCallback = onActivity; - return { - run: vi.fn().mockResolvedValue({ - terminate_reason: AgentTerminateMode.GOAL, - result: 'Success', - }), - } as unknown as LocalAgentExecutor; - }, - ); + const errorItem = progressCalls + .flatMap((p) => p.recentActivity) + .find((a) => a.type === 'thought' && a.status === 'error'); - vi.mocked(createBrowserAgentDefinition).mockResolvedValue({ - definition: { - name: 'browser_agent', - } as unknown as LocalAgentExecutor, - browserManager: {} as unknown as LocalAgentExecutor, - }); + expect(errorItem).toBeDefined(); + expect(errorItem?.content).not.toContain('sk-secret-abc1234567890'); + expect(errorItem?.content).toContain('[REDACTED]'); + }); + + it('should sanitize inline PEM content in error messages', async () => { + const { fireActivity } = setupActivityCapture(); + const updateOutput = vi.fn(); const invocation = new BrowserAgentInvocation( mockConfig, @@ -878,56 +535,42 @@ describe('BrowserAgentInvocation', () => { mockMessageBus, ); - await invocation.execute(new AbortController().signal, updateOutput); + const executePromise = invocation.execute( + new AbortController().signal, + updateOutput, + ); - activityCallback!({ + await Promise.resolve(); + await Promise.resolve(); + + fireActivity({ isSubagentActivityEvent: true, agentName: 'browser_agent', - type: 'TOOL_CALL_START', + type: 'ERROR', data: { - name: 'testTool', - args: { - 'api%5fkey': 'secret-value', - 'auth%2dtoken': 'token-value', - }, + error: + 'Failed to authenticate:\n-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEA12345...\n-----END RSA PRIVATE KEY-----\nPlease check credentials.', }, }); - const lastProgress = updateOutput.mock.calls[ - updateOutput.mock.calls.length - 1 - ][0] as SubagentProgress; - const toolCall = lastProgress.recentActivity.find( - (item) => item.type === 'tool_call', - ); - const argsObj = JSON.parse(toolCall!.args!); - expect(argsObj['api%5fkey']).toBe('[REDACTED]'); - expect(argsObj['auth%2dtoken']).toBe('[REDACTED]'); - }); + await executePromise; - it('should redact JSON-style keys and space-separated values', async () => { - const updateOutput = vi.fn(); - let activityCallback: - | ((activity: SubagentActivityEvent) => void) - | undefined; + const progressCalls = updateOutput.mock.calls + .map((c) => c[0] as SubagentProgress) + .filter((p) => p.isSubagentProgress); - vi.mocked(LocalAgentExecutor.create).mockImplementation( - async (_def, _config, onActivity) => { - activityCallback = onActivity; - return { - run: vi.fn().mockResolvedValue({ - terminate_reason: AgentTerminateMode.GOAL, - result: 'Success', - }), - } as unknown as LocalAgentExecutor; - }, - ); + const errorItem = progressCalls + .flatMap((p) => p.recentActivity) + .find((a) => a.type === 'thought' && a.status === 'error'); - vi.mocked(createBrowserAgentDefinition).mockResolvedValue({ - definition: { - name: 'browser_agent', - } as unknown as LocalAgentExecutor, - browserManager: {} as unknown as LocalAgentExecutor, - }); + expect(errorItem).toBeDefined(); + expect(errorItem?.content).toContain('[REDACTED_PEM]'); + expect(errorItem?.content).not.toContain('-----BEGIN'); + }); + + it('should mark all running tools as errored when ERROR has no callId', async () => { + const { fireActivity } = setupActivityCapture(); + const updateOutput = vi.fn(); const invocation = new BrowserAgentInvocation( mockConfig, @@ -935,166 +578,53 @@ describe('BrowserAgentInvocation', () => { mockMessageBus, ); - await invocation.execute(new AbortController().signal, updateOutput); + const executePromise = invocation.execute( + new AbortController().signal, + updateOutput, + ); - // JSON-style keys - activityCallback!({ - isSubagentActivityEvent: true, - agentName: 'browser_agent', - type: 'ERROR', - data: { - error: 'Error: {"api_key": "secret123", "other": "val"}', - }, - }); + await Promise.resolve(); + await Promise.resolve(); - // Space-separated tokens - activityCallback!({ + fireActivity({ isSubagentActivityEvent: true, agentName: 'browser_agent', - type: 'ERROR', - data: { - error: - 'Connection failed: token eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...', - }, + type: 'TOOL_CALL_START', + data: { name: 'tool_a', callId: 'c1', args: {} }, }); - // Bearer token - activityCallback!({ + fireActivity({ isSubagentActivityEvent: true, agentName: 'browser_agent', - type: 'ERROR', - data: { - error: 'Unauthorized: Bearer sk_test_51Mz...', - }, + type: 'TOOL_CALL_START', + data: { name: 'tool_b', callId: 'c2', args: {} }, }); - // Partial redaction with delimiters - activityCallback!({ + // ERROR with no callId should mark ALL running tools as error + fireActivity({ isSubagentActivityEvent: true, agentName: 'browser_agent', type: 'ERROR', - data: { - error: 'Failed with api_key=foo&bar;token=baz', - }, + data: { error: 'Agent crashed' }, }); - const progressResults = updateOutput.mock.calls.map( - (c) => c[0] as SubagentProgress, - ); + await executePromise; - const jsonError = progressResults.find((p) => - p.recentActivity.some((a) => - a.content.includes('"api_key": [REDACTED]'), - ), - ); - expect(jsonError).toBeDefined(); - expect( - jsonError?.recentActivity.some((a) => a.content.includes('secret123')), - ).toBe(false); + const progressCalls = updateOutput.mock.calls + .map((c) => c[0] as SubagentProgress) + .filter((p) => p.isSubagentProgress); - const tokenError = progressResults.find((p) => - p.recentActivity.some((a) => a.content.includes('token [REDACTED]')), + const allItems = progressCalls.flatMap((p) => p.recentActivity); + const toolA = allItems.find( + (a) => a.type === 'tool_call' && a.content === 'tool_a', ); - expect(tokenError).toBeDefined(); - expect( - tokenError?.recentActivity.some((a) => - a.content.includes('eyJhbGciOi'), - ), - ).toBe(false); - - const bearerError = progressResults.find((p) => - p.recentActivity.some((a) => a.content.includes('Bearer [REDACTED]')), + const toolB = allItems.find( + (a) => a.type === 'tool_call' && a.content === 'tool_b', ); - expect(bearerError).toBeDefined(); - expect( - bearerError?.recentActivity.some((a) => - a.content.includes('sk_test_51Mz'), - ), - ).toBe(false); - const delimiterError = progressResults.find((p) => - p.recentActivity.some((a) => a.content.includes('api_key=[REDACTED]')), - ); - expect(delimiterError).toBeDefined(); - expect( - delimiterError?.recentActivity.some((a) => a.content.includes('foo')), - ).toBe(false); - expect( - delimiterError?.recentActivity.some((a) => a.content.includes('bar')), - ).toBe(false); - expect( - delimiterError?.recentActivity.some((a) => a.content.includes('baz')), - ).toBe(false); - }); - - it('should redact inline PEM key content', async () => { - const updateOutput = vi.fn(); - let activityCallback: - | ((activity: SubagentActivityEvent) => void) - | undefined; - - vi.mocked(LocalAgentExecutor.create).mockImplementation( - async (_def, _config, onActivity) => { - activityCallback = onActivity; - return { - run: vi.fn().mockResolvedValue({ - terminate_reason: AgentTerminateMode.GOAL, - result: 'Success', - }), - } as unknown as LocalAgentExecutor; - }, - ); - - vi.mocked(createBrowserAgentDefinition).mockResolvedValue({ - definition: { - name: 'browser_agent', - } as unknown as LocalAgentExecutor, - browserManager: {} as unknown as LocalAgentExecutor, - }); - - const invocation = new BrowserAgentInvocation( - mockConfig, - mockParams, - mockMessageBus, - ); - - await invocation.execute(new AbortController().signal, updateOutput); - - activityCallback!({ - isSubagentActivityEvent: true, - agentName: 'browser_agent', - type: 'ERROR', - data: { - error: - 'Failed to authenticate:\n-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEA12345...\n-----END RSA PRIVATE KEY-----\nPlease check your credentials.', - }, - }); - - const lastProgress = updateOutput.mock.calls[ - updateOutput.mock.calls.length - 1 - ][0] as SubagentProgress; - const errorThought = lastProgress.recentActivity.find( - (item) => item.type === 'thought' && item.status === 'error', - ); - - expect(errorThought).toBeDefined(); - expect(errorThought!.content).toContain('[REDACTED_PEM]'); - expect(errorThought!.content).not.toContain('-----BEGIN'); - expect(errorThought!.content).not.toContain('MIIEowIBAAKCAQEA12345...'); - }); - }); - - describe('toolLocations', () => { - it('should return empty array by default', () => { - const invocation = new BrowserAgentInvocation( - mockConfig, - mockParams, - mockMessageBus, - ); - - const locations = invocation.toolLocations(); - - expect(locations).toEqual([]); + // Both should be error since no callId was specified + expect(toolA?.status).toBe('error'); + expect(toolB?.status).toBe('error'); }); }); });