Skip to content

Add shared browser session daemon bridge#83

Merged
glittercowboy merged 2 commits intomainfrom
codex/browser-session-daemon
Apr 28, 2026
Merged

Add shared browser session daemon bridge#83
glittercowboy merged 2 commits intomainfrom
codex/browser-session-daemon

Conversation

@glittercowboy
Copy link
Copy Markdown
Contributor

@glittercowboy glittercowboy commented Apr 28, 2026

Summary

  • Add daemon-side shared browser session management with grant lifecycle, frame forwarding, control arbitration, and user input handling.
  • Advertise browser session capabilities in relay hello payloads and pass browser grant context into Pi tasks.
  • Register the gsd_browser Pi extension tool for browser-granted tasks while keeping the claude-cli provider path.

Verification

  • npm test in internal/pi/extension
  • go test ./...
  • go build -o gsd-cloud . && rm -f gsd-cloud

Dependencies and merge order

  • Depends on gsd-build/protocol-go v0.24.0.
  • Runtime behavior depends on the gsd-browser shared browser service PR and release.
  • Merge after the browser service PR is merged and released; release daemon after merge.

Summary by CodeRabbit

  • New Features
    • Full browser session management with open, close, and control ownership transfers
    • Real-time frame capture and streaming from active browser sessions
    • Browser tool execution and user input support
    • Automatic session expiration and cleanup
    • Task-based browser access grants for controlled access

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 28, 2026

📝 Walkthrough

Walkthrough

Introduces a comprehensive browser session management system spanning daemon, service, and extension layers. Implements a Manager that coordinates browser lifecycle (open, claim, release, close), forwards control ownership and user input, streams frame content periodically, and emits session events. Adds a Service abstraction with JSON-RPC-based LocalService communicating with a local browser daemon via UNIX sockets. Integrates browser context propagation through daemon, session actor, and pi executor. Extends the TypeScript extension with a gsd_browser tool for remote execution.

Changes

Cohort / File(s) Summary
Browser Core Types
internal/browser/types.go
Defines data contracts: ControlOwner constants, OpenRequest/OpenResult for session initialization, Frame for screen capture metadata, and ToolResult for tool execution outcomes.
Browser Service
internal/browser/service.go
Implements Service interface with LocalService managing local daemon lifecycle via JSON-RPC over UNIX sockets. Handles frame capture, tool execution, user input, and status queries with configurable RPC timeouts and 16 MiB frame size limits.
Browser Manager
internal/browser/manager.go, internal/browser/manager_test.go
Coordinates session state, control ownership, and message forwarding. Manages session lifecycle with expiry tracking, periodic frame loop, and mutex-protected control transitions. Tests validate tool authorization, expiry detection, and input gating.
Daemon Integration
internal/loop/daemon.go
Initializes browserManager, routes incoming browser protocol messages, and supplies browser Grant/BrowserID context to session actors during task spawning and execution.
Session & Actor Integration
internal/session/actor.go, internal/session/browser_tool_test.go
Propagates browser context (BrowserGrantID, BrowserID, SessionID) from daemon through actor to pi executor environment. Test validates end-to-end propagation via temporary fake pi script.
Pi Executor Environment
internal/pi/executor.go
Adds browserEnv helper that cleanses inherited environment and conditionally injects GSD_BROWSER_* variables when browser identifiers are non-empty.
Browser Extension Tool
internal/pi/extension/index.ts, internal/pi/extension/browser-tool.test.mjs, internal/pi/extension/package.json
Implements TypeScript gsd_browser tool with JSON-RPC client over UNIX socket. Conditionally registers tool when environment variables present. Test suite added with tsx runner.
Relay Capabilities
internal/relay/conn.go
Advertises browser-related relay capabilities: BrowserSessions, BrowserFrameStream, BrowserUserControl, BrowserIdentities, BrowserSensitiveActionApproval, and BrowserMaxFrameBytes (262144 bytes).

Sequence Diagram

sequenceDiagram
    participant Client
    participant Daemon
    participant Manager
    participant Service
    participant BrowserDaemon as Browser<br/>Daemon

    Client->>Daemon: BrowserSessionOpen
    Daemon->>Manager: Open(msg)
    Manager->>Service: Open(OpenRequest)
    Service->>BrowserDaemon: Start daemon process
    Service->>BrowserDaemon: RPC: cloud_session_status
    BrowserDaemon-->>Service: URL, Title
    Service-->>Manager: OpenResult
    Manager->>Manager: Store session state, start frameLoop
    Manager->>Client: emit BrowserSessionOpened
    
    Client->>Daemon: BrowserControlClaim
    Daemon->>Manager: Claim(msg)
    Manager->>Manager: Update control owner (OwnerLex)
    
    Client->>Daemon: BrowserToolCall
    Daemon->>Manager: Tool(msg)
    Manager->>Service: Tool(browserID, method, params)
    Service->>BrowserDaemon: RPC: cloud_tool
    BrowserDaemon-->>Service: result JSON
    Service-->>Manager: ToolResult
    Manager->>Client: emit BrowserToolResult
    
    Client->>Daemon: BrowserControlRelease
    Daemon->>Manager: Release(msg)
    Manager->>Manager: Reset control owner (OwnerAgent)
    
    Client->>Daemon: BrowserSessionClose
    Daemon->>Manager: Close(msg)
    Manager->>Manager: Remove session state
    Manager->>Service: Close(browserID)
    Service->>BrowserDaemon: Terminate daemon
    Manager->>Client: emit BrowserSessionClosed
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

  • Persist Pi sessions by GSD session ID #52: Modifies internal/session/actor.go to extend pi executor options; this PR similarly extends the same options struct with browser-related fields and updates executor environment setup.
  • Add pi task engine #38: Changes daemon/session wiring to propagate runtime context IDs through actors; this PR follows the same pattern to propagate browser grant and session identifiers.

Poem

🐰 A bunny hops through browser lands so wide,
With sessions managed, state kept safe inside,
Control can shift from agent to lex's hand,
Frame loops and tools dance across the land,
Where sockets whisper JSON through the night! 🌙

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 5.26% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Add shared browser session daemon bridge' clearly summarizes the main change: introducing daemon-side support for shared browser session management as described in PR objectives.
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.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ 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 codex/browser-session-daemon

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

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 9

Caution

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

⚠️ Outside diff range comments (1)
internal/loop/daemon.go (1)

621-640: ⚠️ Potential issue | 🔴 Critical

Task-scoped browser grants are only applied on first actor spawn.

Grant lookup is currently inside if actor == nil, so subsequent tasks on the same session actor reuse stale BrowserGrantID/BrowserID. That can authorize browser access for tasks that were never granted.

🔧 Proposed fix direction
+browserGrantID := ""
+browserID := ""
+if d.browserManager != nil {
+	if browserGrant, ok := d.browserManager.GrantForTask(msg.TaskID); ok {
+		browserGrantID = browserGrant.GrantID
+		browserID = browserGrant.BrowserID
+	}
+}
+
 actor := d.manager.Get(msg.SessionID)
 if actor == nil {
   // spawn with current task grant
 } else {
+  // update/clear actor browser context per incoming task before SendTask
+  // (add Actor.SetBrowserContext(grantID, browserID) in internal/session/actor.go)
+  actor.SetBrowserContext(browserGrantID, browserID)
 }

Also ensure the actor clears browser context ("", "") when no grant exists for a task.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/loop/daemon.go` around lines 621 - 640, The browser grant lookup
must run for every task (not only when actor == nil) so each Spawn/Resume call
gets the current grant; move the GrantForTask call
(d.browserManager.GrantForTask) out of the actor==nil guard and always compute
browserGrantID/browserID before calling d.manager.Spawn, passing the values into
the Session options (BrowserGrantID, BrowserID). Also ensure that when no grant
exists you explicitly set BrowserGrantID and BrowserID to "" to clear any prior
browser context on a reused actor.
🧹 Nitpick comments (1)
internal/pi/extension/browser-tool.test.mjs (1)

5-15: Cover the no-grant branch as well.

This only proves the positive path. A regression that always registers gsd_browser would still pass. Please add a companion assertion that buildClaudeCliToolsForTest({}) does not surface the browser tool.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/pi/extension/browser-tool.test.mjs` around lines 5 - 15, Add a
negative-test case to assert the no-grant branch: call
buildClaudeCliToolsForTest({}) and assert that none of the returned tools has
name "gsd_browser" (e.g., assert.equal(tools.some(tool => tool.name ===
"gsd_browser"), false)). Update or add an it() in browser-tool.test.mjs
alongside the existing positive test to cover the missing browserGrant path so
regressions that always register gsd_browser fail.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@internal/browser/manager.go`:
- Around line 126-132: sendFrame currently returns early on expiry and leaves
session data and the frameLoop ticker running; change the expiry path in
Manager.sendFrame to perform a full teardown: while holding m.mu remove the
session from m.byID and m.byTask, stop/disable its frameLoop/ticker and invoke
the session’s shutdown (e.g.,
state.close()/state.browser.Close()/state.cancel()—use the existing session
shutdown method) and only then unlock; ensure no lingering references remain so
expired sessions and their frameLoop/ticker are fully cleaned up.
- Around line 209-221: UserInput currently skips checking state.expiresAt so
expired grants still accept events; update Manager.UserInput to mirror
Tool/GrantForTask by checking state.expiresAt (e.g., if
time.Now().After(state.expiresAt) return an error like "browser grant expired")
before calling m.service.UserInput, while keeping the existing owner check and
proper mutex use around reading state from m.byID and releasing m.mu before
forwarding to m.service.UserInput (refer to Manager.UserInput, state.expiresAt,
OwnerLex, m.byID, m.mu, and m.service.UserInput).

In `@internal/browser/service.go`:
- Around line 185-192: writeFrame currently writes the header and payload with
single Write calls which can result in short writes and truncated JSON-RPC
frames; update writeFrame (the function named writeFrame and the header buffer
logic) to loop until the full header and then the full payload are written
(checking the returned n and advancing the slice on each iteration) and return
any write error immediately, or alternatively construct a bytes.Reader for
header+payload and use io.Copy to ensure the entire framed payload is sent;
ensure you propagate errors from partial writes correctly.
- Around line 154-165: After establishing the unix socket (using
net.Dialer.DialContext) ensure I/O honors ctx by applying deadlines or closing
the connection when ctx is done: before calling writeFrame/readFrame set conn
deadlines using conn.SetDeadline/SetWriteDeadline/SetReadDeadline derived from
ctx.Deadline() (or a reasonable fallback), and also start a goroutine that does
<-ctx.Done() then conn.Close() to unblock blocking I/O; update the code around
the DialContext/conn usage (referencing conn, ctx, writeFrame, readFrame, and
s.socketPath(grantID)) so all subsequent reads/writes are interrupted when ctx
is canceled.

In `@internal/loop/daemon.go`:
- Around line 242-247: The code builds browserStateDir by joining homeDir and
".gsd-browser" but when os.UserHomeDir() fails (homeErr != nil) homeDir is set
to "" which makes filepath.Join produce a relative path; change the fallback so
that when homeErr != nil or homeDir == "" you set homeDir to a safe absolute
fallback (e.g., os.TempDir()) before computing browserStateDir; update the logic
around homeDir/homeErr and the browserStateDir assignment (variables: homeDir,
homeErr, browserStateDir) so the result is always an absolute path.

In `@internal/pi/executor.go`:
- Around line 168-174: The child process currently inherits the full parent
environment when cmd.Env is not set, which can leak stale GSD_BROWSER_*
variables; update the logic around cmd.Env in executor.go (the exec.Cmd variable
`cmd` and the executor struct `e` with `e.opts.BrowserGrantID`,
`e.opts.BrowserID`, `e.opts.BrowserSessionID`) to first build cmd.Env from
os.Environ() filtered to remove any existing keys starting with "GSD_BROWSER_"
and then, only if all three options are non-empty, append the three sanitized
"GSD_BROWSER_GRANT_ID", "GSD_BROWSER_ID", and "GSD_BROWSER_SESSION_ID" entries;
ensure cmd.Env is always assigned the filtered slice so no stale GSD_BROWSER_*
variables are inherited when the condition is false.

In `@internal/pi/extension/index.ts`:
- Around line 55-58: The schema makes BrowserToolParams.params optional but
elsewhere the tool's parameters schema (look for the `parameters` object and its
`input_schema.required` array) still lists "params" as required, causing
validation mismatches; fix by removing "params" from `input_schema.required` (or
conditionally include it only when params are required) so
`input_schema.required` aligns with BrowserToolParams where `params` is
optional, ensuring `parameters` and `BrowserToolParams` agree; update the
corresponding duplicate block around the `parameters` definition at the other
location (lines ~66-74) as well.
- Around line 90-119: The browserRpc function can hang and leak sockets and the
input schema is inconsistent; update browserRpc to accept an AbortSignal
(forward the executor's _signal from execute), set a connection/read timeout
(e.g., setTimeout or socket.setTimeout) that destroys the socket and rejects the
promise, listen and handle 'end' and 'close' events to parse/cleanup or reject
on premature close, call socket.destroy() on error/timeout/abort, and remove the
unused 'params' entry from the input_schema.required array so it matches the
TypeBox Optional params; reference browserRpc (add an AbortSignal parameter and
signal handler), the execute call site (forward _signal to browserRpc), and
input_schema.required for the schema fix.

---

Outside diff comments:
In `@internal/loop/daemon.go`:
- Around line 621-640: The browser grant lookup must run for every task (not
only when actor == nil) so each Spawn/Resume call gets the current grant; move
the GrantForTask call (d.browserManager.GrantForTask) out of the actor==nil
guard and always compute browserGrantID/browserID before calling
d.manager.Spawn, passing the values into the Session options (BrowserGrantID,
BrowserID). Also ensure that when no grant exists you explicitly set
BrowserGrantID and BrowserID to "" to clear any prior browser context on a
reused actor.

---

Nitpick comments:
In `@internal/pi/extension/browser-tool.test.mjs`:
- Around line 5-15: Add a negative-test case to assert the no-grant branch: call
buildClaudeCliToolsForTest({}) and assert that none of the returned tools has
name "gsd_browser" (e.g., assert.equal(tools.some(tool => tool.name ===
"gsd_browser"), false)). Update or add an it() in browser-tool.test.mjs
alongside the existing positive test to cover the missing browserGrant path so
regressions that always register gsd_browser fail.
🪄 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 Plus

Run ID: b0c9e851-c55a-426e-a301-ad1e1c8a4225

📥 Commits

Reviewing files that changed from the base of the PR and between def6903 and 4d87d70.

⛔ Files ignored due to path filters (2)
  • go.sum is excluded by !**/*.sum
  • internal/pi/extension/package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (13)
  • go.mod
  • internal/browser/manager.go
  • internal/browser/manager_test.go
  • internal/browser/service.go
  • internal/browser/types.go
  • internal/loop/daemon.go
  • internal/pi/executor.go
  • internal/pi/extension/browser-tool.test.mjs
  • internal/pi/extension/index.ts
  • internal/pi/extension/package.json
  • internal/relay/conn.go
  • internal/session/actor.go
  • internal/session/browser_tool_test.go

Comment on lines +81 to +106
m.mu.Lock()
state := &sessionState{
openRequest: req,
browserID: result.BrowserID,
owner: OwnerAgent,
expiresAt: expiresAt,
frameCancel: frameCancel,
}
m.byID[result.BrowserID] = state
if msg.TaskID != "" {
m.byTask[msg.TaskID] = state
}
m.mu.Unlock()
if err := m.sender.Send(ctx, &protocol.BrowserSessionOpened{
Type: protocol.MsgTypeBrowserSessionOpened,
RequestID: msg.RequestID,
BrowserID: result.BrowserID,
GrantID: msg.GrantID,
SessionID: msg.SessionID,
ChannelID: msg.ChannelID,
URL: result.URL,
Title: result.Title,
OpenedAt: time.Now().UTC().Format(time.RFC3339Nano),
}); err != nil {
frameCancel()
return err
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Roll back manager state if BrowserSessionOpened cannot be sent.

The session is inserted into m.byID/m.byTask before line 94, but the error path only cancels the frame loop. If Send fails, Open returns an error while leaving a live session registered and never closing the underlying browser service. Remove the map entries and best-effort Close the browser before returning.

Suggested fix
  m.mu.Unlock()
  if err := m.sender.Send(ctx, &protocol.BrowserSessionOpened{
  		Type:      protocol.MsgTypeBrowserSessionOpened,
  		RequestID: msg.RequestID,
  		BrowserID: result.BrowserID,
  		GrantID:   msg.GrantID,
  		SessionID: msg.SessionID,
  		ChannelID: msg.ChannelID,
  		URL:       result.URL,
  		Title:     result.Title,
  		OpenedAt:  time.Now().UTC().Format(time.RFC3339Nano),
  	}); err != nil {
+ 		m.mu.Lock()
+ 		delete(m.byID, result.BrowserID)
+ 		if msg.TaskID != "" {
+ 			delete(m.byTask, msg.TaskID)
+ 		}
+ 		m.mu.Unlock()
  		frameCancel()
+ 		_ = m.service.Close(ctx, result.BrowserID)
  		return err
  	}
📝 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
m.mu.Lock()
state := &sessionState{
openRequest: req,
browserID: result.BrowserID,
owner: OwnerAgent,
expiresAt: expiresAt,
frameCancel: frameCancel,
}
m.byID[result.BrowserID] = state
if msg.TaskID != "" {
m.byTask[msg.TaskID] = state
}
m.mu.Unlock()
if err := m.sender.Send(ctx, &protocol.BrowserSessionOpened{
Type: protocol.MsgTypeBrowserSessionOpened,
RequestID: msg.RequestID,
BrowserID: result.BrowserID,
GrantID: msg.GrantID,
SessionID: msg.SessionID,
ChannelID: msg.ChannelID,
URL: result.URL,
Title: result.Title,
OpenedAt: time.Now().UTC().Format(time.RFC3339Nano),
}); err != nil {
frameCancel()
return err
m.mu.Lock()
state := &sessionState{
openRequest: req,
browserID: result.BrowserID,
owner: OwnerAgent,
expiresAt: expiresAt,
frameCancel: frameCancel,
}
m.byID[result.BrowserID] = state
if msg.TaskID != "" {
m.byTask[msg.TaskID] = state
}
m.mu.Unlock()
if err := m.sender.Send(ctx, &protocol.BrowserSessionOpened{
Type: protocol.MsgTypeBrowserSessionOpened,
RequestID: msg.RequestID,
BrowserID: result.BrowserID,
GrantID: msg.GrantID,
SessionID: msg.SessionID,
ChannelID: msg.ChannelID,
URL: result.URL,
Title: result.Title,
OpenedAt: time.Now().UTC().Format(time.RFC3339Nano),
}); err != nil {
m.mu.Lock()
delete(m.byID, result.BrowserID)
if msg.TaskID != "" {
delete(m.byTask, msg.TaskID)
}
m.mu.Unlock()
frameCancel()
_ = m.service.Close(ctx, result.BrowserID)
return err
}

Comment thread internal/browser/manager.go
Comment thread internal/browser/manager.go
Comment thread internal/browser/service.go
Comment thread internal/browser/service.go Outdated
Comment thread internal/loop/daemon.go
Comment thread internal/pi/executor.go Outdated
Comment thread internal/pi/extension/index.ts
Comment thread internal/pi/extension/index.ts Outdated
@glittercowboy glittercowboy force-pushed the codex/browser-session-daemon branch from 4d87d70 to f22b8ef Compare April 28, 2026 21:21
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

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

⚠️ Outside diff range comments (1)
internal/session/actor.go (1)

487-494: ⚠️ Potential issue | 🟠 Major

Snapshot browser context while holding taskMu.

SetBrowserContext can modify a.opts.BrowserGrantID/a.opts.BrowserID under taskMu, but executeTask reads these fields after releasing the lock (lines 526-527). This creates a race condition where concurrent SetBrowserContext calls during task execution can cause the wrong browser grant/ID to be attached to the task.

Suggested fix
@@
 	a.taskMu.Lock()
 	a.taskCancel = cancel
 	a.pendingTaskID = ""
 	a.taskID = task.TaskID
+	browserGrantID := a.opts.BrowserGrantID
+	browserID := a.opts.BrowserID
 	now := time.Now()
 	a.taskStartedAt = &now
 	a.idleSince = nil
 	a.taskMu.Unlock()
@@
 		CustomInstructions: task.CustomInstructions,
-		BrowserGrantID:     a.opts.BrowserGrantID,
-		BrowserID:          a.opts.BrowserID,
+		BrowserGrantID:     browserGrantID,
+		BrowserID:          browserID,
 	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/session/actor.go` around lines 487 - 494, executeTask releases
a.taskMu before reading browser fields, allowing SetBrowserContext to race and
change a.opts.BrowserGrantID / a.opts.BrowserID; fix by snapshotting the browser
context while still holding a.taskMu (e.g., read and store a.opts.BrowserGrantID
and a.opts.BrowserID into local variables or into the task object inside the
critical section) before unlocking, so when executeTask later uses those values
they reflect the state at task start; ensure the snapshot uses the same lock
(a.taskMu) and update references in executeTask to use the local snapshot
variables instead of reading a.opts directly after unlock.
♻️ Duplicate comments (1)
internal/browser/manager.go (1)

81-107: ⚠️ Potential issue | 🟠 Major

Roll back manager state when BrowserSessionOpened send fails.

State is inserted before send (Line 89-92), but failure path (Line 105-106) only cancels the frame context. This leaves stale entries in m.byID/m.byTask and leaks the underlying browser session.

Suggested fix
@@
 	if err := m.sender.Send(ctx, &protocol.BrowserSessionOpened{
@@
 	}); err != nil {
+		m.mu.Lock()
+		delete(m.byID, result.BrowserID)
+		if msg.TaskID != "" {
+			delete(m.byTask, msg.TaskID)
+		}
+		m.mu.Unlock()
 		frameCancel()
+		_ = m.service.Close(ctx, result.BrowserID)
 		return err
 	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/browser/manager.go` around lines 81 - 107, The code stores the new
sessionState into m.byID and m.byTask before sending the BrowserSessionOpened
message but never removes those entries if m.sender.Send fails, leaking state
and the browser session; update the error path after calling m.sender.Send so
that on error you acquire m.mu.Lock(), delete m.byID[result.BrowserID] and if
msg.TaskID != "" delete m.byTask[msg.TaskID], m.mu.Unlock(), then call
frameCancel() and return the error (keep using the existing sessionState,
frameCancel, and m.sender.Send symbols to locate the changes).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@internal/browser/manager.go`:
- Around line 160-181: The frame loop currently calls m.sender.Send with the
unbounded ctx from frameLoop which can block and stall the loop; wrap each
m.sender.Send call (the BrowserFrame and BrowserNavigation sends) with a short
cancellable context using context.WithTimeout (e.g., 500ms–1s), defer cancel()
and use that child context for the Send, and also propagate/check the returned
error instead of assigning to blank identifier so blocked sends are bounded and
errors are logged/handled; update the calls around m.sender.Send in frameLoop to
use the new timeout context and handle the error value.

---

Outside diff comments:
In `@internal/session/actor.go`:
- Around line 487-494: executeTask releases a.taskMu before reading browser
fields, allowing SetBrowserContext to race and change a.opts.BrowserGrantID /
a.opts.BrowserID; fix by snapshotting the browser context while still holding
a.taskMu (e.g., read and store a.opts.BrowserGrantID and a.opts.BrowserID into
local variables or into the task object inside the critical section) before
unlocking, so when executeTask later uses those values they reflect the state at
task start; ensure the snapshot uses the same lock (a.taskMu) and update
references in executeTask to use the local snapshot variables instead of reading
a.opts directly after unlock.

---

Duplicate comments:
In `@internal/browser/manager.go`:
- Around line 81-107: The code stores the new sessionState into m.byID and
m.byTask before sending the BrowserSessionOpened message but never removes those
entries if m.sender.Send fails, leaking state and the browser session; update
the error path after calling m.sender.Send so that on error you acquire
m.mu.Lock(), delete m.byID[result.BrowserID] and if msg.TaskID != "" delete
m.byTask[msg.TaskID], m.mu.Unlock(), then call frameCancel() and return the
error (keep using the existing sessionState, frameCancel, and m.sender.Send
symbols to locate the changes).
🪄 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 Plus

Run ID: 79a7fcc1-50f1-466c-a6b8-1e168067c723

📥 Commits

Reviewing files that changed from the base of the PR and between 4d87d70 and 40c0e2a.

⛔ Files ignored due to path filters (2)
  • go.sum is excluded by !**/*.sum
  • internal/pi/extension/package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (13)
  • go.mod
  • internal/browser/manager.go
  • internal/browser/manager_test.go
  • internal/browser/service.go
  • internal/browser/types.go
  • internal/loop/daemon.go
  • internal/pi/executor.go
  • internal/pi/extension/browser-tool.test.mjs
  • internal/pi/extension/index.ts
  • internal/pi/extension/package.json
  • internal/relay/conn.go
  • internal/session/actor.go
  • internal/session/browser_tool_test.go
✅ Files skipped from review due to trivial changes (3)
  • internal/pi/extension/package.json
  • go.mod
  • internal/browser/types.go
🚧 Files skipped from review as they are similar to previous changes (7)
  • internal/session/browser_tool_test.go
  • internal/pi/extension/browser-tool.test.mjs
  • internal/browser/manager_test.go
  • internal/relay/conn.go
  • internal/pi/executor.go
  • internal/loop/daemon.go
  • internal/pi/extension/index.ts

Comment on lines +160 to +181
_ = m.sender.Send(ctx, &protocol.BrowserFrame{
Type: protocol.MsgTypeBrowserFrame,
BrowserID: browserID,
SessionID: req.SessionID,
ChannelID: req.ChannelID,
Seq: frame.Sequence,
ContentType: frame.ContentType,
DataBase64: frame.DataBase64,
Width: frame.Width,
Height: frame.Height,
CapturedAt: frame.CapturedAt,
})
if frame.URL != "" {
_ = m.sender.Send(ctx, &protocol.BrowserNavigation{
Type: protocol.MsgTypeBrowserNavigation,
BrowserID: browserID,
SessionID: req.SessionID,
ChannelID: req.ChannelID,
URL: frame.URL,
Title: frame.Title,
EndedAt: time.Now().UTC().Format(time.RFC3339Nano),
})
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Bound frame event sends with a timeout.

m.sender.Send uses ctx from frameLoop (no deadline). If sender blocks, one call can stall the frame loop indefinitely.

Suggested fix
@@
-	_ = m.sender.Send(ctx, &protocol.BrowserFrame{
+	sendCtx, sendCancel := context.WithTimeout(ctx, 5*time.Second)
+	_ = m.sender.Send(sendCtx, &protocol.BrowserFrame{
@@
-	})
+	})
+	sendCancel()
 	if frame.URL != "" {
-		_ = m.sender.Send(ctx, &protocol.BrowserNavigation{
+		navCtx, navCancel := context.WithTimeout(ctx, 5*time.Second)
+		_ = m.sender.Send(navCtx, &protocol.BrowserNavigation{
@@
-		})
+		})
+		navCancel()
 	}
📝 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
_ = m.sender.Send(ctx, &protocol.BrowserFrame{
Type: protocol.MsgTypeBrowserFrame,
BrowserID: browserID,
SessionID: req.SessionID,
ChannelID: req.ChannelID,
Seq: frame.Sequence,
ContentType: frame.ContentType,
DataBase64: frame.DataBase64,
Width: frame.Width,
Height: frame.Height,
CapturedAt: frame.CapturedAt,
})
if frame.URL != "" {
_ = m.sender.Send(ctx, &protocol.BrowserNavigation{
Type: protocol.MsgTypeBrowserNavigation,
BrowserID: browserID,
SessionID: req.SessionID,
ChannelID: req.ChannelID,
URL: frame.URL,
Title: frame.Title,
EndedAt: time.Now().UTC().Format(time.RFC3339Nano),
})
sendCtx, sendCancel := context.WithTimeout(ctx, 5*time.Second)
_ = m.sender.Send(sendCtx, &protocol.BrowserFrame{
Type: protocol.MsgTypeBrowserFrame,
BrowserID: browserID,
SessionID: req.SessionID,
ChannelID: req.ChannelID,
Seq: frame.Sequence,
ContentType: frame.ContentType,
DataBase64: frame.DataBase64,
Width: frame.Width,
Height: frame.Height,
CapturedAt: frame.CapturedAt,
})
sendCancel()
if frame.URL != "" {
navCtx, navCancel := context.WithTimeout(ctx, 5*time.Second)
_ = m.sender.Send(navCtx, &protocol.BrowserNavigation{
Type: protocol.MsgTypeBrowserNavigation,
BrowserID: browserID,
SessionID: req.SessionID,
ChannelID: req.ChannelID,
URL: frame.URL,
Title: frame.Title,
EndedAt: time.Now().UTC().Format(time.RFC3339Nano),
})
navCancel()
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/browser/manager.go` around lines 160 - 181, The frame loop currently
calls m.sender.Send with the unbounded ctx from frameLoop which can block and
stall the loop; wrap each m.sender.Send call (the BrowserFrame and
BrowserNavigation sends) with a short cancellable context using
context.WithTimeout (e.g., 500ms–1s), defer cancel() and use that child context
for the Send, and also propagate/check the returned error instead of assigning
to blank identifier so blocked sends are bounded and errors are logged/handled;
update the calls around m.sender.Send in frameLoop to use the new timeout
context and handle the error value.

@glittercowboy glittercowboy force-pushed the codex/browser-session-daemon branch from 40c0e2a to bf55277 Compare April 28, 2026 21:41
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

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

⚠️ Outside diff range comments (1)
internal/session/actor.go (1)

487-528: ⚠️ Potential issue | 🟠 Major

Snapshot the browser context before releasing taskMu.

SetBrowserContext updates a.opts.BrowserGrantID / a.opts.BrowserID under taskMu, but Lines 526-527 read them after the lock was dropped. That leaves executeTask racy and can attach the wrong browser grant to a task while it is starting.

Proposed fix
 	a.taskMu.Lock()
 	a.taskCancel = cancel
 	a.pendingTaskID = ""
 	a.taskID = task.TaskID
 	now := time.Now()
 	a.taskStartedAt = &now
 	a.idleSince = nil
+	browserGrantID := a.opts.BrowserGrantID
+	browserID := a.opts.BrowserID
 	a.taskMu.Unlock()
@@
 	tc := &taskContext{
 		TaskID:             task.TaskID,
 		ChannelID:          task.ChannelID,
 		ActorContext:       ctx,
@@
-		BrowserGrantID:     a.opts.BrowserGrantID,
-		BrowserID:          a.opts.BrowserID,
+		BrowserGrantID:     browserGrantID,
+		BrowserID:          browserID,
 	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/session/actor.go` around lines 487 - 528, The code reads
a.opts.BrowserGrantID and a.opts.BrowserID into the taskContext (tc) after
releasing a.taskMu, which creates a race if SetBrowserContext is called under
the lock; fix by snapshotting the browser grant and browser id into local
variables while holding a.taskMu (use the same critical section where
a.taskCancel, a.taskID, a.taskStartedAt and idle fields are set) and then
populate tc.BrowserGrantID and tc.BrowserID from those local variables before
releasing the lock so executeTask uses the consistent browser context.
♻️ Duplicate comments (2)
internal/browser/manager.go (2)

154-181: ⚠️ Potential issue | 🟠 Major

Bound the frame and navigation sends.

frameLoop runs on a background context, so Lines 160 and 173 can block indefinitely inside m.sender.Send. One stuck relay send will freeze frame streaming and stop later expiry checks from running on that loop.

Proposed fix
-	_ = m.sender.Send(ctx, &protocol.BrowserFrame{
+	sendCtx, sendCancel := context.WithTimeout(ctx, 5*time.Second)
+	err = m.sender.Send(sendCtx, &protocol.BrowserFrame{
 		Type:        protocol.MsgTypeBrowserFrame,
 		BrowserID:   browserID,
 		SessionID:   req.SessionID,
@@
 		Height:      frame.Height,
 		CapturedAt:  frame.CapturedAt,
 	})
+	sendCancel()
+	if err != nil {
+		return
+	}
 	if frame.URL != "" {
-		_ = m.sender.Send(ctx, &protocol.BrowserNavigation{
+		navCtx, navCancel := context.WithTimeout(ctx, 5*time.Second)
+		_ = m.sender.Send(navCtx, &protocol.BrowserNavigation{
 			Type:      protocol.MsgTypeBrowserNavigation,
 			BrowserID: browserID,
 			SessionID: req.SessionID,
@@
 			Title:     frame.Title,
 			EndedAt:   time.Now().UTC().Format(time.RFC3339Nano),
 		})
+		navCancel()
 	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/browser/manager.go` around lines 154 - 181, The frameLoop uses the
background ctx and currently calls m.sender.Send(...) for protocol.BrowserFrame
and protocol.BrowserNavigation which can block indefinitely; wrap each send in a
short bounded context (e.g., context.WithTimeout with a small timeout like 1–2s)
so a stuck relay won't stall the loop, then cancel the timeout context and
ignore/send error handling as appropriate; update the calls around m.sender.Send
in the frameLoop (the BrowserFrame and BrowserNavigation sends) to use this
timeout context when invoking m.sender.Send.

81-106: ⚠️ Potential issue | 🟠 Major

Roll back the opened session if BrowserSessionOpened fails.

By Line 93 the session is already registered in m.byID / m.byTask. If Line 94 fails, Open returns an error but leaves the state behind and the browser process alive; frameCancel() only stops future frame sends.

Proposed fix
 	m.mu.Unlock()
 	if err := m.sender.Send(ctx, &protocol.BrowserSessionOpened{
 		Type:      protocol.MsgTypeBrowserSessionOpened,
 		RequestID: msg.RequestID,
@@
 		Title:     result.Title,
 		OpenedAt:  time.Now().UTC().Format(time.RFC3339Nano),
 	}); err != nil {
+		m.mu.Lock()
+		m.removeStateLocked(state)
+		m.mu.Unlock()
 		frameCancel()
+		closeCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+		defer cancel()
+		_ = m.service.Close(closeCtx, result.BrowserID)
 		return err
 	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/browser/manager.go` around lines 81 - 106, The session is inserted
into m.byID / m.byTask before sending the BrowserSessionOpened message, so if
m.sender.Send(...) fails the registered state and live browser remain; modify
Open to undo registration and cancel the session when sender.Send returns an
error: after detecting the send error call frameCancel(), remove the session
from m.byID and, if msg.TaskID != "" remove from m.byTask, and clear any other
state (e.g., ensure no lingering references to sessionState) before returning
the error so the browser/process and in-memory maps are rolled back; look for
the insertion sites using sessionState, m.byID, m.byTask and the send call to
implement the rollback.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@internal/browser/manager_test.go`:
- Around line 11-54: The test doubles fakeService and recordingSender are not
concurrency-safe as Manager.Open starts frameLoop which mutates
fakeService.calls and recordingSender.msgs from background goroutines; make them
safe by adding a sync.Mutex (or RWMutex) to fakeService and recordingSender,
lock around all mutations in methods Open/Close/Frame/Tool/UserInput and Send,
and add thread-safe snapshot accessor methods (e.g., fakeService.Calls() and
recordingSender.Msgs()) that return a copy under the lock so tests can read
stable snapshots when asserting after Manager.Open/frameLoop.

---

Outside diff comments:
In `@internal/session/actor.go`:
- Around line 487-528: The code reads a.opts.BrowserGrantID and a.opts.BrowserID
into the taskContext (tc) after releasing a.taskMu, which creates a race if
SetBrowserContext is called under the lock; fix by snapshotting the browser
grant and browser id into local variables while holding a.taskMu (use the same
critical section where a.taskCancel, a.taskID, a.taskStartedAt and idle fields
are set) and then populate tc.BrowserGrantID and tc.BrowserID from those local
variables before releasing the lock so executeTask uses the consistent browser
context.

---

Duplicate comments:
In `@internal/browser/manager.go`:
- Around line 154-181: The frameLoop uses the background ctx and currently calls
m.sender.Send(...) for protocol.BrowserFrame and protocol.BrowserNavigation
which can block indefinitely; wrap each send in a short bounded context (e.g.,
context.WithTimeout with a small timeout like 1–2s) so a stuck relay won't stall
the loop, then cancel the timeout context and ignore/send error handling as
appropriate; update the calls around m.sender.Send in the frameLoop (the
BrowserFrame and BrowserNavigation sends) to use this timeout context when
invoking m.sender.Send.
- Around line 81-106: The session is inserted into m.byID / m.byTask before
sending the BrowserSessionOpened message, so if m.sender.Send(...) fails the
registered state and live browser remain; modify Open to undo registration and
cancel the session when sender.Send returns an error: after detecting the send
error call frameCancel(), remove the session from m.byID and, if msg.TaskID !=
"" remove from m.byTask, and clear any other state (e.g., ensure no lingering
references to sessionState) before returning the error so the browser/process
and in-memory maps are rolled back; look for the insertion sites using
sessionState, m.byID, m.byTask and the send call to implement the rollback.
🪄 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 Plus

Run ID: 657e93fc-7624-4450-903b-a7893c8ad234

📥 Commits

Reviewing files that changed from the base of the PR and between 40c0e2a and bf55277.

⛔ Files ignored due to path filters (1)
  • internal/pi/extension/package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (12)
  • internal/browser/manager.go
  • internal/browser/manager_test.go
  • internal/browser/service.go
  • internal/browser/types.go
  • internal/loop/daemon.go
  • internal/pi/executor.go
  • internal/pi/extension/browser-tool.test.mjs
  • internal/pi/extension/index.ts
  • internal/pi/extension/package.json
  • internal/relay/conn.go
  • internal/session/actor.go
  • internal/session/browser_tool_test.go
✅ Files skipped from review due to trivial changes (3)
  • internal/pi/extension/package.json
  • internal/pi/extension/browser-tool.test.mjs
  • internal/browser/types.go
🚧 Files skipped from review as they are similar to previous changes (2)
  • internal/pi/extension/index.ts
  • internal/loop/daemon.go

Comment on lines +11 to +54
type fakeService struct {
calls []string
}

func (f *fakeService) Open(ctx context.Context, req OpenRequest) (OpenResult, error) {
f.calls = append(f.calls, "open:"+req.GrantID)
return OpenResult{BrowserID: "browser_1", URL: "about:blank", Title: "Blank"}, nil
}

func (f *fakeService) Close(ctx context.Context, browserID string) error {
f.calls = append(f.calls, "close:"+browserID)
return nil
}

func (f *fakeService) Frame(ctx context.Context, browserID string) (Frame, error) {
f.calls = append(f.calls, "frame:"+browserID)
return Frame{
Sequence: 1,
ContentType: "image/jpeg",
DataBase64: "aGVsbG8=",
Width: 1280,
Height: 720,
CapturedAt: time.Now().UTC().Format(time.RFC3339Nano),
}, nil
}

func (f *fakeService) Tool(ctx context.Context, browserID string, method string, params []byte) (ToolResult, error) {
f.calls = append(f.calls, "tool:"+method)
return ToolResult{OK: true, ResultJSON: []byte(`{"ok":true}`)}, nil
}

func (f *fakeService) UserInput(ctx context.Context, browserID string, input *protocol.BrowserUserInput) error {
f.calls = append(f.calls, "input:"+input.Kind)
return nil
}

type recordingSender struct {
msgs []any
}

func (r *recordingSender) Send(ctx context.Context, msg any) error {
r.msgs = append(r.msgs, msg)
return nil
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Make the test doubles concurrency-safe.

Manager.Open starts frameLoop immediately, so fakeService.calls and recordingSender.msgs are mutated from background code while the tests inspect them on the main goroutine. The raw slices here will race under go test -race and can make the assertions flaky.

Proposed fix
 type fakeService struct {
+	mu    sync.Mutex
 	calls []string
 }
 
 func (f *fakeService) Open(ctx context.Context, req OpenRequest) (OpenResult, error) {
+	f.mu.Lock()
 	f.calls = append(f.calls, "open:"+req.GrantID)
+	f.mu.Unlock()
 	return OpenResult{BrowserID: "browser_1", URL: "about:blank", Title: "Blank"}, nil
 }
@@
 type recordingSender struct {
+	mu   sync.Mutex
 	msgs []any
 }
 
 func (r *recordingSender) Send(ctx context.Context, msg any) error {
+	r.mu.Lock()
 	r.msgs = append(r.msgs, msg)
+	r.mu.Unlock()
 	return nil
 }

You’ll also want to read these slices through a locked snapshot helper in the assertions.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/browser/manager_test.go` around lines 11 - 54, The test doubles
fakeService and recordingSender are not concurrency-safe as Manager.Open starts
frameLoop which mutates fakeService.calls and recordingSender.msgs from
background goroutines; make them safe by adding a sync.Mutex (or RWMutex) to
fakeService and recordingSender, lock around all mutations in methods
Open/Close/Frame/Tool/UserInput and Send, and add thread-safe snapshot accessor
methods (e.g., fakeService.Calls() and recordingSender.Msgs()) that return a
copy under the lock so tests can read stable snapshots when asserting after
Manager.Open/frameLoop.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant