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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
261 changes: 261 additions & 0 deletions assistant/src/ipc/__tests__/browser-ipc.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,261 @@
/**
* Tests for the `browser_execute` IPC route.
*
* Mocks executeBrowserOperation at the module boundary so the route
* handler can be exercised without spinning up real browser state.
*/

import { afterEach, describe, expect, mock, test } from "bun:test";

import type { ToolExecutionResult } from "../../tools/types.js";

// ---------------------------------------------------------------------------
// Mock state
// ---------------------------------------------------------------------------

let mockOperationResult: ToolExecutionResult = {
content: "ok",
isError: false,
};
let mockOperationCalls: Array<{
operation: string;
input: Record<string, unknown>;
conversationId: string;
}> = [];

mock.module("../../browser/operations.js", () => ({
executeBrowserOperation: async (
operation: string,
input: Record<string, unknown>,
context: { conversationId: string },
) => {
mockOperationCalls.push({
operation,
input,
conversationId: context.conversationId,
});
return mockOperationResult;
},
}));

// Import after mocking
const { browserExecuteRoute, browserCliConversationKey } =
await import("../routes/browser.js");

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

afterEach(() => {
mockOperationResult = { content: "ok", isError: false };
mockOperationCalls = [];
});

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

describe("browser_execute IPC route", () => {
test("method is browser_execute", () => {
expect(browserExecuteRoute.method).toBe("browser_execute");
});

// ── Successful dispatch ────────────────────────────────────────────

test("dispatches a valid operation and returns structured result", async () => {
mockOperationResult = {
content: "Navigated to https://example.com",
isError: false,
};

const result = await browserExecuteRoute.handler({
operation: "navigate",
input: { url: "https://example.com" },
sessionId: "test-session",
});

expect(result).toEqual({
content: "Navigated to https://example.com",
isError: false,
});
expect(mockOperationCalls).toHaveLength(1);
expect(mockOperationCalls[0].operation).toBe("navigate");
expect(mockOperationCalls[0].input).toEqual({
url: "https://example.com",
});
});

// ── Unknown operation rejection ────────────────────────────────────

test("rejects unknown operation with a validation error", async () => {
await expect(
browserExecuteRoute.handler({
operation: "nonexistent_operation",
input: {},
}),
).rejects.toThrow();

// Should never reach executeBrowserOperation
expect(mockOperationCalls).toHaveLength(0);
});

// ── Session ID mapping ─────────────────────────────────────────────

test("maps sessionId to deterministic conversation key", async () => {
await browserExecuteRoute.handler({
operation: "snapshot",
input: {},
sessionId: "my-session",
});

expect(mockOperationCalls).toHaveLength(1);
expect(mockOperationCalls[0].conversationId).toBe("browser-cli:my-session");
});

test("defaults sessionId to 'default' when omitted", async () => {
await browserExecuteRoute.handler({
operation: "snapshot",
input: {},
});

expect(mockOperationCalls).toHaveLength(1);
expect(mockOperationCalls[0].conversationId).toBe("browser-cli:default");
});

test("same sessionId produces same conversation key", () => {
const key1 = browserCliConversationKey("alpha");
const key2 = browserCliConversationKey("alpha");
expect(key1).toBe(key2);
expect(key1).toBe("browser-cli:alpha");
});

test("different sessionIds produce different conversation keys", () => {
const key1 = browserCliConversationKey("alpha");
const key2 = browserCliConversationKey("beta");
expect(key1).not.toBe(key2);
});

// ── Screenshot payload transport ───────────────────────────────────

test("extracts screenshot base64 payloads from content blocks", async () => {
mockOperationResult = {
content: "Screenshot taken",
isError: false,
contentBlocks: [
{
type: "image",
source: {
type: "base64",
media_type: "image/png",
data: "iVBORw0KGgoAAAANS...",
},
},
],
};

const result = (await browserExecuteRoute.handler({
operation: "screenshot",
input: {},
sessionId: "screenshot-test",
})) as {
content: string;
isError: boolean;
screenshots: Array<{ mediaType: string; data: string }>;
};

expect(result.content).toBe("Screenshot taken");
expect(result.isError).toBe(false);
expect(result.screenshots).toHaveLength(1);
expect(result.screenshots[0].mediaType).toBe("image/png");
expect(result.screenshots[0].data).toBe("iVBORw0KGgoAAAANS...");
});

test("omits screenshots field when no image blocks present", async () => {
mockOperationResult = {
content: "Snapshot taken",
isError: false,
};

const result = (await browserExecuteRoute.handler({
operation: "snapshot",
input: {},
})) as Record<string, unknown>;

expect(result.content).toBe("Snapshot taken");
expect(result.isError).toBe(false);
expect(result).not.toHaveProperty("screenshots");
});

test("handles multiple screenshot content blocks", async () => {
mockOperationResult = {
content: "Multiple screenshots",
isError: false,
contentBlocks: [
{
type: "image",
source: {
type: "base64",
media_type: "image/png",
data: "first-screenshot-data",
},
},
{
type: "text",
text: "some text block",
},
{
type: "image",
source: {
type: "base64",
media_type: "image/jpeg",
data: "second-screenshot-data",
},
},
],
};

const result = (await browserExecuteRoute.handler({
operation: "screenshot",
input: { full_page: true },
})) as {
content: string;
isError: boolean;
screenshots: Array<{ mediaType: string; data: string }>;
};

expect(result.screenshots).toHaveLength(2);
expect(result.screenshots[0].data).toBe("first-screenshot-data");
expect(result.screenshots[1].data).toBe("second-screenshot-data");
expect(result.screenshots[1].mediaType).toBe("image/jpeg");
});

// ── Error propagation ──────────────────────────────────────────────

test("propagates isError from operation result", async () => {
mockOperationResult = {
content: "Error: page not found",
isError: true,
};

const result = await browserExecuteRoute.handler({
operation: "navigate",
input: { url: "https://404.example.com" },
});

expect(result).toEqual({
content: "Error: page not found",
isError: true,
});
});

// ── Input defaults ─────────────────────────────────────────────────

test("defaults input to empty object when omitted", async () => {
await browserExecuteRoute.handler({
operation: "snapshot",
});

expect(mockOperationCalls).toHaveLength(1);
expect(mockOperationCalls[0].input).toEqual({});
});
});
89 changes: 89 additions & 0 deletions assistant/src/ipc/routes/browser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/**
* IPC route for browser operations.
*
* Exposes `browser_execute` so CLI commands and external processes can
* invoke browser operations without going through skill tool wrappers.
*
* The `sessionId` parameter (default `"default"`) is mapped to a
* deterministic conversation key `browser-cli:<sessionId>` so that
* sequential IPC calls with the same session reuse browser state.
*/

import { z } from "zod";

import { executeBrowserOperation } from "../../browser/operations.js";
import {
BROWSER_OPERATIONS,
type BrowserOperation,
} from "../../browser/types.js";
import type { ContentBlock } from "../../providers/types.js";
import type { IpcRoute } from "../cli-server.js";

// ── Param validation ─────────────────────────────────────────────────

const BrowserExecuteParams = z.object({
operation: z.enum(BROWSER_OPERATIONS as unknown as [string, ...string[]]),
input: z.record(z.string(), z.unknown()).default({}),
sessionId: z.string().min(1).default("default"),
});

// ── Conversation key ─────────────────────────────────────────────────

/**
* Build a deterministic conversation key from a session ID.
* All CLI browser calls with the same session share browser state.
*/
export function browserCliConversationKey(sessionId: string): string {
return `browser-cli:${sessionId}`;
}

// ── Screenshot extraction ────────────────────────────────────────────

/**
* Extract base64 screenshot payloads from tool execution content blocks.
* Returns an array of `{ mediaType, data }` objects for each image found.
*/
function extractScreenshots(
contentBlocks?: ContentBlock[],
): Array<{ mediaType: string; data: string }> {
if (!contentBlocks) return [];
const screenshots: Array<{ mediaType: string; data: string }> = [];
for (const block of contentBlocks) {
if (block.type === "image" && block.source.type === "base64") {
screenshots.push({
mediaType: block.source.media_type,
data: block.source.data,
});
}
}
return screenshots;
}

// ── Route definition ─────────────────────────────────────────────────

export const browserExecuteRoute: IpcRoute = {
method: "browser_execute",
handler: async (params) => {
const { operation, input, sessionId } = BrowserExecuteParams.parse(params);

const conversationId = browserCliConversationKey(sessionId);

const result = await executeBrowserOperation(
operation as BrowserOperation,
input,
{
workingDir: process.cwd(),
conversationId,
trustClass: "guardian",
Comment on lines +75 to +77

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Authenticate IPC caller before assigning guardian trust

This route hardcodes trustClass: "guardian" for every browser_execute request, but the CLI IPC protocol itself does not authenticate callers (the socket server dispatches any JSON request it receives). As a result, any process that can connect to assistant-cli.sock can execute privileged browser operations (including sensitive ones like fill_credential) with guardian-level trust, bypassing the trust boundary expected for non-guardian actors. Please derive trust from an authenticated IPC identity or explicitly restrict high-risk operations on this route.

Useful? React with 👍 / 👎.

},
);

const screenshots = extractScreenshots(result.contentBlocks);

return {
content: result.content,
isError: result.isError,
...(screenshots.length > 0 ? { screenshots } : {}),
};
},
};
6 changes: 5 additions & 1 deletion assistant/src/ipc/routes/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import type { IpcRoute } from "../cli-server.js";
import { browserExecuteRoute } from "./browser.js";
import { wakeConversationRoute } from "./wake-conversation.js";

/** All built-in CLI IPC routes. */
export const cliIpcRoutes: IpcRoute[] = [wakeConversationRoute];
export const cliIpcRoutes: IpcRoute[] = [
browserExecuteRoute,
wakeConversationRoute,
];
Loading