Skip to content
Draft
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
1 change: 1 addition & 0 deletions app/build/.gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
*
!.gitignore
!icon.icns
!entitlements.mac.plist
14 changes: 14 additions & 0 deletions app/build/entitlements.mac.plist
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "https://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
<key>com.apple.security.device.audio-input</key>
<true/>
</dict>
</plist>
9 changes: 7 additions & 2 deletions app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
"@types/react-dom": "^19.0.3",
"@vitejs/plugin-react": "^4.4.1",
"autoprefixer": "^10.4.20",
"electron": "37.2.0",
"electron": "37.10.3",
"electron-builder": "^26.0.12",
"electron-vite": "^3.1.0",
"postcss": "^8.5.1",
Expand All @@ -49,7 +49,7 @@
"build": {
"appId": "com.stevibe.benchlocal",
"productName": "BenchLocal",
"electronVersion": "37.2.0",
"electronVersion": "37.10.3",
"electronUpdaterCompatibility": ">=2.16",
"directories": {
"output": "dist"
Expand Down Expand Up @@ -105,6 +105,11 @@
"icon": "build/icon.icns",
"category": "public.app-category.developer-tools",
"hardenedRuntime": true,
"entitlements": "build/entitlements.mac.plist",
"entitlementsInherit": "build/entitlements.mac.plist",
"extendInfo": {
"NSMicrophoneUsageDescription": "BenchLocal allows approved Web Bench Packs to capture microphone audio when you start a microphone-enabled benchmark."
},
"gatekeeperAssess": false,
"type": "distribution",
"notarize": true,
Expand Down
4 changes: 3 additions & 1 deletion app/src/main/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { app, BrowserWindow, Menu, nativeTheme, screen, type MenuItemConstructorOptions } from "electron";
import { app, BrowserWindow, Menu, nativeTheme, screen, session, type MenuItemConstructorOptions } from "electron";
import { promises as fs } from "node:fs";
import path from "node:path";
import { getBenchLocalHome, loadOrCreateConfig } from "@core";
Expand All @@ -7,6 +7,7 @@ import { APP_OPEN_ABOUT_CHANNEL, APP_OPEN_SETTINGS_CHANNEL, registerIpcHandlers,
import { loadAvailableTheme } from "./themes";
import { checkForAppUpdatesInteractively, initializeAppUpdater } from "./updater";
import { agentServer } from "./agent-server";
import { registerWebPackMediaPermissionHandlers } from "./web-pack-media-permission-policy";

const isDev = !app.isPackaged;
const shouldOpenDevTools = process.env.BENCHLOCAL_OPEN_DEVTOOLS === "1";
Expand Down Expand Up @@ -372,6 +373,7 @@ app.whenReady().then(async () => {
...(appMetadata.copyright ? { copyright: appMetadata.copyright } : {})
});
registerIpcHandlers();
registerWebPackMediaPermissionHandlers(session.defaultSession);
await agentServer.initialize();
initializeAppUpdater();
buildApplicationMenu(appMetadata.productName);
Expand Down
16 changes: 16 additions & 0 deletions app/src/main/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { listAvailableThemes, loadAvailableTheme } from "./themes";
import { checkForAppUpdates, getAppUpdateState, installDownloadedAppUpdate } from "./updater";
import { benchLocalController } from "./controller";
import { agentServer } from "./agent-server";
import { webPackMediaPermissionPolicy } from "./web-pack-media-permission-policy";

const CONFIG_LOAD_CHANNEL = "benchlocal:config:load";
const CONFIG_SAVE_CHANNEL = "benchlocal:config:save";
Expand Down Expand Up @@ -60,6 +61,7 @@ const WEB_PACK_STREAM_CHAT_CHANNEL = "benchlocal:webpacks:stream-chat";
const WEB_PACK_STREAM_EVENT_CHANNEL = "benchlocal:webpacks:stream-event";
const WEB_PACK_HISTORY_SAVE_CHANNEL = "benchlocal:webpacks:history-save";
const WEB_PACK_ARTIFACT_WRITE_CHANNEL = "benchlocal:webpacks:artifact-write";
const WEB_PACK_MEDIA_PERMISSION_POLICY_CHANNEL = "benchlocal:webpacks:media-permission-policy";
const VERIFIERS_LIST_CHANNEL = "benchlocal:verifiers:list";
const VERIFIERS_START_CHANNEL = "benchlocal:verifiers:start";
const VERIFIERS_STOP_CHANNEL = "benchlocal:verifiers:stop";
Expand All @@ -73,6 +75,7 @@ const AGENT_STATE_CHANNEL = "benchlocal:agent:state";
const AGENT_GET_STATE_CHANNEL = "benchlocal:agent:get-state";
const AGENT_CONFIGURE_CHANNEL = "benchlocal:agent:configure";
const AGENT_REGENERATE_TOKEN_CHANNEL = "benchlocal:agent:regenerate-token";
const mediaPolicySenders = new WeakSet<Electron.WebContents>();

export function stopActiveBenchPackRunsForShutdown(options?: { timeoutMs?: number; intervalMs?: number }): Promise<void> {
return benchLocalController.stopActiveBenchPackRunsForShutdown(options);
Expand Down Expand Up @@ -119,6 +122,19 @@ export function registerIpcHandlers(): void {
return benchLocalController.loadConfig();
});

ipcMain.handle(
WEB_PACK_MEDIA_PERMISSION_POLICY_CHANNEL,
async (event, input: { microphoneOrigins?: string[] }) => {
webPackMediaPermissionPolicy.update(event.sender.id, input.microphoneOrigins ?? []);
if (!mediaPolicySenders.has(event.sender)) {
mediaPolicySenders.add(event.sender);
const webContentsId = event.sender.id;
event.sender.once("destroyed", () => webPackMediaPermissionPolicy.clear(webContentsId));
}
return { updated: true };
}
);

ipcMain.handle(CONFIG_SAVE_CHANNEL, async (_event, config: BenchLocalConfig) => {
return benchLocalController.saveConfig(config);
});
Expand Down
73 changes: 73 additions & 0 deletions app/src/main/web-pack-media-permission-policy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { toAllowedWebPackOrigin } from "@benchlocal/core";

export type WebPackMediaPermissionRequest = {
webContentsId: number;
permission: string;
requestingUrl?: string;
securityOrigin?: string;
mediaTypes: readonly string[];
};

export class WebPackMediaPermissionPolicy {
readonly #microphoneOriginsByWebContents = new Map<number, ReadonlySet<string>>();

update(webContentsId: number, microphoneOrigins: readonly string[]): void {
const normalizedOrigins = microphoneOrigins
.map(toAllowedWebPackOrigin)
.filter((origin): origin is string => origin !== null);
this.#microphoneOriginsByWebContents.set(webContentsId, new Set(normalizedOrigins));
}

clear(webContentsId: number): void {
this.#microphoneOriginsByWebContents.delete(webContentsId);
}

allows(request: WebPackMediaPermissionRequest): boolean {
if (request.permission !== "media" || request.mediaTypes.length === 0 || request.mediaTypes.some((type) => type !== "audio")) {
return false;
}

const origin = toAllowedWebPackOrigin(request.securityOrigin ?? request.requestingUrl ?? "");
return origin !== null && this.#microphoneOriginsByWebContents.get(request.webContentsId)?.has(origin) === true;
}
}

export const webPackMediaPermissionPolicy = new WebPackMediaPermissionPolicy();

type PermissionSession = {
setPermissionCheckHandler(handler: (
webContents: { id: number } | null,
permission: string,
requestingOrigin: string,
details: { mediaType?: string; requestingUrl?: string; securityOrigin?: string }
) => boolean): void;
setPermissionRequestHandler(handler: (
webContents: { id: number },
permission: string,
callback: (granted: boolean) => void,
details: { requestingUrl?: string; securityOrigin?: string; mediaTypes?: readonly string[] }
) => void): void;
};

export function registerWebPackMediaPermissionHandlers(permissionSession: PermissionSession): void {
const allows = (
webContentsId: number,
permission: string,
details: { mediaType?: string; mediaTypes?: readonly string[]; requestingUrl?: string; securityOrigin?: string },
requestingOrigin?: string
): boolean => webPackMediaPermissionPolicy.allows({
webContentsId,
permission,
requestingUrl: details.requestingUrl ?? requestingOrigin,
securityOrigin: details.securityOrigin,
mediaTypes: details.mediaTypes ?? (details.mediaType ? [details.mediaType] : [])
});

permissionSession.setPermissionCheckHandler((webContents, permission, requestingOrigin, details) => {
return allows(webContents?.id ?? -1, permission, details, requestingOrigin);
});

permissionSession.setPermissionRequestHandler((webContents, permission, callback, details) => {
callback(allows(webContents.id, permission, details));
});
}
2 changes: 2 additions & 0 deletions app/src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const WEB_PACK_STREAM_CHAT_CHANNEL = "benchlocal:webpacks:stream-chat";
const WEB_PACK_STREAM_EVENT_CHANNEL = "benchlocal:webpacks:stream-event";
const WEB_PACK_HISTORY_SAVE_CHANNEL = "benchlocal:webpacks:history-save";
const WEB_PACK_ARTIFACT_WRITE_CHANNEL = "benchlocal:webpacks:artifact-write";
const WEB_PACK_MEDIA_PERMISSION_POLICY_CHANNEL = "benchlocal:webpacks:media-permission-policy";
const VERIFIERS_PROGRESS_CHANNEL = "benchlocal:verifiers:progress";
const DETACHED_LOGS_STATE_CHANNEL = "benchlocal:logs:state";
const DETACHED_LOGS_CLOSED_CHANNEL = "benchlocal:logs:closed";
Expand Down Expand Up @@ -156,6 +157,7 @@ const api: BenchLocalDesktopApi = {
}
},
webPacks: {
setMediaPermissionPolicy: (input) => ipcRenderer.invoke(WEB_PACK_MEDIA_PERMISSION_POLICY_CHANNEL, input),
chat: (input) => ipcRenderer.invoke(WEB_PACK_CHAT_CHANNEL, input),
streamChat: (input, listener) => {
const wrapped = (_event: Electron.IpcRendererEvent, payload: Parameters<typeof listener>[0]) => {
Expand Down
23 changes: 23 additions & 0 deletions app/src/renderer/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
Wrench,
X
} from "lucide-react";
import { getWebPackIframeAllow } from "./web-pack-permissions";
import type {
ArtifactRef,
BenchLocalChatRequest,
Expand Down Expand Up @@ -2280,6 +2281,27 @@ export function App() {
() => benchPackInspections.find((inspection) => inspection.id === activeTab?.benchPackId) ?? null,
[benchPackInspections, activeTab]
);
const activeWebPackMicrophoneOrigins = useMemo(() => {
const manifest = activeInspection?.manifest;
if ((manifest?.type ?? "table") !== "web" || !manifest?.web?.permissions.includes("media:microphone")) {
return [];
}

return Array.from(new Set(
[manifest.entry, ...manifest.web.allowedOrigins]
.map(getOriginFromUrl)
.filter((origin): origin is string => Boolean(origin))
));
}, [activeInspection]);
useEffect(() => {
void window.benchlocal.webPacks.setMediaPermissionPolicy({
microphoneOrigins: activeWebPackMicrophoneOrigins
});

return () => {
void window.benchlocal.webPacks.setMediaPermissionPolicy({ microphoneOrigins: [] });
};
}, [activeWebPackMicrophoneOrigins]);
const activeVerifierStatus = useMemo(
() => (activeInspection ? verifierStatuses[activeInspection.id] ?? null : null),
[activeInspection, verifierStatuses]
Expand Down Expand Up @@ -7512,6 +7534,7 @@ function WebBenchPackSection({
title={manifest.name}
src={entryUrl}
className="web-benchpack-frame"
allow={getWebPackIframeAllow(manifest.web?.permissions ?? [])}
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-modals allow-downloads"
referrerPolicy="no-referrer"
/>
Expand Down
3 changes: 3 additions & 0 deletions app/src/renderer/src/web-pack-permissions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export function getWebPackIframeAllow(permissions: readonly string[]): string | undefined {
return permissions.includes("media:microphone") ? "microphone" : undefined;
}
1 change: 1 addition & 0 deletions app/src/shared/desktop-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,7 @@ export interface BenchLocalDesktopApi {
onRunEvent(listener: (payload: { tabId: string; benchPackId?: string; event: ProgressEvent }) => void): () => void;
};
webPacks: {
setMediaPermissionPolicy(input: { microphoneOrigins: string[] }): Promise<{ updated: boolean }>;
chat(input: BenchLocalChatRequest): Promise<BenchLocalChatResponse>;
streamChat(
input: { streamId: string; request: BenchLocalChatRequest },
Expand Down
1 change: 1 addition & 0 deletions docs/interactive-web-benchpacks.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ Required rules:
- Do not expose provider API keys, secret environment names, or raw provider config to the web app.
- Do not provide an arbitrary credentialed HTTP proxy.
- Treat bridge permissions as capabilities and enforce them per installed pack.
- Delegate microphone access only when the active pack declares `media:microphone`; scope Electron media approval to that pack's declared origins and audio-only requests.
- Persist pack id, version, entry URL, and build metadata into each run.

The web app may call its own server if the manifest declares it. Provider requests still go through BenchLocal.
Expand Down
Loading