Skip to content
Open
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
3 changes: 2 additions & 1 deletion frontend/.depcheckrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@
"lint-staged",
"concurrently",
"electron-builder",
"madge"
"madge",
"playwright"
],
"ignore-patterns": ["*.d.ts", ".next/**", "dist-desktop/**", "desktop/dist/**"],
"specials": ["next", "webpack", "babel"],
Expand Down
29 changes: 0 additions & 29 deletions frontend/desktop/logic/security.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,35 +86,6 @@ export function hardenWebContents(window: electron.BrowserWindow, appOrigin: str
});
}

export function registerNavigationPolicy(appOrigin: string): void {
electron.app.on("web-contents-created", (_, contents: electron.WebContents) => {
contents.on("will-attach-webview", (_event, webPreferences, _params) => {
delete webPreferences.preload;
webPreferences.nodeIntegration = false;
webPreferences.contextIsolation = true;
webPreferences.sandbox = true;
});

contents.on("will-navigate", (event) => {
// Guest WebContents (the embedded browser webview plus cross-origin
// iframes / OOPIFs) must be able to perform their own navigations.
// Keep the app shell origin-locked, but do not turn the Computer browser
// into a single-load preview.
if (
contents.getType() === "webview" ||
electron.BrowserWindow.fromWebContents(contents) == null
) {
return;
}
const targetUrl = event.url;
const targetOrigin = safeOrigin(targetUrl);
if (!targetOrigin || targetOrigin !== appOrigin) {
event.preventDefault();
}
});
});
}

function safeOrigin(input: string | undefined): string | null {
if (!input) return null;
try {
Expand Down
1 change: 0 additions & 1 deletion frontend/desktop/logic/window-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ export function createMainWindow(appUrl: string): BrowserWindow {
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
webviewTag: true,
webSecurity: true,
devTools: !process.env.LOCAL_STUDIO_DESKTOP_DISABLE_DEVTOOLS,
allowRunningInsecureContent: false,
Expand Down
2 changes: 0 additions & 2 deletions frontend/desktop/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import { writeJsonAtomic } from "./helpers/fs-json";
import { log } from "./helpers/logger";
import { isHttpUrl } from "./helpers/url";
import { createMainWindow } from "./logic/window-manager";
import { registerNavigationPolicy } from "./logic/security";
import { startFrontendServer, stopFrontendServer, type ServerHandle } from "./logic/app-server";
import { checkForUpdates, getUpdateState, initializeAutoUpdates } from "./logic/update-manager";
import { addProject, listProjectsWithMeta, removeProject } from "./logic/projects-store";
Expand Down Expand Up @@ -71,7 +70,6 @@ async function processMemorySummary(): Promise<string> {
async function bootstrap(): Promise<void> {
if (!frontendServer) {
frontendServer = await startFrontendServer({ onExit: handleFrontendServerExit });
registerNavigationPolicy(new URL(frontendServer.runtime.url).origin);
startFrontendHealthMonitor();
}
if (!mainWindow) {
Expand Down
68 changes: 46 additions & 22 deletions frontend/desktop/resources/pi-extensions/browser.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,45 @@
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { Schema } from "effect";
import { Type } from "typebox";

type ToolResult = {
content: Array<{ type: "text"; text: string }>;
details: Record<string, unknown>;
};

const FRONTEND_BASE = process.env.LOCAL_STUDIO_FRONTEND_BASE ?? "http://127.0.0.1:3000";
const BROWSER_SESSION_ID = process.env.LOCAL_STUDIO_BROWSER_SESSION_ID ?? "";
const DEFAULT_BROWSER_TOOL_TIMEOUT_MS = 60_000;
const BrowserActionResponseSchema = Schema.Struct({
ok: Schema.Boolean,
data: Schema.optional(Schema.Unknown),
error: Schema.optional(Schema.String),
});

function readTimeoutMs(name: string, fallback: number): number {
const value = Number(process.env[name]);
return Number.isFinite(value) && value > 0 ? Math.trunc(value) : fallback;
}

const BROWSER_TOOL_TIMEOUT_MS = readTimeoutMs(
"LOCAL_STUDIO_BROWSER_TOOL_TIMEOUT_MS",
DEFAULT_BROWSER_TOOL_TIMEOUT_MS,
);
type BrowserExtensionConfig = {
frontendBase: string;
sessionHeader: string;
sessionId: string;
timeoutMs: number;
};

function browserExtensionConfig(): BrowserExtensionConfig {
const sessionHeader = process.env.LOCAL_STUDIO_BROWSER_SESSION_HEADER ?? "";
const sessionId = process.env.LOCAL_STUDIO_BROWSER_SESSION_ID ?? "";
if (!sessionHeader || !sessionId) throw new Error("Browser session configuration is missing");
return {
frontendBase: process.env.LOCAL_STUDIO_FRONTEND_BASE ?? "http://127.0.0.1:3000",
sessionHeader,
sessionId,
timeoutMs: readTimeoutMs(
"LOCAL_STUDIO_BROWSER_TOOL_TIMEOUT_MS",
DEFAULT_BROWSER_TOOL_TIMEOUT_MS,
),
};
}

function failedToolResult(
verb: string,
Expand All @@ -33,21 +54,20 @@ function failedToolResult(
}

async function callBrowserAction(
config: BrowserExtensionConfig,
verb: string,
payload: Record<string, unknown>,
signal: AbortSignal,
): Promise<ToolResult> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), BROWSER_TOOL_TIMEOUT_MS);
const timeout = setTimeout(() => controller.abort(), config.timeoutMs);
const abort = () => controller.abort();
signal.addEventListener("abort", abort, { once: true });
if (signal.aborted) controller.abort();
const response = await fetch(`${FRONTEND_BASE}/api/agent/browser/${verb}`, {
const response = await fetch(`${config.frontendBase}/api/agent/browser/${verb}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(
BROWSER_SESSION_ID ? { ...payload, sessionId: BROWSER_SESSION_ID } : payload,
),
headers: { "Content-Type": "application/json", [config.sessionHeader]: config.sessionId },
body: JSON.stringify(payload),
signal: controller.signal,
}).finally(() => {
clearTimeout(timeout);
Expand All @@ -57,7 +77,7 @@ async function callBrowserAction(
const errBody = await response.text().catch(() => "");
throw new Error(`HTTP ${response.status} ${errBody}`);
}
const result = (await response.json()) as { ok: boolean; data?: unknown; error?: string };
const result = Schema.decodeUnknownSync(BrowserActionResponseSchema)(await response.json());
if (!result.ok) throw new Error(result.error || "browser bridge returned ok=false");
const text = typeof result.data === "string" ? result.data : JSON.stringify(result.data, null, 2);
return {
Expand All @@ -67,18 +87,22 @@ async function callBrowserAction(
}

async function safeBrowserAction(
config: BrowserExtensionConfig,
verb: string,
payload: Record<string, unknown>,
signal: AbortSignal,
): Promise<ToolResult> {
try {
return await callBrowserAction(verb, payload, signal);
return await callBrowserAction(config, verb, payload, signal);
} catch (error) {
return failedToolResult(verb, payload, error);
}
}

export default function registerBrowserExtension(pi: ExtensionAPI) {
const config = browserExtensionConfig();
const run = (verb: string, payload: Record<string, unknown>, signal: AbortSignal) =>
safeBrowserAction(config, verb, payload, signal);
pi.registerTool({
name: "browser_navigate",
label: "Browser: Navigate",
Expand All @@ -88,7 +112,7 @@ export default function registerBrowserExtension(pi: ExtensionAPI) {
url: Type.String({ description: "Absolute http(s) URL to load" }),
}),
async execute(_id, params, signal) {
return safeBrowserAction("navigate", { url: params.url }, signal);
return run("navigate", { url: params.url }, signal);
},
});

Expand All @@ -98,7 +122,7 @@ export default function registerBrowserExtension(pi: ExtensionAPI) {
description: "Return the current URL of the embedded browser.",
parameters: Type.Object({}),
async execute(_id, _params, signal) {
return safeBrowserAction("get-url", {}, signal);
return run("get-url", {}, signal);
},
});

Expand All @@ -109,7 +133,7 @@ export default function registerBrowserExtension(pi: ExtensionAPI) {
"Return the visible text of the current page (innerText of <body>). Use after navigating to read page contents.",
parameters: Type.Object({}),
async execute(_id, _params, signal) {
return safeBrowserAction("get-text", {}, signal);
return run("get-text", {}, signal);
},
});

Expand All @@ -120,7 +144,7 @@ export default function registerBrowserExtension(pi: ExtensionAPI) {
"Return the rendered HTML of the current page. Useful when text alone isn't enough.",
parameters: Type.Object({}),
async execute(_id, _params, signal) {
return safeBrowserAction("get-html", {}, signal);
return run("get-html", {}, signal);
},
});

Expand All @@ -130,7 +154,7 @@ export default function registerBrowserExtension(pi: ExtensionAPI) {
description: "Capture a PNG screenshot of the current page; returns a base64 data URI.",
parameters: Type.Object({}),
async execute(_id, _params, signal) {
return safeBrowserAction("screenshot", {}, signal);
return run("screenshot", {}, signal);
},
});

Expand All @@ -142,7 +166,7 @@ export default function registerBrowserExtension(pi: ExtensionAPI) {
selector: Type.String({ description: "CSS selector for the element to click" }),
}),
async execute(_id, params, signal) {
return safeBrowserAction("click", { selector: params.selector }, signal);
return run("click", { selector: params.selector }, signal);
},
});

Expand All @@ -154,7 +178,7 @@ export default function registerBrowserExtension(pi: ExtensionAPI) {
deltaY: Type.Number({ description: "Pixels to scroll vertically" }),
}),
async execute(_id, params, signal) {
return safeBrowserAction("scroll", { deltaY: params.deltaY }, signal);
return run("scroll", { deltaY: params.deltaY }, signal);
},
});

Expand All @@ -168,7 +192,7 @@ export default function registerBrowserExtension(pi: ExtensionAPI) {
value: Type.String({ description: "Value to set" }),
}),
async execute(_id, params, signal) {
return safeBrowserAction("fill", { selector: params.selector, value: params.value }, signal);
return run("fill", { selector: params.selector, value: params.value }, signal);
},
});
}
Loading