diff --git a/docs/superpowers/plans/2026-05-26-multi-window-injection.md b/docs/superpowers/plans/2026-05-26-multi-window-injection.md new file mode 100644 index 0000000..d4bbe87 --- /dev/null +++ b/docs/superpowers/plans/2026-05-26-multi-window-injection.md @@ -0,0 +1,238 @@ +# Multi-Window Injection Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Implement multi-window Codex Helper injection from `docs/superpowers/specs/2026-05-26-multi-window-injection-design.md`. + +**Architecture:** Add a multi-target CDP discovery API, carry caller identity through the bridge envelope, and replace single-target injection with a controller-owned injection registry. Target-scoped routes use caller target identity, while global and session-scoped routes keep their existing business semantics. + +**Tech Stack:** Rust/Tauri backend, Chrome DevTools Protocol over WebSocket, injected browser JavaScript runtime, Bun runtime tests. + +--- + +## File Structure + +- Modify `src-tauri/src/cdp.rs`: add `codex_page_targets`, target lookup helpers, and focused tests. +- Modify `src-tauri/src/bridge.rs`: add `BridgeCaller`, `BridgeRequest`, caller-aware bridge script generation, and request parsing tests. +- Modify `src-tauri/src/routes.rs`: make bridge request routing accept caller identity and make `/devtools/open` target-aware. +- Modify `src-tauri/src/codex_control.rs`: add injection registry and sync all Codex targets. +- Modify `runtime/core.js`: keep public `bridge(path, payload)` simple while relying on the injected bridge envelope. +- Modify `runtime/bootstrap.js`: send runtime readiness and activity diagnostics. +- Modify `runtime/ports.js`: gate automatic discovery and cleanup behind active window ownership. +- Modify runtime tests under `runtime/_test-*.test.js`: verify caller identity and active-window port behavior. + +## Task 1: Multi-Target CDP Discovery + +**Files:** +- Modify: `src-tauri/src/cdp.rs` + +- [x] **Step 1: Write failing tests** + +Add tests in `src-tauri/src/cdp.rs`: + +```rust +#[test] +fn cdp_returns_all_codex_page_targets() { + let targets = vec![ + target("one", "page", "Codex", Some("ws://one")), + target("two", "page", "Codex", Some("ws://two")), + target("worker", "worker", "Codex", Some("ws://worker")), + target("missing", "page", "Codex", None), + ]; + + let selected = codex_page_targets(&targets); + + assert_eq!( + selected.iter().map(|target| target.id.as_str()).collect::>(), + vec!["one", "two"] + ); +} +``` + +- [x] **Step 2: Verify red** + +Run: `cargo test cdp_returns_all_codex_page_targets` + +Expected: compile failure because `codex_page_targets` is not defined. + +- [x] **Step 3: Implement discovery helper** + +Add `pub fn codex_page_targets(targets: &[CdpTarget]) -> Vec` using the existing Codex title/URL criteria and websocket requirement. + +- [x] **Step 4: Verify green** + +Run: `cargo test cdp_returns_all_codex_page_targets` + +Expected: test passes. + +## Task 2: Bridge Caller Envelope + +**Files:** +- Modify: `src-tauri/src/bridge.rs` + +- [x] **Step 1: Write failing tests** + +Add tests proving: + +```rust +let script = build_bridge_script( + "codexHelperBridgeV1", + &BridgeCaller::new_for_target("target-1", "instance-1"), +); +assert!(script.contains("\"targetId\":\"target-1\"")); +assert!(script.contains("\"helperInstanceId\":\"instance-1\"")); +``` + +Add a parsing test for: + +```json +{"id":"1","path":"/devtools/open","payload":{},"caller":{"targetId":"target-1","helperInstanceId":"instance-1","href":"app://-/index.html","hasFocus":true,"visibilityState":"visible"}} +``` + +- [x] **Step 2: Verify red** + +Run: `cargo test bridge_script_includes_caller_identity bridge_request_parses_caller_identity` + +Expected: compile failure because caller types and parser are not defined. + +- [x] **Step 3: Implement caller types and parser** + +Add `BridgeCaller`, `BridgeRequest`, `parse_bridge_request`, and update `build_bridge_script` to include caller metadata automatically. + +- [x] **Step 4: Verify green** + +Run: `cargo test bridge_script_includes_caller_identity bridge_request_parses_caller_identity` + +Expected: tests pass. + +## Task 3: Caller-Aware Routes + +**Files:** +- Modify: `src-tauri/src/routes.rs` +- Modify: `src-tauri/src/bridge.rs` + +- [x] **Step 1: Write failing tests** + +Add route tests proving `/devtools/open` uses caller `targetId` instead of `pick_codex_page_target`. + +- [x] **Step 2: Verify red** + +Run: `cargo test devtools_open_uses_caller_target` + +Expected: compile failure or assertion failure because current route still picks the first target. + +- [x] **Step 3: Implement caller-aware route context** + +Update the bridge handler to pass `BridgeRequest` into route handling. Add target lookup by caller target id and make unknown target ids fail explicitly. + +- [x] **Step 4: Verify green** + +Run: `cargo test devtools_open_uses_caller_target` + +Expected: test passes. + +## Task 4: Injection Registry + +**Files:** +- Modify: `src-tauri/src/codex_control.rs` +- Modify: `src-tauri/src/bridge.rs` + +- [x] **Step 1: Write failing tests** + +Add unit-testable pure functions for registry sync decisions: + +```rust +let current = vec!["target-a".to_string(), "target-b".to_string()]; +let existing = vec!["target-a".to_string(), "target-old".to_string()]; +let plan = plan_injection_sync(¤t, &existing); +assert_eq!(plan.inject, vec!["target-b"]); +assert_eq!(plan.retain, vec!["target-a"]); +assert_eq!(plan.prune, vec!["target-old"]); +``` + +- [x] **Step 2: Verify red** + +Run: `cargo test injection_sync_plans_inject_retain_and_prune` + +Expected: compile failure because the sync planner is missing. + +- [x] **Step 3: Implement registry sync path** + +Add sync planning, registry structs, and controller `sync_injected_targets`. Initial launch, Open Codex, and Restart Codex should call sync instead of a single-target inject. + +- [x] **Step 4: Verify green** + +Run: `cargo test injection_sync_plans_inject_retain_and_prune` + +Expected: test passes. + +## Task 5: Runtime Activity and Port Ownership + +**Files:** +- Modify: `runtime/core.js` +- Modify: `runtime/bootstrap.js` +- Modify: `runtime/ports.js` +- Modify: `runtime/_test-ports-panel.test.js` +- Modify: `runtime/_test-port-detection.test.js` + +- [x] **Step 1: Write failing tests** + +Add runtime source tests proving: + +```js +expect(source).toContain("runtime.ready"); +expect(source).toContain("/runtime/activity"); +expect(source).toContain("document.hasFocus()"); +expect(source).toContain("helperWindowIsPortOwner"); +``` + +- [x] **Step 2: Verify red** + +Run: `bun test runtime/_test-ports-panel.test.js runtime/_test-port-detection.test.js` + +Expected: tests fail because runtime activity and ownership guards are absent. + +- [x] **Step 3: Implement runtime activity and ownership guard** + +Send ready/activity diagnostics from bootstrap. Gate automatic port discovery, auto-forwarding, stale cleanup, and duplicate auto cleanup behind `helperWindowIsPortOwner()`. + +- [x] **Step 4: Verify green** + +Run: `bun test runtime/_test-ports-panel.test.js runtime/_test-port-detection.test.js` + +Expected: tests pass. + +## Task 6: Final Verification + +**Files:** +- Read: `docs/superpowers/specs/2026-05-26-multi-window-injection-design.md` + +- [x] **Step 1: Format** + +Run: `cargo fmt` + +Expected: no output and exit 0. + +- [x] **Step 2: Rust tests** + +Run: `cargo test` + +Expected: all Rust tests pass. + +Current evidence: default parallel `cargo test` passes with 82 tests. + +- [x] **Step 3: Runtime tests** + +Run: `bun test runtime` + +Expected: all runtime tests pass. + +- [x] **Step 4: Completion audit** + +Read the design spec and verify each named requirement has code or test evidence. Keep the goal active if any required item remains incomplete. + +## Self-Review + +- Spec coverage: all design sections map to at least one task. +- Empty-marker scan: no incomplete requirements remain. +- Type consistency: `targetId`, `helperInstanceId`, `BridgeCaller`, and `BridgeRequest` names are consistent across backend and runtime tasks. diff --git a/docs/superpowers/specs/2026-05-26-multi-window-injection-design.md b/docs/superpowers/specs/2026-05-26-multi-window-injection-design.md new file mode 100644 index 0000000..bb21671 --- /dev/null +++ b/docs/superpowers/specs/2026-05-26-multi-window-injection-design.md @@ -0,0 +1,375 @@ +# Codex Helper Multi-Window Injection Design + +## Goal + +Codex Helper should support multiple open Codex windows by injecting Helper runtime capabilities into every injectable Codex page target and by making every bridge request carry an explicit caller identity. Helper should no longer rely on a single selected Codex target or infer the active window from CDP target order. + +## Product Shape + +Users should be able to open more than one Codex window and use Helper features from whichever Codex window they are interacting with. The Helper Settings entry, session context actions, port forwarding UI, diagnostics, and local bridge calls should behave as local enhancements inside each injected Codex window. + +Target-specific commands should operate on the window that initiated the request. Global commands should stay global and make that scope clear. Session-level commands should continue to use business identifiers such as session id, host id, remote project path, and thread id. + +## Current Behavior + +The current backend chooses one Codex page target and injects only that target: + +- `src-tauri/src/cdp.rs` exposes `pick_codex_page_target`, which returns one target. +- `src-tauri/src/codex_control.rs` calls `wait_for_codex_target` and passes one `target_id` to the bridge installer. +- `src-tauri/src/bridge.rs` attaches one CDP session to that target and installs one runtime binding pump. +- `src-tauri/src/routes.rs` route handlers such as `/devtools/open` re-run target selection instead of using the window that made the bridge request. + +This is acceptable for a single Codex window but becomes ambiguous when more than one Codex page target exists. + +## Definitions + +Codex Helper should distinguish three identity layers: + +- `targetId`: the CDP page target id. This identifies a Codex renderer target. +- `helperInstanceId`: a Helper-generated id for one runtime installation inside one target. +- Business identity: session-level fields such as `sessionId`, `hostId`, `remotePath`, and `threadId`. + +These identities must not be conflated. `targetId` is for window-scoped behavior. `helperInstanceId` is for injection lifecycle and diagnostics. Business identity is for export, fork, port forwarding, and remote project actions. + +## Non-Goals + +This design does not add a new standalone window manager UI. + +This design does not modify Codex application files or rely on private native hooks. + +This design does not make user scripts singleton processes. User scripts remain per injected Codex page. + +This design does not require perfect OS-level frontmost-window detection. Helper can infer active Helper instances from runtime focus and activity events. + +This design does not change the security boundary of existing bridge routes. Routes remain allowlisted local Helper capabilities. + +## Architecture + +### Target Discovery + +`src-tauri/src/cdp.rs` should add a multi-target API: + +```text +codex_page_targets(targets: &[CdpTarget]) -> Vec +``` + +The filter should accept only targets that: + +- have `target_type == "page"`; +- have a non-empty `web_socket_debugger_url`; +- match Codex by title or URL using the existing Codex target criteria. + +The existing single-target picker can remain for transitional commands and tests, but multi-window injection should use the new list API. + +### Injection Registry + +`CodexController` should own an injection registry keyed by `targetId`: + +```text +HashMap +``` + +Each `InjectedTarget` should store: + +- `target_id`; +- `helper_instance_id`; +- last known `title`; +- last known `url`; +- `last_ready_at`; +- `last_seen_at`; +- the binding pump task handle or cancellation handle. + +The controller should expose a synchronization operation: + +```text +sync_injected_targets() +``` + +The operation should: + +1. Query current CDP targets. +2. Filter Codex page targets. +3. Inject any Codex target that is not already registered. +4. Keep existing registered targets whose CDP target still exists. +5. Prune destroyed targets and cancel their binding pumps. +6. Log the count of discovered, injected, retained, and pruned targets. + +Initial launch, Open Codex, Restart Codex, and explicit reinjection actions should call `sync_injected_targets` instead of injecting a single target. + +### Target Event Watcher + +The first implementation can be polling-based through `sync_injected_targets`. A later incremental improvement should add a long-lived browser-level CDP watcher using target discovery events. + +The watcher should: + +- observe new page targets; +- inject new Codex page targets; +- update title and URL when target metadata changes; +- remove registry entries when targets are destroyed. + +The watcher should not replace the sync operation. Sync remains the recovery path after missed events, watcher restarts, or CDP reconnects. + +## Bridge Envelope + +`build_bridge_script` should accept caller metadata and embed it into every bridge request. The renderer-facing `bridge(path, payload)` helper can stay simple; the low-level bridge function should add the caller envelope automatically. + +Bridge payloads should become: + +```json +{ + "id": "1", + "path": "/devtools/open", + "payload": {}, + "caller": { + "targetId": "CDP_TARGET_ID", + "helperInstanceId": "HELPER_INSTANCE_ID", + "href": "app://-/index.html", + "hasFocus": true, + "visibilityState": "visible" + } +} +``` + +The Rust bridge layer should parse this envelope into: + +```text +BridgeRequest { + id: String, + path: String, + payload: Value, + caller: BridgeCaller, +} + +BridgeCaller { + target_id: String, + helper_instance_id: String, + href: String, + has_focus: bool, + visibility_state: String, +} +``` + +Malformed caller data should fail the request explicitly for target-scoped routes. Global routes may still run if caller data is missing, but they should log that the request was caller-less. + +## Route Scope + +Routes should be grouped by scope. + +### Target-Scoped Routes + +These routes must use caller identity: + +- `/devtools/open`; +- current-window reload routes added in the future; +- runtime readiness and activity routes. + +`/devtools/open` should open DevTools for `caller.targetId`. It should not call `pick_codex_page_target`. + +### Global Routes + +These routes remain global: + +- `/backend/status`; +- `/runtime/user-scripts`; +- `/settings/get`; +- `/settings/set`; +- `/diagnostics/read-latest`; +- `/diagnostics/reveal-log`; +- `/logs/reveal`; +- `/scripts/reveal`; +- `/state/reveal`; +- `/url/open-external`; +- `/zed-remote/status`; +- `/projects/remote-list`; +- `/zed-remote/resolve-host`; +- `/zed-remote/fallback-request`; +- `/zed-remote/open`. + +Global routes may record caller metadata for diagnostics, but their behavior should not depend on the caller target. + +### Session-Scoped Routes + +These routes should continue to use explicit business payload fields: + +- `/export-markdown`; +- `/fork-thread-project`; +- `/ports/discover`; +- `/ports/forward`; +- `/ports/stop`. + +The caller target should be recorded for diagnostics and ownership decisions, but the authoritative session identity remains `sessionId`, `hostId`, `remotePath`, and `threadId`. + +## Runtime Activity + +The runtime should report readiness after successful installation: + +```text +event: runtime.ready +detail: targetId, helperInstanceId, href, hasFocus, visibilityState +``` + +The runtime should also report activity when focus or visibility changes: + +```text +path: /runtime/activity +payload: targetId, helperInstanceId, href, hasFocus, visibilityState +``` + +The backend should use this to maintain: + +- `last_active_target_id`; +- `last_active_helper_instance_id`; +- activity timestamps for diagnostics and ownership decisions. + +This is an application-level active Helper signal, not a guaranteed OS frontmost-window detector. + +## Port Forwarding Ownership + +Port tunnels remain global because they are local machine resources. Auto-discovery and stale cleanup must not run with equal authority in every injected window. + +The design should use an owner model: + +- The active Helper instance may run remote discovery and auto-forwarding. +- Non-active Helper instances may display global tunnel state through `/ports/list`. +- Stale cleanup and duplicate auto-tunnel cleanup may only be performed by the active owner for that session context. +- Manual user actions from any window may still request explicit forward or stop operations. + +The owner identity should be: + +```text +ownerHelperInstanceId +ownerTargetId +sessionKey = hostId + remotePath + threadId +``` + +Ownership should be time-bounded. If the active owner stops heartbeating or loses focus, another focused Helper instance can become owner for the same session key. + +This prevents one background Codex window from stopping a tunnel that another active Codex window is still using. + +## User Scripts + +User scripts should continue to run per injected Codex page. This should be documented in the Settings surface or diagnostics text when user scripts are listed. + +Helper should not attempt to make user scripts singleton-global. If a user script needs singleton behavior, it should implement its own external coordination or avoid global side effects. + +The existing runtime cleanup hook remains important because repeated injection into the same target should replace Helper event listeners, timers, observers, and UI roots instead of stacking duplicates. + +## Diagnostics + +Diagnostics should include caller identity for every bridge request where available: + +- `targetId`; +- `helperInstanceId`; +- `href`; +- `hasFocus`; +- `visibilityState`; +- route path; +- result status. + +Injection diagnostics should include: + +- target discovery count; +- Codex page target count; +- injected target count; +- retained target count; +- pruned target count; +- per-target injection failure details. + +This makes multi-window failures debuggable without requiring users to know CDP target ids. + +## Error Handling + +Missing caller identity for a target-scoped route should return a failed response with a clear message. + +Unknown target ids should return a failed response instead of falling back to the first Codex target. + +Per-target injection failure should not abort injection for other Codex targets. The sync operation should return an aggregate status and log per-target failures. + +If no Codex page targets exist, launch should continue to report the existing explicit no-target error. + +If CDP target listing fails, sync should surface the error and avoid clearing the registry until a later successful query can confirm target destruction. + +## Testing Strategy + +Unit tests should cover: + +- `codex_page_targets` returns all matching Codex page targets and rejects non-page or missing-websocket targets. +- Existing single-target picker behavior remains stable until all callers migrate. +- Bridge script includes caller metadata in every request. +- Bridge request parsing rejects missing caller data for target-scoped routes. +- `/devtools/open` uses caller target id. +- Injection sync injects missing targets, keeps existing targets, and prunes destroyed targets. +- Injection sync continues after one target fails to inject. +- Port ownership allows only active owners to run auto-discovery cleanup. + +Runtime tests should cover: + +- `bridge(path, payload)` remains the public runtime helper. +- The low-level bridge envelope includes `targetId`, `helperInstanceId`, `href`, `hasFocus`, and `visibilityState`. +- Runtime cleanup still removes event listeners, observers, timers, and temporary UI state before reinjection. +- Non-active window port scanning does not auto-forward or stop stale tunnels. + +Manual validation should cover: + +- Open two Codex windows and confirm both show Helper Settings. +- Open DevTools from each window and confirm the correct window target is selected. +- Run session actions from both windows and confirm each action uses the clicked row/session payload. +- Use remote port forwarding from one active remote window and confirm a background window does not stop its tunnel. +- Close one Codex window and confirm the target registry prunes only that target. + +## Implementation Phases + +### Phase 1: Multi-Target Discovery + +Add `codex_page_targets` and tests. Keep `pick_codex_page_target` for existing single-target callers. + +### Phase 2: Bridge Caller Identity + +Add `BridgeCaller` and `BridgeRequest` parsing. Update `build_bridge_script` so every request includes caller metadata. + +### Phase 3: Injection Registry + +Replace single-target injection in `CodexController` with `sync_injected_targets`. Initial implementation can use list-and-sync without a target event watcher. + +### Phase 4: Caller-Aware Routes + +Convert `/devtools/open` to operate on `caller.targetId`. Add explicit failures for missing or unknown caller targets. + +### Phase 5: Runtime Activity and Port Ownership + +Add runtime readiness and activity reporting. Gate automatic port discovery, auto-forwarding, and stale cleanup behind the active owner model. + +### Phase 6: Optional Target Watcher + +Add browser-level target event watching as a responsiveness improvement after the sync-based path is stable. + +## Review Notes + +The design intentionally favors full Codex page injection over active-window-only injection. Full injection ensures the active window already has Helper features when the user interacts with it. Caller identity then makes target-scoped actions precise. + +The design also keeps global and session-scoped features stable. It does not move business identity into CDP target identity, and it does not make port tunnels window-local. + +## Self-Review + +- Empty-marker scan: no incomplete requirements remain. +- Consistency check: `targetId` is used only for CDP/window identity, `helperInstanceId` is used only for injection lifecycle, and session fields remain business identity. +- Scope check: the design is focused on multi-window injection and target identity. It does not include unrelated UI redesign or new remote services. +- Ambiguity check: target-scoped routes must use caller identity; global routes may log caller identity; session-scoped routes use explicit business payload fields. + +## Implementation Review + +The current implementation covers the core sync-based path: + +- CDP target discovery can return all injectable Codex page targets. +- Bridge requests carry `targetId`, `helperInstanceId`, `href`, focus state, and visibility state. +- `/devtools/open` resolves DevTools for the caller target instead of selecting the first Codex target. +- `CodexController` owns an injection registry and syncs newly discovered Codex targets. +- The injection registry owns binding pump task handles and aborts them when targets are pruned. +- Runtime focus and visibility events report Helper activity. +- Automatic port discovery, auto-forwarding, and stale cleanup are gated to the focused Helper window. + +The remaining production-hardening items are intentionally separate: + +- Add browser-level CDP target event watching after the polling sync path is stable. +- Promote runtime activity from last-observed diagnostics to a session-keyed ownership lease if port ownership needs backend enforcement beyond the current focused-window gate. +- Add a diagnostic surface for the last active Helper target if users need support visibility. diff --git a/runtime/_test-port-detection.test.js b/runtime/_test-port-detection.test.js index 0d45128..753322f 100644 --- a/runtime/_test-port-detection.test.js +++ b/runtime/_test-port-detection.test.js @@ -727,6 +727,21 @@ test("remote port lifecycle loop is not gated by pinned summary visibility", () expect(maintainPortsPanelNow).not.toContain("stopPortScanLoop();"); }); +test("remote port sync is throttled between session changes", () => { + const syncRemoteSessionPortsOnce = extractFunction("syncRemoteSessionPortsOnce"); + + expect(source).toContain("REMOTE_PORT_SYNC_MIN_INTERVAL_MS"); + expect(source).toContain("lastRemotePortSyncStartedAt"); + expect(source).toContain("lastRemotePortSyncSessionKey"); + expect(source).toContain("function remotePortSyncIsThrottled("); + expect(syncRemoteSessionPortsOnce).toContain( + "remotePortSyncIsThrottled(initialSessionKey)", + ); + expect(syncRemoteSessionPortsOnce.indexOf("remotePortSyncIsThrottled")).toBeLessThan( + syncRemoteSessionPortsOnce.indexOf('bridge("/ports/discover"'), + ); +}); + test("runtime keeps managed tunnels outside the active session", () => { const syncRemoteSessionPortsOnce = extractFunction("syncRemoteSessionPortsOnce"); const maintainPortsPanelNow = extractFunction("maintainPortsPanelNow"); diff --git a/runtime/_test-ports-panel.test.js b/runtime/_test-ports-panel.test.js index a761d9d..270f81f 100644 --- a/runtime/_test-ports-panel.test.js +++ b/runtime/_test-ports-panel.test.js @@ -20,6 +20,28 @@ test("ports UI uses allowlisted bridge routes", () => { expect(source).toContain('bridge("/ports/stop"'); }); +test("runtime reports window activity with caller identity", () => { + expect(source).toContain("runtime.ready"); + expect(source).toContain('bridge("/runtime/activity"'); + expect(source).toContain("document.hasFocus()"); + expect(source).toContain("document.visibilityState"); + expect(source).toContain("helperRuntimeActivityDetail()"); +}); + +test("runtime activity reports are deduplicated", () => { + expect(source).toContain("RUNTIME_ACTIVITY_REPORT_MIN_INTERVAL_MS"); + expect(source).toContain("lastRuntimeActivityKey"); + expect(source).toContain("lastRuntimeActivityAt"); + expect(source).toContain("runtimeActivityKey(detail)"); + expect(source).toContain('bridge("/runtime/activity", detail)'); +}); + +test("port automation is gated to the active helper window", () => { + expect(source).toContain("function helperWindowIsPortOwner()"); + expect(source).toContain("!helperWindowIsPortOwner()"); + expect(source).toContain("helperWindowIsPortOwner() &&"); +}); + test("ports render only in pinned summary card", () => { expect(source).toContain("function findPinnedSummaryCard()"); expect(source).toContain("function findSourcesSummarySection("); diff --git a/runtime/bootstrap.js b/runtime/bootstrap.js index fa4a087..1041f08 100644 --- a/runtime/bootstrap.js +++ b/runtime/bootstrap.js @@ -202,11 +202,45 @@ function onHelperRuntimeChange(event) { }); } +function runtimeActivityKey(detail) { + return [ + detail?.targetId || "", + detail?.helperInstanceId || "", + detail?.href || "", + detail?.hasFocus ? "focused" : "blurred", + detail?.visibilityState || "", + ].join("\n"); +} + +function reportHelperRuntimeActivity() { + const detail = helperRuntimeActivityDetail(); + const key = runtimeActivityKey(detail); + const now = Date.now(); + if ( + key === lastRuntimeActivityKey && + now - lastRuntimeActivityAt < RUNTIME_ACTIVITY_REPORT_MIN_INTERVAL_MS + ) { + return; + } + lastRuntimeActivityKey = key; + lastRuntimeActivityAt = now; + bridge("/runtime/activity", detail).catch((error) => { + console.warn("[Codex Helper] runtime activity report failed", error); + }); +} + +function onHelperRuntimeActivity() { + reportHelperRuntimeActivity(); +} + function removeHelperRuntimeEventListeners() { document.removeEventListener("click", onHelperRuntimeClick, true); document.removeEventListener("contextmenu", onHelperRuntimeContextMenu, true); document.removeEventListener("keydown", onHelperRuntimeKeydown, true); document.removeEventListener("change", onHelperRuntimeChange, true); + window.removeEventListener("focus", onHelperRuntimeActivity, true); + window.removeEventListener("blur", onHelperRuntimeActivity, true); + document.removeEventListener("visibilitychange", onHelperRuntimeActivity, true); } function installHelperRuntimeEventListeners() { @@ -215,6 +249,9 @@ function installHelperRuntimeEventListeners() { document.addEventListener("contextmenu", onHelperRuntimeContextMenu, true); document.addEventListener("keydown", onHelperRuntimeKeydown, true); document.addEventListener("change", onHelperRuntimeChange, true); + window.addEventListener("focus", onHelperRuntimeActivity, true); + window.addEventListener("blur", onHelperRuntimeActivity, true); + document.addEventListener("visibilitychange", onHelperRuntimeActivity, true); } window.__codexHelperRuntimeCleanup = () => { @@ -233,6 +270,12 @@ window.__codexHelperRuntimeCleanup = () => { maintainPortsPanelTimer = 0; refreshPortsPanelTimer = 0; pinnedSummaryHideTimer = 0; + lastRuntimeActivityKey = ""; + lastRuntimeActivityAt = 0; + lastRemotePortSyncStartedAt = 0; + lastRemotePortSyncSessionKey = ""; + cachedRemoteProjectMetadata = []; + cachedRemoteProjectMetadataLoaded = false; observerInstalled = false; helperRuntimeObserver = null; }; @@ -242,6 +285,8 @@ installHelperStyles(); removeLegacyPortsBottomPanelUi(); maintainPortsPanel(); installNativeHelperSettingsGroup(); +logDiagnostic("runtime.ready", helperRuntimeActivityDetail()); +reportHelperRuntimeActivity(); refreshFeatureSettings().catch((error) => { logDiagnostic("settings_feature_refresh_failed", { error: error?.message || String(error), diff --git a/runtime/constants.js b/runtime/constants.js index e6311d5..8b33b1a 100644 --- a/runtime/constants.js +++ b/runtime/constants.js @@ -38,6 +38,12 @@ let maintainPortsPanelTimer = 0; let refreshPortsPanelTimer = 0; let pinnedSummaryHideTimer = 0; let portScanIntervalId = 0; +const RUNTIME_ACTIVITY_REPORT_MIN_INTERVAL_MS = 2000; +let lastRuntimeActivityKey = ""; +let lastRuntimeActivityAt = 0; +const REMOTE_PORT_SYNC_MIN_INTERVAL_MS = 2000; +let lastRemotePortSyncStartedAt = 0; +let lastRemotePortSyncSessionKey = ""; let remotePortSyncInFlight = false; let managedPortStopInFlight = false; let pinnedSummaryCardRef = null; diff --git a/runtime/core.js b/runtime/core.js index 108d7d0..018c242 100644 --- a/runtime/core.js +++ b/runtime/core.js @@ -9,6 +9,25 @@ return window.__codexHelperBridge(path, payload); } + function helperCallerSnapshot() { + if (typeof window.__codexHelperCaller === "function") { + return window.__codexHelperCaller(); + } + return { + href: window.location.href, + hasFocus: document.hasFocus(), + visibilityState: document.visibilityState || "", + }; + } + + function helperRuntimeActivityDetail() { + return helperCallerSnapshot(); + } + + function helperWindowIsPortOwner() { + return document.hasFocus(); + } + function logDiagnostic(event, detail = {}) { bridge("/diagnostics/log", { event, diff --git a/runtime/ports.js b/runtime/ports.js index 0faf4aa..742758c 100644 --- a/runtime/ports.js +++ b/runtime/ports.js @@ -493,7 +493,8 @@ function ensurePortScanLoop() { portScanIntervalId = window.setInterval(() => { if ( !featureSettings.portForwardingEnabled || - !hasRemoteForwardingContext() + !hasRemoteForwardingContext() || + !helperWindowIsPortOwner() ) { stopPortScanLoop(); return; @@ -524,7 +525,7 @@ function findTerminalPortScanRoots() { for (const node of document.querySelectorAll(selector)) { if (!(node instanceof HTMLElement)) continue; if (!isVisibleElement(node)) continue; - if (node.closest(`[${helperPageAttribute}], [${helperToastAttribute}]`)) { + if (node.closest(`[${helperNativeSettingsPageAttribute}], [${helperToastAttribute}]`)) { continue; } if (roots.some((root) => root.contains(node))) continue; @@ -541,7 +542,7 @@ function appendTerminalTextFromRoot(root, parts, seen) { const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); while (walker.nextNode()) { const parent = walker.currentNode.parentElement; - if (parent?.closest(`[${helperPageAttribute}], [${helperToastAttribute}]`)) { + if (parent?.closest(`[${helperNativeSettingsPageAttribute}], [${helperToastAttribute}]`)) { continue; } const text = (walker.currentNode.nodeValue || "").trim(); @@ -558,7 +559,7 @@ function terminalTextForPortScan() { appendTerminalTextFromRoot(root, parts, seen); } for (const xterm of document.querySelectorAll(".xterm, [class*='xterm' i]")) { - if (xterm.closest(`[${helperPageAttribute}], [${helperToastAttribute}]`)) { + if (xterm.closest(`[${helperNativeSettingsPageAttribute}], [${helperToastAttribute}]`)) { continue; } appendTerminalTextFromRoot(xterm, parts, seen); @@ -624,7 +625,11 @@ function shouldAutoForwardDetectedPort(entry, context) { } function scanTerminalWebPorts() { - if (!featureSettings.portForwardingEnabled || !hasRemoteForwardingContext()) { + if ( + !featureSettings.portForwardingEnabled || + !hasRemoteForwardingContext() || + !helperWindowIsPortOwner() + ) { return; } if (pruneDetectedPortsForSessionChange()) { @@ -942,8 +947,11 @@ function reconcileDiscoveredRemotePorts( discoveredRemotePorts = discoveredRemotePortSet(ports), ) { let changed = markRemotePortDiscoverySucceeded(context); + const canOwnPorts = helperWindowIsPortOwner(); const activeForwardedPorts = activeForwardedPortMap(activePorts, context); - changed = pruneStaleDetectedPorts(context, discoveredRemotePorts) || changed; + changed = + (canOwnPorts && pruneStaleDetectedPorts(context, discoveredRemotePorts)) || + changed; for (const port of ports) { const remotePort = Number(port.remotePort); if (!Number.isInteger(remotePort) || remotePort < 1 || remotePort > 65535) @@ -981,6 +989,7 @@ function reconcileDiscoveredRemotePorts( existing.status === "detected" && !activeForward && featureSettings.portForwardingEnabled && + helperWindowIsPortOwner() && shouldAutoForwardDetectedPort(existing, context) ) { changed = true; @@ -1018,6 +1027,7 @@ function reconcileDiscoveredRemotePorts( if ( !activeForward && featureSettings.portForwardingEnabled && + helperWindowIsPortOwner() && shouldAutoForwardDetectedPort(entry, context) ) { forwardDetectedPort(entry).catch((error) => { @@ -1053,11 +1063,26 @@ function remoteForwardingContextChanged(initialSessionKey) { return !currentSessionKey || currentSessionKey !== initialSessionKey; } +function remotePortSyncIsThrottled(sessionKey) { + const now = Date.now(); + if ( + sessionKey === lastRemotePortSyncSessionKey && + now - lastRemotePortSyncStartedAt < REMOTE_PORT_SYNC_MIN_INTERVAL_MS + ) { + return true; + } + lastRemotePortSyncSessionKey = sessionKey; + lastRemotePortSyncStartedAt = now; + return false; +} + async function syncRemoteSessionPortsOnce() { if (!featureSettings.portForwardingEnabled) return; + if (!helperWindowIsPortOwner()) return; const context = await resolveRemoteForwardingContext(); if (!remoteForwardingContextIsReady(context)) return; const initialSessionKey = contextSessionKey(context); + if (remotePortSyncIsThrottled(initialSessionKey)) return; const result = await bridge("/ports/discover", { hostId: context.hostId, remotePath: context.path, @@ -1079,6 +1104,7 @@ async function syncRemoteSessionPortsOnce() { activeResult?.status === "ok" && Array.isArray(activeResult.ports) ? activeResult.ports : []; + if (!helperWindowIsPortOwner()) return; const stopped = await stopStaleForwardedTunnels( context, discoveredRemotePorts, diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 59093f5..7b9a9b7 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1403,7 +1403,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" dependencies = [ "heck 0.4.1", - "proc-macro-crate 2.0.0", + "proc-macro-crate 2.0.2", "proc-macro-error", "proc-macro2", "quote", @@ -2476,9 +2476,9 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "open" -version = "5.3.5" +version = "5.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fbaa89d2ddc8473c78a3adf69eea8cffa28c483b8e02a971ef31527cd0fc92c" +checksum = "9f3bab717c29a857abf75fcef718d441ec7cb2725f937343c734740a985d37fd" dependencies = [ "dunce", "is-wsl", @@ -2749,11 +2749,12 @@ dependencies = [ [[package]] name = "proc-macro-crate" -version = "2.0.0" +version = "2.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e8366a6159044a37876a2b9817124296703c586a5c92e2c53751fa06d8d43e8" +checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" dependencies = [ - "toml_edit 0.20.7", + "toml_datetime 0.6.3", + "toml_edit 0.20.2", ] [[package]] @@ -3674,7 +3675,7 @@ dependencies = [ "cfg-expr", "heck 0.5.0", "pkg-config", - "toml 0.8.23", + "toml 0.8.2", "version-compare", ] @@ -3850,9 +3851,9 @@ dependencies = [ [[package]] name = "tauri-plugin" -version = "2.6.2" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e126abc9e84e35cdfd01596140a73a1850cdb0df0a23acf0185776c30b469a6e" +checksum = "eefb2c18e8a605c23edb48fc56bb77381199e1a1e7f6ff0c9b970afe7b3cb8ee" dependencies = [ "anyhow", "glob", @@ -3908,9 +3909,9 @@ dependencies = [ [[package]] name = "tauri-plugin-opener" -version = "2.5.4" +version = "2.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17e1bea14edce6b793a04e2417e3fd924b9bc4faae83cdee7d714156cceeed29" +checksum = "fc624469b06f59f5a29f874bbc61a2ed737c0f9c23ef09855a292c389c42e83f" dependencies = [ "dunce", "glob", @@ -4215,14 +4216,14 @@ dependencies = [ [[package]] name = "toml" -version = "0.8.23" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" dependencies = [ "serde", "serde_spanned 0.6.9", - "toml_datetime 0.6.11", - "toml_edit 0.22.27", + "toml_datetime 0.6.3", + "toml_edit 0.20.2", ] [[package]] @@ -4257,9 +4258,9 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "0.6.11" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" dependencies = [ "serde", ] @@ -4289,32 +4290,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" dependencies = [ "indexmap 2.14.0", - "toml_datetime 0.6.11", + "toml_datetime 0.6.3", "winnow 0.5.40", ] [[package]] name = "toml_edit" -version = "0.20.7" +version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70f427fce4d84c72b5b732388bf4a9f4531b53f74e2887e3ecb2481f68f66d81" -dependencies = [ - "indexmap 2.14.0", - "toml_datetime 0.6.11", - "winnow 0.5.40", -] - -[[package]] -name = "toml_edit" -version = "0.22.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" dependencies = [ "indexmap 2.14.0", "serde", "serde_spanned 0.6.9", - "toml_datetime 0.6.11", - "winnow 0.7.15", + "toml_datetime 0.6.3", + "winnow 0.5.40", ] [[package]] @@ -5369,9 +5359,6 @@ name = "winnow" version = "0.7.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" -dependencies = [ - "memchr", -] [[package]] name = "winnow" diff --git a/src-tauri/src/app.rs b/src-tauri/src/app.rs index 9b76c1c..a19d73a 100644 --- a/src-tauri/src/app.rs +++ b/src-tauri/src/app.rs @@ -2,23 +2,140 @@ use std::sync::Arc; use crate::codex_control::CodexController; use crate::ports::PortForwardManager; +use crate::proxy_env::configure_process_loopback_no_proxy; +use tauri_plugin_dialog::{ + DialogExt, MessageDialogButtons, MessageDialogKind, MessageDialogResult, +}; + +struct TrayMenuItemSpec { + id: &'static str, + label: &'static str, +} + +fn tray_menu_item_specs() -> [TrayMenuItemSpec; 1] { + [TrayMenuItemSpec { + id: "quit-helper", + label: "Quit Codex Helper", + }] +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum HelperQuitChoice { + Quit, + Cancel, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum StartupRecoveryChoice { + CleanUpAndStart, + QuitHelper, +} + +const QUIT_HELPER_LABEL: &str = "Quit"; +const CANCEL_QUIT_LABEL: &str = "Cancel"; +const CLEAN_UP_AND_START_LABEL: &str = "Clean Up and Start Codex"; +const QUIT_HELPER_STARTUP_LABEL: &str = "Quit Helper"; + +fn helper_quit_choice_from_dialog_result(result: MessageDialogResult) -> HelperQuitChoice { + match result { + MessageDialogResult::Ok => HelperQuitChoice::Quit, + MessageDialogResult::Custom(label) if label == QUIT_HELPER_LABEL => HelperQuitChoice::Quit, + _ => HelperQuitChoice::Cancel, + } +} + +fn startup_recovery_choice_from_dialog_result( + result: MessageDialogResult, +) -> StartupRecoveryChoice { + match result { + MessageDialogResult::Ok => StartupRecoveryChoice::CleanUpAndStart, + MessageDialogResult::Custom(label) if label == CLEAN_UP_AND_START_LABEL => { + StartupRecoveryChoice::CleanUpAndStart + } + _ => StartupRecoveryChoice::QuitHelper, + } +} + +fn should_confirm_helper_quit(has_connected_codex: bool) -> bool { + has_connected_codex +} + +fn show_helper_quit_confirmation(app: &tauri::AppHandle, on_choice: F) +where + F: FnOnce(HelperQuitChoice) + Send + 'static, +{ + app.dialog() + .message( + "Quitting Codex Helper will stop Helper features. Codex windows will keep running.", + ) + .title("Quit Codex Helper?") + .kind(MessageDialogKind::Warning) + .buttons(MessageDialogButtons::OkCancelCustom( + QUIT_HELPER_LABEL.to_string(), + CANCEL_QUIT_LABEL.to_string(), + )) + .show_with_result(move |result| on_choice(helper_quit_choice_from_dialog_result(result))); +} + +fn show_startup_recovery_confirmation(app: &tauri::AppHandle, on_choice: F) +where + F: FnOnce(StartupRecoveryChoice) + Send + 'static, +{ + app.dialog() + .message("Codex Helper found an existing Codex debugging environment but could not attach to it. It can close Codex debugging instances and start a clean Codex session.") + .title("Start Codex Helper?") + .kind(MessageDialogKind::Warning) + .buttons(MessageDialogButtons::OkCancelCustom( + CLEAN_UP_AND_START_LABEL.to_string(), + QUIT_HELPER_STARTUP_LABEL.to_string(), + )) + .show_with_result(move |result| { + on_choice(startup_recovery_choice_from_dialog_result(result)) + }); +} pub fn run() { + configure_process_loopback_no_proxy(); let port_manager = PortForwardManager::new(); let controller = CodexController::new(); let startup_controller = controller.clone(); let startup_port_manager = port_manager.clone(); let shutdown_port_manager = port_manager.clone(); tauri::Builder::default() + .plugin(tauri_plugin_dialog::init()) + .plugin(tauri_plugin_opener::init()) .setup(move |app| { app.set_activation_policy(tauri::ActivationPolicy::Accessory); install_menu_bar_item(app.handle(), controller.clone(), port_manager.clone())?; + let startup_app = app.handle().clone(); tauri::async_runtime::spawn(async move { if let Err(error) = startup_controller - .initial_launch(startup_port_manager) + .initial_launch(startup_port_manager.clone()) .await { eprintln!("{error}"); + let controller = startup_controller.clone(); + let port_manager = startup_port_manager.clone(); + let app = startup_app.clone(); + show_startup_recovery_confirmation(&startup_app, move |choice| { + tauri::async_runtime::spawn(async move { + match choice { + StartupRecoveryChoice::CleanUpAndStart => { + if let Err(error) = + controller.recover_codex_launch(port_manager.clone()).await + { + eprintln!("{error}"); + port_manager.stop_all(); + app.exit(1); + } + } + StartupRecoveryChoice::QuitHelper => { + port_manager.stop_all(); + app.exit(0); + } + } + }); + }); } }); Ok(()) @@ -37,26 +154,12 @@ fn install_menu_bar_item( controller: Arc, port_manager: PortForwardManager, ) -> anyhow::Result<()> { - use tauri::menu::{Menu, MenuItem, PredefinedMenuItem}; + use tauri::menu::{Menu, MenuItem}; use tauri::tray::TrayIconBuilder; - let quit_codex = MenuItem::with_id(app, "quit-codex", "Quit Codex", true, None::<&str>)?; - let reload_codex = MenuItem::with_id(app, "reload-codex", "Reload Codex", true, None::<&str>)?; - let restart_codex = - MenuItem::with_id(app, "restart-codex", "Restart Codex", true, None::<&str>)?; - let separator = PredefinedMenuItem::separator(app)?; - let quit_helper = - MenuItem::with_id(app, "quit-helper", "Quit Codex Helper", true, None::<&str>)?; - let menu = Menu::with_items( - app, - &[ - &quit_codex, - &reload_codex, - &restart_codex, - &separator, - &quit_helper, - ], - )?; + let specs = tray_menu_item_specs(); + let quit_helper = MenuItem::with_id(app, specs[0].id, specs[0].label, true, None::<&str>)?; + let menu = Menu::with_items(app, &[&quit_helper])?; let mut tray = TrayIconBuilder::with_id("codex-helper") .icon(tauri::include_image!("icons/tray-menu.png")) .tooltip("Codex Helper is running") @@ -68,36 +171,94 @@ fn install_menu_bar_item( tray = tray.icon_as_template(true); } tray.on_menu_event(move |app, event| match event.id().as_ref() { - "quit-codex" => { - let controller = controller.clone(); - tauri::async_runtime::spawn(async move { - if let Err(error) = controller.quit_codex().await { - eprintln!("{error}"); - } - }); - } - "reload-codex" => { - let controller = controller.clone(); - tauri::async_runtime::spawn(async move { - if let Err(error) = controller.reload_codex().await { - eprintln!("{error}"); - } - }); - } - "restart-codex" => { + "quit-helper" => { let controller = controller.clone(); + let port_manager = port_manager.clone(); + let app = app.clone(); tauri::async_runtime::spawn(async move { - if let Err(error) = controller.restart_codex().await { - eprintln!("{error}"); + if !should_confirm_helper_quit(controller.has_connected_codex_instance().await) { + if let Err(error) = controller.prepare_helper_shutdown().await { + eprintln!("{error}"); + } + port_manager.stop_all(); + app.exit(0); + return; } + let confirmation_app = app.clone(); + show_helper_quit_confirmation(&confirmation_app, move |choice| { + tauri::async_runtime::spawn(async move { + match choice { + HelperQuitChoice::Cancel => {} + HelperQuitChoice::Quit => { + if let Err(error) = controller.prepare_helper_shutdown().await { + eprintln!("{error}"); + } + port_manager.stop_all(); + app.exit(0); + } + } + }); + }); }); } - "quit-helper" => { - port_manager.stop_all(); - app.exit(0); - } _ => {} }) .build(app)?; Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tray_menu_only_exposes_helper_quit() { + let items = tray_menu_item_specs(); + + assert_eq!(items.len(), 1); + assert_eq!(items[0].id, "quit-helper"); + assert_eq!(items[0].label, "Quit Codex Helper"); + } + + #[test] + fn helper_quit_choice_maps_ok_to_quit() { + assert_eq!( + helper_quit_choice_from_dialog_result(tauri_plugin_dialog::MessageDialogResult::Ok), + HelperQuitChoice::Quit + ); + } + + #[test] + fn helper_quit_choice_maps_cancel_or_unknown_to_cancel() { + assert_eq!( + helper_quit_choice_from_dialog_result(tauri_plugin_dialog::MessageDialogResult::Cancel), + HelperQuitChoice::Cancel + ); + } + + #[test] + fn helper_quit_only_confirms_when_codex_is_connected() { + assert!(should_confirm_helper_quit(true)); + assert!(!should_confirm_helper_quit(false)); + } + + #[test] + fn startup_recovery_choice_maps_ok_to_cleanup() { + assert_eq!( + startup_recovery_choice_from_dialog_result( + tauri_plugin_dialog::MessageDialogResult::Ok + ), + StartupRecoveryChoice::CleanUpAndStart + ); + } + + #[test] + fn startup_recovery_choice_maps_cancel_to_quit_helper() { + assert_eq!( + startup_recovery_choice_from_dialog_result( + tauri_plugin_dialog::MessageDialogResult::Cancel + ), + StartupRecoveryChoice::QuitHelper + ); + } +} diff --git a/src-tauri/src/bridge.rs b/src-tauri/src/bridge.rs index 60e7e43..d2710fe 100644 --- a/src-tauri/src/bridge.rs +++ b/src-tauri/src/bridge.rs @@ -6,7 +6,9 @@ use std::sync::Arc; use std::time::Duration; use futures_util::{SinkExt, StreamExt}; +use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; +use tokio::task::JoinHandle; use tokio_tungstenite::tungstenite::Message; use crate::cdp::connect_cdp_websocket; @@ -16,19 +18,75 @@ const BRIDGE_BINDING_NAME: &str = "codexHelperBridgeV1"; const CDP_COMMAND_TIMEOUT: Duration = Duration::from_secs(5); type BridgeHandler = Arc< - dyn Fn(String, Value) -> Pin> + Send>> + dyn Fn(BridgeRequest) -> Pin> + Send>> + Send + Sync, >; static NEXT_MESSAGE_ID: AtomicU64 = AtomicU64::new(100); -pub fn build_bridge_script(binding_name: &str) -> String { +#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct BridgeCaller { + pub target_id: String, + pub helper_instance_id: String, + #[serde(default)] + pub href: String, + #[serde(default)] + pub has_focus: bool, + #[serde(default)] + pub visibility_state: String, +} + +impl BridgeCaller { + pub fn new_for_target(target_id: &str, helper_instance_id: &str) -> Self { + Self { + target_id: target_id.to_string(), + helper_instance_id: helper_instance_id.to_string(), + href: String::new(), + has_focus: false, + visibility_state: String::new(), + } + } +} + +#[derive(Debug, Clone, Deserialize, PartialEq)] +pub struct BridgeRequest { + pub id: String, + pub path: String, + #[serde(default = "empty_payload")] + pub payload: Value, + #[serde(default)] + pub caller: BridgeCaller, +} + +fn empty_payload() -> Value { + json!({}) +} + +pub struct InstalledBridge { + pub binding_task: JoinHandle<()>, + pub script_identifiers: Vec, +} + +fn parse_bridge_request(payload_text: &str) -> anyhow::Result { + Ok(serde_json::from_str(payload_text)?) +} + +pub fn build_bridge_script(binding_name: &str, caller: &BridgeCaller) -> String { + let caller_json = serde_json::to_string(caller).expect("bridge caller metadata must serialize"); format!( r#" (() => {{ window.__codexHelperCallbacks = new Map(); window.__codexHelperSeq = 0; + window.__codexHelperCallerBase = {caller_json}; + window.__codexHelperCaller = () => ({{ + ...window.__codexHelperCallerBase, + href: window.location.href, + hasFocus: document.hasFocus(), + visibilityState: document.visibilityState || "", + }}); window.__codexHelperResolve = (id, result) => {{ const callback = window.__codexHelperCallbacks.get(id); if (!callback) return; @@ -44,7 +102,7 @@ pub fn build_bridge_script(binding_name: &str) -> String { window.__codexHelperBridge = (path, payload = {{}}) => new Promise((resolve) => {{ const id = String(++window.__codexHelperSeq); window.__codexHelperCallbacks.set(id, {{ resolve }}); - window.{binding_name}(JSON.stringify({{ id, path, payload }})); + window.{binding_name}(JSON.stringify({{ id, path, payload, caller: window.__codexHelperCaller() }})); }}); }})(); "# @@ -54,9 +112,10 @@ pub fn build_bridge_script(binding_name: &str) -> String { pub async fn install_bridge( websocket_url: &str, target_id: &str, + caller: BridgeCaller, ctx: BridgeContext, runtime_scripts: Vec, -) -> anyhow::Result<()> { +) -> anyhow::Result { let handler = bridge_handler(ctx); let socket = connect_cdp_websocket(websocket_url).await?; let mut session = BindingCdpSession::new(socket).with_handler(handler); @@ -91,14 +150,18 @@ pub async fn install_bridge( ) .await?; - let bridge_script = build_bridge_script(BRIDGE_BINDING_NAME); - session + let bridge_script = build_bridge_script(BRIDGE_BINDING_NAME, &caller); + let mut script_identifiers = Vec::new(); + let bridge_script_response = session .send_command( 5, "Page.addScriptToEvaluateOnNewDocument", json!({ "source": bridge_script }), ) .await?; + if let Some(identifier) = script_identifier_from_add_script_response(&bridge_script_response) { + script_identifiers.push(identifier); + } session .send_command( 6, @@ -109,13 +172,16 @@ pub async fn install_bridge( for script in runtime_scripts { let message_id = next_message_id(); - session + let add_script_response = session .send_command( message_id, "Page.addScriptToEvaluateOnNewDocument", json!({ "source": script }), ) .await?; + if let Some(identifier) = script_identifier_from_add_script_response(&add_script_response) { + script_identifiers.push(identifier); + } let message_id = next_message_id(); session .send_command( @@ -126,7 +192,7 @@ pub async fn install_bridge( .await?; } - tokio::spawn(async move { + let binding_task = tokio::spawn(async move { loop { if session.drain_binding_queue().await.is_err() { break; @@ -138,13 +204,76 @@ pub async fn install_bridge( } }); + Ok(InstalledBridge { + binding_task, + script_identifiers, + }) +} + +pub async fn restore_injected_runtime( + websocket_url: &str, + target_id: &str, + script_identifiers: &[String], +) -> anyhow::Result<()> { + let socket = connect_cdp_websocket(websocket_url).await?; + let mut session = BindingCdpSession::new(socket); + let attached = session + .send_command( + next_message_id(), + "Target.attachToTarget", + json!({ "targetId": target_id, "flatten": true }), + ) + .await?; + let session_id = attached + .get("result") + .and_then(|result| result.get("sessionId")) + .and_then(Value::as_str) + .ok_or_else(|| anyhow::anyhow!("CDP attach response did not include sessionId"))? + .to_string(); + session = session.with_session_id(session_id.clone()); + session + .send_command(next_message_id(), "Runtime.enable", json!({})) + .await?; + session + .send_command( + next_message_id(), + "Runtime.evaluate", + runtime_evaluate_params(&build_runtime_restore_script()), + ) + .await?; + let _ = session + .send_command( + next_message_id(), + "Runtime.removeBinding", + json!({ "name": BRIDGE_BINDING_NAME }), + ) + .await; + for identifier in script_identifiers + .iter() + .filter(|value| !value.trim().is_empty()) + { + let _ = session + .send_command( + next_message_id(), + "Page.removeScriptToEvaluateOnNewDocument", + json!({ "identifier": identifier }), + ) + .await; + } + let _ = session + .send_command( + next_message_id(), + "Target.detachFromTarget", + json!({ "sessionId": session_id }), + ) + .await; Ok(()) } fn bridge_handler(ctx: BridgeContext) -> BridgeHandler { - Arc::new(move |path, payload| { + Arc::new(move |request| { let ctx = ctx.clone(); - Box::pin(async move { Ok(handle_bridge_request(ctx, &path, payload).await) }) + Box::pin(async move { Ok(handle_bridge_request(ctx, request).await) }) }) } @@ -156,6 +285,45 @@ fn runtime_evaluate_params(expression: &str) -> Value { }) } +pub fn build_runtime_restore_script() -> String { + r#" +(() => { + try { + if (typeof window.__codexHelperRuntimeCleanup === "function") { + window.__codexHelperRuntimeCleanup(); + } + } catch (error) { + console.warn("[Codex Helper] runtime restore failed", error); + } + for (const key of [ + "__codexHelperBridge", + "__codexHelperCallbacks", + "__codexHelperCaller", + "__codexHelperCallerBase", + "__codexHelperResolve", + "__codexHelperReject", + "__codexHelperRuntimeCleanup", + "__codexHelperSeq", + ]) { + try { + delete window[key]; + } catch (_error) { + window[key] = undefined; + } + } +})(); +"# + .to_string() +} + +fn script_identifier_from_add_script_response(response: &Value) -> Option { + response + .get("result") + .and_then(|result| result.get("identifier")) + .and_then(Value::as_str) + .map(ToString::to_string) +} + fn cdp_command(id: u64, method: &str, params: Value, session_id: Option<&str>) -> Value { let mut command = json!({ "id": id, "method": method, "params": params }); if let Some(session_id) = session_id { @@ -317,7 +485,7 @@ where return Ok(()); }; - let parsed: Value = match serde_json::from_str(payload_text) { + let request = match parse_bridge_request(payload_text) { Ok(parsed) => parsed, Err(error) => { if let Some(request_id) = extract_string_field(payload_text, "id") { @@ -330,29 +498,19 @@ where return Ok(()); } }; - self.route_parsed_binding_call(&handler, parsed).await + self.route_parsed_binding_call(&handler, request).await }) } async fn route_parsed_binding_call( &mut self, handler: &BridgeHandler, - parsed: Value, + request: BridgeRequest, ) -> anyhow::Result<()> { - let Some(request_id) = parsed.get("id").and_then(Value::as_str) else { - return Ok(()); - }; - let path = parsed - .get("path") - .and_then(Value::as_str) - .unwrap_or_default() - .to_string(); - let payload = parsed.get("payload").cloned().unwrap_or_else(|| json!({})); - - match handler(path, payload).await { - Ok(result) => self.resolve_bridge_request(request_id, &result).await?, + match handler(request.clone()).await { + Ok(result) => self.resolve_bridge_request(&request.id, &result).await?, Err(error) => { - self.reject_bridge_request(request_id, &error.to_string()) + self.reject_bridge_request(&request.id, &error.to_string()) .await? } } @@ -435,7 +593,10 @@ mod tests { #[test] fn bridge_script_defines_cdp_binding_bridge() { - let script = build_bridge_script("codexHelperBridgeV1"); + let script = build_bridge_script( + "codexHelperBridgeV1", + &BridgeCaller::new_for_target("target-1", "instance-1"), + ); assert!(script.contains("window.__codexHelperBridge")); assert!(script.contains("window.codexHelperBridgeV1")); @@ -443,6 +604,70 @@ mod tests { assert!(script.contains("window.__codexHelperReject")); } + #[test] + fn bridge_script_includes_caller_identity() { + let script = build_bridge_script( + "codexHelperBridgeV1", + &BridgeCaller::new_for_target("target-1", "instance-1"), + ); + + assert!(script.contains("\"targetId\":\"target-1\"")); + assert!(script.contains("\"helperInstanceId\":\"instance-1\"")); + assert!(script.contains("document.hasFocus()")); + assert!(script.contains("document.visibilityState")); + } + + #[test] + fn runtime_restore_script_runs_cleanup_and_removes_bridge_globals() { + let script = build_runtime_restore_script(); + + assert!(script.contains("__codexHelperRuntimeCleanup")); + assert!(script.contains("__codexHelperBridge")); + assert!(script.contains("__codexHelperCallbacks")); + assert!(script.contains("__codexHelperResolve")); + assert!(script.contains("__codexHelperReject")); + assert!(script.contains("delete window[key]")); + } + + #[test] + fn script_identifier_reads_add_script_response() { + let response = json!({ + "result": { + "identifier": "script-1", + }, + }); + + assert_eq!( + script_identifier_from_add_script_response(&response).as_deref(), + Some("script-1"), + ); + } + + #[test] + fn bridge_request_parses_caller_identity() { + let request = parse_bridge_request( + r#"{"id":"1","path":"/devtools/open","payload":{},"caller":{"targetId":"target-1","helperInstanceId":"instance-1","href":"app://-/index.html","hasFocus":true,"visibilityState":"visible"}}"#, + ) + .expect("request"); + + assert_eq!(request.id, "1"); + assert_eq!(request.path, "/devtools/open"); + assert_eq!(request.caller.target_id, "target-1"); + assert_eq!(request.caller.helper_instance_id, "instance-1"); + assert_eq!(request.caller.href, "app://-/index.html"); + assert!(request.caller.has_focus); + assert_eq!(request.caller.visibility_state, "visible"); + } + + #[test] + fn bridge_request_allows_missing_caller_for_global_routes() { + let request = parse_bridge_request(r#"{"id":"1","path":"/backend/status","payload":{}}"#) + .expect("request"); + + assert_eq!(request.path, "/backend/status"); + assert_eq!(request.caller, BridgeCaller::default()); + } + #[test] fn resolve_bridge_expression_serializes_result() { let expression = diff --git a/src-tauri/src/cdp.rs b/src-tauri/src/cdp.rs index 1e9af9c..c66d86b 100644 --- a/src-tauri/src/cdp.rs +++ b/src-tauri/src/cdp.rs @@ -8,7 +8,9 @@ use tokio_tungstenite::connect_async; use tokio_tungstenite::tungstenite::Message; const CDP_CONNECT_TIMEOUT: Duration = Duration::from_secs(5); +const CDP_HTTP_TIMEOUT: Duration = Duration::from_secs(3); const CDP_COMMAND_TIMEOUT: Duration = Duration::from_secs(5); +const CODEX_APP_URL: &str = "app://-/index.html"; #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] @@ -28,17 +30,42 @@ pub struct CdpVersion { pub web_socket_debugger_url: String, } +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +struct CdpTargetInfo { + target_id: Option, + #[serde(rename = "type")] + target_type: Option, + title: Option, + url: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +struct CdpTargetInfosResult { + target_infos: Option>, +} + pub async fn is_debug_port_ready(debug_port: u16) -> bool { browser_websocket_url(debug_port).await.is_ok() } pub async fn has_codex_cdp_target(debug_port: u16) -> bool { - match list_targets(debug_port).await { - Ok(targets) => find_codex_page_target(&targets).is_some(), + match list_browser_targets(debug_port).await { + Ok(targets) => !codex_injectable_page_targets(&targets).is_empty(), Err(_) => false, } } +pub async fn find_existing_codex_debug_port(ports: impl IntoIterator) -> Option { + for port in ports { + if has_codex_cdp_target(port).await { + return Some(port); + } + } + None +} + pub async fn wait_for_debug_port(debug_port: u16, timeout: Duration) -> anyhow::Result<()> { let started_at = std::time::Instant::now(); while started_at.elapsed() < timeout { @@ -53,9 +80,59 @@ pub async fn wait_for_debug_port(debug_port: u16, timeout: Duration) -> anyhow:: ); } +pub async fn wait_for_debug_port_to_close( + debug_port: u16, + timeout: Duration, +) -> anyhow::Result<()> { + let started_at = std::time::Instant::now(); + while started_at.elapsed() < timeout { + if !is_debug_port_ready(debug_port).await { + return Ok(()); + } + tokio::time::sleep(Duration::from_millis(250)).await; + } + anyhow::bail!( + "Timed out waiting for Codex debug port {debug_port} to close after {:?}", + started_at.elapsed() + ); +} + +pub async fn wait_for_codex_targets( + debug_port: u16, + timeout: Duration, +) -> anyhow::Result> { + let started_at = std::time::Instant::now(); + let mut last_error = "No injectable Codex page target found".to_string(); + while started_at.elapsed() < timeout { + match list_browser_targets(debug_port).await { + Ok(targets) => { + let codex_targets = codex_injectable_page_targets(&targets); + if !codex_targets.is_empty() { + return Ok(codex_targets); + } + last_error = format!( + "No injectable Codex page target found among {}", + targets.len() + ); + } + Err(error) => { + last_error = error.to_string(); + } + } + tokio::time::sleep(Duration::from_millis(250)).await; + } + anyhow::bail!( + "Timed out waiting for Codex CDP targets on port {debug_port} after {:?}: {last_error}", + started_at.elapsed() + ); +} + pub async fn browser_websocket_url(debug_port: u16) -> anyhow::Result { let url = format!("http://127.0.0.1:{debug_port}/json/version"); - let response = reqwest::get(&url) + let client = cdp_http_client()?; + let response = client + .get(&url) + .send() .await .map_err(|error| anyhow::anyhow!("CDP version query failed: {error}"))?; if !response.status().is_success() { @@ -77,7 +154,10 @@ pub async fn browser_websocket_url(debug_port: u16) -> anyhow::Result { pub async fn list_targets(debug_port: u16) -> anyhow::Result> { let url = format!("http://127.0.0.1:{debug_port}/json"); - let response = reqwest::get(&url) + let client = cdp_http_client()?; + let response = client + .get(&url) + .send() .await .map_err(|error| anyhow::anyhow!("CDP target query failed: {error}"))?; if !response.status().is_success() { @@ -93,6 +173,49 @@ pub async fn list_targets(debug_port: u16) -> anyhow::Result> { .map_err(|error| anyhow::anyhow!("CDP target response decode failed: {error}")) } +fn cdp_http_client() -> anyhow::Result { + Ok(reqwest::Client::builder() + .no_proxy() + .timeout(CDP_HTTP_TIMEOUT) + .build()?) +} + +pub async fn list_browser_targets(debug_port: u16) -> anyhow::Result> { + let websocket_url = browser_websocket_url(debug_port).await?; + let socket = connect_cdp_websocket(&websocket_url).await?; + let mut session = OneShotCdpSession::new(socket); + let response = session + .send_command(1, "Target.getTargets", json!({ "filter": [{}] })) + .await?; + let result = response + .get("result") + .cloned() + .ok_or_else(|| anyhow::anyhow!("Target.getTargets response did not include result"))?; + browser_targets_from_result(result) +} + +fn browser_targets_from_result(result: Value) -> anyhow::Result> { + let result = serde_json::from_value::(result)?; + Ok(result + .target_infos + .unwrap_or_default() + .into_iter() + .filter_map(|target| { + let id = target.target_id?; + let target_type = target.target_type?; + Some(CdpTarget { + id, + target_type, + title: Some(target.title.unwrap_or_default()), + url: Some(target.url.unwrap_or_default()), + devtools_frontend_url: None, + web_socket_debugger_url: None, + }) + }) + .collect()) +} + +#[cfg(test)] pub fn pick_codex_page_target(targets: &[CdpTarget]) -> anyhow::Result { let pages = targets .iter() @@ -105,35 +228,52 @@ pub fn pick_codex_page_target(targets: &[CdpTarget]) -> anyhow::Result Vec { + targets + .iter() + .filter(|target| is_codex_page_target(target) && has_target_websocket(target)) + .cloned() + .collect() +} + +pub fn codex_injectable_page_targets(targets: &[CdpTarget]) -> Vec { + targets + .iter() + .filter(|target| is_codex_page_target(target)) + .cloned() + .collect() +} + +#[cfg(test)] pub fn find_codex_page_target(targets: &[CdpTarget]) -> Option<&CdpTarget> { - targets.iter().find(|target| { - target.target_type == "page" - && target.web_socket_debugger_url.is_some() - && format!( - "{} {}", - target.title.as_deref().unwrap_or_default(), - target.url.as_deref().unwrap_or_default() - ) - .to_lowercase() - .contains("codex") - }) + targets + .iter() + .find(|target| is_codex_page_target(target) && has_target_websocket(target)) } -pub async fn wait_for_codex_target(debug_port: u16) -> anyhow::Result { - let mut last_error = None; - for _ in 0..40 { - match list_targets(debug_port) - .await - .and_then(|targets| pick_codex_page_target(&targets)) - { - Ok(target) => return Ok(target), - Err(error) => { - last_error = Some(error); - tokio::time::sleep(Duration::from_millis(250)).await; - } - } +fn is_codex_page_target(target: &CdpTarget) -> bool { + if target.target_type != "page" { + return false; + } + if target.url.as_deref() == Some(CODEX_APP_URL) { + return true; } - Err(last_error.unwrap_or_else(|| anyhow::anyhow!("Timed out waiting for Codex CDP target"))) + format!( + "{} {}", + target.title.as_deref().unwrap_or_default(), + target.url.as_deref().unwrap_or_default() + ) + .to_lowercase() + .contains("codex") +} + +#[cfg(test)] +fn has_target_websocket(target: &CdpTarget) -> bool { + target + .web_socket_debugger_url + .as_deref() + .is_some_and(|url| !url.trim().is_empty()) } pub async fn connect_cdp_websocket( @@ -153,32 +293,14 @@ pub async fn connect_cdp_websocket( Ok(socket) } -pub async fn reload_codex_page(debug_port: u16) -> anyhow::Result<()> { +pub async fn close_browser(debug_port: u16) -> anyhow::Result<()> { let websocket_url = browser_websocket_url(debug_port).await?; - let targets = list_targets(debug_port).await?; - let target = pick_codex_page_target(&targets)?; - let target_id = target.id; - let socket = connect_cdp_websocket(&websocket_url).await?; let mut session = OneShotCdpSession::new(socket); - let attached = session - .send_command( - 1, - "Target.attachToTarget", - json!({ "targetId": target_id, "flatten": true }), - ) - .await?; - let session_id = attached - .get("result") - .and_then(|result| result.get("sessionId")) - .and_then(Value::as_str) - .ok_or_else(|| anyhow::anyhow!("CDP attach response did not include sessionId"))? - .to_string(); - session.set_session_id(session_id); session - .send_command(2, "Page.reload", json!({ "ignoreCache": false })) - .await?; - Ok(()) + .send_command(1, "Browser.close", json!({})) + .await + .map(|_| ()) } fn cdp_command(id: u64, method: &str, params: Value, session_id: Option<&str>) -> Value { @@ -211,10 +333,6 @@ where } } - fn set_session_id(&mut self, session_id: String) { - self.session_id = Some(session_id); - } - async fn send_command( &mut self, id: u64, @@ -302,6 +420,79 @@ mod tests { assert_eq!(selected.id, "two"); } + #[test] + fn cdp_returns_all_codex_page_targets() { + let mut settling = target( + "settling", + "page", + "app://-/index.html", + Some("ws://settling"), + ); + settling.url = Some("app://-/index.html".to_string()); + let targets = vec![ + target("one", "page", "Codex", Some("ws://one")), + target("two", "page", "Codex", Some("ws://two")), + settling, + target("worker", "worker", "Codex", Some("ws://worker")), + target("missing", "page", "Codex", None), + target("empty", "page", "Codex", Some("")), + ]; + + let selected = codex_page_targets(&targets); + + assert_eq!( + selected + .iter() + .map(|target| target.id.as_str()) + .collect::>(), + vec!["one", "two", "settling"] + ); + } + + #[test] + fn cdp_injectable_targets_accept_browser_target_infos() { + let mut browser_page = target("browser-page", "page", "", None); + browser_page.url = Some("app://-/index.html".to_string()); + let mut browser_tab = target("browser-tab", "tab", "Codex", None); + browser_tab.url = Some("app://-/index.html".to_string()); + let mut worker = target("worker", "worker", "Codex", None); + worker.url = Some("app://-/index.html".to_string()); + + let selected = codex_injectable_page_targets(&[browser_page, browser_tab, worker]); + + assert_eq!( + selected + .iter() + .map(|target| target.id.as_str()) + .collect::>(), + vec!["browser-page"] + ); + } + + #[test] + fn cdp_converts_browser_target_infos() { + let targets = browser_targets_from_result(json!({ + "targetInfos": [ + { + "targetId": "page", + "type": "page", + "title": "Codex", + "url": "app://-/index.html" + }, + { + "targetId": "missing-type", + "title": "Codex" + } + ] + })) + .expect("targets"); + + assert_eq!(targets.len(), 1); + assert_eq!(targets[0].id, "page"); + assert_eq!(targets[0].target_type, "page"); + assert_eq!(targets[0].web_socket_debugger_url, None); + } + #[test] fn cdp_rejects_missing_websocket_targets() { let targets = vec![target("one", "page", "Codex", None)]; @@ -317,4 +508,11 @@ mod tests { assert!(find_codex_page_target(&targets).is_none()); } + + #[tokio::test] + async fn cdp_wait_for_debug_port_to_close_returns_when_port_is_absent() { + wait_for_debug_port_to_close(9, Duration::from_millis(50)) + .await + .expect("closed port"); + } } diff --git a/src-tauri/src/codex_app_server.rs b/src-tauri/src/codex_app_server.rs index 4941de0..0a2f816 100644 --- a/src-tauri/src/codex_app_server.rs +++ b/src-tauri/src/codex_app_server.rs @@ -6,6 +6,7 @@ use std::time::Duration; use serde_json::{json, Value}; +use crate::proxy_env::loopback_no_proxy_value; use crate::zed::SshTarget; const GENERATED_TITLE_TIMEOUT_SECS: u64 = 120; @@ -70,9 +71,13 @@ impl CodexAppServerClient { .arg(remote_app_server_command(&self.codex_command)); return command; } + let no_proxy = loopback_no_proxy_value(); let mut command = Command::new(&self.codex_command); command.arg("app-server").arg("--listen").arg("stdio://"); command + .env("NO_PROXY", &no_proxy) + .env("no_proxy", &no_proxy); + command } fn run_requests(&self, requests: &[Value]) -> anyhow::Result { diff --git a/src-tauri/src/codex_control.rs b/src-tauri/src/codex_control.rs index 5723e04..bcc8b0b 100644 --- a/src-tauri/src/codex_control.rs +++ b/src-tauri/src/codex_control.rs @@ -1,19 +1,30 @@ +use std::collections::{HashMap, HashSet}; use std::path::PathBuf; +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; -use std::time::Duration; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use tokio::process::Child; use tokio::sync::Mutex; +use tokio::task::JoinHandle; +use tokio_tungstenite::tungstenite::Message; +use tokio_tungstenite::{MaybeTlsStream, WebSocketStream}; -use crate::bridge::install_bridge; -use crate::cdp::{browser_websocket_url, reload_codex_page, wait_for_codex_target}; -use crate::debug_port::{resolve_debug_port, DebugPortMode, PREFERRED_DEBUG_PORT}; -use crate::launcher::{ensure_codex_launched_with_debug_port, quit_codex, resolve_codex_app_path}; +use crate::bridge::{install_bridge, restore_injected_runtime, BridgeCaller}; +use crate::cdp::{ + browser_websocket_url, close_browser, codex_injectable_page_targets, connect_cdp_websocket, + find_existing_codex_debug_port, has_codex_cdp_target, list_browser_targets, + wait_for_codex_targets, wait_for_debug_port_to_close, +}; +use crate::debug_port::{debug_port_scan_candidates, resolve_debug_port, PREFERRED_DEBUG_PORT}; +use crate::launcher::{ensure_codex_launched_with_debug_port, resolve_codex_app_path}; use crate::logging::DiagnosticLogger; use crate::ports::PortForwardManager; -use crate::routes::BridgeContext; +use crate::routes::{BridgeContext, RuntimeActivity}; use crate::runtime::build_runtime_bundle; use crate::state_dir::StateDir; +#[derive(Clone)] pub struct LaunchContext { pub debug_port: u16, pub app_path: PathBuf, @@ -24,142 +35,774 @@ pub struct LaunchContext { pub struct CodexController { ctx: Mutex>, + injected_targets: Mutex>, + target_watcher: Mutex>>, + managed_codex_process: Mutex>, + managed_codex_online: Mutex, + injection_sync_busy: Mutex<()>, + runtime_activity: RuntimeActivity, busy: Mutex<()>, } +struct InjectedTarget { + target_id: String, + helper_instance_id: String, + title: Option, + url: Option, + script_identifiers: Vec, + last_ready_at: u128, + last_seen_at: u128, + binding_task: JoinHandle<()>, +} + +#[derive(Debug, PartialEq, Eq)] +struct InjectionSyncPlan { + inject: Vec, + retain: Vec, + prune: Vec, +} + +static NEXT_HELPER_INSTANCE_ID: AtomicU64 = AtomicU64::new(0); +const TARGET_WATCHER_RECONNECT: Duration = Duration::from_secs(1); +const TARGET_WATCHER_MAX_RECONNECT: Duration = Duration::from_secs(30); +const TARGET_WATCHER_DISCONNECT_PROBE_LIMIT: usize = 3; +const TARGET_DISCOVERY_TIMEOUT: Duration = Duration::from_secs(5); +const TARGET_DISCOVERY_COMMAND_ID: u64 = 1; +const TARGET_EVENT_DEBOUNCE: Duration = Duration::from_millis(200); +const CODEX_RECOVERY_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(5); +type BrowserCdpSocket = WebSocketStream>; + impl CodexController { pub fn new() -> Arc { Arc::new(Self { ctx: Mutex::new(None), + injected_targets: Mutex::new(HashMap::new()), + target_watcher: Mutex::new(None), + managed_codex_process: Mutex::new(None), + managed_codex_online: Mutex::new(false), + injection_sync_busy: Mutex::new(()), + runtime_activity: RuntimeActivity::default(), busy: Mutex::new(()), }) } - pub async fn initial_launch(&self, port_manager: PortForwardManager) -> anyhow::Result<()> { + pub async fn initial_launch( + self: &Arc, + port_manager: PortForwardManager, + ) -> anyhow::Result<()> { let _busy = self.busy.lock().await; let state_dir = StateDir::init()?; let logger = Arc::new(DiagnosticLogger::new(state_dir.logs_dir.clone())); let app_path = resolve_codex_app_path(None)?; - let resolved = resolve_debug_port(PREFERRED_DEBUG_PORT).await?; - let debug_port = resolved.port; logger.append( "launcher.starting", - serde_json::json!({ "debugPort": debug_port, "preferred": PREFERRED_DEBUG_PORT }), + serde_json::json!({ "preferred": PREFERRED_DEBUG_PORT }), )?; logger.append( "launcher.codex_app_resolved", serde_json::json!({ "appPath": app_path }), )?; - logger.append( + if let Some(debug_port) = + find_existing_codex_debug_port(debug_port_scan_candidates(PREFERRED_DEBUG_PORT)).await + { + let launch_ctx = LaunchContext { + debug_port, + app_path, + state_dir, + logger, + port_manager, + }; + return self.attach_to_existing_codex(launch_ctx).await; + } + let resolved = resolve_debug_port(PREFERRED_DEBUG_PORT).await?; + let debug_port = resolved.port; + let launch_ctx = LaunchContext { + debug_port, + app_path, + state_dir, + logger, + port_manager, + }; + self.launch_new_codex(launch_ctx, resolved.port_hold).await + } + + async fn attach_to_existing_codex( + self: &Arc, + launch_ctx: LaunchContext, + ) -> anyhow::Result<()> { + launch_ctx.logger.append( + "launcher.debug_port_resolved", + serde_json::json!({ + "debugPort": launch_ctx.debug_port, + "mode": "attach", + }), + )?; + wait_for_codex_targets_ready(&launch_ctx, "attach-existing").await?; + self.sync_injected_targets(&launch_ctx).await?; + *self.ctx.lock().await = Some(launch_ctx); + self.start_target_watcher().await; + Ok(()) + } + + async fn launch_new_codex( + self: &Arc, + launch_ctx: LaunchContext, + port_hold: Option, + ) -> anyhow::Result<()> { + let debug_port = launch_ctx.debug_port; + launch_ctx.logger.append( "launcher.debug_port_resolved", serde_json::json!({ "debugPort": debug_port, - "mode": match resolved.mode { - DebugPortMode::Attach => "attach", - DebugPortMode::Launch => "launch", - }, + "mode": "launch", }), )?; - let attach_only = matches!(resolved.mode, DebugPortMode::Attach); - ensure_codex_launched_with_debug_port( - &app_path, + let child = ensure_codex_launched_with_debug_port( + &launch_ctx.app_path, debug_port, - attach_only, - resolved.port_hold, + false, + port_hold, ) .await?; - logger.append( - if attach_only { - "launcher.attached" - } else { - "launcher.codex_started" - }, + self.set_managed_codex_process(child).await; + launch_ctx.logger.append( + "launcher.codex_started", serde_json::json!({ "debugPort": debug_port }), )?; + wait_for_codex_targets_ready(&launch_ctx, "initial-launch").await?; + self.sync_injected_targets(&launch_ctx).await?; + *self.ctx.lock().await = Some(launch_ctx); + self.start_target_watcher().await; + Ok(()) + } + + pub async fn recover_codex_launch( + self: &Arc, + port_manager: PortForwardManager, + ) -> anyhow::Result<()> { + let _busy = self.busy.lock().await; + let state_dir = StateDir::init()?; + let logger = Arc::new(DiagnosticLogger::new(state_dir.logs_dir.clone())); + let app_path = resolve_codex_app_path(None)?; + logger.append( + "launcher.recovery_starting", + serde_json::json!({ "preferred": PREFERRED_DEBUG_PORT }), + )?; + self.close_existing_codex_debug_ports(&logger).await?; + let resolved = resolve_debug_port(PREFERRED_DEBUG_PORT).await?; let launch_ctx = LaunchContext { - debug_port, + debug_port: resolved.port, app_path, state_dir, logger, port_manager, }; - connect_and_inject(&launch_ctx).await?; - *self.ctx.lock().await = Some(launch_ctx); - Ok(()) + self.launch_new_codex(launch_ctx, resolved.port_hold).await } - pub async fn quit_codex(&self) -> anyhow::Result<()> { + pub async fn prepare_helper_shutdown(&self) -> anyhow::Result<()> { let _busy = self.busy.lock().await; - let ctx = self.ctx.lock().await; - let Some(ctx) = ctx.as_ref() else { - anyhow::bail!("Codex Helper has not finished launching yet"); - }; - ctx.logger - .append("tray.quit_codex", serde_json::json!({}))?; - quit_codex(Duration::from_secs(15)).await + if let Some(ctx) = self.current_context_if_ready().await { + self.cleanup_managed_codex(&ctx, "helper-shutdown", true) + .await?; + } else { + self.abort_target_watcher().await; + self.disconnect_injected_targets(None).await; + } + Ok(()) } - pub async fn reload_codex(&self) -> anyhow::Result<()> { - let _busy = self.busy.lock().await; - let ctx = self.ctx.lock().await; - let Some(ctx) = ctx.as_ref() else { - anyhow::bail!("Codex Helper has not finished launching yet"); + pub async fn has_connected_codex_instance(&self) -> bool { + let Some(ctx) = self.current_context_if_ready().await else { + return false; }; - ctx.logger - .append("tray.reload_codex", serde_json::json!({ "debugPort": ctx.debug_port }))?; - reload_codex_page(ctx.debug_port).await + has_codex_cdp_target(ctx.debug_port).await } - pub async fn restart_codex(&self) -> anyhow::Result<()> { - let _busy = self.busy.lock().await; - let ctx = { - let guard = self.ctx.lock().await; - let Some(ctx) = guard.as_ref() else { - anyhow::bail!("Codex Helper has not finished launching yet"); - }; - LaunchContext { - debug_port: ctx.debug_port, - app_path: ctx.app_path.clone(), + async fn sync_injected_targets(&self, ctx: &LaunchContext) -> anyhow::Result<()> { + let _sync_busy = self.injection_sync_busy.lock().await; + let targets = list_browser_targets(ctx.debug_port).await?; + let codex_targets = codex_injectable_page_targets(&targets); + let current_ids = codex_targets + .iter() + .map(|target| target.id.clone()) + .collect::>(); + let existing_ids = { + let mut injected_targets = self.injected_targets.lock().await; + injected_targets.retain(|_, target| !target.binding_task.is_finished()); + injected_targets.keys().cloned().collect::>() + }; + let plan = plan_injection_sync(¤t_ids, &existing_ids); + let mut injection_failures = Vec::new(); + let mut newly_injected_target_ids = Vec::new(); + let mut injected_count = 0usize; + + if !plan.inject.is_empty() { + let websocket_url = browser_websocket_url(ctx.debug_port).await?; + let runtime_scripts = build_runtime_bundle(&ctx.state_dir, &ctx.logger)?; + let bridge_ctx = BridgeContext { state_dir: ctx.state_dir.clone(), logger: ctx.logger.clone(), + debug_port: ctx.debug_port, port_manager: ctx.port_manager.clone(), + runtime_activity: self.runtime_activity.clone(), + }; + for target_id in &plan.inject { + let target = codex_targets + .iter() + .find(|target| &target.id == target_id) + .ok_or_else(|| { + anyhow::anyhow!("Codex target disappeared before injection: {target_id}") + })?; + let helper_instance_id = next_helper_instance_id(); + let caller = BridgeCaller::new_for_target(&target.id, &helper_instance_id); + let installed_bridge = match install_bridge( + &websocket_url, + &target.id, + caller, + bridge_ctx.clone(), + runtime_scripts.clone(), + ) + .await + { + Ok(installed_bridge) => installed_bridge, + Err(error) => { + injection_failures.push(serde_json::json!({ + "targetId": target.id.clone(), + "title": target.title.clone(), + "url": target.url.clone(), + "error": error.to_string(), + })); + continue; + } + }; + injected_count += 1; + newly_injected_target_ids.push(target.id.clone()); + let timestamp = unix_time_millis(); + self.injected_targets.lock().await.insert( + target.id.clone(), + InjectedTarget { + target_id: target.id.clone(), + helper_instance_id, + title: target.title.clone(), + url: target.url.clone(), + script_identifiers: installed_bridge.script_identifiers, + last_ready_at: timestamp, + last_seen_at: timestamp, + binding_task: installed_bridge.binding_task, + }, + ); } + } + + { + let mut injected_targets = self.injected_targets.lock().await; + for target_id in &plan.retain { + if let Some(target) = codex_targets.iter().find(|target| &target.id == target_id) { + if let Some(injected) = injected_targets.get_mut(target_id) { + injected.title = target.title.clone(); + injected.url = target.url.clone(); + injected.last_seen_at = unix_time_millis(); + } + } + } + for target_id in &plan.prune { + if let Some(injected) = injected_targets.remove(target_id) { + injected.binding_task.abort(); + } + } + } + if should_log_injection_sync(injected_count, plan.prune.len(), injection_failures.len()) { + let injected_target_details = self + .injected_targets + .lock() + .await + .values() + .map(|target| { + serde_json::json!({ + "targetId": target.target_id.clone(), + "helperInstanceId": target.helper_instance_id.clone(), + "title": target.title.clone(), + "url": target.url.clone(), + "lastReadyAt": target.last_ready_at, + "lastSeenAt": target.last_seen_at, + }) + }) + .collect::>(); + + ctx.logger.append( + "injection.targets_synced", + serde_json::json!({ + "discovered": targets.len(), + "codexTargets": current_ids.len(), + "injected": injected_count, + "retained": plan.retain.len(), + "pruned": plan.prune.len(), + "failures": injection_failures, + "targets": injected_target_details, + }), + )?; + } + if !injection_failures.is_empty() { + let mut injected_targets = self.injected_targets.lock().await; + for target_id in newly_injected_target_ids { + if let Some(injected) = injected_targets.remove(&target_id) { + injected.binding_task.abort(); + } + } + anyhow::bail!( + "Codex target injection failed for {} target(s)", + injection_failures.len() + ); + } + if current_ids.is_empty() { + anyhow::bail!("No injectable Codex page target found"); + } + *self.managed_codex_online.lock().await = true; + Ok(()) + } + + async fn current_context_if_ready(&self) -> Option { + self.ctx.lock().await.as_ref().cloned() + } + + async fn cleanup_managed_codex( + &self, + ctx: &LaunchContext, + reason: &str, + stop_watcher: bool, + ) -> anyhow::Result<()> { + let was_online = { + let mut online = self.managed_codex_online.lock().await; + let was_online = *online; + *online = false; + was_online }; - ctx.logger.append( - "tray.restart_codex", - serde_json::json!({ "debugPort": ctx.debug_port }), - )?; - quit_codex(Duration::from_secs(15)).await?; - tokio::time::sleep(Duration::from_millis(500)).await; - ensure_codex_launched_with_debug_port(&ctx.app_path, ctx.debug_port, false, None).await?; - ctx.logger.append( - "launcher.codex_restarted", - serde_json::json!({ "debugPort": ctx.debug_port }), - )?; - connect_and_inject(&ctx).await + ctx.port_manager.stop_all(); + let disconnected_targets = self.disconnect_injected_targets(Some(ctx)).await; + let aborted_watcher = if stop_watcher { + self.abort_target_watcher().await + } else { + false + }; + if was_online || disconnected_targets > 0 || aborted_watcher { + ctx.logger.append( + "codex.cleaned_up", + serde_json::json!({ + "reason": reason, + "wasOnline": was_online, + "disconnectedTargets": disconnected_targets, + "abortedWatcher": aborted_watcher, + }), + )?; + } + Ok(()) + } + + async fn close_existing_codex_debug_ports( + &self, + logger: &DiagnosticLogger, + ) -> anyhow::Result<()> { + for port in debug_port_scan_candidates(PREFERRED_DEBUG_PORT) { + if !has_codex_cdp_target(port).await { + continue; + } + logger.append( + "launcher.recovery_closing_codex", + serde_json::json!({ "debugPort": port }), + )?; + let _ = close_browser(port).await; + if let Err(error) = + wait_for_debug_port_to_close(port, CODEX_RECOVERY_SHUTDOWN_TIMEOUT).await + { + logger.append( + "launcher.recovery_close_failed", + serde_json::json!({ + "debugPort": port, + "error": error.to_string(), + }), + )?; + anyhow::bail!( + "Codex debug port {port} did not close during recovery. Quit Codex manually and start Codex Helper again." + ); + } + } + Ok(()) + } + + async fn disconnect_injected_targets(&self, ctx: Option<&LaunchContext>) -> usize { + let mut injected_targets = self.injected_targets.lock().await; + let count = injected_targets.len(); + let drained_targets = injected_targets + .drain() + .map(|(_, target)| target) + .collect::>(); + drop(injected_targets); + let restore_websocket_url = match ctx { + Some(ctx) => match browser_websocket_url(ctx.debug_port).await { + Ok(url) => Some(url), + Err(error) => { + let _ = ctx.logger.append( + "injection.restore_failed", + serde_json::json!({ + "error": error.to_string(), + }), + ); + None + } + }, + None => None, + }; + for target in drained_targets { + if let (Some(ctx), Some(websocket_url)) = (ctx, restore_websocket_url.as_deref()) { + if let Err(error) = restore_injected_runtime( + websocket_url, + &target.target_id, + &target.script_identifiers, + ) + .await + { + let _ = ctx.logger.append( + "injection.restore_failed", + serde_json::json!({ + "targetId": target.target_id, + "error": error.to_string(), + }), + ); + } + } + target.binding_task.abort(); + } + count + } + + async fn set_managed_codex_process(&self, child: Option) { + let mut managed_process = self.managed_codex_process.lock().await; + if let Some(mut existing_child) = managed_process.take() { + let _ = existing_child.start_kill(); + } + *managed_process = child; + } + + async fn abort_target_watcher(&self) -> bool { + let mut watcher = self.target_watcher.lock().await; + let Some(task) = watcher.take() else { + return false; + }; + task.abort(); + true + } + + async fn start_target_watcher(self: &Arc) { + let mut watcher = self.target_watcher.lock().await; + if watcher.as_ref().is_some_and(|task| !task.is_finished()) { + return; + } + let controller = self.clone(); + *watcher = Some(tokio::spawn(async move { + controller.target_watcher_loop().await; + })); + } + + async fn target_watcher_loop(self: Arc) { + let mut reconnect_delay = TARGET_WATCHER_RECONNECT; + let mut disconnect_probe_misses = 0usize; + loop { + let ctx = { + let guard = self.ctx.lock().await; + guard.as_ref().cloned() + }; + let Some(ctx) = ctx else { + tokio::time::sleep(reconnect_delay).await; + reconnect_delay = next_target_watcher_reconnect_delay(reconnect_delay); + continue; + }; + if let Err(error) = self.run_target_watcher_once(ctx.clone()).await { + let was_online = *self.managed_codex_online.lock().await; + if was_online { + let _ = ctx.logger.append( + "target_watcher.failed", + serde_json::json!({ "error": error.to_string() }), + ); + } + let target_present = has_codex_cdp_target(ctx.debug_port).await; + let (next_misses, should_cleanup) = + target_watcher_disconnect_probe(disconnect_probe_misses, target_present); + disconnect_probe_misses = next_misses; + if should_cleanup { + let _ = self + .cleanup_managed_codex(&ctx, "codex-disconnected", false) + .await; + } + tokio::time::sleep(reconnect_delay).await; + reconnect_delay = next_target_watcher_reconnect_delay(reconnect_delay); + continue; + } + disconnect_probe_misses = 0; + reconnect_delay = TARGET_WATCHER_RECONNECT; + tokio::time::sleep(reconnect_delay).await; + } + } + + async fn run_target_watcher_once(&self, ctx: LaunchContext) -> anyhow::Result<()> { + let websocket_url = browser_websocket_url(ctx.debug_port).await?; + let mut socket = connect_cdp_websocket(&websocket_url).await?; + futures_util::SinkExt::send( + &mut socket, + Message::Text( + serde_json::json!({ + "id": TARGET_DISCOVERY_COMMAND_ID, + "method": "Target.setDiscoverTargets", + "params": { "discover": true, "filter": [{}] }, + }) + .to_string() + .into(), + ), + ) + .await?; + + tokio::time::timeout(TARGET_DISCOVERY_TIMEOUT, async { + loop { + let Some(message) = futures_util::StreamExt::next(&mut socket).await else { + anyhow::bail!("Target.setDiscoverTargets socket closed before response"); + }; + let message = message?; + let value = cdp_message_value(message)?; + if let Some(method) = value.get("method").and_then(serde_json::Value::as_str) { + if is_target_discovery_event(method) { + continue; + } + continue; + } + if value.get("id").and_then(serde_json::Value::as_u64) + == Some(TARGET_DISCOVERY_COMMAND_ID) + { + if let Some(error) = value.get("error") { + anyhow::bail!("Target.setDiscoverTargets failed: {error}"); + } + return Ok(()); + } + } + }) + .await + .map_err(|_| { + anyhow::anyhow!( + "Target.setDiscoverTargets timed out after {}s", + TARGET_DISCOVERY_TIMEOUT.as_secs() + ) + })??; + + ctx.logger + .append("target_watcher.ready", serde_json::json!({}))?; + self.sync_injected_targets(&ctx).await?; + while let Some(message) = futures_util::StreamExt::next(&mut socket).await { + let value = cdp_message_value(message?)?; + let Some(method) = value.get("method").and_then(serde_json::Value::as_str) else { + continue; + }; + if !is_target_discovery_event(method) { + continue; + } + drain_target_discovery_events(&mut socket, TARGET_EVENT_DEBOUNCE).await?; + self.sync_injected_targets(&ctx).await?; + } + anyhow::bail!("Target discovery socket closed") } } -async fn connect_and_inject(ctx: &LaunchContext) -> anyhow::Result<()> { - let target = wait_for_codex_target(ctx.debug_port).await?; - let target_id = target.id.clone(); +async fn drain_target_discovery_events( + socket: &mut BrowserCdpSocket, + quiet_period: Duration, +) -> anyhow::Result<()> { + loop { + match tokio::time::timeout(quiet_period, futures_util::StreamExt::next(socket)).await { + Ok(Some(message)) => { + let value = cdp_message_value(message?)?; + let Some(method) = value.get("method").and_then(serde_json::Value::as_str) else { + continue; + }; + if is_target_discovery_event(method) { + continue; + } + } + Ok(None) => anyhow::bail!("Target discovery socket closed"), + Err(_) => return Ok(()), + } + } +} + +fn cdp_message_value(message: Message) -> anyhow::Result { + match message { + Message::Text(text) => Ok(serde_json::from_str(&text)?), + Message::Binary(bytes) => Ok(serde_json::from_slice(&bytes)?), + _ => Ok(serde_json::json!({})), + } +} + +fn is_target_discovery_event(method: &str) -> bool { + matches!( + method, + "Target.targetCreated" | "Target.targetInfoChanged" | "Target.targetDestroyed" + ) +} + +fn should_log_injection_sync( + injected_count: usize, + pruned_count: usize, + failure_count: usize, +) -> bool { + injected_count > 0 || pruned_count > 0 || failure_count > 0 +} + +fn next_target_watcher_reconnect_delay(current: Duration) -> Duration { + current + .checked_mul(2) + .unwrap_or(TARGET_WATCHER_MAX_RECONNECT) + .min(TARGET_WATCHER_MAX_RECONNECT) +} + +fn target_watcher_disconnect_probe(current_misses: usize, target_present: bool) -> (usize, bool) { + if target_present { + return (0, false); + } + let misses = current_misses.saturating_add(1); + (misses, misses >= TARGET_WATCHER_DISCONNECT_PROBE_LIMIT) +} + +async fn wait_for_codex_targets_ready(ctx: &LaunchContext, reason: &str) -> anyhow::Result<()> { + let targets = wait_for_codex_targets(ctx.debug_port, Duration::from_secs(60)) + .await + .map_err(|error| { + let _ = ctx.logger.append( + "launcher.codex_targets_wait_failed", + serde_json::json!({ + "debugPort": ctx.debug_port, + "reason": reason, + "error": error.to_string(), + }), + ); + error + })?; ctx.logger.append( - "cdp.target_selected", + "launcher.codex_targets_ready", serde_json::json!({ - "id": target.id, - "title": target.title, - "url": target.url, + "debugPort": ctx.debug_port, + "reason": reason, + "targetIds": targets.iter().map(|target| target.id.clone()).collect::>(), }), )?; - let websocket_url = browser_websocket_url(ctx.debug_port).await?; - let runtime_scripts = build_runtime_bundle(&ctx.state_dir, &ctx.logger)?; - let bridge_ctx = BridgeContext { - state_dir: ctx.state_dir.clone(), - logger: ctx.logger.clone(), - debug_port: ctx.debug_port, - port_manager: ctx.port_manager.clone(), - }; - install_bridge(&websocket_url, &target_id, bridge_ctx, runtime_scripts).await?; - ctx.logger.append("bridge.injected", serde_json::json!({}))?; Ok(()) } + +fn next_helper_instance_id() -> String { + let id = NEXT_HELPER_INSTANCE_ID.fetch_add(1, Ordering::Relaxed) + 1; + format!("helper-{id}") +} + +fn unix_time_millis() -> u128 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis()) + .unwrap_or_default() +} + +fn plan_injection_sync(current: &[String], existing: &[String]) -> InjectionSyncPlan { + let current_set = current.iter().collect::>(); + let existing_set = existing.iter().collect::>(); + InjectionSyncPlan { + inject: current + .iter() + .filter(|target_id| !existing_set.contains(target_id)) + .cloned() + .collect(), + retain: current + .iter() + .filter(|target_id| existing_set.contains(target_id)) + .cloned() + .collect(), + prune: existing + .iter() + .filter(|target_id| !current_set.contains(target_id)) + .cloned() + .collect(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn injection_sync_plans_inject_retain_and_prune() { + let current = vec!["target-a".to_string(), "target-b".to_string()]; + let existing = vec!["target-a".to_string(), "target-old".to_string()]; + + let plan = plan_injection_sync(¤t, &existing); + + assert_eq!(plan.inject, vec!["target-b"]); + assert_eq!(plan.retain, vec!["target-a"]); + assert_eq!(plan.prune, vec!["target-old"]); + } + + #[test] + fn injection_sync_plans_prune_when_no_targets_remain() { + let current = Vec::new(); + let existing = vec!["target-old".to_string()]; + + let plan = plan_injection_sync(¤t, &existing); + + assert!(plan.inject.is_empty()); + assert!(plan.retain.is_empty()); + assert_eq!(plan.prune, vec!["target-old"]); + } + + #[test] + fn injection_sync_logging_only_records_changes_or_failures() { + assert!(!should_log_injection_sync(0, 0, 0)); + assert!(should_log_injection_sync(1, 0, 0)); + assert!(should_log_injection_sync(0, 1, 0)); + assert!(should_log_injection_sync(0, 0, 1)); + } + + #[test] + fn target_watcher_reconnect_delay_caps_at_maximum() { + assert_eq!( + next_target_watcher_reconnect_delay(Duration::from_secs(1)), + Duration::from_secs(2) + ); + assert_eq!( + next_target_watcher_reconnect_delay(Duration::from_secs(20)), + TARGET_WATCHER_MAX_RECONNECT + ); + assert_eq!( + next_target_watcher_reconnect_delay(Duration::MAX), + TARGET_WATCHER_MAX_RECONNECT + ); + } + + #[test] + fn target_watcher_requires_repeated_misses_before_cleanup() { + assert_eq!(target_watcher_disconnect_probe(0, false), (1, false)); + assert_eq!(target_watcher_disconnect_probe(1, false), (2, false)); + assert_eq!(target_watcher_disconnect_probe(2, false), (3, true)); + } + + #[test] + fn target_watcher_probe_resets_when_target_is_present() { + assert_eq!(target_watcher_disconnect_probe(2, true), (0, false)); + } + + #[tokio::test] + async fn controller_starts_with_managed_codex_offline() { + let controller = CodexController::new(); + + assert!(!*controller.managed_codex_online.lock().await); + } + + #[tokio::test] + async fn controller_without_context_has_no_connected_codex_instance() { + let controller = CodexController::new(); + + assert!(!controller.has_connected_codex_instance().await); + } +} diff --git a/src-tauri/src/debug_port.rs b/src-tauri/src/debug_port.rs index f6432bb..b4da99c 100644 --- a/src-tauri/src/debug_port.rs +++ b/src-tauri/src/debug_port.rs @@ -1,66 +1,66 @@ use std::net::TcpListener; -use crate::cdp; -use crate::launcher::listen_pids_on_port; - pub const PREFERRED_DEBUG_PORT: u16 = 9229; -pub const DEBUG_PORT_SCAN_LIMIT: u16 = 32; - -pub enum DebugPortMode { - Attach, - Launch, -} +pub const DEBUG_PORT_SCAN_END: u16 = 9260; pub struct DebugPortResolution { pub port: u16, - pub mode: DebugPortMode, pub port_hold: Option, } -pub async fn find_attachable_debug_port(preferred: u16, scan_limit: u16) -> Option { - for offset in 0..scan_limit { - let port = preferred.saturating_add(offset); - if cdp::has_codex_cdp_target(port).await { - return Some(port); - } - } - None -} - -pub fn find_free_debug_port(preferred: u16, scan_limit: u16) -> anyhow::Result> { - for offset in 0..scan_limit { - let port = preferred.saturating_add(offset); - if listen_pids_on_port(port)?.is_empty() { - return Ok(Some(port)); - } - } - Ok(None) +fn reserve_port(port: u16) -> anyhow::Result { + Ok(TcpListener::bind(("127.0.0.1", port))?) } pub fn reserve_ephemeral_port() -> anyhow::Result<(u16, TcpListener)> { - let listener = TcpListener::bind("127.0.0.1:0")?; + let listener = reserve_port(0)?; Ok((listener.local_addr()?.port(), listener)) } pub async fn resolve_debug_port(preferred: u16) -> anyhow::Result { - if let Some(port) = find_attachable_debug_port(preferred, DEBUG_PORT_SCAN_LIMIT).await { - return Ok(DebugPortResolution { - port, - mode: DebugPortMode::Attach, - port_hold: None, - }); - } - if let Some(port) = find_free_debug_port(preferred, DEBUG_PORT_SCAN_LIMIT)? { - return Ok(DebugPortResolution { - port, - mode: DebugPortMode::Launch, - port_hold: None, - }); + for port in debug_port_scan_candidates(preferred) { + if let Ok(port_hold) = reserve_port(port) { + return Ok(DebugPortResolution { + port, + port_hold: Some(port_hold), + }); + } } let (port, port_hold) = reserve_ephemeral_port()?; Ok(DebugPortResolution { port, - mode: DebugPortMode::Launch, port_hold: Some(port_hold), }) } + +pub fn debug_port_scan_candidates(preferred: u16) -> impl Iterator { + preferred..=DEBUG_PORT_SCAN_END +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn resolve_debug_port_reserves_managed_launch_port() { + let Some((preferred, preferred_hold)) = debug_port_scan_candidates(PREFERRED_DEBUG_PORT) + .find_map(|port| reserve_port(port).ok().map(|hold| (port, hold))) + else { + return; + }; + drop(preferred_hold); + + let resolved = resolve_debug_port(preferred).await.unwrap(); + + assert_eq!(resolved.port, preferred); + assert!(resolved.port_hold.is_some()); + } + + #[test] + fn debug_port_scan_candidates_cover_helper_range() { + let ports = debug_port_scan_candidates(PREFERRED_DEBUG_PORT).collect::>(); + + assert_eq!(ports.first(), Some(&9229)); + assert_eq!(ports.last(), Some(&9260)); + } +} diff --git a/src-tauri/src/launcher.rs b/src-tauri/src/launcher.rs index 6d63c26..e73b02b 100644 --- a/src-tauri/src/launcher.rs +++ b/src-tauri/src/launcher.rs @@ -2,9 +2,13 @@ use std::path::{Path, PathBuf}; use std::process::Stdio; use std::time::Duration; +use tokio::process::{Child, Command}; + use crate::cdp; +use crate::proxy_env::loopback_no_proxy_value; pub const DEFAULT_CODEX_APP_PATH: &str = "/Applications/Codex.app"; + pub fn resolve_codex_app_path(explicit_path: Option<&Path>) -> anyhow::Result { let candidate = explicit_path .map(Path::to_path_buf) @@ -15,148 +19,64 @@ pub fn resolve_codex_app_path(explicit_path: Option<&Path>) -> anyhow::Result PathBuf { + if app_path + .extension() + .and_then(|extension| extension.to_str()) + == Some("app") + { + return app_path.join("Contents").join("MacOS").join("Codex"); + } + app_path.to_path_buf() +} + pub fn codex_debug_args(debug_port: u16) -> Vec { vec![ format!("--remote-debugging-port={debug_port}"), + "--remote-debugging-address=127.0.0.1".to_string(), format!("--remote-allow-origins=http://127.0.0.1:{debug_port}"), ] } -pub fn process_command(pid: u32) -> anyhow::Result { - let output = std::process::Command::new("ps") - .args(["-p", &pid.to_string(), "-o", "command="]) - .output() - .map_err(|error| anyhow::anyhow!("ps failed for pid {pid}: {error}"))?; - if !output.status.success() { - anyhow::bail!( - "ps failed for pid {pid} with status {}: {}", - output - .status - .code() - .map_or_else(|| "signal".to_string(), |code| code.to_string()), - String::from_utf8_lossy(&output.stderr).trim() - ); - } - Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) +#[derive(Debug, Clone, PartialEq, Eq)] +struct CodexLaunchCommand { + program: PathBuf, + args: Vec, + keeps_child: bool, } -pub fn is_killable_port_blocker(command: &str) -> bool { - let normalized = command.to_lowercase(); - normalized.contains("codex.app") - || normalized.split_whitespace().any(|word| word == "codex") - || normalized.contains("/codex.app/") +fn codex_launch_command(app_path: &Path, debug_port: u16) -> CodexLaunchCommand { + codex_launch_command_for_platform(app_path, debug_port, std::env::consts::OS) } -pub fn listen_pids_on_port(port: u16) -> anyhow::Result> { - let listen_arg = format!("-tiTCP:{port}"); - let output = std::process::Command::new("lsof") - .args([&listen_arg, "-sTCP:LISTEN"]) - .output() - .map_err(|error| anyhow::anyhow!("lsof failed for port {port}: {error}"))?; - if !output.status.success() { - let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); - let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); - if stdout.is_empty() && stderr.is_empty() { - return Ok(Vec::new()); - } - anyhow::bail!( - "lsof failed for port {port} with status {}: {}", - output - .status - .code() - .map_or_else(|| "signal".to_string(), |code| code.to_string()), - if stderr.is_empty() { stdout } else { stderr } - ); - } - Ok(String::from_utf8_lossy(&output.stdout) - .split_whitespace() - .filter_map(|value| value.parse::().ok()) - .collect()) -} - -pub fn is_codex_running() -> bool { - std::process::Command::new("pgrep") - .args(["-x", "Codex"]) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .map(|status| status.success()) - .unwrap_or(false) -} - -pub async fn quit_codex(timeout: Duration) -> anyhow::Result<()> { - let _ = std::process::Command::new("osascript") - .args(["-e", r#"tell application "Codex" to quit"#]) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status(); - let started_at = std::time::Instant::now(); - while started_at.elapsed() < timeout { - if !is_codex_running() { - return Ok(()); - } - tokio::time::sleep(Duration::from_millis(250)).await; +fn codex_launch_command_for_platform( + app_path: &Path, + debug_port: u16, + platform: &str, +) -> CodexLaunchCommand { + let debug_args = codex_debug_args(debug_port); + if platform == "macos" && is_app_bundle_path(app_path) { + let mut args = vec![ + "-na".to_string(), + app_path.to_string_lossy().into_owned(), + "--args".to_string(), + ]; + args.extend(debug_args); + return CodexLaunchCommand { + program: PathBuf::from("open"), + args, + keeps_child: false, + }; + } + CodexLaunchCommand { + program: codex_executable_path(app_path), + args: debug_args, + keeps_child: true, } - anyhow::bail!("Timed out waiting for Codex to quit") } -pub async fn release_blocked_debug_port(debug_port: u16) -> anyhow::Result<()> { - if cdp::is_debug_port_ready(debug_port).await { - if cdp::has_codex_cdp_target(debug_port).await { - return Ok(()); - } - anyhow::bail!( - "Debug port {debug_port} exposes a browser CDP endpoint but not Codex. Stop the other app or let Codex Helper auto-select another port." - ); - } - let initial_pids = listen_pids_on_port(debug_port)?; - if initial_pids.is_empty() { - return Ok(()); - } - - let mut killable_pids = Vec::new(); - for pid in &initial_pids { - if is_killable_port_blocker(&process_command(*pid)?) { - killable_pids.push(*pid); - } - } - let blocked_by: Vec = initial_pids - .iter() - .filter(|pid| !killable_pids.contains(pid)) - .map(|pid| process_command(*pid).map(|command| format!("{pid}:{command}"))) - .collect::>>()?; - if !blocked_by.is_empty() { - anyhow::bail!( - "Debug port {debug_port} is blocked by a non-Codex process: {}. Stop it manually or let Codex Helper auto-select a port.", - blocked_by.join("; ") - ); - } - - for pid in &killable_pids { - let _ = std::process::Command::new("kill") - .arg(pid.to_string()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status(); - } - tokio::time::sleep(Duration::from_millis(750)).await; - if cdp::is_debug_port_ready(debug_port).await { - return Ok(()); - } - for pid in listen_pids_on_port(debug_port)? { - if is_killable_port_blocker(&process_command(pid)?) { - let _ = std::process::Command::new("kill") - .args(["-9", &pid.to_string()]) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status(); - } - } - tokio::time::sleep(Duration::from_millis(500)).await; - if !listen_pids_on_port(debug_port)?.is_empty() { - anyhow::bail!("Debug port {debug_port} is still blocked after releasing Codex listeners"); - } - Ok(()) +fn is_app_bundle_path(path: &Path) -> bool { + path.extension().and_then(|extension| extension.to_str()) == Some("app") } pub async fn ensure_codex_launched_with_debug_port( @@ -164,36 +84,57 @@ pub async fn ensure_codex_launched_with_debug_port( debug_port: u16, attach_only: bool, port_hold: Option, -) -> anyhow::Result<()> { - let _port_hold = port_hold; +) -> anyhow::Result> { + let mut port_hold = port_hold; if attach_only { if cdp::has_codex_cdp_target(debug_port).await { - return Ok(()); + return Ok(None); } anyhow::bail!( "Codex CDP is not ready on port {debug_port}. Start Codex with remote debugging on that port or let Codex Helper auto-select." ); } - if cdp::is_debug_port_ready(debug_port).await { - return Ok(()); + if port_hold.is_none() && cdp::is_debug_port_ready(debug_port).await { + if cdp::has_codex_cdp_target(debug_port).await { + anyhow::bail!( + "Debug port {debug_port} already exposes Codex CDP. Use an explicit debug port only when you intend to attach to that existing Codex, or let Codex Helper launch a managed Codex on a random port." + ); + } + anyhow::bail!( + "Debug port {debug_port} exposes a browser CDP endpoint but not Codex. Stop the other app or let Codex Helper auto-select another port." + ); } - release_blocked_debug_port(debug_port).await?; - if is_codex_running() { - quit_codex(Duration::from_secs(15)).await?; - tokio::time::sleep(Duration::from_millis(500)).await; - release_blocked_debug_port(debug_port).await?; + + let launch_command = codex_launch_command(app_path, debug_port); + if !launch_command.program.exists() && launch_command.program != PathBuf::from("open") { + anyhow::bail!( + "Codex executable not found: {}", + launch_command.program.display() + ); } - let app_path_string = app_path.to_string_lossy().to_string(); - let mut open_args = vec!["-na".to_string(), app_path_string, "--args".to_string()]; - open_args.extend(codex_debug_args(debug_port)); - std::process::Command::new("open") - .args(open_args) + + drop(port_hold.take()); + let no_proxy = loopback_no_proxy_value(); + let mut command = Command::new(&launch_command.program); + command + .args(&launch_command.args) + .env("NO_PROXY", &no_proxy) + .env("no_proxy", &no_proxy) + .stdin(Stdio::null()) .stdout(Stdio::null()) - .stderr(Stdio::null()) + .stderr(Stdio::null()); + let child = command .spawn() - .map_err(|error| anyhow::anyhow!("failed to open Codex app: {error}"))?; - cdp::wait_for_debug_port(debug_port, Duration::from_secs(60)).await?; - Ok(()) + .map_err(|error| anyhow::anyhow!("failed to launch Codex executable: {error}"))?; + let mut child = child; + if let Err(error) = cdp::wait_for_debug_port(debug_port, Duration::from_secs(60)).await { + let _ = child.kill().await; + return Err(error); + } + if !launch_command.keeps_child { + return Ok(None); + } + Ok(Some(child)) } #[cfg(test)] @@ -207,19 +148,10 @@ mod tests { let args = codex_debug_args(9229); assert!(args.contains(&"--remote-debugging-port=9229".to_string())); + assert!(args.contains(&"--remote-debugging-address=127.0.0.1".to_string())); assert!(args.contains(&"--remote-allow-origins=http://127.0.0.1:9229".to_string())); } - #[test] - fn launcher_identifies_codex_port_blockers() { - assert!(is_killable_port_blocker( - "/Applications/Codex.app/Contents/MacOS/Codex" - )); - assert!(!is_killable_port_blocker( - "/System/Library/PrivateFrameworks/SkyComputerUseService" - )); - } - #[test] fn launcher_reports_missing_codex_app_path() { let missing = Path::new("/tmp/codex-helper-missing/Codex.app"); @@ -231,4 +163,58 @@ mod tests { "Codex app not found: /tmp/codex-helper-missing/Codex.app" ); } + + #[test] + fn launcher_resolves_macos_bundle_executable_path() { + let executable = codex_executable_path(Path::new("/Applications/Codex.app")); + + assert_eq!( + executable, + PathBuf::from("/Applications/Codex.app/Contents/MacOS/Codex") + ); + } + + #[test] + fn launcher_preserves_direct_executable_path() { + let executable = codex_executable_path(Path::new("/usr/local/bin/codex")); + + assert_eq!(executable, PathBuf::from("/usr/local/bin/codex")); + } + + #[test] + fn launcher_uses_launchservices_for_macos_app_bundles() { + let command = + codex_launch_command_for_platform(Path::new("/Applications/Codex.app"), 9229, "macos"); + + assert_eq!(command.program, PathBuf::from("open")); + assert_eq!( + command.args, + vec![ + "-na", + "/Applications/Codex.app", + "--args", + "--remote-debugging-port=9229", + "--remote-debugging-address=127.0.0.1", + "--remote-allow-origins=http://127.0.0.1:9229", + ] + ); + assert!(!command.keeps_child); + } + + #[test] + fn launcher_starts_direct_executables_without_launchservices() { + let command = + codex_launch_command_for_platform(Path::new("/usr/local/bin/codex"), 9229, "linux"); + + assert_eq!(command.program, PathBuf::from("/usr/local/bin/codex")); + assert_eq!( + command.args, + vec![ + "--remote-debugging-port=9229", + "--remote-debugging-address=127.0.0.1", + "--remote-allow-origins=http://127.0.0.1:9229", + ] + ); + assert!(command.keeps_child); + } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b20d33a..6c9e719 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -9,6 +9,7 @@ mod logging; mod markdown; mod models; mod ports; +mod proxy_env; mod routes; mod runtime; pub mod session_actions; diff --git a/src-tauri/src/ports.rs b/src-tauri/src/ports.rs index d02752f..a056679 100644 --- a/src-tauri/src/ports.rs +++ b/src-tauri/src/ports.rs @@ -523,10 +523,21 @@ async fn wait_for_tunnel_start(child: &mut Child, local_port: u16) -> Result<(), #[cfg(test)] mod tests { + use std::sync::{MutexGuard, OnceLock}; + use serde_json::json; use super::*; + static LOCAL_PORT_TEST_LOCK: OnceLock> = OnceLock::new(); + + fn local_port_test_guard() -> MutexGuard<'static, ()> { + LOCAL_PORT_TEST_LOCK + .get_or_init(|| std::sync::Mutex::new(())) + .lock() + .expect("local port test lock") + } + #[test] fn parse_port_accepts_valid_port() { assert_eq!(parse_port(&json!(5173), "remotePort").unwrap(), 5173); @@ -567,6 +578,7 @@ mod tests { #[test] fn local_port_available_reports_bound_ports() { + let _port_guard = local_port_test_guard(); let listener = std::net::TcpListener::bind(("127.0.0.1", 0)).expect("bind free port"); let port = listener.local_addr().expect("local addr").port(); @@ -759,6 +771,7 @@ mod tests { #[tokio::test] async fn start_rejects_tunnel_when_ssh_exits_immediately() { + let _port_guard = local_port_test_guard(); let manager = PortForwardManager::with_ssh_program("/bin/false"); let listener = std::net::TcpListener::bind(("127.0.0.1", 0)).expect("bind free port"); let local_port = listener.local_addr().expect("local addr").port(); diff --git a/src-tauri/src/proxy_env.rs b/src-tauri/src/proxy_env.rs new file mode 100644 index 0000000..47c8219 --- /dev/null +++ b/src-tauri/src/proxy_env.rs @@ -0,0 +1,51 @@ +const LOOPBACK_NO_PROXY_HOSTS: [&str; 3] = ["127.0.0.1", "localhost", "::1"]; + +pub fn configure_process_loopback_no_proxy() { + let no_proxy = loopback_no_proxy_value(); + std::env::set_var("NO_PROXY", &no_proxy); + std::env::set_var("no_proxy", no_proxy); +} + +pub fn loopback_no_proxy_value() -> String { + let existing = std::env::var("NO_PROXY") + .ok() + .or_else(|| std::env::var("no_proxy").ok()); + merge_loopback_no_proxy(existing.as_deref()) +} + +fn merge_loopback_no_proxy(existing: Option<&str>) -> String { + let mut entries = existing + .unwrap_or_default() + .split(',') + .map(str::trim) + .filter(|entry| !entry.is_empty()) + .map(ToOwned::to_owned) + .collect::>(); + for host in LOOPBACK_NO_PROXY_HOSTS { + if !entries.iter().any(|entry| entry == host) { + entries.push(host.to_string()); + } + } + entries.join(",") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn no_proxy_value_adds_loopback_hosts() { + assert_eq!( + merge_loopback_no_proxy(Some("example.com")), + "example.com,127.0.0.1,localhost,::1" + ); + } + + #[test] + fn no_proxy_value_keeps_existing_loopback_hosts() { + assert_eq!( + merge_loopback_no_proxy(Some("localhost, example.com,::1,127.0.0.1")), + "localhost,example.com,::1,127.0.0.1" + ); + } +} diff --git a/src-tauri/src/routes.rs b/src-tauri/src/routes.rs index 6d82343..7dc8610 100644 --- a/src-tauri/src/routes.rs +++ b/src-tauri/src/routes.rs @@ -1,9 +1,10 @@ -use std::sync::Arc; +use std::sync::{Arc, Mutex}; use serde_json::{json, Value}; use tauri_plugin_opener::{open_path, open_url, reveal_item_in_dir}; -use crate::cdp::{list_targets, pick_codex_page_target, CdpTarget}; +use crate::bridge::{BridgeCaller, BridgeRequest}; +use crate::cdp::{list_targets, CdpTarget}; use crate::logging::DiagnosticLogger; use crate::ports::{ discover_remote_listening_ports, discovery_request_from_payload, request_from_payload, @@ -25,10 +26,54 @@ pub struct BridgeContext { pub logger: Arc, pub debug_port: u16, pub port_manager: PortForwardManager, + pub runtime_activity: RuntimeActivity, } -pub async fn handle_bridge_request(ctx: BridgeContext, path: &str, payload: Value) -> Value { - match path { +#[derive(Clone, Default)] +pub struct RuntimeActivity { + inner: Arc>>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RuntimeActivitySnapshot { + pub target_id: String, + pub helper_instance_id: String, + pub href: String, + pub has_focus: bool, + pub visibility_state: String, +} + +impl RuntimeActivity { + pub fn record(&self, caller: &BridgeCaller) { + if caller.target_id.trim().is_empty() || caller.helper_instance_id.trim().is_empty() { + return; + } + *self.inner.lock().expect("runtime activity poisoned") = Some(RuntimeActivitySnapshot { + target_id: caller.target_id.clone(), + helper_instance_id: caller.helper_instance_id.clone(), + href: caller.href.clone(), + has_focus: caller.has_focus, + visibility_state: caller.visibility_state.clone(), + }); + } + + #[cfg(test)] + pub fn last(&self) -> Option { + self.inner + .lock() + .expect("runtime activity poisoned") + .clone() + } +} + +pub async fn handle_bridge_request(ctx: BridgeContext, request: BridgeRequest) -> Value { + let BridgeRequest { + path, + payload, + caller, + .. + } = request; + let response = match path.as_str() { "/backend/status" => json!({ "status": "ok", "message": "Codex Helper backend connected", @@ -43,6 +88,10 @@ pub async fn handle_bridge_request(ctx: BridgeContext, path: &str, payload: Valu Err(error) => json!({ "status": "failed", "message": error.to_string() }), } } + "/runtime/activity" => { + ctx.runtime_activity.record(&caller); + json!({ "status": "ok" }) + } "/runtime/user-scripts" => match user_script_inventory(&ctx.state_dir) { Ok(scripts) => json!({ "status": "ok", @@ -64,7 +113,7 @@ pub async fn handle_bridge_request(ctx: BridgeContext, path: &str, payload: Valu "/logs/reveal" => reveal_path_response(&ctx.state_dir.logs_dir), "/scripts/reveal" => reveal_path_response(&ctx.state_dir.scripts_dir), "/state/reveal" => reveal_path_response(&ctx.state_dir.root), - "/devtools/open" => open_devtools_response(ctx.debug_port).await, + "/devtools/open" => open_devtools_response(ctx.debug_port, &caller.target_id).await, "/url/open-external" => open_external_local_url_response(&payload), "/auto-rename-chat" => auto_rename_chat_response(&payload), "/export-markdown" => export_markdown_response(&payload), @@ -106,7 +155,46 @@ pub async fn handle_bridge_request(ctx: BridgeContext, path: &str, payload: Valu "status": "failed", "message": format!("Unknown Codex Helper bridge path: {path}") }), + }; + log_bridge_request(&ctx.logger, &path, &caller, &response); + response +} + +fn log_bridge_request( + logger: &DiagnosticLogger, + path: &str, + caller: &BridgeCaller, + response: &Value, +) { + let status = response + .get("status") + .and_then(Value::as_str) + .unwrap_or("unknown"); + if !should_log_bridge_request(path, status) { + return; } + let _ = logger.append( + "bridge.request", + bridge_request_diagnostic(path, caller, response), + ); +} + +fn should_log_bridge_request(path: &str, status: &str) -> bool { + if status != "ok" { + return true; + } + !matches!(path, "/ports/list" | "/runtime/activity") +} + +fn bridge_request_diagnostic(path: &str, caller: &BridgeCaller, response: &Value) -> Value { + json!({ + "path": path, + "status": response + .get("status") + .and_then(Value::as_str) + .unwrap_or("unknown"), + "caller": caller, + }) } fn user_script_inventory(state_dir: &StateDir) -> anyhow::Result> { @@ -156,22 +244,12 @@ fn reveal_path_response(path: &std::path::Path) -> Value { } } -async fn open_devtools_response(debug_port: u16) -> Value { - let target = match list_targets(debug_port) - .await - .and_then(|targets| pick_codex_page_target(&targets)) - { - Ok(target) => target, +async fn open_devtools_response(debug_port: u16, target_id: &str) -> Value { + let targets = match list_targets(debug_port).await { + Ok(targets) => targets, Err(error) => return json!({ "status": "failed", "message": error.to_string() }), }; - let target_id = target.id.clone(); - if target_id.trim().is_empty() { - return json!({ - "status": "failed", - "message": "Codex DevTools target id is empty", - }); - } - let url = match devtools_url(debug_port, &target) { + let url = match devtools_url_for_target_id(debug_port, &targets, target_id) { Ok(url) => url, Err(error) => return json!({ "status": "failed", "message": error.to_string() }), }; @@ -266,6 +344,22 @@ pub fn devtools_url(debug_port: u16, target: &CdpTarget) -> anyhow::Result anyhow::Result { + let target_id = target_id.trim(); + if target_id.is_empty() { + anyhow::bail!("Codex DevTools caller target id is empty"); + } + let target = targets + .iter() + .find(|target| target.id == target_id) + .ok_or_else(|| anyhow::anyhow!("Codex DevTools target not found: {target_id}"))?; + devtools_url(debug_port, target) +} + fn normalize_devtools_frontend_url(debug_port: u16, frontend_url: &str) -> String { if frontend_url.starts_with("http://") || frontend_url.starts_with("https://") @@ -339,6 +433,92 @@ mod tests { ); } + #[test] + fn devtools_open_uses_caller_target() { + let targets = vec![ + CdpTarget { + id: "first-target".to_string(), + target_type: "page".to_string(), + title: Some("Codex".to_string()), + url: Some("app://-/index.html".to_string()), + devtools_frontend_url: None, + web_socket_debugger_url: Some( + "ws://127.0.0.1:9229/devtools/page/first-target".to_string(), + ), + }, + CdpTarget { + id: "caller-target".to_string(), + target_type: "page".to_string(), + title: Some("Codex".to_string()), + url: Some("app://-/index.html".to_string()), + devtools_frontend_url: None, + web_socket_debugger_url: Some( + "ws://127.0.0.1:9229/devtools/page/caller-target".to_string(), + ), + }, + ]; + + assert_eq!( + devtools_url_for_target_id(9229, &targets, "caller-target").expect("devtools url"), + "http://127.0.0.1:9229/devtools/inspector.html?ws=127.0.0.1:9229/devtools/page/caller-target" + ); + } + + #[test] + fn runtime_activity_records_caller_identity() { + let activity = RuntimeActivity::default(); + let caller = BridgeCaller { + target_id: "target-1".to_string(), + helper_instance_id: "helper-1".to_string(), + href: "app://-/index.html".to_string(), + has_focus: true, + visibility_state: "visible".to_string(), + }; + + activity.record(&caller); + + assert_eq!( + activity.last(), + Some(RuntimeActivitySnapshot { + target_id: "target-1".to_string(), + helper_instance_id: "helper-1".to_string(), + href: "app://-/index.html".to_string(), + has_focus: true, + visibility_state: "visible".to_string(), + }) + ); + } + + #[test] + fn bridge_request_diagnostic_includes_route_status_and_caller() { + let caller = BridgeCaller { + target_id: "target-1".to_string(), + helper_instance_id: "helper-1".to_string(), + href: "app://-/index.html".to_string(), + has_focus: true, + visibility_state: "visible".to_string(), + }; + + let diagnostic = + bridge_request_diagnostic("/backend/status", &caller, &json!({ "status": "ok" })); + + assert_eq!(diagnostic["path"], "/backend/status"); + assert_eq!(diagnostic["status"], "ok"); + assert_eq!(diagnostic["caller"]["targetId"], "target-1"); + assert_eq!(diagnostic["caller"]["helperInstanceId"], "helper-1"); + assert_eq!(diagnostic["caller"]["href"], "app://-/index.html"); + assert_eq!(diagnostic["caller"]["hasFocus"], true); + assert_eq!(diagnostic["caller"]["visibilityState"], "visible"); + } + + #[test] + fn bridge_request_logging_suppresses_noisy_success_routes() { + assert!(!should_log_bridge_request("/ports/list", "ok")); + assert!(!should_log_bridge_request("/runtime/activity", "ok")); + assert!(should_log_bridge_request("/ports/list", "failed")); + assert!(should_log_bridge_request("/backend/status", "ok")); + } + #[test] fn local_browser_url_rejects_external_hosts() { let payload = json!({ "url": "https://example.com:3000" }); diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index dba97dd..7a12fb8 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -213,14 +213,18 @@ mod tests { } #[test] - fn read_settings_ignores_known_removed_keys() { + fn read_settings_accepts_settings_with_known_removed_keys() { let temp_dir = tempfile::tempdir().expect("temp dir"); let path = temp_dir.path().join("config.json"); fs::write( &path, r#"{ "markdownExportEnabled": true, - "sessionDeleteEnabled": true + "sessionDeleteEnabled": true, + "autoRenameMenuEnabled": true, + "markdownFriendlyFilenameEnabled": true, + "autoNamingMinChars": 8, + "autoNamingMaxChars": 12 } "#, ) @@ -230,6 +234,10 @@ mod tests { assert!(settings.markdown_export_enabled); assert!(!settings.session_move_enabled); + assert!(settings.auto_rename_menu_enabled); + assert!(settings.markdown_friendly_filename_enabled); + assert_eq!(settings.auto_naming_min_chars, 8); + assert_eq!(settings.auto_naming_max_chars, 12); } #[test] diff --git a/src/bridge.test.ts b/src/bridge.test.ts index 9bcd91e..997a654 100644 --- a/src/bridge.test.ts +++ b/src/bridge.test.ts @@ -4,8 +4,15 @@ import { expect, test } from "bun:test"; const source = readFileSync(join(import.meta.dir, "bridge.ts"), "utf8"); -test("bridge injection starts binding pump without blocking readiness", () => { - const beforePump = source.slice(0, source.indexOf("const pump = async () =>")); - expect(beforePump).not.toContain("await session.drainBindingQueue();"); - expect(source).toContain("void pump();"); +test("bridge routes binding calls from websocket messages", () => { + expect(source).toContain("this.routeBindingCall(message)"); + expect(source).not.toContain("drainBindingQueue"); + expect(source).not.toContain("void pump();"); + expect(source).not.toContain("Bun.sleep(10)"); +}); + +test("dev bridge request includes caller identity", () => { + expect(source).toContain("window.__codexHelperCallerBase"); + expect(source).toContain("window.__codexHelperCaller"); + expect(source).toContain("caller: window.__codexHelperCaller()"); }); diff --git a/src/bridge.ts b/src/bridge.ts index bdf5262..8145cf1 100644 --- a/src/bridge.ts +++ b/src/bridge.ts @@ -26,13 +26,29 @@ type CdpMessage = { sessionId?: string; }; +type BridgeCaller = { + targetId: string; + helperInstanceId: string; + href?: string; + hasFocus?: boolean; + visibilityState?: string; +}; + let nextMessageId = 100; -function buildBridgeScript(bindingName: string): string { +function buildBridgeScript(bindingName: string, caller: BridgeCaller): string { + const callerJson = JSON.stringify(caller); return ` (() => { window.__codexHelperCallbacks = new Map(); window.__codexHelperSeq = 0; + window.__codexHelperCallerBase = ${callerJson}; + window.__codexHelperCaller = () => ({ + ...window.__codexHelperCallerBase, + href: window.location.href, + hasFocus: document.hasFocus(), + visibilityState: document.visibilityState || "", + }); window.__codexHelperResolve = (id, result) => { const callback = window.__codexHelperCallbacks.get(id); if (!callback) return; @@ -48,7 +64,7 @@ function buildBridgeScript(bindingName: string): string { window.__codexHelperBridge = (path, payload = {}) => new Promise((resolve) => { const id = String(++window.__codexHelperSeq); window.__codexHelperCallbacks.set(id, { resolve }); - window.${bindingName}(JSON.stringify({ id, path, payload })); + window.${bindingName}(JSON.stringify({ id, path, payload, caller: window.__codexHelperCaller() })); }); })(); `; @@ -122,8 +138,15 @@ export function bridgeRequestTimeoutMessage( class BindingCdpSession { private socket: WebSocket; - private responses = new Map(); - private bindingCalls: CdpMessage[] = []; + private pendingResponses = new Map< + number, + { + method: string; + resolve: (message: CdpMessage) => void; + reject: (error: Error) => void; + timer: ReturnType; + } + >(); private sessionId?: string; private closed = false; @@ -132,15 +155,24 @@ class BindingCdpSession { this.socket.addEventListener("message", (event) => { const message = JSON.parse(String(event.data)) as CdpMessage; if (message.method === "Runtime.bindingCalled") { - this.bindingCalls.push(message); + this.routeBindingCall(message).catch((error: unknown) => { + console.warn("[Codex Helper] bridge route failed", error); + }); return; } if (message.id !== undefined) { - this.responses.set(message.id, message); + this.resolveCommandResponse(message); } }); this.socket.addEventListener("close", () => { this.closed = true; + for (const [id, pending] of this.pendingResponses) { + clearTimeout(pending.timer); + pending.reject( + new Error(`CDP command ${pending.method} closed before response`), + ); + this.pendingResponses.delete(id); + } }); } @@ -154,8 +186,9 @@ class BindingCdpSession { method: string, params: JsonValue, ): Promise { + const response = this.waitForCommandResponse(id, method); this.socket.send(cdpCommand(id, method, params, this.sessionId)); - return this.waitForResponse(id, method); + return response; } async sendCommandWithoutWait( @@ -166,41 +199,40 @@ class BindingCdpSession { this.socket.send(cdpCommand(id, method, params, this.sessionId)); } - private async waitForResponse( - id: number, - method: string, - ): Promise { - const startedAt = Date.now(); - while (!this.closed) { - const response = this.responses.get(id); - if (response) { - this.responses.delete(id); - if (response.error) { - throw new Error( - `CDP command ${method} failed: ${JSON.stringify(response.error)}`, - ); - } - return response; - } - if (Date.now() - startedAt > CDP_COMMAND_TIMEOUT_MS) { - throw new Error( - `Timed out waiting for CDP command ${method} after ${CDP_COMMAND_TIMEOUT_MS}ms`, - ); - } - await Bun.sleep(10); + private waitForCommandResponse(id: number, method: string): Promise { + if (this.closed) { + return Promise.reject( + new Error(`CDP command ${method} closed before response`), + ); } - throw new Error(`CDP command ${method} closed before response`); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pendingResponses.delete(id); + reject( + new Error( + `Timed out waiting for CDP command ${method} after ${CDP_COMMAND_TIMEOUT_MS}ms`, + ), + ); + }, CDP_COMMAND_TIMEOUT_MS); + this.pendingResponses.set(id, { method, resolve, reject, timer }); + }); } - async drainBindingQueue(): Promise { - while (this.bindingCalls.length > 0) { - const message = this.bindingCalls.shift(); - if (message) { - this.routeBindingCall(message).catch((error: unknown) => { - console.warn("[Codex Helper] bridge route failed", error); - }); - } + private resolveCommandResponse(message: CdpMessage): void { + if (message.id === undefined) return; + const pending = this.pendingResponses.get(message.id); + if (!pending) return; + this.pendingResponses.delete(message.id); + clearTimeout(pending.timer); + if (message.error) { + pending.reject( + new Error( + `CDP command ${pending.method} failed: ${JSON.stringify(message.error)}`, + ), + ); + return; } + pending.resolve(message); } private async routeBindingCall(message: CdpMessage): Promise { @@ -217,11 +249,12 @@ class BindingCdpSession { const requestId = stringValue(parsed.id); const path = stringValue(parsed.path); const payload = (parsed.payload ?? {}) as Record; + const caller = parsed.caller as BridgeCaller | undefined; if (!requestId) return; try { const result = await withBridgeRequestTimeout( - handleBridgeRequest(path, payload), + handleBridgeRequest(path, payload, caller), path, ); const expression = `window.__codexHelperResolve(${JSON.stringify(requestId)}, ${JSON.stringify(result)})`; @@ -258,6 +291,7 @@ export function buildRuntimeBundle(paths: string[]): string { export async function installBridge(options: { debugPort: number; targetId: string; + helperInstanceId: string; runtimeScripts: string[]; timer: LaunchTimer; }): Promise<() => void> { @@ -306,7 +340,13 @@ export async function installBridge(options: { }); timer.stage("inject binding registered"); - const bridgeScript = buildBridgeScript(BRIDGE_BINDING_NAME); + const bridgeScript = buildBridgeScript(BRIDGE_BINDING_NAME, { + targetId: options.targetId, + helperInstanceId: options.helperInstanceId, + href: "", + hasFocus: false, + visibilityState: "", + }); await session.sendCommand(5, "Page.addScriptToEvaluateOnNewDocument", { source: bridgeScript, }); @@ -340,14 +380,7 @@ export async function installBridge(options: { bytes: runtimeBytes, }); - const pump = async () => { - while (socket.readyState === WebSocket.OPEN) { - await session.drainBindingQueue(); - await Bun.sleep(10); - } - }; - void pump(); - timer.stage("inject binding pump"); + timer.stage("inject binding listener"); return () => { socket.close(); diff --git a/src/cdp.test.ts b/src/cdp.test.ts index 904bcee..d32d287 100644 --- a/src/cdp.test.ts +++ b/src/cdp.test.ts @@ -1,6 +1,12 @@ import { expect, test } from "bun:test"; -import { findCodexPageTarget, pickCodexPageTarget, type CdpTarget } from "./cdp"; +import { + codexInjectablePageTargets, + codexPageTargets, + findCodexPageTarget, + pickCodexPageTarget, + type CdpTarget, +} from "./cdp"; function target(id: string, title: string, url: string): CdpTarget { return { @@ -26,3 +32,57 @@ test("pickCodexPageTarget can still fall back after launch owns the port", () => pickCodexPageTarget([target("launched", "", "https://example.test")]).id, ).toBe("launched"); }); + +test("codexPageTargets returns all injectable Codex pages", () => { + const targets: CdpTarget[] = [ + target("one", "Codex", "app://-/index.html"), + target("two", "Codex", "app://-/index.html"), + target("settling", "app://-/index.html", "app://-/index.html"), + { + id: "worker", + type: "worker", + title: "Codex", + url: "app://-/index.html", + webSocketDebuggerUrl: "ws://worker", + }, + { + id: "missing-websocket", + type: "page", + title: "Codex", + url: "app://-/index.html", + }, + ]; + + expect(codexPageTargets(targets).map((target) => target.id)).toEqual([ + "one", + "two", + "settling", + ]); +}); + +test("codexInjectablePageTargets accepts browser target infos", () => { + const targets: CdpTarget[] = [ + { + id: "browser-page", + type: "page", + title: "app://-/index.html", + url: "app://-/index.html", + }, + { + id: "browser-tab", + type: "tab", + title: "Codex", + url: "app://-/index.html", + }, + { + id: "worker", + type: "worker", + title: "Codex", + url: "app://-/index.html", + }, + ]; + + expect( + codexInjectablePageTargets(targets).map((target) => target.id), + ).toEqual(["browser-page"]); +}); diff --git a/src/cdp.ts b/src/cdp.ts index fb4dbcb..080b8bb 100644 --- a/src/cdp.ts +++ b/src/cdp.ts @@ -15,6 +15,21 @@ export type CdpVersion = { webSocketDebuggerUrl: string; }; +export const CODEX_APP_URL = "app://-/index.html"; + +type CdpTargetInfo = { + targetId?: string; + type?: string; + title?: string; + url?: string; +}; + +type CdpTargetInfosResult = { + targetInfos?: CdpTargetInfo[]; +}; + +export const ALL_TARGETS_FILTER = [{}]; + async function fetchCdp( url: string, init?: Omit, @@ -50,8 +65,8 @@ export async function isDebugPortReady(debugPort: number): Promise { export async function hasCodexCdpTarget(debugPort: number): Promise { try { - const targets = await listTargets(debugPort); - return findCodexPageTarget(targets) !== null; + const targets = await listBrowserTargets(debugPort); + return codexInjectablePageTargets(targets).length > 0; } catch { return false; } @@ -101,6 +116,28 @@ export async function listTargets(debugPort: number): Promise { return response.json() as Promise; } +export async function listBrowserTargets( + debugPort: number, +): Promise { + const result = (await cdpCommand( + await browserWebsocketUrl(debugPort), + "Target.getTargets", + { filter: ALL_TARGETS_FILTER }, + )) as CdpTargetInfosResult; + + return (result.targetInfos ?? []).flatMap((targetInfo) => { + if (!targetInfo.targetId || !targetInfo.type) return []; + return [ + { + id: targetInfo.targetId, + type: targetInfo.type, + title: targetInfo.title ?? "", + url: targetInfo.url ?? "", + }, + ]; + }); +} + export function pickCodexPageTarget(targets: CdpTarget[]): CdpTarget { const pages = targets.filter( (target) => target.type === "page" && target.webSocketDebuggerUrl, @@ -113,15 +150,32 @@ export function pickCodexPageTarget(targets: CdpTarget[]): CdpTarget { return selected; } +export function isCodexPageTarget(target: CdpTarget): boolean { + const titleAndUrl = `${target.title ?? ""} ${target.url ?? ""}`.toLowerCase(); + return ( + target.type === "page" && + (target.url === CODEX_APP_URL || titleAndUrl.includes("codex")) + ); +} + +function hasTargetWebsocket(target: CdpTarget): boolean { + return Boolean(target.webSocketDebuggerUrl?.trim()); +} + +export function codexPageTargets(targets: CdpTarget[]): CdpTarget[] { + return targets.filter( + (target) => isCodexPageTarget(target) && hasTargetWebsocket(target), + ); +} + +export function codexInjectablePageTargets(targets: CdpTarget[]): CdpTarget[] { + return targets.filter(isCodexPageTarget); +} + export function findCodexPageTarget(targets: CdpTarget[]): CdpTarget | null { return ( targets.find( - (target) => - target.type === "page" && - Boolean(target.webSocketDebuggerUrl) && - `${target.title ?? ""} ${target.url ?? ""}` - .toLowerCase() - .includes("codex"), + (target) => isCodexPageTarget(target) && hasTargetWebsocket(target), ) ?? null ); } @@ -170,6 +224,53 @@ export async function waitForCodexTarget( ); } +export async function waitForCodexTargets( + debugPort: number, + timer: LaunchTimer, + attempts = 120, +): Promise { + const startedAt = Date.now(); + let lastProgressAt = startedAt; + let lastError: unknown; + timer.stage("wait cdp targets start", { + port: debugPort, + maxAttempts: attempts, + }); + for (let attempt = 0; attempt < attempts; attempt += 1) { + try { + const targets = await listBrowserTargets(debugPort); + const codexTargets = codexInjectablePageTargets(targets); + if (codexTargets.length > 0) { + timer.stage("wait cdp targets done", { + attempts: attempt + 1, + waitedMs: Date.now() - startedAt, + targetIds: codexTargets.map((target) => target.id).join(","), + count: codexTargets.length, + }); + return codexTargets; + } + lastError = new Error("No injectable Codex page target found"); + } catch (error) { + lastError = error; + } + const now = Date.now(); + if (now - lastProgressAt >= CDP_POLL_PROGRESS_MS) { + timer.stage("wait cdp targets polling", { + attempt: attempt + 1, + maxAttempts: attempts, + waitedMs: now - startedAt, + lastError: + lastError instanceof Error ? lastError.message : String(lastError), + }); + lastProgressAt = now; + } + await Bun.sleep(250); + } + throw new Error( + `Timed out waiting for Codex CDP targets after ${Date.now() - startedAt}ms: ${String(lastError)}`, + ); +} + export async function cdpCommand( webSocketUrl: string, method: string, diff --git a/src/debug-port.test.ts b/src/debug-port.test.ts index 45dada5..9f01f75 100644 --- a/src/debug-port.test.ts +++ b/src/debug-port.test.ts @@ -4,6 +4,7 @@ import { findFreeDebugPort, PREFERRED_DEBUG_PORT, reserveEphemeralPort, + resolveDebugPortForLaunch, } from "./debug-port"; import { isPortFree, listenPidsOnPortWithCommand } from "./port"; @@ -24,6 +25,24 @@ test("reserveEphemeralPort holds the port until released", () => { expect(isPortFree(held.port)).toBe(true); }); +test("default launch resolution reserves a managed launch port", async () => { + const preferred = reserveEphemeralPort(); + const preferredPort = preferred.port; + preferred.release(); + + const resolved = await resolveDebugPortForLaunch({ + preferred: preferredPort, + scanLimit: 1, + }); + try { + expect(resolved.mode).toBe("launch"); + expect(resolved.portHold).toBeDefined(); + expect(isPortFree(resolved.port)).toBe(false); + } finally { + resolved.portHold?.release(); + } +}); + test("preferred debug port range starts at 9229", () => { expect(PREFERRED_DEBUG_PORT).toBe(9229); }); diff --git a/src/debug-port.ts b/src/debug-port.ts index 0c51a7e..8bcaf27 100644 --- a/src/debug-port.ts +++ b/src/debug-port.ts @@ -8,27 +8,31 @@ export type PortHold = { release: () => void; }; -export function reserveEphemeralPort(): PortHold { +function reservePort(requestedPort: number): PortHold { const server = Bun.listen({ hostname: "127.0.0.1", - port: 0, + port: requestedPort, socket: { data() {}, }, }); - const port = server.port; - if (!port) { + const reservedPort = server.port; + if (!reservedPort) { server.stop(); throw new Error("Failed to reserve a free local TCP port"); } return { - port, + port: reservedPort, release: () => { server.stop(); }, }; } +export function reserveEphemeralPort(): PortHold { + return reservePort(0); +} + /** @deprecated Use reserveEphemeralPort when the port must stay reserved until Codex binds. */ export function allocateFreePort(): number { return reserveEphemeralPort().port; @@ -76,14 +80,19 @@ export async function resolveDebugPort( preferred = PREFERRED_DEBUG_PORT, scanLimit = DEBUG_PORT_SCAN_LIMIT, ): Promise { - const attachPort = await findAttachableDebugPort(preferred, scanLimit); - if (attachPort !== null) { - return { port: attachPort, mode: "attach" }; - } - const freePort = findFreeDebugPort(preferred, scanLimit); - if (freePort !== null) { - return { port: freePort, mode: "launch" }; + const attachablePort = await findAttachableDebugPort(preferred, scanLimit); + if (attachablePort !== null) return { port: attachablePort, mode: "attach" }; + + for (let offset = 0; offset < scanLimit; offset += 1) { + const port = preferred + offset; + try { + const portHold = reservePort(port); + return { port: portHold.port, mode: "launch", portHold }; + } catch { + continue; + } } + const portHold = reserveEphemeralPort(); return { port: portHold.port, mode: "launch", portHold }; } diff --git a/src/injection-sync.test.ts b/src/injection-sync.test.ts new file mode 100644 index 0000000..7c950d2 --- /dev/null +++ b/src/injection-sync.test.ts @@ -0,0 +1,362 @@ +import { expect, test } from "bun:test"; + +import { + applyTargetDiscoveryMessage, + createSerializedSyncRunner, + disconnectInjectedTargets, + planInjectionSync, + runTargetWatcher, + syncInjectedTargetsForTargets, + type InjectedTarget, +} from "./injection-sync"; +import type { CdpTarget } from "./cdp"; + +function target(id: string): CdpTarget { + return { + id, + type: "page", + title: "Codex", + url: "app://-/index.html", + webSocketDebuggerUrl: `ws://${id}`, + }; +} + +function timer() { + return { + stage: () => {}, + }; +} + +test("injection sync plans inject retain and prune targets", () => { + expect(planInjectionSync(["target-a", "target-b"], ["target-a", "old"])).toEqual( + { + inject: ["target-b"], + retain: ["target-a"], + prune: ["old"], + }, + ); +}); + +test("injection sync injects new targets and disconnects pruned targets", async () => { + const disconnected: string[] = []; + const injectedTargets = new Map([ + ["existing", { disconnect: () => disconnected.push("existing") }], + ["old", { disconnect: () => disconnected.push("old") }], + ]); + const installed: string[] = []; + + await syncInjectedTargetsForTargets({ + targets: [target("existing"), target("new")], + injectedTargets, + timer: timer(), + installTarget: async (currentTarget) => { + installed.push(currentTarget.id); + return () => disconnected.push(currentTarget.id); + }, + }); + + expect(installed).toEqual(["new"]); + expect(disconnected).toEqual(["old"]); + expect(Array.from(injectedTargets.keys()).sort()).toEqual(["existing", "new"]); +}); + +test("injection sync drops targets destroyed while install is pending", async () => { + const destroyedTargetIds = new Set(); + const disconnected: string[] = []; + let finishInstall: (() => void) | undefined; + const injectedTargets = new Map(); + const sync = syncInjectedTargetsForTargets({ + targets: [target("closing")], + injectedTargets, + timer: timer(), + shouldRetainInstalledTarget: (targetId) => + !destroyedTargetIds.has(targetId), + installTarget: async (currentTarget) => { + await new Promise((resolve) => { + finishInstall = resolve; + }); + return () => disconnected.push(currentTarget.id); + }, + }); + + destroyedTargetIds.add("closing"); + finishInstall?.(); + await sync; + + expect(injectedTargets.has("closing")).toBe(false); + expect(disconnected).toEqual(["closing"]); +}); + +test("target discovery events queue Codex page target sync", async () => { + const injectedTargets = new Map(); + let queued = 0; + + await applyTargetDiscoveryMessage({ + message: { + method: "Target.targetInfoChanged", + params: { + targetInfo: { + targetId: "created", + type: "page", + title: "Codex", + url: "app://-/index.html", + }, + }, + }, + injectedTargets, + timer: timer(), + queueSyncTargets: () => { + queued += 1; + }, + }); + + expect(queued).toBe(1); + expect(injectedTargets.has("created")).toBe(false); +}); + +test("target discovery events queue Codex pages before title settles", async () => { + const injectedTargets = new Map(); + let queued = 0; + + await applyTargetDiscoveryMessage({ + message: { + method: "Target.targetInfoChanged", + params: { + targetInfo: { + targetId: "created", + type: "page", + title: "app://-/index.html", + url: "app://-/index.html", + }, + }, + }, + injectedTargets, + timer: timer(), + queueSyncTargets: () => { + queued += 1; + }, + }); + + expect(queued).toBe(1); + expect(injectedTargets.has("created")).toBe(false); +}); + +test("target discovery events do not prune existing targets", async () => { + const disconnected: string[] = []; + let queued = 0; + const injectedTargets = new Map([ + ["existing", { disconnect: () => disconnected.push("existing") }], + ]); + + await applyTargetDiscoveryMessage({ + message: { + method: "Target.targetCreated", + params: { + targetInfo: { + targetId: "new", + type: "page", + title: "Codex", + url: "app://-/index.html", + }, + }, + }, + injectedTargets, + timer: timer(), + queueSyncTargets: () => { + queued += 1; + }, + }); + + expect(queued).toBe(1); + expect(disconnected).toEqual([]); + expect(Array.from(injectedTargets.keys())).toEqual(["existing"]); +}); + +test("target discovery tab events queue target sync", async () => { + const injectedTargets = new Map(); + let queued = 0; + + await applyTargetDiscoveryMessage({ + message: { + method: "Target.targetCreated", + params: { + targetInfo: { + targetId: "tab", + type: "tab", + title: "", + url: "", + }, + }, + }, + injectedTargets, + timer: timer(), + queueSyncTargets: () => { + queued += 1; + }, + }); + + expect(queued).toBe(1); +}); + +test("target destroyed events disconnect injected targets", async () => { + const disconnected: string[] = []; + const destroyedTargetIds = new Set(); + let queued = 0; + const injectedTargets = new Map([ + ["closed", { disconnect: () => disconnected.push("closed") }], + ]); + + await applyTargetDiscoveryMessage({ + message: { + method: "Target.targetDestroyed", + params: { targetId: "closed" }, + }, + injectedTargets, + destroyedTargetIds, + queueSyncTargets: () => { + queued += 1; + }, + timer: timer(), + }); + + expect(disconnected).toEqual(["closed"]); + expect(injectedTargets.has("closed")).toBe(false); + expect(destroyedTargetIds.has("closed")).toBe(true); + expect(queued).toBe(1); +}); + +test("disconnect injected targets clears all active bindings", () => { + const disconnected: string[] = []; + const injectedTargets = new Map([ + ["target-a", { disconnect: () => disconnected.push("target-a") }], + ["target-b", { disconnect: () => disconnected.push("target-b") }], + ]); + + const count = disconnectInjectedTargets(injectedTargets); + + expect(count).toBe(2); + expect(disconnected.sort()).toEqual(["target-a", "target-b"]); + expect(injectedTargets.size).toBe(0); +}); + +test("serialized sync runner coalesces overlapping sync requests", async () => { + const releases: Array<() => void> = []; + let calls = 0; + const runner = createSerializedSyncRunner({ + syncTargets: () => + new Promise((resolve) => { + calls += 1; + releases.push(resolve); + }), + }); + + const first = runner(); + const second = runner(); + + expect(first).toBe(second); + expect(calls).toBe(1); + releases[0]?.(); + await Bun.sleep(0); + expect(calls).toBe(2); + releases[1]?.(); + await first; + expect(calls).toBe(2); +}); + +class FakeSocket extends EventTarget { + readonly sent: unknown[] = []; + + send(message: string): void { + this.sent.push(JSON.parse(message)); + } + + close(): void { + this.dispatchEvent(new Event("close")); + } +} + +async function sendDiscoveryAckAndTargetEvent( + socket: FakeSocket, + promise: Promise, +): Promise { + socket.dispatchEvent( + new MessageEvent("message", { + data: JSON.stringify({ id: 1, result: {} }), + }), + ); + socket.dispatchEvent( + new MessageEvent("message", { + data: JSON.stringify({ + method: "Target.targetInfoChanged", + params: { + targetInfo: { + targetId: "target-a", + type: "page", + title: "Codex", + url: "app://-/index.html", + }, + }, + }), + }), + ); + await Bun.sleep(0); + socket.close(); + await promise; +} + +test("target watcher rejects when socket closes before discovery ack", async () => { + const socket = new FakeSocket(); + const promise = runTargetWatcher({ + socket: socket as unknown as WebSocket, + injectedTargets: new Map(), + syncTargets: async () => {}, + queueSyncTargets: () => {}, + timer: timer(), + stopped: () => false, + }); + + socket.close(); + + await expect(promise).rejects.toThrow( + "Target.setDiscoverTargets socket closed before response", + ); +}); + +test("target watcher suppresses target event logs by default", async () => { + const socket = new FakeSocket(); + const stages: string[] = []; + const promise = runTargetWatcher({ + socket: socket as unknown as WebSocket, + injectedTargets: new Map(), + syncTargets: async () => {}, + queueSyncTargets: () => {}, + timer: { + stage: (name) => stages.push(name), + }, + stopped: () => false, + }); + + await sendDiscoveryAckAndTargetEvent(socket, promise); + + expect(stages).toEqual(["target watcher ready"]); +}); + +test("target watcher logs target events when debug events are enabled", async () => { + const socket = new FakeSocket(); + const stages: string[] = []; + const promise = runTargetWatcher({ + socket: socket as unknown as WebSocket, + injectedTargets: new Map(), + syncTargets: async () => {}, + queueSyncTargets: () => {}, + debugTargetEvents: true, + timer: { + stage: (name) => stages.push(name), + }, + stopped: () => false, + }); + + await sendDiscoveryAckAndTargetEvent(socket, promise); + + expect(stages).toEqual(["target event", "target watcher ready"]); +}); diff --git a/src/injection-sync.ts b/src/injection-sync.ts new file mode 100644 index 0000000..8890eb9 --- /dev/null +++ b/src/injection-sync.ts @@ -0,0 +1,432 @@ +import { installBridge } from "./bridge"; +import { + ALL_TARGETS_FILTER, + browserWebsocketUrl, + codexInjectablePageTargets, + hasCodexCdpTarget, + isCodexPageTarget, + listBrowserTargets, + type CdpTarget, +} from "./cdp"; +import type { LaunchTimer } from "./debug"; + +export type InjectedTarget = { + disconnect: () => void; +}; + +export type InjectionSyncPlan = { + inject: string[]; + retain: string[]; + prune: string[]; +}; + +export type InjectionSyncResult = InjectionSyncPlan & { + failures: string[]; +}; + +type InstallTarget = (target: CdpTarget) => Promise<() => void>; + +type TargetInfo = { + targetId?: string; + type?: string; + title?: string; + url?: string; +}; + +type TargetDiscoveryMessage = { + id?: number; + method?: string; + error?: unknown; + params?: { + targetId?: string; + targetInfo?: TargetInfo; + }; +}; + +const TARGET_WATCHER_RECONNECT_MS = 1000; +const TARGET_WATCHER_DISCOVERY_TIMEOUT_MS = 5000; +const TARGET_SYNC_DELAYS_MS = [250, 1000]; + +export function planInjectionSync( + currentIds: string[], + injectedIds: string[], +): InjectionSyncPlan { + const current = new Set(currentIds); + const injected = new Set(injectedIds); + return { + inject: currentIds.filter((id) => !injected.has(id)), + retain: currentIds.filter((id) => injected.has(id)), + prune: injectedIds.filter((id) => !current.has(id)), + }; +} + +export async function syncInjectedTargetsForTargets(options: { + targets: CdpTarget[]; + injectedTargets: Map; + installTarget: InstallTarget; + shouldRetainInstalledTarget?: (targetId: string) => boolean; + timer: LaunchTimer; +}): Promise { + const currentIds = options.targets.map((target) => target.id); + const plan = planInjectionSync( + currentIds, + Array.from(options.injectedTargets.keys()), + ); + const failures: string[] = []; + + for (const targetId of plan.inject) { + const target = options.targets.find((candidate) => candidate.id === targetId); + if (!target) continue; + try { + const disconnect = await options.installTarget(target); + if (options.shouldRetainInstalledTarget?.(target.id) === false) { + disconnect(); + continue; + } + options.injectedTargets.set(target.id, { disconnect }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + failures.push(`${target.id}: ${message}`); + options.timer.stage("inject target failed", { targetId: target.id, message }); + } + } + + for (const targetId of plan.prune) { + const injected = options.injectedTargets.get(targetId); + if (!injected) continue; + injected.disconnect(); + options.injectedTargets.delete(targetId); + } + + if (plan.inject.length > 0 || plan.prune.length > 0 || failures.length > 0) { + options.timer.stage("sync targets", { + discovered: options.targets.length, + injected: plan.inject.length - failures.length, + retained: plan.retain.length, + pruned: plan.prune.length, + failures: failures.join("; "), + targetIds: currentIds.join(","), + }); + } + + return { ...plan, failures }; +} + +export function disconnectInjectedTargets( + injectedTargets: Map, +): number { + const count = injectedTargets.size; + for (const injected of injectedTargets.values()) injected.disconnect(); + injectedTargets.clear(); + return count; +} + +export async function syncInjectedTargets(options: { + debugPort: number; + runtimeScripts: string[]; + injectedTargets: Map; + shouldRetainInstalledTarget?: (targetId: string) => boolean; + timer: LaunchTimer; + createHelperInstanceId: () => string; +}): Promise { + const targets = codexInjectablePageTargets( + await listBrowserTargets(options.debugPort), + ); + const syncOptions: Parameters[0] = { + targets, + injectedTargets: options.injectedTargets, + timer: options.timer, + installTarget: (target) => + installBridge({ + debugPort: options.debugPort, + targetId: target.id, + helperInstanceId: options.createHelperInstanceId(), + runtimeScripts: options.runtimeScripts, + timer: options.timer, + }), + }; + if (options.shouldRetainInstalledTarget) { + syncOptions.shouldRetainInstalledTarget = options.shouldRetainInstalledTarget; + } + return syncInjectedTargetsForTargets(syncOptions); +} + +function targetFromInfo(targetInfo: TargetInfo): CdpTarget | null { + if (!targetInfo.targetId || !targetInfo.type) return null; + return { + id: targetInfo.targetId, + type: targetInfo.type, + title: targetInfo.title ?? "", + url: targetInfo.url ?? "", + }; +} + +function isCodexTabTargetInfo(target: CdpTarget): boolean { + return target.type === "tab"; +} + +export async function applyTargetDiscoveryMessage(options: { + message: TargetDiscoveryMessage; + injectedTargets: Map; + destroyedTargetIds?: Set; + queueSyncTargets?: () => void; + timer: LaunchTimer; +}): Promise { + if ( + options.message.method === "Target.targetCreated" || + options.message.method === "Target.targetInfoChanged" + ) { + const target = targetFromInfo(options.message.params?.targetInfo ?? {}); + if (!target) return; + if (isCodexTabTargetInfo(target) || isCodexPageTarget(target)) { + options.queueSyncTargets?.(); + } + return; + } + + if (options.message.method === "Target.targetDestroyed") { + const targetId = options.message.params?.targetId; + if (!targetId) return; + options.destroyedTargetIds?.add(targetId); + const injected = options.injectedTargets.get(targetId); + if (injected) { + injected.disconnect(); + options.injectedTargets.delete(targetId); + options.timer.stage("sync targets", { + discovered: 0, + injected: 0, + retained: options.injectedTargets.size, + pruned: 1, + failures: "", + targetIds: Array.from(options.injectedTargets.keys()).join(","), + }); + } + options.queueSyncTargets?.(); + } +} + +export function createSerializedSyncRunner(options: { + syncTargets: () => Promise; +}): () => Promise { + let active: Promise | undefined; + let rerun = false; + + const run = async (): Promise => { + while (true) { + rerun = false; + await options.syncTargets(); + if (!rerun) return; + } + }; + + return () => { + if (active) { + rerun = true; + return active; + } + active = run().finally(() => { + active = undefined; + }); + return active; + }; +} + +async function openBrowserSocket(websocketUrl: string): Promise { + const socket = new WebSocket(websocketUrl); + await new Promise((resolve, reject) => { + socket.addEventListener("open", () => resolve(), { once: true }); + socket.addEventListener( + "error", + () => + reject( + new Error(`Failed to connect CDP browser websocket: ${websocketUrl}`), + ), + { once: true }, + ); + }); + return socket; +} + +export async function runTargetWatcher(options: { + socket: WebSocket; + injectedTargets: Map; + syncTargets: () => Promise; + queueSyncTargets: () => void; + destroyedTargetIds?: Set; + debugTargetEvents?: boolean; + timer: LaunchTimer; + stopped: () => boolean; +}): Promise { + const discoveryCommandId = 1; + const discoveryReady = new Promise((resolve, reject) => { + let settled = false; + const timeout = setTimeout(() => { + fail( + new Error( + `Target.setDiscoverTargets timed out after ${TARGET_WATCHER_DISCOVERY_TIMEOUT_MS}ms`, + ), + ); + }, TARGET_WATCHER_DISCOVERY_TIMEOUT_MS); + const cleanup = () => { + clearTimeout(timeout); + options.socket.removeEventListener("close", onClose); + options.socket.removeEventListener("error", onError); + }; + const done = () => { + if (settled) return; + settled = true; + cleanup(); + resolve(); + }; + const fail = (error: Error) => { + if (settled) return; + settled = true; + cleanup(); + reject(error); + }; + const onClose = () => + fail(new Error("Target.setDiscoverTargets socket closed before response")); + const onError = () => + fail(new Error("Target.setDiscoverTargets socket errored before response")); + options.socket.addEventListener("close", onClose, { once: true }); + options.socket.addEventListener("error", onError, { once: true }); + options.socket.addEventListener("message", (event) => { + const message = JSON.parse(String(event.data)) as TargetDiscoveryMessage; + if (message.id === discoveryCommandId) { + if (message.error) { + fail( + new Error( + `Target.setDiscoverTargets failed: ${JSON.stringify(message.error)}`, + ), + ); + } else { + done(); + } + return; + } + if (options.stopped()) return; + if (options.debugTargetEvents && message.method?.startsWith("Target.")) { + options.timer.stage("target event", { + method: message.method, + targetId: + message.params?.targetId ?? message.params?.targetInfo?.targetId ?? "", + type: message.params?.targetInfo?.type ?? "", + url: message.params?.targetInfo?.url ?? "", + }); + } + const discoveryOptions: Parameters[0] = { + message, + injectedTargets: options.injectedTargets, + queueSyncTargets: options.queueSyncTargets, + timer: options.timer, + }; + if (options.destroyedTargetIds) { + discoveryOptions.destroyedTargetIds = options.destroyedTargetIds; + } + applyTargetDiscoveryMessage(discoveryOptions).catch((error: unknown) => { + options.timer.stage("target event failed", { + message: error instanceof Error ? error.message : String(error), + }); + }); + }); + }); + options.socket.send( + JSON.stringify({ + id: discoveryCommandId, + method: "Target.setDiscoverTargets", + params: { discover: true, filter: ALL_TARGETS_FILTER }, + }), + ); + await discoveryReady; + options.timer.stage("target watcher ready"); + await new Promise((resolve) => { + options.socket.addEventListener("close", () => resolve(), { once: true }); + options.socket.addEventListener("error", () => resolve(), { once: true }); + }); +} + +export function startCodexTargetWatcher(options: { + debugPort: number; + runtimeScripts: string[]; + injectedTargets: Map; + timer: LaunchTimer; + createHelperInstanceId: () => string; + onCodexDisconnected?: () => void; + debugTargetEvents?: boolean; +}): () => void { + let stopped = false; + let codexOnline = true; + let socket: WebSocket | undefined; + const destroyedTargetIds = new Set(); + const queuedSyncTimers = new Map>(); + const syncTargets = () => + syncInjectedTargets({ + debugPort: options.debugPort, + runtimeScripts: options.runtimeScripts, + injectedTargets: options.injectedTargets, + shouldRetainInstalledTarget: (targetId) => !destroyedTargetIds.has(targetId), + timer: options.timer, + createHelperInstanceId: options.createHelperInstanceId, + }).then(() => {}); + const runSyncTargets = createSerializedSyncRunner({ syncTargets }); + const queueSyncTargets = () => { + for (const delayMs of TARGET_SYNC_DELAYS_MS) { + if (queuedSyncTimers.has(delayMs)) continue; + const timer = setTimeout(() => { + queuedSyncTimers.delete(delayMs); + if (stopped) return; + runSyncTargets().catch((error: unknown) => { + options.timer.stage("target sync failed", { + message: error instanceof Error ? error.message : String(error), + }); + }); + }, delayMs); + queuedSyncTimers.set(delayMs, timer); + } + }; + const run = async () => { + while (!stopped) { + try { + await runSyncTargets(); + codexOnline = true; + socket = await openBrowserSocket( + await browserWebsocketUrl(options.debugPort), + ); + await runTargetWatcher({ + socket, + injectedTargets: options.injectedTargets, + syncTargets: runSyncTargets, + queueSyncTargets, + destroyedTargetIds, + debugTargetEvents: options.debugTargetEvents === true, + timer: options.timer, + stopped: () => stopped, + }); + } catch (error) { + if (!stopped) { + const message = error instanceof Error ? error.message : String(error); + if (codexOnline) { + options.timer.stage("target watcher failed", { message }); + } + if (!(await hasCodexCdpTarget(options.debugPort))) { + if (codexOnline) { + options.onCodexDisconnected?.(); + } + codexOnline = false; + } + } + } finally { + socket?.close(); + socket = undefined; + } + if (!stopped) await Bun.sleep(TARGET_WATCHER_RECONNECT_MS); + } + }; + void run(); + return () => { + stopped = true; + for (const timer of queuedSyncTimers.values()) clearTimeout(timer); + queuedSyncTimers.clear(); + socket?.close(); + }; +} diff --git a/src/launch-script.test.js b/src/launch-script.test.js index f828836..616c0d4 100644 --- a/src/launch-script.test.js +++ b/src/launch-script.test.js @@ -14,3 +14,219 @@ test("bun launch builds the Rust bridge binary before starting Codex", () => { "bun run build:bridge && bun src/launch.ts", ); }); + +test("dev launcher configures loopback proxy bypass for both env keys", () => { + const source = readFileSync(join(import.meta.dir, "launch.ts"), "utf8"); + + expect(source).toContain('"127.0.0.1", "localhost", "::1"'); + expect(source).toContain("process.env.NO_PROXY = noProxy"); + expect(source).toContain("process.env.no_proxy = noProxy"); +}); + +test("dev launcher injects all Codex page targets", () => { + const source = readFileSync(join(import.meta.dir, "launch.ts"), "utf8"); + + expect(source).toContain("waitForCodexTargets"); + expect(source).toContain("syncInjectedTargetsForTargets"); + expect(source).toContain("initialSync.failures.length > 0"); + expect(source).toContain("injectedTargets.size !== targets.length"); + expect(source).not.toContain("waitForCodexTarget("); +}); + +test("dev launcher resolves existing Codex CDP before reserving a launch port", () => { + const source = readFileSync(join(import.meta.dir, "debug-port.ts"), "utf8"); + + expect(source).toContain("findAttachableDebugPort"); + const resolverSource = source.slice(source.indexOf("export async function resolveDebugPort")); + expect(resolverSource).toContain("await findAttachableDebugPort"); + expect(resolverSource.indexOf("await findAttachableDebugPort")).toBeLessThan( + resolverSource.indexOf("reserveEphemeralPort()"), + ); +}); + +test("tauri launcher waits for Codex page targets before injection", () => { + const controllerSource = readFileSync( + join(import.meta.dir, "..", "src-tauri", "src", "codex_control.rs"), + "utf8", + ); + const cdpSource = readFileSync( + join(import.meta.dir, "..", "src-tauri", "src", "cdp.rs"), + "utf8", + ); + + expect(cdpSource).toContain("pub async fn wait_for_codex_targets("); + expect(controllerSource).toContain("wait_for_codex_targets_ready"); + const launchNewSource = controllerSource.slice( + controllerSource.indexOf("async fn launch_new_codex"), + controllerSource.indexOf("pub async fn recover_codex_launch"), + ); + expect( + launchNewSource.indexOf( + 'wait_for_codex_targets_ready(&launch_ctx, "initial-launch")', + ), + ).toBeLessThan( + launchNewSource.indexOf("self.sync_injected_targets(&launch_ctx)"), + ); + expect(controllerSource).toContain("launcher.codex_targets_ready"); +}); + +test("tauri desktop startup attaches before launching a new Codex", () => { + const controllerSource = readFileSync( + join(import.meta.dir, "..", "src-tauri", "src", "codex_control.rs"), + "utf8", + ); + + expect(controllerSource).toContain("attach_to_existing_codex"); + expect(controllerSource).toContain("launch_new_codex"); + expect(controllerSource).toContain("find_existing_codex_debug_port"); + expect(controllerSource.indexOf("attach_to_existing_codex")).toBeLessThan( + controllerSource.indexOf("launch_new_codex"), + ); +}); + +test("tauri startup recovery closes existing CDP before clean launch", () => { + const controllerSource = readFileSync( + join(import.meta.dir, "..", "src-tauri", "src", "codex_control.rs"), + "utf8", + ); + const cdpSource = readFileSync( + join(import.meta.dir, "..", "src-tauri", "src", "cdp.rs"), + "utf8", + ); + + expect(controllerSource).toContain("recover_codex_launch"); + expect(controllerSource).toContain("close_existing_codex_debug_ports"); + expect(cdpSource).toContain("pub async fn wait_for_debug_port_to_close("); + expect(controllerSource).toContain("close_browser(port).await"); + expect(controllerSource).toContain("wait_for_debug_port_to_close(port"); + const recoverySource = controllerSource.slice( + controllerSource.indexOf("pub async fn recover_codex_launch"), + controllerSource.indexOf("async fn close_existing_codex_debug_ports"), + ); + expect( + recoverySource.indexOf("close_existing_codex_debug_ports"), + ).toBeLessThan( + recoverySource.indexOf("launch_new_codex"), + ); +}); + +test("tauri CDP probing bypasses system proxies", () => { + const cdpSource = readFileSync( + join(import.meta.dir, "..", "src-tauri", "src", "cdp.rs"), + "utf8", + ); + const appSource = readFileSync( + join(import.meta.dir, "..", "src-tauri", "src", "app.rs"), + "utf8", + ); + const launcherSource = readFileSync( + join(import.meta.dir, "..", "src-tauri", "src", "launcher.rs"), + "utf8", + ); + const appServerSource = readFileSync( + join(import.meta.dir, "..", "src-tauri", "src", "codex_app_server.rs"), + "utf8", + ); + + expect(cdpSource).toContain("fn cdp_http_client()"); + expect(cdpSource).toContain(".no_proxy()"); + expect(appSource).toContain("configure_process_loopback_no_proxy();"); + expect(launcherSource).toContain('env("NO_PROXY"'); + expect(launcherSource).toContain('env("no_proxy"'); + expect(launcherSource).toContain('"--remote-debugging-address=127.0.0.1"'); + expect(launcherSource).not.toContain("quit_existing_codex_processes"); + expect(appServerSource).toContain('env("NO_PROXY"'); + expect(appServerSource).toContain('env("no_proxy"'); +}); + +test("tauri target watcher backs off after failures", () => { + const controllerSource = readFileSync( + join(import.meta.dir, "..", "src-tauri", "src", "codex_control.rs"), + "utf8", + ); + + expect(controllerSource).toContain("TARGET_WATCHER_MAX_RECONNECT"); + expect(controllerSource).toContain("next_target_watcher_reconnect_delay"); + expect(controllerSource).toContain("checked_mul(2)"); +}); + +test("tauri target watcher coalesces discovery events before resync", () => { + const controllerSource = readFileSync( + join(import.meta.dir, "..", "src-tauri", "src", "codex_control.rs"), + "utf8", + ); + + expect(controllerSource).toContain("TARGET_EVENT_DEBOUNCE"); + expect(controllerSource).toContain("drain_target_discovery_events"); + expect(controllerSource).toContain("TARGET_WATCHER_DISCONNECT_PROBE_LIMIT"); + expect(controllerSource).toContain("target_watcher_disconnect_probe"); +}); + +test("dev launcher keeps syncing Codex page target changes", () => { + const source = readFileSync(join(import.meta.dir, "launch.ts"), "utf8"); + const syncSource = readFileSync( + join(import.meta.dir, "injection-sync.ts"), + "utf8", + ); + + expect(source).toContain("startCodexTargetWatcher({"); + expect(syncSource).toContain("Target.setDiscoverTargets"); + expect(syncSource).toContain("ALL_TARGETS_FILTER"); + expect(syncSource).toContain("Target.targetCreated"); + expect(syncSource).toContain("Target.targetInfoChanged"); + expect(syncSource).toContain("Target.targetDestroyed"); + expect(source).toContain("injectedTargets.clear()"); + expect(source).not.toContain("Bun.sleep(2000)"); +}); + +test("dev launcher keeps macOS app launch behind the launch adapter", () => { + const source = readFileSync(join(import.meta.dir, "launcher.ts"), "utf8"); + + expect(source).not.toContain('"osascript"'); + expect(source).toContain('program: "open"'); + expect(source).toContain('"-na"'); + expect(source).not.toContain("quit codex start"); + expect(source).not.toContain("pgrep"); +}); + +test("dev bridge keeps platform open commands behind an adapter", () => { + const routesSource = readFileSync(join(import.meta.dir, "routes.ts"), "utf8"); + const zedSource = readFileSync(join(import.meta.dir, "zed.ts"), "utf8"); + + expect(routesSource).toContain("launchSystemOpen"); + expect(routesSource).not.toContain('spawn("open"'); + expect(zedSource).not.toContain('spawn("open"'); + const launchSource = zedSource.slice( + zedSource.indexOf("function launchZedUrl"), + ); + expect(launchSource.indexOf("if (cliPath)")).toBeLessThan( + launchSource.indexOf('process.platform === "darwin"'), + ); +}); + +test("tauri routes use opener APIs instead of spawning macOS open", () => { + const source = readFileSync( + join(import.meta.dir, "..", "src-tauri", "src", "routes.rs"), + "utf8", + ); + const zedSource = readFileSync( + join(import.meta.dir, "..", "src-tauri", "src", "zed.rs"), + "utf8", + ); + + expect(source).toContain("tauri_plugin_opener"); + expect(source).not.toContain('Command::new("open")'); + expect(source).not.toContain('std::process::Command::new("open")'); + expect(zedSource).toContain("tauri_plugin_opener::open_url"); + expect(zedSource).not.toContain('Command::new("open")'); +}); + +test("tauri build script generates tray icons without sips", () => { + const source = readFileSync( + join(import.meta.dir, "..", "src-tauri", "build.rs"), + "utf8", + ); + + expect(source).not.toContain('"sips"'); + expect(source).not.toContain("std::process::Command"); +}); diff --git a/src/launch.ts b/src/launch.ts index f143097..95588e7 100644 --- a/src/launch.ts +++ b/src/launch.ts @@ -1,7 +1,13 @@ import { installBridge } from "./bridge"; -import { waitForCodexTarget } from "./cdp"; +import { waitForCodexTargets } from "./cdp"; import { createLaunchTimer } from "./debug"; import { resolveDebugPortForLaunch } from "./debug-port"; +import { + disconnectInjectedTargets, + startCodexTargetWatcher, + syncInjectedTargetsForTargets, + type InjectedTarget, +} from "./injection-sync"; import { ensureCodexLaunchedWithDebugPort } from "./launcher"; import { buildRuntimeScripts, resolveCodexAppPath } from "./paths"; import { stopPortForwards } from "./routes"; @@ -12,6 +18,13 @@ type LaunchOptions = { explicitDebugPort?: number; }; +let nextHelperInstanceId = 0; + +function createHelperInstanceId(): string { + nextHelperInstanceId += 1; + return `dev-helper-${Date.now()}-${nextHelperInstanceId}`; +} + function parseArgs(args: string[]): LaunchOptions { const options: LaunchOptions = { preferredDebugPort: 9229 }; for (let index = 0; index < args.length; index += 1) { @@ -45,18 +58,20 @@ function printHelp(): void { ); console.log(""); console.log( - "By default the launcher scans for an existing Codex CDP port or picks a free local port.", + "By default the launcher starts a Helper-managed Codex on a reserved random local port.", ); - console.log("Pass --debug-port only when you need a specific port."); + console.log("Pass --debug-port only when you intentionally want a specific CDP port."); } function ensureLocalCdpBypassesProxy(): void { - const bypassHosts = ["127.0.0.1", "localhost"]; + const bypassHosts = ["127.0.0.1", "localhost", "::1"]; const existing = (process.env.NO_PROXY ?? process.env.no_proxy ?? "") .split(",") .map((entry) => entry.trim()) .filter(Boolean); - process.env.NO_PROXY = [...new Set([...existing, ...bypassHosts])].join(","); + const noProxy = [...new Set([...existing, ...bypassHosts])].join(","); + process.env.NO_PROXY = noProxy; + process.env.no_proxy = noProxy; } async function main(): Promise { @@ -85,24 +100,57 @@ async function main(): Promise { mode, portHold, ); - const target = await waitForCodexTarget(debugPort, timer); + const targets = await waitForCodexTargets(debugPort, timer); const runtimeScripts = buildRuntimeScripts(); - const disconnect = await installBridge({ - debugPort, - targetId: target.id, - runtimeScripts, + const injectedTargets = new Map(); + const initialSync = await syncInjectedTargetsForTargets({ + targets, + injectedTargets, timer, + installTarget: (target) => + installBridge({ + debugPort, + targetId: target.id, + helperInstanceId: createHelperInstanceId(), + runtimeScripts, + timer, + }), + }); + if (initialSync.failures.length > 0 || injectedTargets.size !== targets.length) { + for (const injected of injectedTargets.values()) injected.disconnect(); + injectedTargets.clear(); + throw new Error( + `Codex Helper failed to inject all ${targets.length} target(s): ${initialSync.failures.join("; ")}`, + ); + } + timer.stage("ready", { + targetIds: targets.map((target) => target.id).join(","), + injected: injectedTargets.size, + failures: initialSync.failures.join("; "), + port: debugPort, }); - timer.stage("ready", { targetId: target.id, port: debugPort }); console.log( - `Codex Helper injected into target ${target.id} on debug port ${debugPort}`, + `Codex Helper injected into ${injectedTargets.size} target(s) on debug port ${debugPort}`, ); let cleanedUp = false; + const cleanupManagedCodexResources = () => { + stopPortForwards(); + disconnectInjectedTargets(injectedTargets); + }; + const stopTargetWatcher = startCodexTargetWatcher({ + debugPort, + runtimeScripts, + injectedTargets, + timer, + createHelperInstanceId, + onCodexDisconnected: cleanupManagedCodexResources, + debugTargetEvents: process.env.CODEX_HELPER_DEBUG_TARGET_EVENTS === "1", + }); const cleanupOnce = () => { if (cleanedUp) return; cleanedUp = true; - stopPortForwards(); - disconnect(); + stopTargetWatcher(); + cleanupManagedCodexResources(); }; const exitAfterSignal = () => { cleanupOnce(); diff --git a/src/launcher.test.ts b/src/launcher.test.ts index a3df5e6..b043b36 100644 --- a/src/launcher.test.ts +++ b/src/launcher.test.ts @@ -1,6 +1,9 @@ import { expect, test } from "bun:test"; -import { isKillablePortBlocker } from "./launcher"; +import { + codexLaunchCommand, + isKillablePortBlocker, +} from "./launcher"; test("isKillablePortBlocker allows Codex listeners only", () => { expect( @@ -13,4 +16,31 @@ test("isKillablePortBlocker allows Codex listeners only", () => { "/System/Library/PrivateFrameworks/SkyComputerUseService", ), ).toBe(false); + expect(isKillablePortBlocker("codex-helper --bridge")).toBe(false); + expect(isKillablePortBlocker("codex --serve")).toBe(false); +}); + +test("codex launch command starts macOS app bundles through LaunchServices", () => { + const command = codexLaunchCommand("/Applications/Codex.app", 9229, "darwin"); + + expect(command.program).toBe("open"); + expect(command.args).toEqual([ + "-na", + "/Applications/Codex.app", + "--args", + "--remote-debugging-port=9229", + "--remote-debugging-address=127.0.0.1", + "--remote-allow-origins=http://127.0.0.1:9229", + ]); +}); + +test("codex launch command starts executables directly off macOS", () => { + const command = codexLaunchCommand("/opt/codex/codex", 9229, "linux"); + + expect(command.program).toBe("/opt/codex/codex"); + expect(command.args).toEqual([ + "--remote-debugging-port=9229", + "--remote-debugging-address=127.0.0.1", + "--remote-allow-origins=http://127.0.0.1:9229", + ]); }); diff --git a/src/launcher.ts b/src/launcher.ts index c829423..e3064bb 100644 --- a/src/launcher.ts +++ b/src/launcher.ts @@ -6,69 +6,53 @@ import type { PortHold } from "./debug-port"; import { describePortBlockers, listenPidsOnPort, processCommand } from "./port"; export function codexBinaryPath(appPath: string): string { - return `${appPath}/Contents/MacOS/Codex`; + if (appPath.endsWith(".app")) return `${appPath}/Contents/MacOS/Codex`; + return appPath; } export function codexDebugArgs(debugPort: number): string[] { return [ `--remote-debugging-port=${debugPort}`, + "--remote-debugging-address=127.0.0.1", `--remote-allow-origins=http://127.0.0.1:${debugPort}`, ]; } -export function isCodexRunning(): boolean { - const result = spawnSync("pgrep", ["-x", "Codex"], { stdio: "ignore" }); - return result.status === 0; -} - -export async function quitCodex( - timer: LaunchTimer, - timeoutMs = 15000, -): Promise { - spawnSync("osascript", ["-e", 'tell application "Codex" to quit'], { - stdio: "ignore", - }); - const startedAt = Date.now(); - let lastProgressAt = startedAt; - while (Date.now() - startedAt < timeoutMs) { - if (!isCodexRunning()) { - timer.stage("quit codex done", { waitedMs: Date.now() - startedAt }); - return; - } - const now = Date.now(); - if (now - lastProgressAt >= 2000) { - timer.stage("quit codex waiting", { waitedMs: now - startedAt }); - lastProgressAt = now; - } - await Bun.sleep(250); +export function codexLaunchCommand( + appPath: string, + debugPort: number, + platform = process.platform, +): { program: string; args: string[] } { + const debugArgs = codexDebugArgs(debugPort); + if (platform === "darwin" && appPath.endsWith(".app")) { + return { + program: "open", + args: ["-na", appPath, "--args", ...debugArgs], + }; } - throw new Error("Timed out waiting for Codex to quit"); + return { + program: codexBinaryPath(appPath), + args: debugArgs, + }; } export function isKillablePortBlocker(command: string): boolean { const normalized = command.toLowerCase(); return ( normalized.includes("codex.app") || - /\bcodex\b/.test(normalized) || - normalized.includes("/codex.app/") + normalized.includes("/codex.app/") || + normalized.includes("/contents/macos/codex") ); } -async function terminatePid( - pid: number, - signal: "SIGTERM" | "SIGKILL", -): Promise { - await new Promise((resolve) => { - const child = spawn( - "kill", - [signal === "SIGKILL" ? "-9" : "", String(pid)].filter(Boolean), - { - stdio: "ignore", - }, - ); - child.on("close", () => resolve()); - child.on("error", () => resolve()); - }); +function terminatePid(pid: number, signal: "SIGTERM" | "SIGKILL"): void { + spawnSync( + "kill", + [signal === "SIGKILL" ? "-9" : "", String(pid)].filter(Boolean), + { + stdio: "ignore", + }, + ); } export async function releaseBlockedDebugPort( @@ -138,13 +122,16 @@ export function spawnCodexWithDebugPort( debugPort: number, timer: LaunchTimer, ): void { - const args = ["-na", appPath, "--args", ...codexDebugArgs(debugPort)]; + const command = codexLaunchCommand(appPath, debugPort); timer.stage("open codex", { app: appPath, port: debugPort, - launchArgs: codexDebugArgs(debugPort).join(" "), + launchArgs: command.args.join(" "), + }); + const child = spawn(command.program, command.args, { + stdio: "ignore", + detached: true, }); - const child = spawn("open", args, { stdio: "ignore", detached: true }); child.unref(); } @@ -155,6 +142,11 @@ export async function ensureCodexLaunchedWithDebugPort( mode: "attach" | "launch" = "launch", portHold?: PortHold, ): Promise { + let heldPort = portHold; + const releaseHeldPort = () => { + heldPort?.release(); + heldPort = undefined; + }; try { timer.stage("probe debug port", { port: debugPort, mode }); if (mode === "attach") { @@ -166,32 +158,28 @@ export async function ensureCodexLaunchedWithDebugPort( `Codex CDP is not ready on port ${debugPort}. Start Codex with remote debugging on that port or omit --debug-port to auto-select.`, ); } - if (await isDebugPortReady(debugPort)) { - if (await hasCodexCdpTarget(debugPort)) { - timer.stage("debug port ready", { port: debugPort, path: "existing" }); - return; + if (!heldPort) { + if (await isDebugPortReady(debugPort)) { + if (await hasCodexCdpTarget(debugPort)) { + throw new Error( + `Debug port ${debugPort} already exposes Codex CDP. Pass --debug-port ${debugPort} only when you intend to attach to that existing Codex, or omit --debug-port to launch a Helper-managed Codex on a random port.`, + ); + } + throw new Error( + `Debug port ${debugPort} exposes a browser CDP endpoint but not Codex. Stop the other app or omit --debug-port to auto-select another port.`, + ); } - throw new Error( - `Debug port ${debugPort} exposes a browser CDP endpoint but not Codex. Stop the other app or omit --debug-port to auto-select another port.`, - ); } timer.stage("debug port not ready", { port: debugPort }); - await releaseBlockedDebugPort(debugPort, timer); - - const codexRunning = isCodexRunning(); - timer.stage("check codex process", { running: codexRunning }); - if (codexRunning) { - timer.stage("quit codex start"); - await quitCodex(timer); - await Bun.sleep(500); - timer.stage("post-quit delay", { delayMs: 500 }); + if (!heldPort) { await releaseBlockedDebugPort(debugPort, timer); } + releaseHeldPort(); spawnCodexWithDebugPort(appPath, debugPort, timer); await waitForDebugPort(debugPort, timer); } finally { - portHold?.release(); + releaseHeldPort(); } } diff --git a/src/routes.test.ts b/src/routes.test.ts index b60a0f4..dcb6c6a 100644 --- a/src/routes.test.ts +++ b/src/routes.test.ts @@ -7,7 +7,7 @@ import { bridgeRequestTimeoutMessage, bridgeRequestTimeoutMs, } from "./bridge"; -import { handleBridgeRequest } from "./routes"; +import { devtoolsUrlForTargetId, handleBridgeRequest } from "./routes"; test("dev bridge exposes port forwarding list route", async () => { const result = await handleBridgeRequest("/ports/list", {}); @@ -51,6 +51,23 @@ test("dev bridge only opens local forwarded urls externally", async () => { }); }); +test("devtools url uses caller target id", () => { + const url = devtoolsUrlForTargetId(9229, "caller", [ + { + id: "first", + webSocketDebuggerUrl: "ws://127.0.0.1:9229/devtools/page/first", + }, + { + id: "caller", + webSocketDebuggerUrl: "ws://127.0.0.1:9229/devtools/page/caller", + }, + ]); + + expect(url).toBe( + "http://127.0.0.1:9229/devtools/inspector.html?ws=127.0.0.1:9229/devtools/page/caller", + ); +}); + test("dev bridge returns helper directory paths for native settings", async () => { const previous = process.env.CODEX_HELPER_HOME; const root = mkdtempSync(join(tmpdir(), "codex-helper-routes-")); @@ -78,6 +95,37 @@ test("dev bridge returns helper directory paths for native settings", async () = } }); +test("dev bridge does not log routine runtime activity", async () => { + const previous = process.env.CODEX_HELPER_HOME; + const root = mkdtempSync(join(tmpdir(), "codex-helper-routes-")); + try { + process.env.CODEX_HELPER_HOME = root; + + const result = await handleBridgeRequest( + "/runtime/activity", + {}, + { + targetId: "target-1", + helperInstanceId: "helper-1", + href: "app://-/index.html", + hasFocus: true, + visibilityState: "visible", + }, + ); + const log = await handleBridgeRequest("/diagnostics/read-latest", {}); + + expect(result).toEqual({ status: "ok" }); + expect(log).toMatchObject({ + status: "ok", + path: join(root, "logs", "codex-helper.jsonl"), + contents: "", + }); + } finally { + if (previous === undefined) delete process.env.CODEX_HELPER_HOME; + else process.env.CODEX_HELPER_HOME = previous; + } +}); + test("dev bridge rejects malformed settings files explicitly", async () => { const previous = process.env.CODEX_HELPER_HOME; const root = mkdtempSync(join(tmpdir(), "codex-helper-routes-")); @@ -102,7 +150,7 @@ test("dev bridge rejects malformed settings files explicitly", async () => { } }); -test("dev bridge accepts known removed settings keys", async () => { +test("dev bridge accepts settings with known removed keys", async () => { const previous = process.env.CODEX_HELPER_HOME; const root = mkdtempSync(join(tmpdir(), "codex-helper-routes-")); try { @@ -110,7 +158,14 @@ test("dev bridge accepts known removed settings keys", async () => { mkdirSync(root, { recursive: true }); writeFileSync( join(root, "config.json"), - '{ "markdownExportEnabled": true, "sessionDeleteEnabled": true }', + `{ + "markdownExportEnabled": true, + "sessionDeleteEnabled": true, + "autoRenameMenuEnabled": true, + "markdownFriendlyFilenameEnabled": true, + "autoNamingMinChars": 8, + "autoNamingMaxChars": 12 +}`, "utf8", ); @@ -124,10 +179,10 @@ test("dev bridge accepts known removed settings keys", async () => { portForwardingEnabled: false, portAutoForwardWeb: true, portSameLocalPort: true, - autoRenameMenuEnabled: false, + autoRenameMenuEnabled: true, markdownFriendlyFilenameEnabled: true, - autoNamingMinChars: 4, - autoNamingMaxChars: 10, + autoNamingMinChars: 8, + autoNamingMaxChars: 12, }, }); } finally { diff --git a/src/routes.ts b/src/routes.ts index ae34427..d26f839 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -8,7 +8,7 @@ import { } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; -import { listTargets, pickCodexPageTarget } from "./cdp"; +import { listTargets } from "./cdp"; import { discoverRemoteListeningPorts, discoveryRequestFromPayload, @@ -34,6 +34,14 @@ type JsonValue = | JsonValue[] | { [key: string]: JsonValue }; +export type BridgeCaller = { + targetId: string; + helperInstanceId: string; + href?: string; + hasFocus?: boolean; + visibilityState?: string; +}; + type HelperSettings = { markdownExportEnabled: boolean; sessionMoveEnabled: boolean; @@ -276,7 +284,9 @@ function openPath(path: string, reveal = false): JsonValue { } } -function localBrowserUrlFromPayload(payload: Record): string { +function localBrowserUrlFromPayload( + payload: Record, +): string { const raw = typeof payload.url === "string" ? payload.url.trim() : ""; if (!raw) throw new Error("URL is required"); let url: URL; @@ -307,6 +317,24 @@ function devtoolsUrl( return `http://127.0.0.1:${debugPort}/devtools/inspector.html?ws=${ws.slice(5)}`; } +export function devtoolsUrlForTargetId( + debugPort: number, + targetId: string, + targets: { id?: string; webSocketDebuggerUrl?: string }[], +): string { + const target = targets.find((target) => target.id === targetId); + if (!target) { + throw new Error(`Codex DevTools target not found: ${targetId}`); + } + return devtoolsUrl(debugPort, target); +} + +function requiredCallerTargetId(caller?: BridgeCaller): string { + const targetId = caller?.targetId?.trim() ?? ""; + if (!targetId) throw new Error("Bridge caller target id is required"); + return targetId; +} + function helperDebugPort(): number { const raw = process.env.CODEX_HELPER_DEBUG_PORT || "9229"; const value = Number(raw); @@ -317,6 +345,7 @@ function helperDebugPort(): number { export async function handleBridgeRequest( path: string, payload: Record, + caller?: BridgeCaller, ): Promise { if (isRustBridgePath(path)) { return invokeRustBridge(path, payload); @@ -330,6 +359,8 @@ export async function handleBridgeRequest( appendDiagnostic(event, payload); return { status: "ok" }; } + case "/runtime/activity": + return { status: "ok" }; case "/runtime/user-scripts": return { status: "ok", @@ -371,9 +402,9 @@ export async function handleBridgeRequest( case "/devtools/open": try { const debugPort = helperDebugPort(); + const targetId = requiredCallerTargetId(caller); const targets = await listTargets(debugPort); - const target = pickCodexPageTarget(targets); - const url = devtoolsUrl(debugPort, target); + const url = devtoolsUrlForTargetId(debugPort, targetId, targets); return openPath(url); } catch (error) { return {