Skip to content

Add in-app browser host and surface management#146

Open
sak0a wants to merge 2 commits into
mainfrom
feature/add-in-app-browser-usability
Open

Add in-app browser host and surface management#146
sak0a wants to merge 2 commits into
mainfrom
feature/add-in-app-browser-usability

Conversation

@sak0a

@sak0a sak0a commented Jun 25, 2026

Copy link
Copy Markdown
Owner

Summary

  • Adds a desktop browser host/kernel that can open sessions, navigate pages, inject input, inspect DOM text, and manage storage/cookies.
  • Introduces browser profile and storage helpers for persistent vs temporary sessions, cookie redaction, and storage cleanup.
  • Adds a browser surface manager and IPC/RPC wiring so the desktop app can attach browser views into the UI.
  • Expands server-side browser auth, registry, policy, websocket, and runtime tool support to coordinate browser-host sessions.
  • Ships a new in-app browser panel and related web state/routing updates to surface browser activity in the UI.

Testing

  • Not run (PR description only).
  • Expected verification in CI: bun fmt.
  • Expected verification in CI: bun lint.
  • Expected verification in CI: bun typecheck.

Summary by CodeRabbit

  • New Features
    • Added an in-app Browser workspace tab in the desktop and web apps, including a full Browser panel UI (session, navigation controls, surface display, storage/cookie management, and event-driven updates).
    • Introduced a server-backed browser control plane with WebSocket RPC for browser commands and event streaming.
    • Added browser runtime tool support (including MCP bridging) for supported providers.
  • Bug Fixes
    • Hardened local/loopback-only authorization and improved browser surface attachment/bounds handling and reconnect/shutdown behavior.
  • Documentation
    • Added design specs for the in-app browser and browser storage management flow.

- Wire desktop, web, and server RPC plumbing for hosted browser sessions
- Add navigation, input, storage, cookie, and profile management
@coderabbitai

coderabbitai Bot commented Jun 25, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This PR adds browser control-plane contracts and helpers, server authentication and RPC wiring, desktop host/surface plumbing, web browser-panel routing and UI, provider browser runtime tools, and supporting config, tests, and docs.

Changes

Built-in in-app browser

Layer / File(s) Summary
Contracts, shared helpers, and specs
packages/shared/src/browser.ts, packages/shared/src/browser.test.ts, packages/shared/package.json, packages/contracts/src/browser*.ts, packages/contracts/src/index.ts, packages/contracts/src/ipc.ts, packages/contracts/src/providerRuntime.ts, packages/contracts/src/rpc.ts, packages/contracts/src/rpc.test.ts, docs/superpowers/..., .tmp-agent-handoff.md
Browser URL/profile helpers, browser schemas, RPC/IPC surfaces, package exports, and browser design/spec documents are added.
Browser service, registry, and host RPC
apps/server/src/browser/*, apps/server/src/browserHost/browserHostRpc.ts, apps/server/integration/*, apps/server/src/server.test.ts
Browser policy, artifact storage, host registry, browser service, and host RPC/runtime wiring are added with tests and integration support.
Browser auth, config, and WS runtime wiring
apps/server/src/auth/*, apps/server/src/cli.*, apps/server/src/config.ts, apps/server/src/environment/Layers/ServerEnvironment.test.ts, apps/server/src/diagnostics/Layers/Diagnostics.test.ts, apps/server/src/ws/*, apps/server/src/ws.ts, apps/server/src/server.ts
Browser-host authentication, loopback session metadata, token/config propagation, and WS/server composition add the local-desktop-owner access path.
Desktop browser host and surfaces
apps/desktop/src/browser/BrowserProfiles.ts, apps/desktop/src/browser/BrowserStorageHelpers.ts, apps/desktop/src/browser/BrowserStorageHelpers.test.ts, apps/desktop/src/browser/BrowserKernel.ts, apps/desktop/src/browser/BrowserHostConnection.ts, apps/desktop/src/browser/BrowserHost.ts, apps/desktop/src/browser/BrowserObservationHelpers.ts, apps/desktop/src/browser/BrowserObservationHelpers.test.ts, apps/desktop/src/browser/BrowserSurfaceManager.ts, apps/desktop/src/main.ts, apps/desktop/src/preload.ts
Desktop browser profile management, kernel execution, host connection, observation helpers, and surface attachment/bootstrap logic are added.
Web browser panel and routing
apps/web/src/components/BrowserPanel.tsx, apps/web/src/components/BrowserPanel.logic.ts, apps/web/src/components/BrowserPanel.logic.test.ts, apps/web/src/components/ChatRightPanel.tsx, apps/web/src/components/ThreadWorkspacePanel.tsx, apps/web/src/components/routeViews/*, apps/web/src/rightPanelRouteSearch.*, apps/web/src/workspaceRouteSearch.*, apps/web/src/threadWorkspaceTabs.ts, apps/web/src/environmentApi.ts, apps/web/src/localApi.test.ts, apps/web/src/rpc/wsRpcClient.ts, apps/web/src/rpc/protocol.ts
Browser panel UI, route-search state, workspace tabs, and browser RPC client APIs are added.
Provider browser runtime tools
apps/server/src/provider/*, apps/server/src/provider/tools/*, apps/server/package.json
Browser runtime tools, registry, lifecycle events, MCP bridge helpers, and provider adapter capability wiring are added.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

  • sak0a/ryco#50: This PR extends the same WebSocket auth and RPC access path with browser-host authentication and local-desktop-owner checks.

Suggested labels: vouch:trusted, size:XXL

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description does not follow the required template; it omits the What Changed, Why, UI Changes, and Checklist sections. Rewrite the PR body to match the template, adding What Changed, Why, optional UI Changes, and a completed Checklist.
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise, specific, and accurately summarizes the main in-app browser host change.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/add-in-app-browser-usability
⚔️ Resolve merge conflicts
  • Resolve merge conflict in branch feature/add-in-app-browser-usability

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. size:XXL labels Jun 25, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Note

Due to the large number of review comments, Critical severity comments were prioritized as inline comments.

🟠 Major comments (21)
apps/desktop/src/browser/BrowserKernel.ts-31-34 (1)

31-34: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Clean up temporary profile storage when closing hosted sessions.

BrowserProfiles.cleanupTemporary() is never called because HostedSession drops the BrowserProfile; non-persistent partitions remain cached in sessionsByProfile with cookies/storage and can be reused for the same profileId.

Proposed fix
 interface HostedSession {
   session: BrowserSessionSnapshot;
+  profile: BrowserProfile;
   view: WebContentsView;
 }
-    const hosted: HostedSession = { session: { ...session, status: "ready" }, view };
+    const hosted: HostedSession = { session: { ...session, status: "ready" }, profile, view };
     this.sessions.delete(sessionId);
-    hosted.view.webContents.close({ waitForBeforeUnload: false });
+    try {
+      hosted.view.webContents.close({ waitForBeforeUnload: false });
+    } finally {
+      await this.profiles.cleanupTemporary(hosted.profile);
+    }

Also applies to: 223-234

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/desktop/src/browser/BrowserKernel.ts` around lines 31 - 34, The
hosted-session lifecycle is losing the BrowserProfile reference, so temporary
profiles never get cleaned up and remain cached in sessionsByProfile. Update
HostedSession and the BrowserKernel session management flow to retain the
BrowserProfile alongside the BrowserSessionSnapshot/WebContentsView, then call
BrowserProfiles.cleanupTemporary() when a non-persistent hosted session is
closed or removed. Make sure the cleanup path uses the stored profile identity
so temporary storage is released consistently.
apps/desktop/src/browser/BrowserKernel.ts-226-227 (1)

226-227: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Do not report open_session success after the initial navigation fails.

The current .catch(() => undefined) hides loadURL failures, so the backend receives ok: true even when the requested initial page was never loaded.

Proposed fix
-    await view.webContents.loadURL(initialUrl ?? "about:blank").catch(() => undefined);
+    await view.webContents.loadURL(initialUrl ?? "about:blank");
     return this.updateSnapshotFromWebContents(hosted);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/desktop/src/browser/BrowserKernel.ts` around lines 226 - 227, The
initial navigation in BrowserKernel’s session startup is swallowing loadURL
failures, which allows open_session to be reported as successful even when the
page never loaded. Update the logic around BrowserKernel’s
loadURL/updateSnapshotFromWebContents flow to stop hiding the error, and ensure
the caller only returns success after the initial navigation completes
successfully; if loadURL fails, propagate or convert that failure so
open_session is not marked ok.
apps/desktop/src/browser/BrowserKernel.ts-563-585 (1)

563-585: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Catch event-sink failures from WebContents listeners.

void this.emit(...) drops rejected promises from the async event sink; a transient RPC/WS publishing failure can become an unhandled rejection in the Electron main process.

Proposed fix
-      void this.emit({
+      void this.emit({
         type: "session.updated",
         session: this.updateSnapshotFromWebContents(hosted),
         createdAt: new Date().toISOString(),
-      });
+      }).catch(() => undefined);
-      void this.emit({
+      void this.emit({
         type: "tab.crashed",
         sessionId: hosted.session.sessionId,
         tabId: tab.tabId,
         reason: details.reason,
         createdAt: new Date().toISOString(),
-      });
+      }).catch(() => undefined);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/desktop/src/browser/BrowserKernel.ts` around lines 563 - 585, The
WebContents listeners in installWebContentsListeners are ignoring failures from
the async event sink because void this.emit(...) discards rejected promises.
Update the update callback and the render-process-gone handler to catch and
handle emit failures explicitly, using this.emit and a .catch(...) path (or
equivalent) so transient publishing errors don’t become unhandled rejections in
BrowserKernel.
apps/desktop/src/browser/BrowserHostConnection.ts-68-101 (1)

68-101: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Tear down partial startup state on failed connect/register.

If client creation on Line 77 or register on Lines 79-94 rejects, this.runtime and this.clientScope stay live and the socket/scope never get disposed because only stop() performs cleanup. Wrap the startup sequence in try/catch, call await this.stop(), then rethrow.

Suggested fix
   async start(input: { readonly wsBaseUrl: string; readonly token: string }): Promise<void> {
     await this.stop();
     this.commandStreamAbort = false;
-    const runtime = ManagedRuntime.make(
-      browserHostProtocolLayer(browserHostWsUrl(input.wsBaseUrl), input.token),
-    );
-    this.runtime = runtime;
-    const clientScope = runtime.runSync(Scope.make());
-    this.clientScope = clientScope;
-    const client = await runtime.runPromise(Scope.provide(clientScope)(makeBrowserHostRpcClient));
-    this.client = client;
-    await runtime.runPromise(
-      client[BROWSER_HOST_METHODS.register]({
-        hostId: this.hostId,
-        runId: this.runId,
-        capabilities: {
-          surface: true,
-          persistentProfiles: true,
-          temporaryProfiles: true,
-          screenshots: false,
-          domSnapshot: true,
-          input: true,
-          downloads: false,
-          devtools: false,
-        },
-      }),
-    );
-    this.startCommandStream(client);
-    this.heartbeatTimer = setInterval(() => {
-      void this.heartbeat().catch((error) => {
-        console.warn("[desktop-browser-host] heartbeat failed", error);
-      });
-    }, 5_000);
-    this.heartbeatTimer.unref();
+    try {
+      const runtime = ManagedRuntime.make(
+        browserHostProtocolLayer(browserHostWsUrl(input.wsBaseUrl), input.token),
+      );
+      this.runtime = runtime;
+      const clientScope = runtime.runSync(Scope.make());
+      this.clientScope = clientScope;
+      const client = await runtime.runPromise(Scope.provide(clientScope)(makeBrowserHostRpcClient));
+      this.client = client;
+      await runtime.runPromise(
+        client[BROWSER_HOST_METHODS.register]({
+          hostId: this.hostId,
+          runId: this.runId,
+          capabilities: {
+            surface: true,
+            persistentProfiles: true,
+            temporaryProfiles: true,
+            screenshots: false,
+            domSnapshot: true,
+            input: true,
+            downloads: false,
+            devtools: false,
+          },
+        }),
+      );
+      this.startCommandStream(client);
+      this.heartbeatTimer = setInterval(() => {
+        void this.heartbeat().catch((error) => {
+          console.warn("[desktop-browser-host] heartbeat failed", error);
+        });
+      }, 5_000);
+      this.heartbeatTimer.unref();
+    } catch (error) {
+      await this.stop();
+      throw error;
+    }
   }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/desktop/src/browser/BrowserHostConnection.ts` around lines 68 - 101, The
BrowserHostConnection.start flow leaves partial state behind if
makeBrowserHostRpcClient or the BROWSER_HOST_METHODS.register call fails,
because runtime and clientScope are assigned before the full startup completes.
Update start to wrap the client creation/register sequence in a try/catch, and
on any failure call await this.stop() to dispose the runtime, scope, and socket
before rethrowing. Keep the fix centered in BrowserHostConnection.start and use
the existing stop(), runtime, clientScope, and startCommandStream setup as the
cleanup boundary.
apps/desktop/src/browser/BrowserHostConnection.ts-151-175 (1)

151-175: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Keep one failed command from killing the subscription.

A rejection from this.handleCommand(envelope) on Line 161 or commandResult(...) on Lines 162-168 escapes Stream.runForEach, so the catch on Lines 172-175 logs once and the host stops consuming every later command. Catch failures inside the per-envelope handler and keep the stream alive.

Suggested fix
         Stream.runForEach(
           client[BROWSER_HOST_METHODS.subscribeCommands]({
             hostId: this.hostId,
             runId: this.runId,
           }),
           (envelope) =>
             Effect.promise(async () => {
               if (this.commandStreamAbort) return;
-              const result = await this.handleCommand(envelope);
-              await runtime.runPromise(
-                client[BROWSER_HOST_METHODS.commandResult]({
-                  hostId: this.hostId,
-                  runId: this.runId,
-                  result,
-                }),
-              );
+              try {
+                const result = await this.handleCommand(envelope);
+                if (this.commandStreamAbort) return;
+                await runtime.runPromise(
+                  client[BROWSER_HOST_METHODS.commandResult]({
+                    hostId: this.hostId,
+                    runId: this.runId,
+                    result,
+                  }),
+                );
+              } catch (error) {
+                console.warn("[desktop-browser-host] command failed", error);
+              }
             }),
         ),
       )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/desktop/src/browser/BrowserHostConnection.ts` around lines 151 - 175,
The per-envelope handler in BrowserHostConnection’s subscribeCommands stream is
letting errors from handleCommand(envelope) or
client[BROWSER_HOST_METHODS.commandResult](...) escape Stream.runForEach, which
stops the subscription after one failure. Wrap the logic inside the
Effect.promise handler with its own try/catch (or equivalent failure handling)
so each envelope logs or reports the failure and then returns without rejecting
the outer stream, keeping the command subscription alive for later messages.
apps/web/src/components/BrowserPanel.tsx-421-423 (1)

421-423: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Keep transient command errors out of status.

status also controls native surface attachment. A bad URL or a failed back/reload/open-external call flips the panel to "error", which tears down the surface; later successful control actions usually do not restore "ready", so the browser view can stay blank until remount. Track command errors separately from availability/session lifecycle.

Also applies to: 523-525, 597-639, 726-732, 869-873

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/components/BrowserPanel.tsx` around lines 421 - 423, Transient
command failures are incorrectly being stored in BrowserPanelStatus and that can
tear down the native surface; keep command errors separate from the lifecycle
state. Update BrowserPanel in the status/message/session state flow so commands
like back, reload, and open-external no longer set status to "error" unless the
panel is truly unavailable, and instead store those failures in a separate error
field. Make sure the lifecycle-driven status transitions in the BrowserPanel
controls preserve "ready" once the session is healthy, using the existing
BrowserPanelStatus, setStatus, setMessage, and BrowserSessionSnapshot handling.
apps/web/src/components/BrowserPanel.tsx-455-521 (1)

455-521: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Close the BrowserHost session in the effect cleanup.

openSession() runs every time the route target changes, but the cleanup only removes the event subscription. Closing the panel or switching threads leaves the previous browser session alive, which can accumulate orphaned tabs/processes and stale project-scoped sessions.

Suggested fix
   useEffect(() => {
     let cancelled = false;
     let unsubscribe: (() => void) | undefined;
+    let sessionApi: typeof browserApi | undefined;
+    let openedSessionId: string | null = null;

     const openSession = async () => {
@@
       const currentBrowserApi = readEnvironmentApi(environmentId)?.browser;
+      sessionApi = currentBrowserApi;
@@
         const snapshot = await currentBrowserApi.openSession({
           threadId,
           ...(projectId ? { projectId } : {}),
           profileMode: "project",
         });
         if (cancelled) return;
+        openedSessionId = snapshot.sessionId;
         setSession(snapshot);
         setStatus("ready");
         setMessage(null);
@@
     return () => {
       cancelled = true;
       unsubscribe?.();
+      if (sessionApi && openedSessionId) {
+        void sessionApi.closeSession({ sessionId: openedSessionId }).catch(() => undefined);
+      }
     };
   }, [environmentId, projectId, threadId]);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/components/BrowserPanel.tsx` around lines 455 - 521, The
BrowserPanel effect only unsubscribes from events and never closes the active
BrowserHost session, so sessions can leak when environmentId/projectId/threadId
changes or the component unmounts. Update the cleanup in the BrowserPanel
useEffect/openSession flow to call the browser API’s session-close/teardown
method for the current session (alongside unsubscribe), and make sure it runs
safely even when the async openSession is still in flight. Use the existing
openSession, currentBrowserApi, and cleanup return function as the place to wire
this in.
packages/shared/src/browser.ts-46-53 (1)

46-53: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Don't rewrite explicit non-browser schemes into https://….

mailto:user@example.com currently falls through this branch and becomes a synthetic HTTPS URL, so normalizeBrowserNavigationUrl returns { ok: true } instead of rejecting it. The same branch also misses mixed-case explicit schemes like ABOUT:blank. Please preserve genuine explicit schemes here and only auto-prefix bare host / host:port inputs.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/shared/src/browser.ts` around lines 46 - 53, The URL normalization
logic in normalizeBrowserNavigationUrl is over-prompting bare defaults and
accidentally rewriting explicit non-browser schemes like mailto: and mixed-case
schemes like ABOUT:blank. Update the explicit scheme detection and the candidate
selection in browser.ts so any genuine scheme is preserved as-is, and only bare
host or host:port inputs get an auto-prefixed default scheme; ensure the
explicitScheme check handles case-insensitive matches before deciding whether to
synthesize https://.
apps/server/src/provider/tools/BrowserRuntimeTool.ts-56-66 (1)

56-66: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Align the "browser_open" argument name with what the executor actually reads.

The tool definition advertises initialUrl, but BrowserRuntimeToolCallInput and the executor use url. Calls that follow the advertised schema will open the session without navigating.

Suggested fix
   {
     name: "browser_open",
     description: "Open or focus the isolated Ryco browser session for the current thread.",
     input: {
       type: "object",
       properties: {
-        initialUrl: { type: "string" },
+        url: { type: "string" },
       },
       additionalProperties: false,
     },
   },

Also applies to: 159-170

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/server/src/provider/tools/BrowserRuntimeTool.ts` around lines 56 - 66,
The browser_open tool schema is out of sync with the executor input shape:
BROWSER_RUNTIME_TOOL_DEFINITIONS advertises initialUrl while
BrowserRuntimeToolCallInput and the browser_open execution path read url. Update
the tool definition and any related input typing/default handling so the same
argument name is used end-to-end, and make sure the browser_open executor
continues to navigate using that shared field.
packages/contracts/src/browser.ts-475-484 (1)

475-484: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Narrow data to a JSON-safe shape before it crosses RPC.

BrowserCommandResult is sent over the browser-host JSON websocket, but Schema.Unknown accepts values the transport cannot encode reliably. A host can pass schema validation here and still fail at serialization time.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/contracts/src/browser.ts` around lines 475 - 484, The
BrowserCommandResultPayload schema currently allows data to be Schema.Unknown,
which can pass validation but break when BrowserCommandResult is serialized over
the browser-host JSON websocket. Update the BrowserCommandResultPayload
definition to use a JSON-safe schema for data instead of Schema.Unknown, and
keep the change localized to the
BrowserCommandResultPayload/BrowserCommandResult path so the RPC boundary only
accepts encodable values.
packages/contracts/src/browser.ts-209-215 (1)

209-215: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Reuse the bounded URL schema for all initial-navigation paths.

browser.navigate caps url at 8,192 chars, but BrowserOpenSessionInput.initialUrl and the "open_session" host command still accept an unbounded string. That lets callers bypass the only URL-size guard just by putting the first navigation into openSession.

Suggested contract fix
+const BrowserUrl = Schema.String.check(Schema.isMaxLength(8_192));
+
 export const BrowserOpenSessionInput = Schema.Struct({
   threadId: ThreadId,
   projectId: Schema.optional(ProjectId),
   profileMode: Schema.optional(BrowserProfileMode),
   profileName: Schema.optional(TrimmedNonEmptyStringSchema),
-  initialUrl: Schema.optional(Schema.String),
+  initialUrl: Schema.optional(BrowserUrl),
 });
@@
 export const BrowserNavigateInput = Schema.Struct({
   sessionId: BrowserSessionId,
   tabId: Schema.optional(BrowserTabId),
-  url: Schema.String.check(Schema.isMaxLength(8_192)),
+  url: BrowserUrl,
 });
@@
   Schema.Struct({
     kind: Schema.Literal("open_session"),
     session: BrowserSessionSnapshot,
     profile: BrowserProfile,
-    initialUrl: Schema.optional(Schema.String),
+    initialUrl: Schema.optional(BrowserUrl),
   }),

Also applies to: 229-233, 393-398

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/contracts/src/browser.ts` around lines 209 - 215, Reuse the bounded
URL schema for every initial-navigation entry point so callers cannot bypass the
URL length guard through session startup. Update
BrowserOpenSessionInput.initialUrl and the open_session host command schema to
use the same capped URL schema that browser.navigate uses, and make sure any
session-opening flow passes through that shared validator. Locate the affected
definitions around BrowserOpenSessionInput and the open_session command
contract, plus any related initial-navigation schema uses.
packages/contracts/src/browserHostRpc.ts-57-70 (1)

57-70: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Register BrowserHostEventsRpc in the routed RPC group.

BrowserHostEventsRpc is declared, but BrowserHostRpcGroup omits it. Since /browser-host/ws serves only BrowserHostRpcGroup, "browserHost.events" is currently unreachable.

Suggested fix
 export const BrowserHostRpcGroup = RpcGroup.make(
   BrowserHostRegisterRpc,
   BrowserHostHeartbeatRpc,
   BrowserHostSubscribeCommandsRpc,
   BrowserHostCommandResultRpc,
   BrowserHostEventRpc,
+  BrowserHostEventsRpc,
 );
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/contracts/src/browserHostRpc.ts` around lines 57 - 70,
`BrowserHostEventsRpc` is defined but not included in `BrowserHostRpcGroup`, so
the `browserHost.events` route is never exposed. Update the
`BrowserHostRpcGroup.make(...)` registration in `browserHostRpc.ts` to add
`BrowserHostEventsRpc` alongside the existing browser host RPCs, so the
`/browser-host/ws` server can route this event stream correctly.
apps/server/src/browser/BrowserHostRegistry.ts-130-136 (1)

130-136: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Replace, don’t merge, session state during host re-registration/snapshot heartbeats.

register() preserves previous.sessions, and heartbeat() only upserts input.sessions. If the desktop host restarts or reconnects with a smaller snapshot, sessions that disappeared on the host remain in registry state indefinitely. That leaves snapshot() serving stale sessions/tabs and can route later commands against sessions the host no longer owns.

Also applies to: 151-160

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/server/src/browser/BrowserHostRegistry.ts` around lines 130 - 136, The
host re-registration and heartbeat flow in BrowserHostRegistry is merging
session state instead of replacing it, which leaves stale sessions behind when
the host reconnects with a smaller snapshot. Update register() and heartbeat()
so the registry replaces the stored sessions with the host’s current snapshot
rather than preserving previous.sessions or only upserting input.sessions; keep
failPending(previous.pending, staleHostError()) and state reset behavior, but
ensure snapshot() reflects only the sessions currently reported by the active
host.
apps/server/src/browser/BrowserHostRegistry.ts-97-98 (1)

97-98: 🩺 Stability & Availability | 🟠 Major

Stale commandStream instances steal and drop commands from the shared queue.

The commandQueue (line 97) is a single shared bounded queue. Each call to commandStream() (e.g., lines 291-295) creates a new Stream.fromQueue(commandQueue) consumer. Because the Stream.filter validating runId executes after the item is dequeued, a stale subscriber from a previous host session can pull a command intended for the current run and discard it, causing the active subscriber to miss the command and the caller to time out.

Fix by ensuring commands are routed to specific listeners before queue consumption, such as using per-run queues or a PubSub mechanism that broadcasts to matching filters.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/server/src/browser/BrowserHostRegistry.ts` around lines 97 - 98, The
shared commandQueue is being consumed by multiple commandStream() subscribers,
so a stale BrowserHostRegistry stream can dequeue and drop commands meant for
the active run. Update BrowserHostRegistry so commands are routed to the correct
listener before consumption, using per-run queues or a PubSub-based fanout
instead of Stream.fromQueue(commandQueue) with a post-dequeue runId filter. Make
sure the commandStream() path only receives commands for its own runId and
cannot steal items from another session.
apps/server/src/ws.ts-92-108 (1)

92-108: 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Closed browser-host sockets never clear the registered host.

This route authenticates and hands off the RPC socket, but unlike the main /ws route it has no release/finalizer path. BrowserHostRegistry.disconnect(...) is the only supplied mechanism that flips the host to disconnected and fails pending commands, so an unexpected WebSocket drop after register leaves the last host looking healthy until commands time out.

You’ll need a connection-scoped finalizer that remembers the registered hostId/runId and calls disconnect on close.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/server/src/ws.ts` around lines 92 - 108, The browser-host WebSocket
handler in ws.ts registers a host but never clears it when the socket closes, so
add a connection-scoped finalizer around the Effect.gen flow that remembers the
registered hostId and runId and invokes BrowserHostRegistry.disconnect on
teardown. Update the WebSocket upgrade path used with
authenticateWebSocketUpgrade and RpcServer.toHttpEffectWebsocket so the cleanup
runs on unexpected disconnects as well as normal completion, mirroring the main
/ws route behavior.
apps/server/src/browser/BrowserPolicy.ts-37-39 (1)

37-39: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Don't auto-allow file: navigation.

Folding value.kind === "file" into the allow path lets provider-driven browser flows open file:///... without any approval step, which turns this policy into silent local-file access from the desktop host. Treat file as ask (or deny it) instead of bundling it with about/loopback.

Suggested change
-        const decision =
-          value.kind === "loopback" || value.kind === "about" || value.kind === "file"
-            ? "allow"
-            : "ask";
+        const decision =
+          value.kind === "loopback" || value.kind === "about"
+            ? "allow"
+            : "ask";
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/server/src/browser/BrowserPolicy.ts` around lines 37 - 39, The
navigation policy in BrowserPolicy should not auto-allow file URLs; the current
check in the logic that maps value.kind to allow/ask treats value.kind ===
"file" the same as about and loopback. Update that branch so BrowserPolicy
returns ask (or deny) for file and only keeps loopback/about on the allow path,
using the existing value.kind handling in BrowserPolicy to locate the change.
apps/server/src/browser/BrowserArtifactStore.ts-46-72 (1)

46-72: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

retentionMs only changes metadata right now.

put stamps expiresAt, but nothing ever evicts expired entries and get still returns them after expiry. That means browser artifacts can accumulate indefinitely in memory and outlive the retention policy you expose in the returned contract.

Suggested direction
+    const pruneExpired = (
+      current: ReadonlyMap<string, BrowserArtifactRecord>,
+      now = Date.now(),
+    ) => {
+      const next = new Map(current);
+      for (const [artifactId, record] of next) {
+        if (Date.parse(record.expiresAt) <= now) {
+          next.delete(artifactId);
+        }
+      }
+      return next;
+    };

       put: (input) =>
         Effect.gen(function* () {
           const now = new Date();
           const expiresAt = new Date(now.getTime() + (input.retentionMs ?? 24 * 60 * 60 * 1000));
@@
           yield* Ref.update(records, (current) => {
-            const next = new Map(current);
+            const next = pruneExpired(current, now.getTime());
             next.set(ref.artifactId, record);
             return next;
           });
           return ref;
         }),
       get: (artifactId) =>
-        Ref.get(records).pipe(Effect.map((current) => current.get(artifactId) ?? null)),
+        Ref.modify(records, (current) => {
+          const next = pruneExpired(current);
+          return [next.get(artifactId) ?? null, next] as const;
+        }),
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/server/src/browser/BrowserArtifactStore.ts` around lines 46 - 72, The
BrowserArtifactStore put/get flow currently records expiresAt but never enforces
it, so expired artifacts remain in the in-memory records map and are still
returned. Update the BrowserArtifactStore implementation around put and get to
actively respect retentionMs by filtering out expired entries, either by
evicting stale records before or during reads and writes or by having get return
null once expiresAt has passed. Use the existing artifacts map update path and
the BrowserArtifactRef/BrowseArtifactRecord symbols to keep the retention
behavior consistent with the stored metadata.
apps/server/src/browser/BrowserService.ts-418-423 (1)

418-423: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Make the profile-lock check atomic.

The existing lookup and the later Ref.update() are separate operations. Two concurrent openSession() calls for the same derived profileId can both observe "no existing session" and create distinct sessions before either update lands, which defeats the profile_locked guard.

Also applies to: 452-458

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/server/src/browser/BrowserService.ts` around lines 418 - 423, Make the
profile-lock check atomic in BrowserService.openSession: the current lookup for
an existing non-closed session and the later Ref.update() happen separately, so
concurrent calls can both pass the check and create duplicate sessions. Refactor
the session creation path so the “find existing session by profileId” check and
the state mutation occur as one atomic Ref.modify/Ref.update operation, and
apply the same fix to the related block in the later openSession path as well.
apps/server/src/browser/BrowserService.ts-452-472 (1)

452-472: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Don't persist the session before open_session succeeds.

This writes the new profile/session into state before executeSessionCommand() runs. If the host is unavailable, times out, disconnects, or returns an error, the call fails but the in-memory session remains visible to getStatus() and can block future openSession() calls for the same profile.

Suggested fix
-          yield* Ref.update(state, (latest) => {
-            const profiles = new Map(latest.profiles);
-            profiles.set(profile.profileId, profile);
-            const sessions = new Map(latest.sessions);
-            sessions.set(session.sessionId, session);
-            return { profiles, sessions };
-          });
-
-          return yield* executeSessionCommand({
+          const opened = yield* executeSessionCommand({
             command: {
               kind: "open_session",
               session,
               profile,
               ...(initialNavigation ? { initialUrl: initialNavigation.url } : {}),
             },
             fallbackSession: {
               ...session,
               status: "ready",
               updatedAt: nowIso(),
             },
           });
+          yield* Ref.update(state, (latest) => {
+            const profiles = new Map(latest.profiles);
+            profiles.set(profile.profileId, profile);
+            return { ...latest, profiles };
+          });
+          return opened;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/server/src/browser/BrowserService.ts` around lines 452 - 472, The
BrowserService open-session flow is persisting the new profile/session into
state before `executeSessionCommand()` succeeds, which leaves failed sessions
visible and can block later `openSession()` calls. Update the `openSession`
logic in `BrowserService` so the `Ref.update` that adds `profile` and `session`
happens only after `executeSessionCommand` returns successfully, or roll back
any optimistic state on failure. Use the `openSession`, `executeSessionCommand`,
and `Ref.update` blocks to locate the change and keep the in-memory state
consistent with the host result.
apps/server/src/browser/BrowserHostRegistry.test.ts-92-93 (1)

92-93: 🎯 Functional Correctness | 🟠 Major

Use Effect's public failure helpers here.

The current findFailure helper accesses exit.cause.reasons directly. While this exposes internal structure, Exit.cause itself is a public API property defined in effect/Cause. The private/public distinction here lies in the internal array structure of reasons, but the pattern is widely used in the ecosystem.

However, Cause.failureOption(exit.cause) correctly uses Cause.failureOption which is a stable public utility that safely extracts the error type from a Cause. The suggested refactor using Exit.isFailure and Option.getOrUndefined is the more idiomatic, type-safe approach that decouples the test from the internal representation of multiple reasons or parallel failures, assuming the test intent is to find the failure value.

Since the current test asserts expect(failure?.error), and findFailure currently returns reason (where reason matches { _tag: "Fail", error: ... }), the current code actually works but relies on the specific shape. The refactored code changes the return value to just error. To ensure type safety and stability, use Cause.failureOption.

-  exit._tag === "Failure" ? exit.cause.reasons.find((reason) => reason._tag === "Fail") : undefined;
+  Exit.isFailure(exit) ? Option.getOrUndefined(Cause.failureOption(exit.cause)) : undefined;

This should be used at lines 92-93.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/server/src/browser/BrowserHostRegistry.test.ts` around lines 92 - 93,
The findFailure helper is reaching into Exit.cause.reasons directly, which ties
the test to Cause’s internal structure; refactor it to use the public failure
helpers instead. Update findFailure in BrowserHostRegistry.test.ts to rely on
Exit.isFailure and Cause.failureOption (with Option.getOrUndefined if needed) so
it returns the failure value in a type-safe way without inspecting reasons.
apps/server/src/auth/Layers/BrowserHostAuth.ts-20-26 (1)

20-26: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Validate the peer address, not just the Host header.

Line 45 only checks the client-controlled Host header, and requestHost ?? undefined is accepted as loopback because isLoopbackHost(undefined) returns true. Require a parsed Host and the request source IP to be loopback before accepting the browser-host token.

Suggested fix
 import { ServerConfig } from "../../config.ts";
 import { isLoopbackHost } from "../../startupAccess.ts";
+import { deriveAuthClientMetadata } from "../utils.ts";
 import { BrowserHostAuth, type BrowserHostAuthShape } from "../Services/BrowserHostAuth.ts";
 import { AuthError } from "../Services/ServerAuth.ts";
@@
   try {
     return new URL(`http://${value}`).hostname.toLowerCase();
   } catch {
-    return value.split(":")[0]?.toLowerCase() ?? null;
+    const fallbackHost = value.split(":")[0]?.toLowerCase().trim();
+    return fallbackHost && fallbackHost.length > 0 ? fallbackHost : null;
   }
 }
@@
-        const requestHost = hostnameFromHostHeader(request.headers.host);
-        if (!isLoopbackHost(requestHost ?? undefined)) {
+        const requestHost = hostnameFromHostHeader(request.headers.host);
+        const clientIpAddress = deriveAuthClientMetadata({ request }).ipAddress;
+        if (
+          requestHost === null ||
+          !isLoopbackHost(requestHost) ||
+          clientIpAddress === undefined ||
+          !isLoopbackHost(clientIpAddress)
+        ) {
           return yield* new AuthError({
             message: "BrowserHost route requires loopback desktop access.",
             status: 403,

Also applies to: 45-46

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/server/src/auth/Layers/BrowserHostAuth.ts` around lines 20 - 26, The
browser-host auth check in BrowserHostAuth should not trust only the
client-controlled Host header; update the validation around
hostnameFromHostHeader and the request-host check so a parsed Host is required
and the peer/source IP must also be loopback before accepting the browser-host
token. Make the logic in the auth flow that currently treats requestHost ??
undefined as loopback use the actual request origin address (or equivalent peer
address) alongside the parsed host, and only allow success when both are
loopback-safe.
🟡 Minor comments (3)
apps/desktop/src/browser/BrowserStorageHelpers.ts-86-90 (1)

86-90: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Clamp non-finite storage sizes before returning contract metadata.

typeof candidate.valueBytes === "number" still accepts NaN and Infinity, so renderer-controlled storage inspection data can produce invalid BrowserStorageEntryMetadata.

Proposed fix
-      const size = typeof candidate.valueBytes === "number" ? candidate.valueBytes : 0;
+      const size =
+        typeof candidate.valueBytes === "number" && Number.isFinite(candidate.valueBytes)
+          ? candidate.valueBytes
+          : 0;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/desktop/src/browser/BrowserStorageHelpers.ts` around lines 86 - 90,
Clamp non-finite storage sizes before returning BrowserStorageEntryMetadata in
BrowserStorageHelpers by tightening the valueBytes normalization in the metadata
builder, since typeof candidate.valueBytes alone still allows NaN and Infinity.
Update the logic around the candidate.valueBytes handling in the helper that
returns BrowserStorageEntryMetadata so it only passes through finite numeric
values, otherwise falls back to 0, while keeping the key slicing and existing
floor/max behavior intact.
packages/shared/src/browser.ts-114-139 (1)

114-139: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Bracketed IPv6 locals are misclassified as public hosts.

[fd00::1] and [fe80::1] currently miss all three hostname classifiers, so bare local IPv6 addresses normalize to https://… and report the wrong kind. Users can work around it by typing http:// explicitly, but the helper should strip [/] once and handle local IPv6 ranges directly.

Also applies to: 159-169

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/shared/src/browser.ts` around lines 114 - 139, The hostname
classifiers in browser.ts are treating bracketed IPv6 literals like [fd00::1]
and [fe80::1] as public because isPrivateNetworkHostname and isLinkLocalHostname
only handle IPv4-style parsing and raw fe80: prefixes. Update the shared
hostname normalization used by isPrivateNetworkHostname, isLinkLocalHostname,
and any related classifier logic so it strips surrounding [ ] once, then
recognizes local IPv6 ranges (at least fd00::/8 for private and fe80::/10 for
link-local) before falling back to the existing IPv4 checks and public-host
behavior.
docs/superpowers/specs/2026-06-22-in-app-browser-design.md-145-146 (1)

145-146: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Include BrowserHostRunId in the core ID list.

The design later relies on BrowserHostRunId for reconnect fencing and stale-command rejection at Lines 237, 376, and 657. Leaving it out of the schema checklist makes the contracts section internally inconsistent.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/superpowers/specs/2026-06-22-in-app-browser-design.md` around lines 145
- 146, The core ID list is missing BrowserHostRunId, which makes the schema
checklist inconsistent with the later reconnect and stale-command flow. Update
the IDs section in the design doc to include BrowserHostRunId alongside the
existing BrowserHostId, BrowserProfileId, BrowserSessionId, BrowserTabId,
BrowserCommandId, and BrowserPermissionRequestId entries so the contracts
section matches the BrowserHostRunId usage elsewhere.
🧹 Nitpick comments (2)
apps/desktop/src/preload.ts (1)

44-47: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Deduplicate the browser IPC channel strings.

These same literals are also declared in apps/desktop/src/main.ts:139-142. A future edit can desynchronize preload and main, and surface attach/focus calls will start failing at runtime with no type-level protection. Move them into a shared desktop IPC module and import from both sides.

Suggested refactor
-const BROWSER_ATTACH_SURFACE_CHANNEL = "desktop:browser:attach-surface";
-const BROWSER_UPDATE_SURFACE_BOUNDS_CHANNEL = "desktop:browser:update-surface-bounds";
-const BROWSER_DETACH_SURFACE_CHANNEL = "desktop:browser:detach-surface";
-const BROWSER_FOCUS_SURFACE_CHANNEL = "desktop:browser:focus-surface";
+import {
+  BROWSER_ATTACH_SURFACE_CHANNEL,
+  BROWSER_UPDATE_SURFACE_BOUNDS_CHANNEL,
+  BROWSER_DETACH_SURFACE_CHANNEL,
+  BROWSER_FOCUS_SURFACE_CHANNEL,
+} from "./browser/browserIpcChannels.ts";
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/desktop/src/preload.ts` around lines 44 - 47, The browser IPC channel
literals are duplicated between the preload and main sides, so move the shared
channel constants out of the preload-specific code into a common desktop IPC
module and import them from both places. Update the references in the
preload-side symbols that use BROWSER_ATTACH_SURFACE_CHANNEL,
BROWSER_UPDATE_SURFACE_BOUNDS_CHANNEL, BROWSER_DETACH_SURFACE_CHANNEL, and
BROWSER_FOCUS_SURFACE_CHANNEL, and make the main-side handlers consume the same
shared constants to keep the channel names synchronized.
apps/server/src/test/serverConfigFixtures.ts (1)

25-27: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Keep the shared test fixture aligned with runtime defaults.

Lines 25-27 make makeTestServerConfig enable browser-host auth by default, while apps/server/src/config.ts Line 170 and apps/server/src/cli.ts Lines 347 and 427 keep desktopBrowserHostToken unset unless bootstrap data provides it. That makes this generic helper silently exercise a privileged path that production does not enable by default.

♻️ Proposed change
-  desktopBrowserHostToken: "secret-token",
+  desktopBrowserHostToken: undefined,
// BrowserHostAuth.test.ts
makeTestServerConfig({ desktopBrowserHostToken: "secret-token" });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/server/src/test/serverConfigFixtures.ts` around lines 25 - 27, The
shared test fixture is enabling browser-host auth by default instead of matching
the runtime default. Update makeTestServerConfig in serverConfigFixtures to
leave desktopBrowserHostToken unset unless a test explicitly overrides it, and
move any browser-host auth setup into the specific tests that need it (for
example by passing a token from BrowserHostAuth.test). This keeps the fixture
aligned with config.ts and cli.ts defaults.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Major comments:
In `@apps/desktop/src/browser/BrowserHostConnection.ts`:
- Around line 68-101: The BrowserHostConnection.start flow leaves partial state
behind if makeBrowserHostRpcClient or the BROWSER_HOST_METHODS.register call
fails, because runtime and clientScope are assigned before the full startup
completes. Update start to wrap the client creation/register sequence in a
try/catch, and on any failure call await this.stop() to dispose the runtime,
scope, and socket before rethrowing. Keep the fix centered in
BrowserHostConnection.start and use the existing stop(), runtime, clientScope,
and startCommandStream setup as the cleanup boundary.
- Around line 151-175: The per-envelope handler in BrowserHostConnection’s
subscribeCommands stream is letting errors from handleCommand(envelope) or
client[BROWSER_HOST_METHODS.commandResult](...) escape Stream.runForEach, which
stops the subscription after one failure. Wrap the logic inside the
Effect.promise handler with its own try/catch (or equivalent failure handling)
so each envelope logs or reports the failure and then returns without rejecting
the outer stream, keeping the command subscription alive for later messages.

In `@apps/desktop/src/browser/BrowserKernel.ts`:
- Around line 31-34: The hosted-session lifecycle is losing the BrowserProfile
reference, so temporary profiles never get cleaned up and remain cached in
sessionsByProfile. Update HostedSession and the BrowserKernel session management
flow to retain the BrowserProfile alongside the
BrowserSessionSnapshot/WebContentsView, then call
BrowserProfiles.cleanupTemporary() when a non-persistent hosted session is
closed or removed. Make sure the cleanup path uses the stored profile identity
so temporary storage is released consistently.
- Around line 226-227: The initial navigation in BrowserKernel’s session startup
is swallowing loadURL failures, which allows open_session to be reported as
successful even when the page never loaded. Update the logic around
BrowserKernel’s loadURL/updateSnapshotFromWebContents flow to stop hiding the
error, and ensure the caller only returns success after the initial navigation
completes successfully; if loadURL fails, propagate or convert that failure so
open_session is not marked ok.
- Around line 563-585: The WebContents listeners in installWebContentsListeners
are ignoring failures from the async event sink because void this.emit(...)
discards rejected promises. Update the update callback and the
render-process-gone handler to catch and handle emit failures explicitly, using
this.emit and a .catch(...) path (or equivalent) so transient publishing errors
don’t become unhandled rejections in BrowserKernel.

In `@apps/server/src/auth/Layers/BrowserHostAuth.ts`:
- Around line 20-26: The browser-host auth check in BrowserHostAuth should not
trust only the client-controlled Host header; update the validation around
hostnameFromHostHeader and the request-host check so a parsed Host is required
and the peer/source IP must also be loopback before accepting the browser-host
token. Make the logic in the auth flow that currently treats requestHost ??
undefined as loopback use the actual request origin address (or equivalent peer
address) alongside the parsed host, and only allow success when both are
loopback-safe.

In `@apps/server/src/browser/BrowserArtifactStore.ts`:
- Around line 46-72: The BrowserArtifactStore put/get flow currently records
expiresAt but never enforces it, so expired artifacts remain in the in-memory
records map and are still returned. Update the BrowserArtifactStore
implementation around put and get to actively respect retentionMs by filtering
out expired entries, either by evicting stale records before or during reads and
writes or by having get return null once expiresAt has passed. Use the existing
artifacts map update path and the BrowserArtifactRef/BrowseArtifactRecord
symbols to keep the retention behavior consistent with the stored metadata.

In `@apps/server/src/browser/BrowserHostRegistry.test.ts`:
- Around line 92-93: The findFailure helper is reaching into Exit.cause.reasons
directly, which ties the test to Cause’s internal structure; refactor it to use
the public failure helpers instead. Update findFailure in
BrowserHostRegistry.test.ts to rely on Exit.isFailure and Cause.failureOption
(with Option.getOrUndefined if needed) so it returns the failure value in a
type-safe way without inspecting reasons.

In `@apps/server/src/browser/BrowserHostRegistry.ts`:
- Around line 130-136: The host re-registration and heartbeat flow in
BrowserHostRegistry is merging session state instead of replacing it, which
leaves stale sessions behind when the host reconnects with a smaller snapshot.
Update register() and heartbeat() so the registry replaces the stored sessions
with the host’s current snapshot rather than preserving previous.sessions or
only upserting input.sessions; keep failPending(previous.pending,
staleHostError()) and state reset behavior, but ensure snapshot() reflects only
the sessions currently reported by the active host.
- Around line 97-98: The shared commandQueue is being consumed by multiple
commandStream() subscribers, so a stale BrowserHostRegistry stream can dequeue
and drop commands meant for the active run. Update BrowserHostRegistry so
commands are routed to the correct listener before consumption, using per-run
queues or a PubSub-based fanout instead of Stream.fromQueue(commandQueue) with a
post-dequeue runId filter. Make sure the commandStream() path only receives
commands for its own runId and cannot steal items from another session.

In `@apps/server/src/browser/BrowserPolicy.ts`:
- Around line 37-39: The navigation policy in BrowserPolicy should not
auto-allow file URLs; the current check in the logic that maps value.kind to
allow/ask treats value.kind === "file" the same as about and loopback. Update
that branch so BrowserPolicy returns ask (or deny) for file and only keeps
loopback/about on the allow path, using the existing value.kind handling in
BrowserPolicy to locate the change.

In `@apps/server/src/browser/BrowserService.ts`:
- Around line 418-423: Make the profile-lock check atomic in
BrowserService.openSession: the current lookup for an existing non-closed
session and the later Ref.update() happen separately, so concurrent calls can
both pass the check and create duplicate sessions. Refactor the session creation
path so the “find existing session by profileId” check and the state mutation
occur as one atomic Ref.modify/Ref.update operation, and apply the same fix to
the related block in the later openSession path as well.
- Around line 452-472: The BrowserService open-session flow is persisting the
new profile/session into state before `executeSessionCommand()` succeeds, which
leaves failed sessions visible and can block later `openSession()` calls. Update
the `openSession` logic in `BrowserService` so the `Ref.update` that adds
`profile` and `session` happens only after `executeSessionCommand` returns
successfully, or roll back any optimistic state on failure. Use the
`openSession`, `executeSessionCommand`, and `Ref.update` blocks to locate the
change and keep the in-memory state consistent with the host result.

In `@apps/server/src/provider/tools/BrowserRuntimeTool.ts`:
- Around line 56-66: The browser_open tool schema is out of sync with the
executor input shape: BROWSER_RUNTIME_TOOL_DEFINITIONS advertises initialUrl
while BrowserRuntimeToolCallInput and the browser_open execution path read url.
Update the tool definition and any related input typing/default handling so the
same argument name is used end-to-end, and make sure the browser_open executor
continues to navigate using that shared field.

In `@apps/server/src/ws.ts`:
- Around line 92-108: The browser-host WebSocket handler in ws.ts registers a
host but never clears it when the socket closes, so add a connection-scoped
finalizer around the Effect.gen flow that remembers the registered hostId and
runId and invokes BrowserHostRegistry.disconnect on teardown. Update the
WebSocket upgrade path used with authenticateWebSocketUpgrade and
RpcServer.toHttpEffectWebsocket so the cleanup runs on unexpected disconnects as
well as normal completion, mirroring the main /ws route behavior.

In `@apps/web/src/components/BrowserPanel.tsx`:
- Around line 421-423: Transient command failures are incorrectly being stored
in BrowserPanelStatus and that can tear down the native surface; keep command
errors separate from the lifecycle state. Update BrowserPanel in the
status/message/session state flow so commands like back, reload, and
open-external no longer set status to "error" unless the panel is truly
unavailable, and instead store those failures in a separate error field. Make
sure the lifecycle-driven status transitions in the BrowserPanel controls
preserve "ready" once the session is healthy, using the existing
BrowserPanelStatus, setStatus, setMessage, and BrowserSessionSnapshot handling.
- Around line 455-521: The BrowserPanel effect only unsubscribes from events and
never closes the active BrowserHost session, so sessions can leak when
environmentId/projectId/threadId changes or the component unmounts. Update the
cleanup in the BrowserPanel useEffect/openSession flow to call the browser API’s
session-close/teardown method for the current session (alongside unsubscribe),
and make sure it runs safely even when the async openSession is still in flight.
Use the existing openSession, currentBrowserApi, and cleanup return function as
the place to wire this in.

In `@packages/contracts/src/browser.ts`:
- Around line 475-484: The BrowserCommandResultPayload schema currently allows
data to be Schema.Unknown, which can pass validation but break when
BrowserCommandResult is serialized over the browser-host JSON websocket. Update
the BrowserCommandResultPayload definition to use a JSON-safe schema for data
instead of Schema.Unknown, and keep the change localized to the
BrowserCommandResultPayload/BrowserCommandResult path so the RPC boundary only
accepts encodable values.
- Around line 209-215: Reuse the bounded URL schema for every initial-navigation
entry point so callers cannot bypass the URL length guard through session
startup. Update BrowserOpenSessionInput.initialUrl and the open_session host
command schema to use the same capped URL schema that browser.navigate uses, and
make sure any session-opening flow passes through that shared validator. Locate
the affected definitions around BrowserOpenSessionInput and the open_session
command contract, plus any related initial-navigation schema uses.

In `@packages/contracts/src/browserHostRpc.ts`:
- Around line 57-70: `BrowserHostEventsRpc` is defined but not included in
`BrowserHostRpcGroup`, so the `browserHost.events` route is never exposed.
Update the `BrowserHostRpcGroup.make(...)` registration in `browserHostRpc.ts`
to add `BrowserHostEventsRpc` alongside the existing browser host RPCs, so the
`/browser-host/ws` server can route this event stream correctly.

In `@packages/shared/src/browser.ts`:
- Around line 46-53: The URL normalization logic in
normalizeBrowserNavigationUrl is over-prompting bare defaults and accidentally
rewriting explicit non-browser schemes like mailto: and mixed-case schemes like
ABOUT:blank. Update the explicit scheme detection and the candidate selection in
browser.ts so any genuine scheme is preserved as-is, and only bare host or
host:port inputs get an auto-prefixed default scheme; ensure the explicitScheme
check handles case-insensitive matches before deciding whether to synthesize
https://.

---

Minor comments:
In `@apps/desktop/src/browser/BrowserStorageHelpers.ts`:
- Around line 86-90: Clamp non-finite storage sizes before returning
BrowserStorageEntryMetadata in BrowserStorageHelpers by tightening the
valueBytes normalization in the metadata builder, since typeof
candidate.valueBytes alone still allows NaN and Infinity. Update the logic
around the candidate.valueBytes handling in the helper that returns
BrowserStorageEntryMetadata so it only passes through finite numeric values,
otherwise falls back to 0, while keeping the key slicing and existing floor/max
behavior intact.

In `@docs/superpowers/specs/2026-06-22-in-app-browser-design.md`:
- Around line 145-146: The core ID list is missing BrowserHostRunId, which makes
the schema checklist inconsistent with the later reconnect and stale-command
flow. Update the IDs section in the design doc to include BrowserHostRunId
alongside the existing BrowserHostId, BrowserProfileId, BrowserSessionId,
BrowserTabId, BrowserCommandId, and BrowserPermissionRequestId entries so the
contracts section matches the BrowserHostRunId usage elsewhere.

In `@packages/shared/src/browser.ts`:
- Around line 114-139: The hostname classifiers in browser.ts are treating
bracketed IPv6 literals like [fd00::1] and [fe80::1] as public because
isPrivateNetworkHostname and isLinkLocalHostname only handle IPv4-style parsing
and raw fe80: prefixes. Update the shared hostname normalization used by
isPrivateNetworkHostname, isLinkLocalHostname, and any related classifier logic
so it strips surrounding [ ] once, then recognizes local IPv6 ranges (at least
fd00::/8 for private and fe80::/10 for link-local) before falling back to the
existing IPv4 checks and public-host behavior.

---

Nitpick comments:
In `@apps/desktop/src/preload.ts`:
- Around line 44-47: The browser IPC channel literals are duplicated between the
preload and main sides, so move the shared channel constants out of the
preload-specific code into a common desktop IPC module and import them from both
places. Update the references in the preload-side symbols that use
BROWSER_ATTACH_SURFACE_CHANNEL, BROWSER_UPDATE_SURFACE_BOUNDS_CHANNEL,
BROWSER_DETACH_SURFACE_CHANNEL, and BROWSER_FOCUS_SURFACE_CHANNEL, and make the
main-side handlers consume the same shared constants to keep the channel names
synchronized.

In `@apps/server/src/test/serverConfigFixtures.ts`:
- Around line 25-27: The shared test fixture is enabling browser-host auth by
default instead of matching the runtime default. Update makeTestServerConfig in
serverConfigFixtures to leave desktopBrowserHostToken unset unless a test
explicitly overrides it, and move any browser-host auth setup into the specific
tests that need it (for example by passing a token from BrowserHostAuth.test).
This keeps the fixture aligned with config.ts and cli.ts defaults.

- Desktop host: screenshots, console/network capture, DOM snapshot with ref-based clicking
- Server: BrowserService, artifact storage, provider MCP bridge integration
- Tools: browser_open, navigate, input, snapshot, screenshot, console, network, waitFor
- Providers: Claude (MCP), Copilot, Cursor, OpenCode; Codex schema pending

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 8

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
apps/server/src/browser/BrowserArtifactStore.ts (1)

59-124: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Add artifact expiry cleanup

expiresAt is stored but never used here, so expired artifacts stay in records and on disk for the server lifetime. Add a periodic reaper or remove expired entries/files on access.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/server/src/browser/BrowserArtifactStore.ts` around lines 59 - 124, The
BrowserArtifactStoreLive implementation computes and stores expiresAt in
BrowserArtifactRecord, but nothing ever removes expired artifacts from records
or disk. Add expiry handling in the BrowserArtifactStoreShape methods by either
introducing a periodic cleanup/reaper in BrowserArtifactStoreLive or checking
expiry on get/readData/put and deleting stale entries/files before returning.
Make sure the cleanup updates the records Ref and removes the corresponding
filePath for each expired BrowserArtifactRecord.
apps/server/src/browser/BrowserService.ts (1)

537-599: 🗄️ Data Integrity & Integration | 🔴 Critical | 🏗️ Heavy lift

Claim the profile atomically.

openSession checks for an existing session and later writes the new one as separate steps, so two concurrent calls for the same profile can both pass the lock check and create active sessions. Move the reservation/claim into a single atomic update path before proceeding.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/server/src/browser/BrowserService.ts` around lines 537 - 599,
openSession currently performs the “is this profile already attached?” check and
the session insert as separate steps, which allows two concurrent callers to
race and both create active sessions. Update BrowserService.openSession so the
profile claim happens atomically inside the state update path: check the current
sessions and reserve the profile/session in one Ref.update/modify-style
operation before continuing, and only proceed with
makeSession/executeSessionCommand after the reservation succeeds. Use the
existing openSession, profileFor, state, and sessionOwners/session maps to
locate the logic.
🧹 Nitpick comments (10)
apps/server/src/provider/tools/BrowserRuntimeTool.ts (2)

243-248: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Use Schema.is(BrowserLoadState) instead of hardcoded literals.

This duplicates the literal set already defined by the BrowserLoadState contract schema. If the contract's allowed values change, this helper silently falls out of sync (returning undefined for values that should be valid). browser_input's action validation already uses Schema.is(BrowserInputAction) — apply the same pattern here.

♻️ Proposed fix
-function readBrowserToolLoadState(value: unknown): BrowserLoadState | undefined {
-  if (value === "idle" || value === "loading" || value === "loaded" || value === "failed") {
-    return value;
-  }
-  return undefined;
-}
+function readBrowserToolLoadState(value: unknown): BrowserLoadState | undefined {
+  return Schema.is(BrowserLoadState)(value) ? value : undefined;
+}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/server/src/provider/tools/BrowserRuntimeTool.ts` around lines 243 - 248,
The readBrowserToolLoadState helper is duplicating the BrowserLoadState allowed
values with hardcoded literals, which can drift from the contract schema. Update
readBrowserToolLoadState in BrowserRuntimeTool to validate using
Schema.is(BrowserLoadState), matching the pattern already used for browser_input
with Schema.is(BrowserInputAction), so the helper always stays aligned with the
schema definition.

368-451: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consolidate duplicated missing_session checks around requireSession.

requireSession is only used for snapshot/screenshot/console/network/wait_for; browser_navigate, the back/forward/reload/stop group, and browser_input each re-implement the same if (!input.sessionId) { fail(missing_session) } block inline. Routing all cases through requireSession would remove ~5 duplicated blocks and keep the error message/code consistent if it ever needs to change.

♻️ Example for the navigate case
     case "browser_navigate":
-      if (!input.sessionId) {
-        return Effect.fail(
-          new BrowserRuntimeToolError({
-            code: "missing_session",
-            message: "browser_navigate requires a browser session id.",
-          }),
-        );
-      }
       if (!input.url) {
         return Effect.fail(
           new BrowserRuntimeToolError({
             code: "missing_url",
             message: "browser_navigate requires a URL.",
           }),
         );
       }
-      return browser.navigate({
-        sessionId: input.sessionId,
-        ...(input.tabId ? { tabId: input.tabId } : {}),
-        url: input.url,
-        ...(input.source ? { source: input.source } : {}),
-      });
+      return Effect.gen(function* () {
+        const sessionId = yield* requireSession("browser_navigate");
+        return yield* browser.navigate({
+          sessionId,
+          ...(input.tabId ? { tabId: input.tabId } : {}),
+          url: input.url as string,
+          ...(input.source ? { source: input.source } : {}),
+        });
+      });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/server/src/provider/tools/BrowserRuntimeTool.ts` around lines 368 - 451,
The `missing_session` validation is duplicated in `browser_navigate`, the
`browser_back`/`browser_forward`/`browser_reload`/`browser_stop` branch, and
`browser_input` instead of reusing `requireSession`. Update `BrowserRuntimeTool`
so those cases call `requireSession(input.name)` (or an equivalent shared helper
flow) before invoking `browser.navigate`, `browser[command]`, or
`browser.input`, and keep the existing `BrowserRuntimeToolError` behavior
centralized there for consistent session-id handling.
apps/server/src/browser/BrowserMcpBridge.ts (1)

95-98: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

BrowserMcpBridgeLive has no finalizer to close active servers.

bridgesRef accumulates listening net.Server instances and socket files, but the layer never releases them on teardown. Cleanup only happens via per-thread stop. If the layer is torn down while sessions are still registered (or a start succeeds but the caller never reaches stop), the server sockets and .sock files leak. Consider adding an Effect.addFinalizer that stops all remaining bridges.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/server/src/browser/BrowserMcpBridge.ts` around lines 95 - 98,
BrowserMcpBridgeLive currently creates and stores ActiveBridge instances in
bridgesRef but never cleans them up when the layer shuts down. Add an
Effect.addFinalizer inside the BrowserMcpBridgeLive Effect.gen setup that
iterates over the remaining bridges in bridgesRef and stops each one, ensuring
any net.Server listeners and .sock files are released even if stop was never
called per thread. Use the existing BrowserMcpBridgeLive, bridgesRef, and
ActiveBridge stop/cleanup logic to wire this teardown into the layer lifecycle.
apps/server/src/provider/Layers/CopilotAdapter.ts (1)

258-258: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

runPromise drops the adapter runtime context here.

Unlike the Cursor and Claude adapters — which capture Effect.context<never>() and use Effect.runPromiseWith(runtimeContext) — this executes browser tool effects on Effect's default runtime. That loses the fiber context (logger/tracing span correlation, fiber refs, and any context-provided services) for browser tool calls. Consider capturing the runtime context once in makeCopilotAdapter and threading Effect.runPromiseWith(runtimeContext) through buildSessionConfig for consistency.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/server/src/provider/Layers/CopilotAdapter.ts` at line 258, The browser
tool executor in CopilotAdapter is running effects with the default runtime,
which drops the adapter’s fiber/runtime context. Update makeCopilotAdapter to
capture the Effect context once, then thread that runtimeContext into
buildSessionConfig so the runPromise implementation uses
Effect.runPromiseWith(runtimeContext) instead of Effect.runPromise; keep the
behavior aligned with the Cursor and Claude adapter implementations.
apps/server/src/provider/tools/BrowserToolLifecycleEvents.ts (1)

22-101: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Duplicate event-building logic between started/completed builders.

makeBrowserToolStartedEvent and makeBrowserToolCompletedEvent share nearly identical scaffolding (eventId generation, optional providerInstanceId/turnId spreads, threadId, createdAt, raw.source). Consider extracting a shared base-event builder to reduce duplication and the risk of the two diverging when fields are added later.

♻️ Proposed extraction
+const makeEventBase = (input: {
+  readonly call: BrowserRuntimeToolCallInput;
+  readonly context: BrowserToolLifecycleContext;
+  readonly itemId: RuntimeItemId;
+  readonly createdAt?: string;
+}) =>
+  Effect.gen(function* () {
+    const eventId = EventId.make(yield* Random.nextUUIDv4);
+    const createdAt = input.createdAt ?? new Date().toISOString();
+    return {
+      eventId,
+      provider: input.context.provider,
+      ...(input.context.providerInstanceId
+        ? { providerInstanceId: input.context.providerInstanceId }
+        : {}),
+      threadId: input.call.threadId,
+      createdAt,
+      ...(input.context.turnId ? { turnId: input.context.turnId } : {}),
+      itemId: input.itemId,
+    };
+  });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/server/src/provider/tools/BrowserToolLifecycleEvents.ts` around lines 22
- 101, Both BrowserToolLifecycleEvents builders duplicate the same event
scaffolding, so extract the shared base event creation from
makeBrowserToolStartedEvent and makeBrowserToolCompletedEvent into a helper.
Move the common pieces like EventId.make, provider/providerInstanceId/turnId
spreads, threadId, createdAt, and raw.source assembly into a shared function,
then let each builder add only its specific type and payload fields.
apps/server/src/provider/tools/BrowserRuntimeToolHelpers.ts (1)

39-55: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

Loose validation in readScreenshotArtifact.

Only artifactId and mimeType are checked before casting to BrowserArtifactRef; if enrichBrowserRuntimeToolCallToolResult or downstream consumers ever rely on other required fields of that contract (e.g. byteSize, url), the shallow guard won't catch a malformed structured payload. Given only these two fields are actually consumed here, this is acceptable as-is but worth a comment noting the guard is intentionally partial.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/server/src/provider/tools/BrowserRuntimeToolHelpers.ts` around lines 39
- 55, The validation in readScreenshotArtifact is intentionally shallow, but it
currently looks like a full contract check even though only artifactId and
mimeType are used. Update the guard in readScreenshotArtifact to clearly
document that it is a partial validation for BrowserArtifactRef, and note that
enrichBrowserRuntimeToolCallToolResult and downstream consumers only depend on
those fields. Keep the existing checks, but add an explicit comment near the
artifact cast explaining the intentional scope of validation.
apps/server/src/browser/BrowserObservationHelpers.ts (1)

12-55: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Prefer Schema.decodeUnknownOption over hand-rolled shape checks.

All four decoders duplicate validation logic that already exists as exported effect Schemas in @ryco/contracts (BrowserHostScreenshotData, BrowserHostDomSnapshotData, BrowserHostConsoleData, BrowserHostNetworkData). The manual checks are also shallower than schema validation — e.g. decodeBrowserHostDomSnapshotData never validates nested tree node shapes, and decodeBrowserHostConsoleData/decodeBrowserHostNetworkData only check Array.isArray before unsafely casting entries, so malformed entries pass through untyped.

♻️ Suggested approach
-import type {
+import {
   BrowserHostConsoleData,
   BrowserHostDomSnapshotData,
   BrowserHostNetworkData,
   BrowserHostScreenshotData,
 } from "`@ryco/contracts`";
+import { Schema } from "effect";

 export function decodeBrowserHostScreenshotData(value: unknown): BrowserHostScreenshotData | null {
-  if (!isRecord(value) || value.kind !== "screenshot" || typeof value.base64 !== "string") {
-    return null;
-  }
-  return { kind: "screenshot", base64: value.base64 };
+  return Schema.decodeUnknownOption(BrowserHostScreenshotData)(value).pipe(
+    Option.getOrNull,
+  );
 }

Apply the same pattern to the other three decoders.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/server/src/browser/BrowserObservationHelpers.ts` around lines 12 - 55,
Replace the hand-rolled validation in decodeBrowserHostScreenshotData,
decodeBrowserHostDomSnapshotData, decodeBrowserHostConsoleData, and
decodeBrowserHostNetworkData with the exported effect schemas from
`@ryco/contracts` using Schema.decodeUnknownOption. The current checks only
validate shallow fields and unsafe-cast nested data, so wire each decoder to its
matching schema to get full nested validation (including dom_snapshot tree and
entries shapes) and return null on decode failure.
apps/server/src/provider/Layers/CopilotAdapter.mapEvent.ts (1)

216-219: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Prefix-based browser tool detection can misclassify identically-prefixed MCP tools.

toolName.startsWith("browser_") is checked before isMcpTool, so any MCP-hosted tool whose name happens to start with browser_ (e.g., a user's custom MCP server tool) would be reported as browser_tool_call instead of mcp_tool_call. Consider using the canonical isBrowserRuntimeToolName check (already defined in apps/server/src/provider/tools/BrowserRuntimeTool.ts) instead of a raw string-prefix heuristic, to avoid ambiguity and duplicated matching logic across adapters.

Also applies to: 245-250

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/server/src/provider/Layers/CopilotAdapter.mapEvent.ts` around lines 216
- 219, The tool classification in CopilotAdapter.mapEvent is using a raw
browser_ prefix check that can mislabel MCP tools as browser tools. Update the
tool execution mapping logic to use the canonical isBrowserRuntimeToolName
helper from BrowserRuntimeTool instead of startsWith("browser_"), and keep the
existing isMcpTool branch so MCP-hosted tools like tool.execution_start and the
related mapping block are classified consistently.
apps/server/src/browser/BrowserService.ts (1)

949-988: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

waitFor text-polling triggers a full DOM snapshot + session save + event publish every 250ms.

When text/textGone is set, each poll iteration calls dispatchObservationCommand({ kind: "snapshot_dom" }), which (via executeHostDataCommand) also calls saveSession and publishes a session.updated event on every tick — even when nothing changed. With the default 30s timeout that's up to ~120 host round trips and ~120 broadcast events for a single waitFor call, which could add meaningful load on the desktop host and spam subscribers with redundant updates.

Consider only publishing/saving when the session/tab snapshot actually differs from the last observed one, or backing off the poll interval over time.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/server/src/browser/BrowserService.ts` around lines 949 - 988, The
waitFor polling in BrowserService is repeatedly triggering snapshot_dom through
dispatchObservationCommand, which causes saveSession and session.updated
broadcasts on every 250ms tick even when nothing changes. Update the waitFor
flow to avoid redundant host work by caching the last observed session/tab
snapshot (or visible text) and only calling the snapshot/save/publish path when
the observed state actually differs, or otherwise reduce the polling frequency
over time. Focus the change around waitFor, dispatchObservationCommand, and
executeHostDataCommand so text/textGone waits stop spamming unchanged updates.
apps/server/src/browser/BrowserService.test.ts (1)

419-460: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add coverage for the oversized DOM snapshot → artifact path.

Only the untruncated snapshotDom path is tested here. The snapshotJson.length > 65_536 branch in BrowserService.ts (stores snapshot as an artifact, empties tree, sets truncated: true) isn't covered by any test in this file.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/server/src/browser/BrowserService.test.ts` around lines 419 - 460, The
BrowserService.snapshotDom test only covers the normal in-memory response path;
add a case for the oversized snapshot branch where snapshotJson.length exceeds
65_536 so BrowserService stores the snapshot as an artifact, returns an empty
tree, and marks truncated as true. Update or add a test alongside the existing
BrowserService.openSession/snapshotDom coverage to assert the artifact-backed
response shape and that the browser host command flow still includes
snapshot_dom.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.tmp-agent-handoff.md:
- Around line 121-135: The fenced handoff/architecture blocks in the referenced
markdown need language identifiers added to satisfy markdownlint MD040. Update
the affected fenced sections in the handoff doc so both opening and closing
fences use text as the language tag, keeping the existing content unchanged;
this applies to the blocks around the listed browser/server file references and
the later related block mentioned in the comment.

In `@apps/desktop/src/browser/BrowserKernel.ts`:
- Around line 43-49: Move the webRequest network hooks off each HostedSession
and attach them once to the shared Electron Session returned by
BrowserProfiles.resolve(), since installWebContentsListeners() currently
re-registers handlers per hosted session and can overwrite capture for the same
profile. Also add cleanup/invalidation for domRefCache in the browser lifecycle
so navigation, reload, back, and forward reset cached element bounds and
ref-based clicks don’t use stale data. Use the existing BrowserKernel and
installWebContentsListeners() flow as the main place to centralize both fixes.

In `@apps/server/src/browser/BrowserMcpBridge.ts`:
- Around line 230-261: The browserMcpBridgeRequest promise can stay pending if
the socket connects but never yields a response, so harden the request path in
BrowserMcpBridge.ts. Add a connection/response timeout around the
createConnection(socketPath) flow, and make sure the Promise settles on socket
close/end without a parsed response by rejecting with a clear error. Update the
handlers in browserMcpBridgeRequest to track whether a response was already
received so that connect/data/error/close/end all resolve or reject exactly
once.
- Around line 129-134: The BrowserMcpBridge socket setup can exceed the UNIX
socket path limit and the socket directory is too permissive. In
BrowserMcpBridge, shorten the generated socket name in the socketPath creation
logic (the input.threadId plus randomUUID path) so it stays safely under
sun_path limits, and ensure SOCKET_DIR is created with 0700 permissions before
use. Keep the stale-socket cleanup via Effect.tryPromise/rm, but update the
surrounding socket initialization to enforce the shorter path and locked-down
directory.

In `@apps/server/src/browser/BrowserRuntimeMcpConfig.ts`:
- Around line 10-19: The fallback in resolveBrowserMcpStdioCommand assumes the
bun executable is available on PATH, which can break when apps/server is started
via node or npx. Update the command resolution to first verify Bun is actually
available (or otherwise detect a valid Bun runtime) before returning the MCP
bridge spawn command, and if it is not available, throw or surface a clear error
instead of defaulting to command "bun". Keep the fix localized to
resolveBrowserMcpStdioCommand and any helper you add for Bun availability
checks.

In `@apps/server/src/provider/Layers/CursorAdapter.ts`:
- Around line 517-557: The browser bridge startup in CursorAdapter leaks a
registered bridge when session setup fails before sessions.set(...) runs. Add a
session-scoped finalizer around the Effect.gen block that calls
stopBrowserBridgeForThread(input.threadId) so any bridge started via
bridge.start is cleaned up even if makeCursorAcpRuntime or a later step throws.
Keep the cleanup adjacent to the existing session lifecycle logic in
CursorAdapter and preserve the current bridge start/error handling.

In `@apps/server/src/provider/Layers/OpenCodeAdapter.ts`:
- Around line 990-992: The browser turn-cleanup is implemented directly inside
OpenCodeAdapter’s turn-completion/error handlers, duplicating provider-specific
cleanup logic across adapters. Move the cleanup responsibility out of
OpenCodeAdapter (and the similar CopilotAdapter path) into the orchestration
layer, specifically the turn-completion handling in CheckpointReactor, so
runtime events are projected centrally through the existing
ProviderRuntimeIngestion/ProviderCommandReactor/CheckpointReactor flow. Keep
OpenCodeAdapter limited to emitting provider events and remove the direct
cleanup calls around cleanupBrowserThreadAfterProviderTurn, including the
repeated branch near the second referenced occurrence.
- Around line 1216-1222: `startSession` in `OpenCodeAdapter` should not fail
session startup if `connectOpenCodeBrowserMcp` errors after the session is
already registered and the event pump has started. Wrap the desktop-only
`connectOpenCodeBrowserMcp(context, directory)` call in `Effect.ignore`,
matching the `sendTurn` call site behavior, so bridge/MCP setup failures are
swallowed and the live session entry in `sessions` is not left orphaned.

---

Outside diff comments:
In `@apps/server/src/browser/BrowserArtifactStore.ts`:
- Around line 59-124: The BrowserArtifactStoreLive implementation computes and
stores expiresAt in BrowserArtifactRecord, but nothing ever removes expired
artifacts from records or disk. Add expiry handling in the
BrowserArtifactStoreShape methods by either introducing a periodic
cleanup/reaper in BrowserArtifactStoreLive or checking expiry on
get/readData/put and deleting stale entries/files before returning. Make sure
the cleanup updates the records Ref and removes the corresponding filePath for
each expired BrowserArtifactRecord.

In `@apps/server/src/browser/BrowserService.ts`:
- Around line 537-599: openSession currently performs the “is this profile
already attached?” check and the session insert as separate steps, which allows
two concurrent callers to race and both create active sessions. Update
BrowserService.openSession so the profile claim happens atomically inside the
state update path: check the current sessions and reserve the profile/session in
one Ref.update/modify-style operation before continuing, and only proceed with
makeSession/executeSessionCommand after the reservation succeeds. Use the
existing openSession, profileFor, state, and sessionOwners/session maps to
locate the logic.

---

Nitpick comments:
In `@apps/server/src/browser/BrowserMcpBridge.ts`:
- Around line 95-98: BrowserMcpBridgeLive currently creates and stores
ActiveBridge instances in bridgesRef but never cleans them up when the layer
shuts down. Add an Effect.addFinalizer inside the BrowserMcpBridgeLive
Effect.gen setup that iterates over the remaining bridges in bridgesRef and
stops each one, ensuring any net.Server listeners and .sock files are released
even if stop was never called per thread. Use the existing BrowserMcpBridgeLive,
bridgesRef, and ActiveBridge stop/cleanup logic to wire this teardown into the
layer lifecycle.

In `@apps/server/src/browser/BrowserObservationHelpers.ts`:
- Around line 12-55: Replace the hand-rolled validation in
decodeBrowserHostScreenshotData, decodeBrowserHostDomSnapshotData,
decodeBrowserHostConsoleData, and decodeBrowserHostNetworkData with the exported
effect schemas from `@ryco/contracts` using Schema.decodeUnknownOption. The
current checks only validate shallow fields and unsafe-cast nested data, so wire
each decoder to its matching schema to get full nested validation (including
dom_snapshot tree and entries shapes) and return null on decode failure.

In `@apps/server/src/browser/BrowserService.test.ts`:
- Around line 419-460: The BrowserService.snapshotDom test only covers the
normal in-memory response path; add a case for the oversized snapshot branch
where snapshotJson.length exceeds 65_536 so BrowserService stores the snapshot
as an artifact, returns an empty tree, and marks truncated as true. Update or
add a test alongside the existing BrowserService.openSession/snapshotDom
coverage to assert the artifact-backed response shape and that the browser host
command flow still includes snapshot_dom.

In `@apps/server/src/browser/BrowserService.ts`:
- Around line 949-988: The waitFor polling in BrowserService is repeatedly
triggering snapshot_dom through dispatchObservationCommand, which causes
saveSession and session.updated broadcasts on every 250ms tick even when nothing
changes. Update the waitFor flow to avoid redundant host work by caching the
last observed session/tab snapshot (or visible text) and only calling the
snapshot/save/publish path when the observed state actually differs, or
otherwise reduce the polling frequency over time. Focus the change around
waitFor, dispatchObservationCommand, and executeHostDataCommand so text/textGone
waits stop spamming unchanged updates.

In `@apps/server/src/provider/Layers/CopilotAdapter.mapEvent.ts`:
- Around line 216-219: The tool classification in CopilotAdapter.mapEvent is
using a raw browser_ prefix check that can mislabel MCP tools as browser tools.
Update the tool execution mapping logic to use the canonical
isBrowserRuntimeToolName helper from BrowserRuntimeTool instead of
startsWith("browser_"), and keep the existing isMcpTool branch so MCP-hosted
tools like tool.execution_start and the related mapping block are classified
consistently.

In `@apps/server/src/provider/Layers/CopilotAdapter.ts`:
- Line 258: The browser tool executor in CopilotAdapter is running effects with
the default runtime, which drops the adapter’s fiber/runtime context. Update
makeCopilotAdapter to capture the Effect context once, then thread that
runtimeContext into buildSessionConfig so the runPromise implementation uses
Effect.runPromiseWith(runtimeContext) instead of Effect.runPromise; keep the
behavior aligned with the Cursor and Claude adapter implementations.

In `@apps/server/src/provider/tools/BrowserRuntimeTool.ts`:
- Around line 243-248: The readBrowserToolLoadState helper is duplicating the
BrowserLoadState allowed values with hardcoded literals, which can drift from
the contract schema. Update readBrowserToolLoadState in BrowserRuntimeTool to
validate using Schema.is(BrowserLoadState), matching the pattern already used
for browser_input with Schema.is(BrowserInputAction), so the helper always stays
aligned with the schema definition.
- Around line 368-451: The `missing_session` validation is duplicated in
`browser_navigate`, the
`browser_back`/`browser_forward`/`browser_reload`/`browser_stop` branch, and
`browser_input` instead of reusing `requireSession`. Update `BrowserRuntimeTool`
so those cases call `requireSession(input.name)` (or an equivalent shared helper
flow) before invoking `browser.navigate`, `browser[command]`, or
`browser.input`, and keep the existing `BrowserRuntimeToolError` behavior
centralized there for consistent session-id handling.

In `@apps/server/src/provider/tools/BrowserRuntimeToolHelpers.ts`:
- Around line 39-55: The validation in readScreenshotArtifact is intentionally
shallow, but it currently looks like a full contract check even though only
artifactId and mimeType are used. Update the guard in readScreenshotArtifact to
clearly document that it is a partial validation for BrowserArtifactRef, and
note that enrichBrowserRuntimeToolCallToolResult and downstream consumers only
depend on those fields. Keep the existing checks, but add an explicit comment
near the artifact cast explaining the intentional scope of validation.

In `@apps/server/src/provider/tools/BrowserToolLifecycleEvents.ts`:
- Around line 22-101: Both BrowserToolLifecycleEvents builders duplicate the
same event scaffolding, so extract the shared base event creation from
makeBrowserToolStartedEvent and makeBrowserToolCompletedEvent into a helper.
Move the common pieces like EventId.make, provider/providerInstanceId/turnId
spreads, threadId, createdAt, and raw.source assembly into a shared function,
then let each builder add only its specific type and payload fields.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 9843c7a9-9003-400b-9b44-3515c514eb8e

📥 Commits

Reviewing files that changed from the base of the PR and between b5e7d57 and 765e8d7.

⛔ Files ignored due to path filters (1)
  • bun.lock is excluded by !**/*.lock
📒 Files selected for processing (57)
  • .tmp-agent-handoff.md
  • apps/desktop/src/browser/BrowserHostConnection.ts
  • apps/desktop/src/browser/BrowserKernel.ts
  • apps/desktop/src/browser/BrowserObservationHelpers.test.ts
  • apps/desktop/src/browser/BrowserObservationHelpers.ts
  • apps/desktop/src/browser/BrowserSurfaceManager.ts
  • apps/server/integration/OrchestrationEngineHarness.integration.ts
  • apps/server/integration/providerService.integration.test.ts
  • apps/server/package.json
  • apps/server/src/browser/BrowserArtifactStore.test.ts
  • apps/server/src/browser/BrowserArtifactStore.ts
  • apps/server/src/browser/BrowserMcpBridge.ts
  • apps/server/src/browser/BrowserObservationHelpers.ts
  • apps/server/src/browser/BrowserRuntimeMcpConfig.ts
  • apps/server/src/browser/BrowserService.test.ts
  • apps/server/src/browser/BrowserService.ts
  • apps/server/src/browser/BrowserThreadCleanup.ts
  • apps/server/src/browser/browser-mcp-stdio.ts
  • apps/server/src/provider/Layers/ClaudeAdapter.test.ts
  • apps/server/src/provider/Layers/ClaudeAdapter.ts
  • apps/server/src/provider/Layers/CodexAdapter.test.ts
  • apps/server/src/provider/Layers/CodexAdapter.ts
  • apps/server/src/provider/Layers/CodexSessionRuntime.ts
  • apps/server/src/provider/Layers/CopilotAdapter.mapEvent.ts
  • apps/server/src/provider/Layers/CopilotAdapter.ts
  • apps/server/src/provider/Layers/CursorAdapter.test.ts
  • apps/server/src/provider/Layers/CursorAdapter.ts
  • apps/server/src/provider/Layers/OpenCodeAdapter.test.ts
  • apps/server/src/provider/Layers/OpenCodeAdapter.ts
  • apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts
  • apps/server/src/provider/Layers/ProviderRegistry.test.ts
  • apps/server/src/provider/Layers/ProviderService.test.ts
  • apps/server/src/provider/Layers/ProviderService.ts
  • apps/server/src/provider/acp/AcpSessionRuntime.ts
  • apps/server/src/provider/tools/BrowserRuntimeTool.test.ts
  • apps/server/src/provider/tools/BrowserRuntimeTool.ts
  • apps/server/src/provider/tools/BrowserRuntimeToolHelpers.test.ts
  • apps/server/src/provider/tools/BrowserRuntimeToolHelpers.ts
  • apps/server/src/provider/tools/BrowserRuntimeToolTestLayers.ts
  • apps/server/src/provider/tools/BrowserToolLifecycleEvents.ts
  • apps/server/src/provider/tools/ClaudeBrowserRuntimeTools.test.ts
  • apps/server/src/provider/tools/ClaudeBrowserRuntimeTools.ts
  • apps/server/src/provider/tools/CopilotBrowserRuntimeTools.ts
  • apps/server/src/provider/tools/ProviderRuntimeEventHub.ts
  • apps/server/src/provider/tools/ProviderRuntimeToolRegistry.ts
  • apps/server/src/server.test.ts
  • apps/server/src/server.ts
  • apps/server/src/ws/browserRpc.ts
  • apps/web/src/components/BrowserPanel.logic.test.ts
  • apps/web/src/components/BrowserPanel.logic.ts
  • apps/web/src/components/BrowserPanel.tsx
  • apps/web/src/localApi.test.ts
  • apps/web/src/rpc/protocol.ts
  • packages/contracts/src/browser.ts
  • packages/contracts/src/rpc.ts
  • packages/shared/src/browser.test.ts
  • packages/shared/src/browser.ts
🚧 Files skipped from review as they are similar to previous changes (7)
  • apps/server/src/ws/browserRpc.ts
  • packages/shared/src/browser.test.ts
  • apps/desktop/src/browser/BrowserSurfaceManager.ts
  • apps/desktop/src/browser/BrowserHostConnection.ts
  • apps/web/src/components/BrowserPanel.tsx
  • apps/server/src/server.test.ts
  • apps/server/src/server.ts

Comment thread .tmp-agent-handoff.md
Comment on lines +121 to +135
```
apps/server/src/server.ts # layer wiring
apps/server/src/browser/BrowserService.ts # observation + policy
apps/server/src/provider/tools/BrowserRuntimeTool.ts
apps/server/src/provider/tools/ProviderRuntimeToolRegistry.ts
apps/server/src/provider/tools/ClaudeBrowserRuntimeTools.ts
apps/server/src/provider/tools/CopilotBrowserRuntimeTools.ts
apps/server/src/provider/Layers/CodexSessionRuntime.ts
apps/server/src/browser/BrowserMcpBridge.ts
apps/desktop/src/browser/BrowserKernel.ts
apps/web/src/components/BrowserPanel.tsx
apps/server/src/server.test.ts # fix test layer + typecheck (~line 610 browserLayer, ~line 419 buildAppUnderTest)
docs/superpowers/specs/2026-06-22-in-app-browser-design.md
docs/superpowers/plans/2026-06-22-in-app-browser.md
```

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Add language tags to the fenced blocks.

Both fences are missing a language identifier, which triggers markdownlint MD040. Use text for these handoff/architecture blocks.

Suggested edit
-```
+```text
 ...
-```
+```text

Also applies to: 141-149

🧰 Tools
🪛 markdownlint-cli2 (0.22.1)

[warning] 121-121: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.tmp-agent-handoff.md around lines 121 - 135, The fenced
handoff/architecture blocks in the referenced markdown need language identifiers
added to satisfy markdownlint MD040. Update the affected fenced sections in the
handoff doc so both opening and closing fences use text as the language tag,
keeping the existing content unchanged; this applies to the blocks around the
listed browser/server file references and the later related block mentioned in
the comment.

Source: Linters/SAST tools

Comment on lines +43 to +49
interface HostedSession {
session: BrowserSessionSnapshot;
view: WebContentsView;
consoleEntries: BufferedConsoleEntry[];
networkEntries: BufferedNetworkEntry[];
networkRequestStartedAt: Map<string, string>;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Confirm whether BrowserProfiles resolves persistent profile modes to a shared Electron session partition.
fd -a BrowserProfiles apps/desktop/src/browser
rg -n "fromPartition|persist:" apps/desktop/src/browser -A3 -B3

Repository: sak0a/ryco

Length of output: 843


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- BrowserProfiles.ts ---'
wc -l apps/desktop/src/browser/BrowserProfiles.ts
cat -n apps/desktop/src/browser/BrowserProfiles.ts | sed -n '1,240p'

echo
echo '--- BrowserKernel.ts (relevant ranges) ---'
wc -l apps/desktop/src/browser/BrowserKernel.ts
cat -n apps/desktop/src/browser/BrowserKernel.ts | sed -n '1,260p'

Repository: sak0a/ryco

Length of output: 12451


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- installWebContentsListeners / session lifecycle ---'
rg -n "installWebContentsListeners|closeSession|navigationAction|snapshotDomPayload|domRefCache|webRequest|onBeforeRequest|onCompleted|onErrorOccurred" apps/desktop/src/browser/BrowserKernel.ts -A8 -B8

echo
echo '--- BrowserKernel.ts around the relevant methods ---'
sed -n '260,760p' apps/desktop/src/browser/BrowserKernel.ts | cat -n

Repository: sak0a/ryco

Length of output: 30013


Attach the network hooks to the shared Electron Session, not each hosted session

  • BrowserProfiles.resolve() returns the same persistent Session for a profileId, so browserSession.webRequest.onBeforeRequest/onCompleted/onErrorOccurred in installWebContentsListeners() will overwrite the previous hosted session’s network capture on that profile.
  • domRefCache is also never cleared on navigation/reload/back/forward, so ref-based clicks can use stale bounds after the page changes.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/desktop/src/browser/BrowserKernel.ts` around lines 43 - 49, Move the
webRequest network hooks off each HostedSession and attach them once to the
shared Electron Session returned by BrowserProfiles.resolve(), since
installWebContentsListeners() currently re-registers handlers per hosted session
and can overwrite capture for the same profile. Also add cleanup/invalidation
for domRefCache in the browser lifecycle so navigation, reload, back, and
forward reset cached element bounds and ref-based clicks don’t use stale data.
Use the existing BrowserKernel and installWebContentsListeners() flow as the
main place to centralize both fixes.

Comment on lines +129 to +134
const socketId = crypto.randomUUID();
const socketPath = nodePath.join(SOCKET_DIR, `${input.threadId}-${socketId}.sock`);
yield* Effect.tryPromise({
try: () => rm(socketPath, { force: true }),
catch: () => new Error("Failed to remove stale browser MCP bridge socket."),
}).pipe(Effect.ignore);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the relevant file and nearby socket setup code.
git ls-files 'apps/server/src/browser/BrowserMcpBridge.ts' 'apps/server/src/browser/*' | sed -n '1,20p'
echo '---'
wc -l apps/server/src/browser/BrowserMcpBridge.ts
echo '---'
sed -n '1,220p' apps/server/src/browser/BrowserMcpBridge.ts
echo '---'
rg -n "SOCKET_DIR|mkdtemp|mkdir|chmod|socket|sun_path|listen|net\.createServer|unix" apps/server/src/browser -S

Repository: sak0a/ryco

Length of output: 14180


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Search for other socket directory creation / permission handling in the server codebase.
rg -n "SOCKET_DIR|mkdtemp|chmod\\(|mode:\\s*0o700|0o700|createServer\\(|listen\\(" apps/server/src -S

Repository: sak0a/ryco

Length of output: 12565


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '[BrowserMcpBridge.ts]'
wc -l apps/server/src/browser/BrowserMcpBridge.ts
echo '---'
sed -n '1,220p' apps/server/src/browser/BrowserMcpBridge.ts

echo '---[socket dir handling search]'
rg -n "SOCKET_DIR|mkdtemp|mkdir\\(|chmod\\(|0o700|createServer\\(|listen\\(" apps/server/src -S

echo '---[path-length probe]'
python3 - <<'PY'
from pathlib import Path
import re

p = Path('apps/server/src/browser/BrowserMcpBridge.ts').read_text()
for name in ['SOCKET_DIR','threadId','randomUUID','socketPath']:
    m = re.search(rf'.*{name}.*', p)
    if m:
        print(m.group(0))
PY

Repository: sak0a/ryco

Length of output: 22172


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the ThreadId definition and its typical shape/usages.
rg -n "export type ThreadId|type ThreadId|interface ThreadId|ThreadId" apps packages -S

Repository: sak0a/ryco

Length of output: 50367


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Read the actual ThreadId definition and a few call sites.
files=$(rg -l "ThreadId" . | head -n 20)
printf '%s\n' "$files"
echo '---'
for f in $files; do
  case "$f" in
    *node_modules*) continue;;
  esac
  if [ -f "$f" ]; then
    echo "### $f"
    rg -n "ThreadId|threadId" "$f" -S | sed -n '1,20p'
  fi
done

Repository: sak0a/ryco

Length of output: 15176


🏁 Script executed:

#!/bin/bash
set -euo pipefail

git ls-files | rg 'ThreadId|contracts' -n

Repository: sak0a/ryco

Length of output: 2139


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Estimate socket path lengths under realistic TMPDIR values.
python3 - <<'PY'
from pathlib import Path

samples = [
    "/tmp",
    "/var/folders/zz/zyxvpxvq6csfxvn_n00000sm00006d/T",
    "/var/folders/ab/cdefghijklmnopqrstuvwx/T",
]
thread_ids = ["1", "thread-1234567890", "0123456789abcdef0123456789abcdef"]
uuid = "123e4567-e89b-12d3-a456-426614174000"
for tmp in samples:
    base = Path(tmp) / "ryco-browser-mcp"
    for tid in thread_ids:
        path = base / f"{tid}-{uuid}.sock"
        print(len(str(path).encode()), str(path))
    print("---")
PY

Repository: sak0a/ryco

Length of output: 1168


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show Node's unix socket path limit behavior from docs isn't directly available here,
# but inspect the current code for any permission hardening around the socket dir.
sed -n '1,260p' apps/server/src/browser/BrowserMcpBridge.ts | nl -ba | sed -n '120,150p'

Repository: sak0a/ryco

Length of output: 188


🏁 Script executed:

#!/bin/bash
set -euo pipefail

git ls-files | rg '^.*contracts.*\.(ts|tsx|js|jsx)$|^.*ThreadId.*$' -n

Repository: sak0a/ryco

Length of output: 2064


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n "export (type|interface) ThreadId|type ThreadId|interface ThreadId" . -S

Repository: sak0a/ryco

Length of output: 2171


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Use the repository's file list to locate the contracts package and inspect any ThreadId type.
git ls-files | rg 'contracts|ThreadId' | sed -n '1,120p'

Repository: sak0a/ryco

Length of output: 1904


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n "threadId" apps/server/src apps/client/src packages src -S | sed -n '1,200p'

Repository: sak0a/ryco

Length of output: 20081


Shorten the socket path and lock down the socket dir A long TMPDIR can push this UNIX socket path over the macOS/Linux sun_path limit, and the directory should be created with 0700 so other local users can’t connect.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/server/src/browser/BrowserMcpBridge.ts` around lines 129 - 134, The
BrowserMcpBridge socket setup can exceed the UNIX socket path limit and the
socket directory is too permissive. In BrowserMcpBridge, shorten the generated
socket name in the socketPath creation logic (the input.threadId plus randomUUID
path) so it stays safely under sun_path limits, and ensure SOCKET_DIR is created
with 0700 permissions before use. Keep the stale-socket cleanup via
Effect.tryPromise/rm, but update the surrounding socket initialization to
enforce the shorter path and locked-down directory.

Comment on lines +230 to +261
return new Promise((resolve, reject) => {
const id = crypto.randomUUID();
const socket = createConnection(input.socketPath);
let buffer = "";
socket.on("connect", () => {
socket.write(
`${JSON.stringify({
id,
toolName: input.toolName,
arguments: input.arguments,
})}\n`,
);
});
socket.on("data", (chunk) => {
buffer += chunk.toString("utf8");
const newlineIndex = buffer.indexOf("\n");
if (newlineIndex === -1) return;
const line = buffer.slice(0, newlineIndex).trim();
socket.end();
try {
const response = JSON.parse(line) as BrowserMcpBridgeResponse;
if (!response.ok) {
reject(new Error(response.message ?? "Browser MCP bridge request failed."));
return;
}
resolve(response.result);
} catch (cause) {
reject(cause);
}
});
socket.on("error", reject);
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

browserMcpBridgeRequest can hang indefinitely.

There is no connection/response timeout and no close/end handler. If the bridge server accepts the connection but closes without writing a response (e.g., server crash mid-request, or socket.end() before write), neither data nor error fires with a settled result, so the returned Promise never resolves or rejects. Since this is awaited from MCP tool handlers, a stuck request blocks the tool call.

Suggested hardening
   return new Promise((resolve, reject) => {
     const id = crypto.randomUUID();
     const socket = createConnection(input.socketPath);
     let buffer = "";
+    let settled = false;
+    const finish = (fn: () => void) => {
+      if (settled) return;
+      settled = true;
+      socket.end();
+      fn();
+    };
+    socket.setTimeout(30_000, () =>
+      finish(() => reject(new Error("Browser MCP bridge request timed out."))),
+    );
     socket.on("connect", () => {
       socket.write(
         `${JSON.stringify({ id, toolName: input.toolName, arguments: input.arguments })}\n`,
       );
     });
     socket.on("data", (chunk) => {
       buffer += chunk.toString("utf8");
       const newlineIndex = buffer.indexOf("\n");
       if (newlineIndex === -1) return;
       const line = buffer.slice(0, newlineIndex).trim();
-      socket.end();
       try {
         const response = JSON.parse(line) as BrowserMcpBridgeResponse;
-        if (!response.ok) {
-          reject(new Error(response.message ?? "Browser MCP bridge request failed."));
-          return;
-        }
-        resolve(response.result);
+        finish(() =>
+          response.ok
+            ? resolve(response.result)
+            : reject(new Error(response.message ?? "Browser MCP bridge request failed.")),
+        );
       } catch (cause) {
-        reject(cause);
+        finish(() => reject(cause));
       }
     });
-    socket.on("error", reject);
+    socket.on("error", (cause) => finish(() => reject(cause)));
+    socket.on("close", () =>
+      finish(() => reject(new Error("Browser MCP bridge connection closed before response."))),
+    );
   });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
return new Promise((resolve, reject) => {
const id = crypto.randomUUID();
const socket = createConnection(input.socketPath);
let buffer = "";
socket.on("connect", () => {
socket.write(
`${JSON.stringify({
id,
toolName: input.toolName,
arguments: input.arguments,
})}\n`,
);
});
socket.on("data", (chunk) => {
buffer += chunk.toString("utf8");
const newlineIndex = buffer.indexOf("\n");
if (newlineIndex === -1) return;
const line = buffer.slice(0, newlineIndex).trim();
socket.end();
try {
const response = JSON.parse(line) as BrowserMcpBridgeResponse;
if (!response.ok) {
reject(new Error(response.message ?? "Browser MCP bridge request failed."));
return;
}
resolve(response.result);
} catch (cause) {
reject(cause);
}
});
socket.on("error", reject);
});
return new Promise((resolve, reject) => {
const id = crypto.randomUUID();
const socket = createConnection(input.socketPath);
let buffer = "";
let settled = false;
const finish = (fn: () => void) => {
if (settled) return;
settled = true;
socket.end();
fn();
};
socket.setTimeout(30_000, () =>
finish(() => reject(new Error("Browser MCP bridge request timed out."))),
);
socket.on("connect", () => {
socket.write(
`${JSON.stringify({
id,
toolName: input.toolName,
arguments: input.arguments,
})}\n`,
);
});
socket.on("data", (chunk) => {
buffer += chunk.toString("utf8");
const newlineIndex = buffer.indexOf("\n");
if (newlineIndex === -1) return;
const line = buffer.slice(0, newlineIndex).trim();
try {
const response = JSON.parse(line) as BrowserMcpBridgeResponse;
finish(() =>
response.ok
? resolve(response.result)
: reject(new Error(response.message ?? "Browser MCP bridge request failed.")),
);
} catch (cause) {
finish(() => reject(cause));
}
});
socket.on("error", (cause) => finish(() => reject(cause)));
socket.on("close", () =>
finish(() => reject(new Error("Browser MCP bridge connection closed before response."))),
);
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/server/src/browser/BrowserMcpBridge.ts` around lines 230 - 261, The
browserMcpBridgeRequest promise can stay pending if the socket connects but
never yields a response, so harden the request path in BrowserMcpBridge.ts. Add
a connection/response timeout around the createConnection(socketPath) flow, and
make sure the Promise settles on socket close/end without a parsed response by
rejecting with a clear error. Update the handlers in browserMcpBridgeRequest to
track whether a response was already received so that
connect/data/error/close/end all resolve or reject exactly once.

Comment on lines +10 to +19
export function resolveBrowserMcpStdioCommand(): {
readonly command: string;
readonly args: ReadonlyArray<string>;
} {
const runtime = process.execPath;
const usesBun = nodePath.basename(runtime).includes("bun");
return usesBun
? { command: runtime, args: [browserMcpStdioEntry] }
: { command: "bun", args: [browserMcpStdioEntry] };
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Is bun expected to always be present in PATH for a Node.js-hosted server that spawns bun as a child process?

💡 Result:

No, Bun is not expected to always be present in the system PATH for a Node.js-hosted server to successfully spawn a Bun child process. However, whether the process succeeds depends on how you resolve the executable and configure the environment. When spawning a child process (using either the native node:child_process module or Bun's own spawn APIs), you have two primary ways to avoid reliance on the system PATH: 1. Use an Absolute Path: When you provide the full, absolute file path to the Bun executable (e.g., /usr/local/bin/bun or a custom installation directory) as the command argument, the operating system does not need to look up the location in the PATH variable [1][2]. 2. Explicit Environment Configuration: If you need to spawn a process using a bare command name (like bun), you can explicitly provide the environment variables via the env option in your spawn configuration [1][2]. By passing an env object that includes the necessary PATH, you ensure the spawned process can locate the executable without relying on the parent process's environment or the system-wide global PATH [1][2]. Important Considerations: - Execution Context: If you do not provide an explicit environment, Bun generally defaults to using the process.env snapshot from when the parent process was launched [1][2]. Recent updates have improved how Bun handles runtime mutations to process.env.PATH, particularly for synchronous spawn variants, to ensure they more closely mirror Node.js behavior [3]. - Troubleshooting: If you encounter errors like Executable not found in $PATH, it typically indicates that the environment the child process is inheriting does not include the directory where Bun is installed [4][5]. This is common in containerized environments or restricted shell sessions. You can resolve this by either using an absolute path or by explicitly injecting the correct PATH into the child process's environment [1][2]. In summary, while using the system PATH is the default and most convenient method, it is not a strict requirement if you manage the executable's location or environment configuration explicitly in your code [1][2].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Target file =="
ast-grep outline apps/server/src/browser/BrowserRuntimeMcpConfig.ts --view expanded || true

echo
echo "== File contents =="
cat -n apps/server/src/browser/BrowserRuntimeMcpConfig.ts

echo
echo "== References =="
rg -n "resolveBrowserMcpStdioCommand|browserMcpStdioEntry|BrowserRuntimeMcpConfig|McpStdio" apps/server -S

echo
echo "== Nearby runtime / spawn logic =="
rg -n "spawn\\(|command: \"bun\"|process.execPath|process.env.PATH|PATH" apps/server/src -S

Repository: sak0a/ryco

Length of output: 21269


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Package scripts and runtime hints =="
for f in package.json apps/server/package.json bun.lockb bun.lock; do
  if [ -f "$f" ]; then
    echo "--- $f ---"
    cat "$f"
    echo
  fi
done

echo "== Bun / Node mentions in docs and config =="
rg -n "bun|node\.|process\.execPath|PATH|runtime" README* docs apps/server -g '!**/node_modules/**' -S

Repository: sak0a/ryco

Length of output: 50367


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== package.json scripts =="
python3 - <<'PY'
import json, pathlib
for path in ["package.json", "apps/server/package.json"]:
    p = pathlib.Path(path)
    if p.exists():
        data = json.loads(p.read_text())
        print(f"--- {path} ---")
        scripts = data.get("scripts", {})
        for k in sorted(scripts):
            print(f"{k}: {scripts[k]}")
        print()
PY

echo "== apps/server/README.md (selected lines) =="
sed -n '1,120p' apps/server/README.md

echo
echo "== bun mentions in root README and server README =="
rg -n "bun|Node|node" README.md apps/server/README.md -S

Repository: sak0a/ryco

Length of output: 6659


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Browser runtime MCP related references =="
rg -n "BrowserRuntimeMcpConfig|browser-mcp-stdio|resolveBrowserMcpStdioCommand|makeOpenCodeBrowserMcpConfig|makeCopilotBrowserMcpServerConfig|makeAcpBrowserMcpServer" apps/server -S

echo
echo "== MCP stdio bridge file =="
cat -n apps/server/src/browser/browser-mcp-stdio.ts

Repository: sak0a/ryco

Length of output: 3411


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== package.json scripts =="
python3 - <<'PY'
import json, pathlib
for path in ["package.json", "apps/server/package.json"]:
    p = pathlib.Path(path)
    if p.exists():
        data = json.loads(p.read_text())
        print(f"--- {path} ---")
        for k, v in sorted(data.get("scripts", {}).items()):
            print(f"{k}: {v}")
        print()
PY

echo "== apps/server/README.md =="
sed -n '1,120p' apps/server/README.md

echo
echo "== Root README.md bun/node mentions =="
sed -n '1,120p' README.md 2>/dev/null || true
rg -n "bun|node" README.md apps/server/README.md -S

Repository: sak0a/ryco

Length of output: 13226


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== apps/server/README.md (top) =="
sed -n '1,120p' apps/server/README.md

echo
echo "== package.json scripts only =="
python3 - <<'PY'
import json
from pathlib import Path
for path in [Path("package.json"), Path("apps/server/package.json")]:
    if path.exists():
        data = json.loads(path.read_text())
        print(f"--- {path} ---")
        for k, v in sorted(data.get("scripts", {}).items()):
            print(f"{k}: {v}")
        print()
PY

echo
echo "== top-level docs mentions of bun/node =="
rg -n "bun|Node|node" README.md apps/server/README.md docs/observability.md docs/effect-fn-checklist.md -S

Repository: sak0a/ryco

Length of output: 7086


Don't assume bun is on PATH here. apps/server is launched via node/npx, so this fallback can fail when Bun isn’t installed; add an availability check or surface a clearer error before spawning the MCP bridge.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/server/src/browser/BrowserRuntimeMcpConfig.ts` around lines 10 - 19, The
fallback in resolveBrowserMcpStdioCommand assumes the bun executable is
available on PATH, which can break when apps/server is started via node or npx.
Update the command resolution to first verify Bun is actually available (or
otherwise detect a valid Bun runtime) before returning the MCP bridge spawn
command, and if it is not available, throw or surface a clear error instead of
defaulting to command "bun". Keep the fix localized to
resolveBrowserMcpStdioCommand and any helper you add for Bun availability
checks.

Comment on lines +517 to +557
if (serverConfig.mode === "desktop") {
yield* Option.match(browserMcpBridge, {
onNone: () => Effect.void,
onSome: (bridge) =>
Option.match(providerRuntimeToolRegistry, {
onNone: () => Effect.void,
onSome: (registry) =>
Effect.gen(function* () {
const bridgeHandle = yield* bridge
.start({
threadId: input.threadId,
runPromise,
executeBrowserTool: (toolInput) =>
registry.executeBrowserTool(toolInput, {
lifecycle: {
provider: PROVIDER,
providerInstanceId: boundInstanceId,
...(ctxHolder.current?.activeTurnId
? { turnId: ctxHolder.current.activeTurnId }
: {}),
},
}),
})
.pipe(
Effect.mapError(
(cause) =>
new ProviderAdapterProcessError({
provider: PROVIDER,
threadId: input.threadId,
detail: cause.message,
cause,
}),
),
);
browserMcpServers.push(
makeAcpBrowserMcpServer({ socketPath: bridgeHandle.socketPath }),
);
}),
}),
});
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map the file structure first, then inspect the relevant ranges.
ast-grep outline apps/server/src/provider/Layers/CursorAdapter.ts --view expanded || true

printf '\n--- around bridge.start ---\n'
sed -n '470,580p' apps/server/src/provider/Layers/CursorAdapter.ts

printf '\n--- around session registration/cleanup ---\n'
sed -n '840,930p' apps/server/src/provider/Layers/CursorAdapter.ts

printf '\n--- around turn cleanup ---\n'
sed -n '980,1045p' apps/server/src/provider/Layers/CursorAdapter.ts

printf '\n--- browser bridge implementation references ---\n'
rg -n "BrowserMcpBridgeLive|browserMcpBridge|start\\(|stopSessionInternal|sessions|socketPath" apps/server/src/provider -S

Repository: sak0a/ryco

Length of output: 33463


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n--- stopSessionInternal and helpers ---\n'
sed -n '420,470p' apps/server/src/provider/Layers/CursorAdapter.ts

printf '\n--- cleanupBrowserThreadAfterProviderTurn ---\n'
sed -n '1070,1125p' apps/server/src/provider/Layers/CursorAdapter.ts

printf '\n--- BrowserMcpBridge interface/provider ---\n'
rg -n "export interface BrowserMcpBridge|class .*BrowserMcpBridge|Layer\\.succeed\\(BrowserMcpBridge|start: \\(" apps/server/src/provider -S

printf '\n--- Browser runtime tool layer ---\n'
sed -n '1,120p' apps/server/src/provider/tools/BrowserRuntimeToolTestLayers.ts

printf '\n--- cursor provider session startup for comparison ---\n'
sed -n '460,560p' apps/server/src/provider/Layers/CursorProvider.ts

Repository: sak0a/ryco

Length of output: 9621


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n--- stopBrowserBridgeForThread and related state ---\n'
sed -n '340,430p' apps/server/src/provider/Layers/CursorAdapter.ts

printf '\n--- BrowserMcpBridge type/implementation ---\n'
fd -a 'BrowserMcpBridge.ts' apps/server/src/provider apps/server/src -t f
printf '\n'
rg -n "export const BrowserMcpBridge|export interface BrowserMcpBridge|start: \\(|stop: \\(" apps/server/src -S

printf '\n--- cursor adapter bridge cleanup references ---\n'
rg -n "stopBrowserBridgeForThread|browserMcpServers|bridgeHandle|socketPath" apps/server/src/provider/Layers/CursorAdapter.ts -n -S

Repository: sak0a/ryco

Length of output: 6551


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n--- BrowserMcpBridge.ts ---\n'
sed -n '1,220p' apps/server/src/browser/BrowserMcpBridge.ts

printf '\n--- CursorAdapter startSession continuation after acp.start ---\n'
sed -n '560,760p' apps/server/src/provider/Layers/CursorAdapter.ts

printf '\n--- CursorAdapter finalizers and shutdown path ---\n'
sed -n '1088,1125p' apps/server/src/provider/Layers/CursorAdapter.ts

printf '\n--- any startup-failure cleanup helpers ---\n'
rg -n "stopBrowserBridgeForThread|cleanupBrowserThreadAfterProviderTurn|sessionScopeTransferred|Effect.addFinalizer|Scope.close\\(sessionScope" apps/server/src/provider/Layers/CursorAdapter.ts -S

Repository: sak0a/ryco

Length of output: 18465


Clean up the browser bridge on failed session startup. If makeCursorAcpRuntime or a later step throws before sessions.set(...), the bridge started here stays registered in BrowserMcpBridgeLive with its socket open until a later same-thread restart or process shutdown. Add a finalizer for stopBrowserBridgeForThread(input.threadId) next to the session-scope cleanup.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/server/src/provider/Layers/CursorAdapter.ts` around lines 517 - 557, The
browser bridge startup in CursorAdapter leaks a registered bridge when session
setup fails before sessions.set(...) runs. Add a session-scoped finalizer around
the Effect.gen block that calls stopBrowserBridgeForThread(input.threadId) so
any bridge started via bridge.start is cleaned up even if makeCursorAcpRuntime
or a later step throws. Keep the cleanup adjacent to the existing session
lifecycle logic in CursorAdapter and preserve the current bridge start/error
handling.

Comment on lines +990 to +992
yield* cleanupBrowserThreadAfterProviderTurn(context.session.threadId).pipe(
Effect.ignore,
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy lift

Browser turn-cleanup logic duplicated per adapter instead of centralized.

cleanupBrowserThreadAfterProviderTurn is invoked directly inside OpenCodeAdapter's event-handling code on turn completion/error. Graph evidence shows the same pattern independently reimplemented in CopilotAdapter.ts (with stopBridge: false) and referenced via CursorAdapter.ts/CodexAdapter.ts's BrowserMcpBridge wiring. This duplicates a cross-cutting concern across every provider adapter rather than reacting centrally to the turn.completed/session.error runtime events already emitted by these adapters.

As per coding guidelines, "Provider runtime events must be projected into orchestration through ProviderRuntimeIngestion.ts, ProviderCommandReactor.ts, CheckpointReactor.ts, and OrchestrationEngine.ts" — moving this cleanup into CheckpointReactor.ts (reacting to the turn-completion event) would avoid the per-adapter duplication and drift risk (e.g., the stopBridge option already differs between OpenCode and Copilot).

Also applies to: 1022-1024

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/server/src/provider/Layers/OpenCodeAdapter.ts` around lines 990 - 992,
The browser turn-cleanup is implemented directly inside OpenCodeAdapter’s
turn-completion/error handlers, duplicating provider-specific cleanup logic
across adapters. Move the cleanup responsibility out of OpenCodeAdapter (and the
similar CopilotAdapter path) into the orchestration layer, specifically the
turn-completion handling in CheckpointReactor, so runtime events are projected
centrally through the existing
ProviderRuntimeIngestion/ProviderCommandReactor/CheckpointReactor flow. Keep
OpenCodeAdapter limited to emitting provider events and remove the direct
cleanup calls around cleanupBrowserThreadAfterProviderTurn, including the
repeated branch near the second referenced occurrence.

Source: Coding guidelines

Comment on lines 1216 to +1222
sessions.set(input.threadId, context);
yield* startEventPump(context);

if (serverConfig.mode === "desktop") {
yield* connectOpenCodeBrowserMcp(context, directory);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

ast-grep outline apps/server/src/provider/Layers/OpenCodeAdapter.ts --view expanded

Repository: sak0a/ryco

Length of output: 3154


🏁 Script executed:

rg -n "connectOpenCodeBrowserMcp|sendTurn|startSession|stopSession|ProviderAdapterProcessError|ProviderAdapterRequestError" apps/server/src/provider/Layers/OpenCodeAdapter.ts

Repository: sak0a/ryco

Length of output: 1618


🏁 Script executed:

sed -n '470,540p;1110,1245p;1420,1485p' apps/server/src/provider/Layers/OpenCodeAdapter.ts

Repository: sak0a/ryco

Length of output: 11164


🏁 Script executed:

rg -n "startEventPump|sessionScope|sessions.delete\\(|ensuring\\(|onExit\\(|tapError\\(|catchAll\\(" apps/server/src/provider/Layers/OpenCodeAdapter.ts

Repository: sak0a/ryco

Length of output: 1666


🏁 Script executed:

sed -n '560,660p;1040,1110p' apps/server/src/provider/Layers/OpenCodeAdapter.ts

Repository: sak0a/ryco

Length of output: 6650


Guard connectOpenCodeBrowserMcp during startSession

startSession has already registered the session and started the event pump by this point. If the desktop bridge/MCP setup fails here, startup aborts after the session is live, leaving an orphaned entry/scope until a later cleanup path happens. Wrap this in Effect.ignore like the sendTurn call site so a bridge hiccup doesn’t fail session startup.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/server/src/provider/Layers/OpenCodeAdapter.ts` around lines 1216 - 1222,
`startSession` in `OpenCodeAdapter` should not fail session startup if
`connectOpenCodeBrowserMcp` errors after the session is already registered and
the event pump has started. Wrap the desktop-only
`connectOpenCodeBrowserMcp(context, directory)` call in `Effect.ignore`,
matching the `sendTurn` call site behavior, so bridge/MCP setup failures are
swallowed and the live session entry in `sessions` is not left orphaned.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXL vouch:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant