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
2 changes: 1 addition & 1 deletion apps/desktop/src/components/settings/browser-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ export function BrowserSection({ settings }: { settings: Settings }) {
label="Keep browser alive"
htmlFor="keep-alive"
disabled={!b.enabled}
description="Close an idle browser after this many minutes. Sessions and cookies are kept either way."
description="A browser only starts when an agent opens a website and closes after this many idle minutes (0 keeps it open). Sessions and cookies are kept either way."
>
<NumberField
id="keep-alive"
Expand Down
5 changes: 5 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,11 @@ Server → UI events are defined in `packages/shared/src/events.ts`. The UI keep
is going or someone watches its live view; the browser's last page is kept, blank, for the next chat (closing the
last window would quit Chromium on Windows and Linux). Chromium has one download folder per profile, so two agents
downloading through one profile at the same moment may find the file in the folder of the one that set it last.
* **On demand**: a run's endpoint only starts Chromium when browser-use first asks for `/json/version`, on its first
browser tool call, so a run that never browses never opens a browser. A start that fails or outlasts browser-use's
15 s connect timeout becomes a warning in that run. Idle browsers (no CDP client attached, no watcher, window not
focused) stop after `browser.keepAliveMinutes` (default 5); browsers an earlier core left running are adopted at
startup and closed unless something still uses them.
* **Session import** (“continue where Chrome left off”): the importer uses the same technique as browser-use’s
`profile-use` — copy the Chrome profile’s cookie store to a temp dir, start the real Chrome binary headless on it
with CDP, read decrypted cookies via `Storage.getCookies`, inject them into the Godmode profile with
Expand Down
94 changes: 76 additions & 18 deletions packages/core/src/browser/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -343,14 +343,7 @@ async function startBrowser(profileId: string, opts: { headless?: boolean }): Pr
log.info(`adopting running browser for profile ${profileId} (pid ${pid}, port ${port})`);
} else {
if (marker) clearLaunchMarker(profile.user_data_dir);
const chrome = findChrome(settings.browser.chromePath);
if (!chrome) {
throw new HttpError(
400,
"No Chrome or Chromium browser found. Install Google Chrome, or install Chromium from Settings → Dependencies.",
"chrome_missing",
);
}
const chrome = requireChrome(settings.browser.chromePath);
headless = opts.headless ?? settings.browser.headless;
try {
proc = await launchChrome({ executable: chrome.path, userDataDir: profile.user_data_dir, headless });
Expand Down Expand Up @@ -408,17 +401,34 @@ async function startBrowser(profileId: string, opts: { headless?: boolean }): Pr
return rb;
}

/** Take over browsers a previous core process left running, so idle shutdown and the UI cover them. */
async function adoptOrphans() {
/**
* Take over browsers a previous core process left running, so idle shutdown and the UI cover them. This core doesn't use
* them, so they close right away unless something else still does (another CDP client, a focused window).
*/
export async function adoptOrphans() {
let adopted = false;
for (const r of all<ProfileRow>("SELECT * FROM browser_profiles")) {
const marker = readLaunchMarker(r.user_data_dir);
if (!marker || getRegistered(r.id)) continue;
if (!isProcessAlive(marker.pid) || !(await probeCdp(marker.port))) {
clearLaunchMarker(r.user_data_dir);
continue;
}
await ensureBrowser(r.id).catch((err) => log.warn(`could not adopt browser for profile ${r.id}`, err));
const rb = await ensureBrowser(r.id).catch((err) => log.warn(`could not adopt browser for profile ${r.id}`, err));
if (rb && !rb.process) {
rb.lastUsedAt = 0;
adopted = true;
}
}
if (adopted) await sweepIdleBrowsers();
}

function requireChrome(customPath: string) {
const chrome = findChrome(customPath);
if (!chrome) {
throw new HttpError(400, "No Chrome or Chromium browser found. Install Google Chrome, or install Chromium from Settings → Dependencies.", "chrome_missing");
}
return chrome;
}

function pidAlive(rb: RunningBrowser): boolean {
Expand Down Expand Up @@ -547,6 +557,8 @@ export async function sweepIdleBrowsers(): Promise<void> {
continue;
}
if (Date.now() - rb.lastUsedAt < keepAlive * 60_000) continue;
const checkedAt = Date.now();
const reason = rb.lastUsedAt ? `unused for ${keepAlive} min` : "left running by an earlier core";
// Passive previews attach our own screencast to the tab; pause it so it doesn't look like another CDP client.
const previewing = hasBrowserSubscribers(rb.profileId);
if (previewing) await pauseLiveViews(rb.profileId);
Expand All @@ -557,7 +569,8 @@ export async function sweepIdleBrowsers(): Promise<void> {
if (previewing) resumeLiveViews(rb.profileId);
continue;
}
log.info(`stopping idle browser for profile ${rb.profileId} (unused for ${keepAlive} min)`);
if ((handedOut.get(rb.profileId) ?? 0) >= checkedAt) continue;
log.info(`stopping idle browser for profile ${rb.profileId} (${reason})`);
await stopBrowser(rb.profileId).catch((err) => log.warn("idle stop failed", err));
}
}
Expand Down Expand Up @@ -777,6 +790,54 @@ export function requireRunning(profileId: string): RunningBrowser {
/* ------------------------------------------------------------------ */

const runDirs = new Map<string, string>();
/** When a profile's browser was last handed to a run (the idle sweep must not close it right after). */
const handedOut = new Map<string, number>();
/** browser-use gives up on connecting after 15 s and can't recover within the same run. */
const BROWSER_USE_CONNECT_BUDGET_MS = 14_000;

type LaunchProblemListener = (runId: string, text: string) => void;
const launchProblemListeners = new Set<LaunchProblemListener>();

/** Told when a run's browser couldn't be started in time, so the run can tell the human. */
export function onLaunchProblem(fn: LaunchProblemListener): () => void {
launchProblemListeners.add(fn);
return () => {
launchProblemListeners.delete(fn);
};
}

function reportLaunchProblem(runId: string, text: string) {
for (const fn of [...launchProblemListeners]) {
try {
fn(runId, text);
} catch (err) {
log.warn("launch problem listener failed", err);
}
}
}

/** Start (or reuse) the profile's browser for a run's endpoint, with a tab ready for its chat. */
async function openForRun(runId: string, profileId: string, conversationId: string, headless: boolean): Promise<RunningBrowser> {
const startedAt = Date.now();
let rb: RunningBrowser;
try {
rb = await ensureBrowser(profileId, { headless });
if (rb.stopping) rb = await ensureBrowser(profileId, { headless });
await ensureChatTab(rb, conversationId);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
log.warn(`could not start the browser for profile ${profileId} (run ${runId}): ${message}`);
reportLaunchProblem(runId, `The browser couldn't start, so browser tools won't work in this run. ${message}`);
throw err;
}
const took = Date.now() - startedAt;
if (took > BROWSER_USE_CONNECT_BUDGET_MS) {
reportLaunchProblem(runId, `The browser took ${Math.round(took / 1000)} s to start, too long for this run's browser tools. It's running now, so the next message can use it.`);
}
rb.lastUsedAt = Date.now();
handedOut.set(profileId, rb.lastUsedAt);
return rb;
}

/**
* MCP server entry giving a run browser tools: browser-use MCP connected over CDP to the profile's Chromium — `profileId`
Expand All @@ -799,16 +860,13 @@ export async function browserMcpServer(

const profile = profileId ? getProfile(profileId) : resolveProfileForAgent(agent, run.conversationId);
const headless = agent.browser.headless ?? settings.browser.headless;
await launchBrowser(profile.id, { headless });
if (!getRunning(profile.id)) requireChrome(settings.browser.chromePath);
// No browser starts here: browser-use asks the run's endpoint for it on its first browser tool call.
const cdpUrl = openChatLease({
runId: run.runId,
profileId: profile.id,
conversationId: run.conversationId,
open: async () => {
const rb = await ensureBrowser(profile.id, { headless });
await ensureChatTab(rb, run.conversationId);
return rb;
},
open: () => openForRun(run.runId, profile.id, run.conversationId, headless),
});
emitProfileSoon(profile.id);

Expand Down
9 changes: 8 additions & 1 deletion packages/core/src/runner/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import { isDirectory, workingDirectoryProblem } from "../services/folders";
import { prepareSources, type RunSource } from "../services/workspaceSources";
import { getSettings } from "../services/settings";
import { reportMissingLogin } from "../services/missingLogins";
import { BROWSER_LLM_TOOLS, browserLlmKey, chatProfileId, currentPage, getProfile, releaseChatBrowser, resolveProfileForAgent } from "../browser/manager";
import { BROWSER_LLM_TOOLS, browserLlmKey, chatProfileId, currentPage, getProfile, onLaunchProblem, releaseChatBrowser, resolveProfileForAgent } from "../browser/manager";
import {
addMessage,
appendTranscript,
Expand Down Expand Up @@ -649,6 +649,13 @@ function computerHolder(job: Job): Job | null {
return null;
}

onLaunchProblem((runId, text) => {
const job = jobs.get(runId);
if (job?.status !== "running") return;
job.acc.addNotice("warning", text);
scheduleDelta(job);
});

/**
* The running job working in the same VM as `job` (excluding its own ancestors), if any. Runs sharing a browser profile
* on this computer don't wait for each other: every chat works in its own tabs.
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/services/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export const DEFAULT_SETTINGS: Settings = {
chromePath: "",
headless: false,
browserUseCommand: "",
keepAliveMinutes: 15,
keepAliveMinutes: 5,
liveView: true,
},
computer: {
Expand Down
11 changes: 7 additions & 4 deletions packages/core/test/browser-mcp.e2e.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
/**
* End-to-end: the browser-use MCP server Godmode configures for an agent must drive Godmode's managed
* Chromium (via cdp_url), not a browser of its own. Needs uvx, a Chromium-family browser and network access
* (downloads browser-use on first run and opens https://example.com), so it only runs with GODMODE_E2E=1:
* Chromium (via cdp_url), not a browser of its own, and that browser only starts on the first browser tool call.
* Needs uvx, a Chromium-family browser and network access (downloads browser-use on first run and opens
* https://example.com), so it only runs with GODMODE_E2E=1:
*
* GODMODE_E2E=1 bun test test/browser-mcp.e2e.test.ts
*/
Expand Down Expand Up @@ -145,8 +146,7 @@ suite("browser-use MCP drives the managed Chromium", () => {
const env = server.env!;
expect(env.ANONYMIZED_TELEMETRY).toBe("false");
const profile = manager.resolveProfileForAgent(a);
const rb = getRunning(profile.id)!;
expect(rb).toBeTruthy();
expect(getRunning(profile.id)).toBeNull();

// browser-use reaches the browser through the run's own endpoint, configured in a file of its own.
expect(env.BROWSER_USE_CONFIG_PATH).toBe(join(env.BROWSER_USE_CONFIG_DIR!, "runs", "run_e2e", "config.json"));
Expand All @@ -160,9 +160,12 @@ suite("browser-use MCP drives the managed Chromium", () => {
const tools = (list.result as { tools: { name: string }[] }).tools.map((t) => t.name);
expect(tools).toContain("browser_navigate");
expect(tools).toContain("browser_get_state");
expect(getRunning(profile.id)).toBeNull();

const nav = await mcp.request("tools/call", { name: "browser_navigate", arguments: { url: "https://example.com" } }, 180_000);
expect(nav.error).toBeUndefined();
const rb = getRunning(profile.id)!;
expect(rb).toBeTruthy();
const pagesAfter = await listPages(rb.client);
console.log(JSON.stringify({ serverInfo: init.serverInfo, toolCount: tools.length, navigateResult: text(nav).slice(0, 200), pagesAfter: pagesAfter.map((p) => p.url) }, null, 2));
expect(rb.tabs.currentPage("cnv_e2e")?.url).toStartWith("https://example.com");
Expand Down
72 changes: 70 additions & 2 deletions packages/core/test/cdp-integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@
* cookie round-trips and importing sessions from another profile's cookie store.
*/
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { mkdtempSync, realpathSync, rmSync } from "node:fs";
import { mkdtempSync, readFileSync, realpathSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { ServerWebSocket } from "bun";
import type { ServerEvent } from "@godmode/shared";
import type { Agent, ServerEvent } from "@godmode/shared";
import { loadConfig } from "../src/config";
import { closeDb, openDb } from "../src/db";
import { bus } from "../src/events/bus";
Expand All @@ -20,6 +20,7 @@ import { websocketHandler, type WsData } from "../src/server/ws";
import { startLiveView, stopLiveView, dispatchInput } from "../src/browser/screencast";
import * as manager from "../src/browser/manager";
import { loginFillScope, originRefusal } from "../src/browser/fill";
import type { McpServerJson } from "../src/types";

const chrome = findChrome();
const suite = chrome && !process.env.GODMODE_SKIP_BROWSER_TESTS ? describe : describe.skip;
Expand Down Expand Up @@ -65,6 +66,16 @@ let server: ReturnType<typeof Bun.serve>;
let origin = "";
let dataDir = "";

function onDemandAgent(profileId: string): Agent {
return { id: "agt_on_demand", workspaceId: null, repoPath: null, browser: { profileId, enabled: true, headless: true } } as unknown as Agent;
}

function browserUseCdpUrl(entry: McpServerJson | null): string {
if (!entry || !("env" in entry)) throw new Error("expected a stdio server");
const config = JSON.parse(readFileSync(entry.env!.BROWSER_USE_CONFIG_PATH!, "utf8"));
return (Object.values(config.browser_profile)[0] as { cdp_url: string }).cdp_url;
}

async function waitFor<T>(fn: () => Promise<T | null | undefined | false> | T | null | undefined | false, timeoutMs = 10_000): Promise<T> {
const deadline = Date.now() + timeoutMs;
let last: unknown;
Expand Down Expand Up @@ -481,6 +492,63 @@ suite("managed Chromium (CDP integration)", () => {
}
}, 60_000);

test("startup closes a left-over browser nothing uses", async () => {
await manager.launchBrowser(profileId, { headless: true });
const rb = getRunning(profileId)!;
unregisterBrowser(rb);
rb.client.close();
try {
await manager.adoptOrphans();
expect(getRunning(profileId)).toBeNull();
await Promise.race([rb.process!.exited, Bun.sleep(8000)]);
expect(rb.process!.isAlive()).toBe(false);
} finally {
if (rb.process!.isAlive()) rb.process!.kill("SIGKILL");
}
}, 60_000);

test("agent browser tools start the browser only when browser-use first connects", async () => {
await manager.stopBrowser(profileId);
updateSettings({ browser: { browserUseCommand: "browser-use --mcp" } });
const run = { runId: "run_on_demand", conversationId: "cnv_on_demand" };
try {
const cdpUrl = browserUseCdpUrl(await manager.browserMcpServer(onDemandAgent(profileId), run, profileId));
expect(manager.getProfile(profileId).running).toBe(false);

const res = await fetch(`${cdpUrl}/json/version`);
expect(res.status).toBe(200);
expect(((await res.json()) as { webSocketDebuggerUrl: string }).webSocketDebuggerUrl).toStartWith(cdpUrl.replace("http:", "ws:"));
const rb = getRunning(profileId)!;
expect(rb.headless).toBe(true);
expect(rb.tabs.currentPage(run.conversationId)).toBeTruthy();
} finally {
manager.releaseChatBrowser(run.runId);
updateSettings({ browser: { browserUseCommand: "" } });
}
}, 60_000);

test("a browser that can't start on demand is reported to the run", async () => {
await manager.stopBrowser(profileId);
updateSettings({ browser: { browserUseCommand: "browser-use --mcp" } });
const squatter = await launchChrome({ executable: chrome!.path, userDataDir: manager.getProfile(profileId).userDataDir, headless: true });
const run = { runId: "run_blocked", conversationId: "cnv_blocked" };
const problems: string[] = [];
const off = manager.onLaunchProblem((runId, text) => void (runId === run.runId && problems.push(text)));
try {
const cdpUrl = browserUseCdpUrl(await manager.browserMcpServer(onDemandAgent(profileId), run, profileId));
expect((await fetch(`${cdpUrl}/json/version`)).status).toBe(503);
expect(problems).toHaveLength(1);
expect(problems[0]).toContain("won't work in this run");
expect(problems[0]).toContain("already in use");
} finally {
off();
manager.releaseChatBrowser(run.runId);
squatter.kill("SIGKILL");
await Promise.race([squatter.exited, Bun.sleep(5000)]);
updateSettings({ browser: { browserUseCommand: "" } });
}
}, 60_000);

test("imports sessions from another profile's cookie store (profile-use technique)", async () => {
// The source browser is stopped (cookie store flushed to disk); import it into a fresh profile.
await manager.stopBrowser(profileId);
Expand Down
Loading