Skip to content

feat(ide): add /ide slash command with live IDE binding#652

Open
edwinhu wants to merge 29 commits intoThe-Vibe-Company:mainfrom
edwinhu:feature/companion-ide-integration
Open

feat(ide): add /ide slash command with live IDE binding#652
edwinhu wants to merge 29 commits intoThe-Vibe-Company:mainfrom
edwinhu:feature/companion-ide-integration

Conversation

@edwinhu
Copy link
Copy Markdown
Contributor

@edwinhu edwinhu commented Apr 17, 2026

Summary

  • Adds opt-in /ide slash command to Companion: discovers running Claude IDE lockfiles (~/.claude/ide/*.lock), exposes them via REST, and binds a selected IDE to a live session by reusing the CLI's mcp_set_servers control channel.
  • Client-side intercept in Composer (primary in selectCommand, secondary in handleSend) ensures the CLI never receives /ide. Codex sessions get the row disabled with a tooltip.
  • BIND-05 disconnect banner in ChatView with exact spec copy and per-binding dismissal (no toast/modal).
  • +111 tests (4948 → 5059 across 200 files), typecheck clean.

Why

HANDOFF phases 0+1+2: users want in-session IDE context/diff/selection without manually editing MCP config or relaunching the CLI.

Testing

  • Full Vitest suite: 5059/5059 passing
  • Typecheck clean
  • Backend: unit + integration (Hono test client) for discovery, matcher, routes, ws-bridge bind/unbind, persistence sanitizer
  • Frontend: component tests with jest-axe (zero a11y violations) for IdePicker (19), IdeDisconnectBanner, Composer (+8 IDE tests), ChatView (+8), Playground registration
  • Protocol contract test (ide-lockfile-contract.test.ts) pins lockfile schema against observed Claude CLI v2.1.112

Review provenance

  • Implemented by AI agent (Claude Opus 4.7) via /workflows:dev
  • Human review: no

github-actions Bot and others added 7 commits April 2, 2026 18:39
…+ opus alias

The Claude CLI rejects `<family>-latest` compound forms (e.g.
`claude-opus-latest`). Valid model IDs are either short aliases
(`opus`, `sonnet`, `haiku`) or full version strings
(`claude-opus-4-7`, `claude-sonnet-4-6`).

Replaces the broken `claude-opus-latest` entry (added in e723252) with:
- `claude-opus-4-7` — pinned latest Opus
- `opus` — floating alias that auto-follows Anthropic's current Opus
  (preserves the original intent of e723252's fallback option)

Adds a two-sided regression guard asserting `claude-opus-latest` is
never re-introduced.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Claude Opus 4.7 returns thinking blocks with empty `thinking` text and
a non-empty `signature` by design — the Anthropic API defaults
`thinking.display` to `"omitted"` on 4.7, returning reasoning only as
an opaque encrypted blob. The previous fallback "No thinking text
captured." surfaced this as UI noise on every 4.7 turn.

ThinkingBlock now returns null when the thinking text trims to empty,
keeping the UI clean for 4.7 while preserving full behavior for
models that return plaintext thinking (e.g. Opus 4.6).

Upstream: the Claude Code CLI 2.1.112 does not expose a knob to
override the display mode (tested --settings, env vars, --betas);
getting readable thinking on 4.7 requires upstream CLI support for
`thinking.display: "summarized"`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…tures

Claude Opus 4.7 removed `temperature`/`top_p`/`top_k` — sending any of
them returns 400. Two Companion backend callers hit the Anthropic API
directly (auto-namer for session titles, ai-validator for permission
scoring) and unconditionally set `temperature`. Since `anthropicModel`
is a free-text field in SettingsPage, a user configuring `claude-opus-4-7`
(or the `opus` short alias) would get 400s on every auto-name and
AI-validation call.

- Add `supportsSamplingParams(model)` helper in settings-manager that
  treats `claude-opus-4-7` and `opus` as unsupported.
- Gate `temperature` behind the helper in auto-namer.ts and ai-validator.ts.
- Add regression tests for the helper and for auto-namer's request body.

Also bumps stale model IDs in test fixtures and the Playground mock
(`claude-opus-4-20250514` → `claude-opus-4-7`, `claude-sonnet-4-5` →
`claude-sonnet-4-6`) for consistency with the active model set.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Adds an opt-in IDE integration: Companion discovers running Claude IDE
lockfiles under ~/.claude/ide/, exposes them via REST, and binds a
selected IDE to a live session by reusing the CLI's own mcp_set_servers
control channel. Users type /ide in the composer to open a picker; the
CLI never receives the slash command — it is intercepted client-side.

Server:
- ide-discovery: fs.watch + PID liveness pruning; emits ide:added/removed/changed
- ide-matcher: longest-prefix workspace match, mtime tiebreak
- ws-bridge.bindIde/unbindIde: sends mcp_set_servers with {ide: ws-ide|sse-ide};
  auto-unbinds on ide:removed (BIND-04)
- REST: GET /api/ide/available, POST/DELETE /api/sessions/:id/ide
- session-store sanitizer strips authToken before disk persist (BIND-03)
- broadcasts {type: ide_list_changed} so open pickers live-refresh (DISC-03)

Frontend:
- Composer injects /ide via client-side overlay (no session.slash_commands
  mutation), intercepts selection AND manual-typed submission; CLI never
  sees /ide (BIND-06). Codex sessions get the row disabled with tooltip.
- IdePicker modal (createPortal, focus trap, axe-clean): 5 states covering
  empty, single, many-with-best-match, currently-bound, bind-failed.
- ChatView renders IdeDisconnectBanner on binding non-null → null with the
  exact BIND-05 copy ("IDE disconnected — rebind via /ide"), per-session
  dismissal, no toast/modal.
- Playground registers the 5 picker states + banner for visual/axe coverage.

Includes scripts/probe-ide.ts for local diagnostics and an "IDE Channel"
appendix to WEBSOCKET_PROTOCOL_REVERSED.md documenting the ws-ide/sse-ide
MCP server type, lockfile schema, and bind mechanism (probed against
Claude CLI v2.1.112).

+111 tests (4948 → 5059), typecheck clean, full suite green.

Implemented by AI agent (Claude). Human review: no.

Co-Authored-By: Claude <noreply@anthropic.com>
@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 17, 2026

@edwinhu is attempting to deploy a commit to the The Vibe Company Team on Vercel.

A member of the Team first needs to authorize it.

Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

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

11 issues found across 52 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="web/src/components/ChatView.tsx">

<violation number="1" location="web/src/components/ChatView.tsx:45">
P2: IDE picker open state is not cleared on session change, allowing modal visibility/actions to leak into a different session context.</violation>
</file>

<file name="web/server/session-types.ts">

<violation number="1" location="web/server/session-types.ts:402">
P1: IDE auth token is included in browser-facing session payloads, creating a credential leakage path.</violation>
</file>

<file name="web/server/ide-matcher.ts">

<violation number="1" location="web/server/ide-matcher.ts:36">
P2: `isPathPrefix` uses an overbroad `startsWith('..')` check that falsely rejects valid descendants like `..cache`, leading to incorrect IDE candidate ranking.</violation>
</file>

<file name="web/server/ide-discovery.ts">

<violation number="1" location="web/server/ide-discovery.ts:118">
P2: Synchronous busy-wait retry blocks the Node event loop and can cause avoidable latency spikes during lockfile scans.</violation>
</file>

<file name="web/server/ide-matcher.test.ts">

<violation number="1" location="web/server/ide-matcher.test.ts:126">
P2: The boundary regression test is too weak: it only asserts that the exact match stays first, so a buggy partial-prefix match can still pass.</violation>
</file>

<file name="scripts/probe-ide.ts">

<violation number="1" location="scripts/probe-ide.ts:77">
P2: `parseLockfile` accepts non-positive PIDs, which can make `isPidAlive` return true due to process-group semantics of `process.kill(pid, 0)` and misclassify invalid lockfiles as live.</violation>
</file>

<file name="web/server/ws-bridge.ts">

<violation number="1" location="web/server/ws-bridge.ts:1069">
P2: `bindIde` marks IDE as bound even when backend is absent, causing persisted/UI binding state to diverge from actual CLI MCP configuration.</violation>
</file>

<file name="web/src/components/IdePicker.test.tsx">

<violation number="1" location="web/src/components/IdePicker.test.tsx:333">
P2: This test is a tautology and does not verify the claimed store-isolation contract, so it cannot catch regressions where IdePicker starts touching the store.</violation>
</file>

<file name="web/src/components/IdePicker.tsx">

<violation number="1" location="web/src/components/IdePicker.tsx:154">
P2: Global Enter handler overrides native button activation inside the dialog, causing incorrect action (bind) when keyboard users press Enter on focused controls.</violation>

<violation number="2" location="web/src/components/IdePicker.tsx:174">
P2: Busy-state protection is bypassed by keyboard actions, allowing concurrent bind/unbind requests.</violation>

<violation number="3" location="web/src/components/IdePicker.tsx:201">
P2: Disconnect failures reuse bind-error UI, so the visible Retry action triggers bind retry instead of retrying disconnect.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

/** Transport advertised in the lockfile. */
transport: "ws-ide" | "sse-ide";
/** Auth token from lockfile — runtime only, never persisted. */
authToken?: string;
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot Apr 17, 2026

Choose a reason for hiding this comment

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

P1: IDE auth token is included in browser-facing session payloads, creating a credential leakage path.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At web/server/session-types.ts, line 402:

<comment>IDE auth token is included in browser-facing session payloads, creating a credential leakage path.</comment>

<file context>
@@ -375,6 +378,34 @@ export interface BufferedBrowserEvent {
+  /** Transport advertised in the lockfile. */
+  transport: "ws-ide" | "sse-ide";
+  /** Auth token from lockfile — runtime only, never persisted. */
+  authToken?: string;
+  /** Epoch milliseconds when the binding was established. */
+  boundAt: number;
</file context>
Fix with Cubic

Comment thread web/src/components/ChatView.tsx
Comment thread web/server/ide-matcher.ts Outdated
Comment thread web/server/ide-discovery.ts Outdated
Comment thread web/server/ide-matcher.test.ts
Comment thread web/server/ws-bridge.ts Outdated
Comment thread web/src/components/IdePicker.test.tsx Outdated
Comment thread web/src/components/IdePicker.tsx
Comment thread web/src/components/IdePicker.tsx Outdated
Comment thread web/src/components/IdePicker.tsx
edwinhu and others added 2 commits April 17, 2026 11:08
Three issues surfaced when testing the merged feature on a live session:

1. Discovery never started — `startIdeDiscovery()` was defined but not
   invoked from the server bootstrap, so no lockfiles were ever watched.

2. Container sessions couldn't reach the host IDE — `bindIde()` hardcoded
   `127.0.0.1`, which is the container's own loopback. Use
   `host.docker.internal` when the session is containerized.

3. Only 1 of 10 IDE tools were exposed to the model (BIND-07). The Claude
   Code CLI binary contains a hardcoded filter (`_35`) that drops every
   `mcp__ide__*` tool except `getDiagnostics` and `executeCode`. Naming
   the MCP server "ide" triggered this prefix; renaming to the sanitized
   ideName (e.g. `neovim`) causes tools to be prefixed `mcp__neovim__*`
   which bypasses the filter. All 10 tools now reach the model. Live
   recording confirms the fix end-to-end.

Regression test BIND-07 pins the sanitized-key contract so a future edit
that reverts to "ide" fails loudly. BIND-01 and BIND-06 assertions were
updated to match the new key shape.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Round 4–7 adversarial review fixes for PR The-Vibe-Company#652 /ide integration:

- Track session.dynamicMcpServers so bindIde merges on Claude (full-replace
  wire) and unbindIde preserves user's other dynamic MCP servers via
  deleteKeys on Codex (per-key upsert).
- Short-circuit bindIde when sanitized ideName is empty (prevents
  servers[""] orphan on the CLI).
- Pre-drain session.pendingMessages in bindIde/unbindIde before direct
  adapter.send so a stale queued mcp_set_servers cannot replay and
  clobber the IDE entry.
- Keep the dynamicMcpServers mirror in sync during flushQueuedBrowserMessages
  so the restore-from-disk path (pendingMessages persisted,
  dynamicMcpServers not) stays consistent.
- Mirror-safe unbind payload shape: Claude drops IDE via omission,
  Codex drops IDE via deleteKeys.

Also address earlier greptile/vercel/cubic review comments:
- host.docker.internal for containerized sessions in bindIde URL.
- auth token stripped from ide-session-routes HTTP responses and from
  broadcast session_update payloads (BIND-03).
- ide-discovery: async sleep, scan generation counter, sync initial
  snapshot, post-watch catch-up, known map cleared on restart.
- ide-matcher: proper prefix check (rel === ".." handling).
- probe-ide: reject non-integer / non-positive pids.
- IdePicker: busyRef flipped before await; lastFailedOp retry; Enter
  guard widened to all interactive elements.
- ChatView: close IdePicker on session switch.
- routes/ide-session-routes: map bind/unbind errors to HTTP 409.

All 5095 tests pass; typecheck clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@edwinhu
Copy link
Copy Markdown
Contributor Author

edwinhu commented Apr 17, 2026

@cubic-dev-ai please re-review — the latest commit 95666bd addresses all P1/P2 items from the prior cubic pass plus four rounds of codex adversarial review:

  • P1 auth token leakstripAuthToken() on HTTP responses and session_update broadcasts; authToken is runtime-only and never persisted.
  • P2 IdePicker session leakuseLayoutEffect closes picker on session switch.
  • P2 isPathPrefix over-broad → now rel === ".." || rel.startsWith(".." + path.sep).
  • P2 busy-wait → async sleep + generation counter in ide-discovery.
  • P2 weak boundary test → expanded assertions in ide-matcher.test.ts.
  • P2 non-positive PIDparseLockfile rejects non-integer / pid ≤ 0.
  • P2 backend-absent bind → three-layer adapter guard (attached + isConnected + send accepted).
  • P2 tautological store-isolation test → now mocks store with spies; verifies no set/get during render.
  • P2 busy-state bypass via keyboardbusyRef flipped before await; Enter guard widened to all interactive elements in dialog.
  • P2 disconnect retrylastFailedOp tracks bind vs disconnect so Retry dispatches correctly.
  • P2 global Enter handler → scoped to non-button interactive elements within the picker.

Additional hardening from codex rounds 4–7:

  • Merge-safe dynamicMcpServers state tracking so bindIde/unbindIde preserve the user's other dynamic MCP servers (Claude full-replace; Codex per-key via new deleteKeys contract).
  • Empty-sanitized-key guard in bindIde.
  • Pre-drain pendingMessages before direct adapter.send() in bind/unbind.
  • Mirror catch-up inside flushQueuedBrowserMessages so restore-from-disk paths don't clobber queued servers on bind.

5095/5095 tests pass, typecheck clean.

@cubic-dev-ai
Copy link
Copy Markdown

cubic-dev-ai Bot commented Apr 17, 2026

@cubic-dev-ai please re-review — the latest commit 95666bd addresses all P1/P2 items from the prior cubic pass plus four rounds of codex adversarial review:

  • P1 auth token leakstripAuthToken() on HTTP responses and session_update broadcasts; authToken is runtime-only and never persisted.
  • P2 IdePicker session leakuseLayoutEffect closes picker on session switch.
  • P2 isPathPrefix over-broad → now rel === ".." || rel.startsWith(".." + path.sep).
    ...

@edwinhu I have started the AI code review. It will take a few minutes to complete.

Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

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

4 issues found across 61 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="web/src/ws.ts">

<violation number="1" location="web/src/ws.ts:1253">
P2: Global `ide_list_changed` event is emitted per-session message, causing duplicate IDE-list refetches when multiple session sockets are connected.</violation>
</file>

<file name="web/src/components/IdePicker.tsx">

<violation number="1" location="web/src/components/IdePicker.tsx:98">
P2: Concurrent `loadList` calls can resolve out of order and overwrite newer IDE list state with stale results.</violation>
</file>

<file name="scripts/probe-ide.ts">

<violation number="1" location="scripts/probe-ide.ts:132">
P2: Port derivation is filename-only and unvalidated, allowing invalid (`NaN`) bind URLs and inaccurate diagnostic payload output.</violation>
</file>

<file name="web/server/ws-bridge.ts">

<violation number="1" location="web/server/ws-bridge.ts:1547">
P1: `mcp_set_servers` from browser is sent to Claude without re-merging active IDE entry, so full-replace semantics can silently drop IDE tools and desync UI/backend state.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Comment thread web/server/ws-bridge.ts
Comment thread web/src/ws.ts Outdated
Comment thread web/src/components/IdePicker.tsx
Comment thread scripts/probe-ide.ts Outdated
Four cubic-ai issues on commit 95666bd:

- ws-bridge: browser mcp_set_servers on Claude now preserves the active
  IDE entry by re-merging session.dynamicMcpServers[ideKey] into the
  outbound payload (full-replace semantics would otherwise drop IDE tools
  when the user edits MCP via McpPanel while bound). deleteKeys honoring
  the IDE key still drops it.
- ws (client): dedupe ide_list_changed on a monotonic generation from
  ide-discovery.scanGeneration instead of a 100ms time window, so fast
  add+remove sequences aren't silently dropped.
- IdePicker: concurrent loadList calls now use an AbortController per
  call (aborts the prior in-flight request on supersede and on unmount)
  plus an epoch counter as defense-in-depth. AbortError is swallowed
  on both DOMException and Error shapes. api.getAvailableIdes gains an
  optional signal parameter.
- probe-ide: port is validated as a positive integer < 65536 before use;
  malformed filenames (e.g. notanumber.lock) are skipped with reason.

Tests: 5113 passing (+18 new: browser mcp_set_servers merge on Claude,
generation-based dedupe across two sockets, abort-on-unmount + abort-on-
supersede, api signal threading, probe() integration skip).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

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

2 issues found across 14 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="web/src/ws.ts">

<violation number="1" location="web/src/ws.ts:30">
P2: Generation-based dedupe can suppress legitimate IDE refresh events after server/discovery restart because client generation state is never reset.</violation>
</file>

<file name="scripts/probe-ide.ts">

<violation number="1" location="scripts/probe-ide.ts:156">
P2: New port-range gate conflicts with documented Neovim `<pid>.lock` naming and can skip live Neovim lockfiles when PID stem exceeds 65535.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Comment thread web/src/ws.ts
Comment thread scripts/probe-ide.ts
- probe-ide: prefer JSON raw.port as authoritative over filename stem
  (Neovim names lockfiles by pid, not port); validate final port is
  integer in 1..65535.
- ws.ts: reset generation-dedupe high-water mark on ws.onopen so server
  restarts don't suppress legitimate ide_list_changed events.
- ws-bridge: drop unused ideRemovedUnsubscribe field (TS6133 in CI).
- ide-routes.test: cover 409/500 error paths (coverage 78% -> 93%).
- Test gaps: probe integration test for JSON-over-stem precedence;
  assert ideBinding stays null on POST-500 invalid-name path.

All 5124 tests pass; typecheck clean; deadcode:check clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@edwinhu
Copy link
Copy Markdown
Contributor Author

edwinhu commented Apr 17, 2026

@cubic-dev-ai please re-review

@cubic-dev-ai
Copy link
Copy Markdown

cubic-dev-ai Bot commented Apr 17, 2026

@cubic-dev-ai please re-review

@edwinhu I have started the AI code review. It will take a few minutes to complete.

Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

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

3 issues found across 61 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="web/server/ws-bridge.ts">

<violation number="1" location="web/server/ws-bridge.ts:1166">
P1: IDE server key shares user MCP namespace, allowing key collisions that overwrite and later delete unrelated user MCP servers.</violation>
</file>

<file name="web/src/api.ts">

<violation number="1" location="web/src/api.ts:91">
P2: Expected fetch cancellations (AbortError) are being logged as API failures/exceptions after adding abortable GET, causing noisy and misleading telemetry.</violation>
</file>

<file name="web/src/components/ChatView.tsx">

<violation number="1" location="web/src/components/ChatView.tsx:102">
P2: IDE disconnect banner state can remain visible after rebind because visibility is not gated by current binding state and disconnect id is not cleared on reconnect.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Comment thread web/server/ws-bridge.ts Outdated
Comment thread web/src/api.ts
Comment thread web/src/components/ChatView.tsx
Addresses cubic round-3 review on PR The-Vibe-Company#652.

ws-bridge (P1 namespace collision):
- Reserve `companion-ide-*` prefix for IDE-bound MCP server keys. The
  separator characters (`-`) are stripped by our ideName sanitization
  (`[^a-z0-9]`), so our keyspace is structurally disjoint from any key
  our sanitizer can emit (addresses codex round-4 BLOCK: `companionide`
  concatenation only moved the collision point).
- Defend reserved namespace at every mirror write site:
  - routeBrowserMessage: strip `companion-ide-*` from user servers +
    deleteKeys before merge/mirror/send.
  - flushQueuedBrowserMessages: re-strip on replay so mirror protection
    is structural, not accidental (codex round-5 CONDITIONAL-GO fix).
  - bindIde/unbindIde bypass strip — they own the reserved key.
- Tests: BIND-08a/b/c (namespace preservation), BIND-08d (legacy-name
  collision), BIND-08e/f/g (reserved-key stripping + prefix vs substring),
  BIND-08h (replay invariant), MIGRATE-01/02 (dynamicMcpServers not
  persisted).

api.ts (P2 AbortError noise):
- Add `isAbortError()` helper; gate trackApiFailure in post/get/put/
  patch/del + raw bindIde fetch path. Abort-01/02/03 cover plain Error,
  non-abort network error, DOMException branches.

ChatView.tsx (P2 stale disconnect banner):
- Clear disconnectedBindingId + dismissedForBinding on null → non-null
  ideBinding transition so rebind removes the banner. BIND-09 pins.

All 5138 tests pass; typecheck clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@edwinhu
Copy link
Copy Markdown
Contributor Author

edwinhu commented Apr 17, 2026

@cubic-dev-ai please re-review

@cubic-dev-ai
Copy link
Copy Markdown

cubic-dev-ai Bot commented Apr 17, 2026

@cubic-dev-ai please re-review

@edwinhu I have started the AI code review. It will take a few minutes to complete.

Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

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

3 issues found across 61 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="web/server/ws-bridge.ts">

<violation number="1" location="web/server/ws-bridge.ts:223">
P2: Auto-unbind on `ide:removed` silently drops failures when backend is disconnected, leaving stale `ideBinding` state with no retry.</violation>
</file>

<file name="web/server/ide-discovery.ts">

<violation number="1" location="web/server/ide-discovery.ts:122">
P2: Persistent `readdirSync` failures skip reconciliation, allowing stale IDE entries to remain visible indefinitely.</violation>

<violation number="2" location="web/server/ide-discovery.ts:207">
P1: Transient lockfile parse/read failures are treated as removals, causing spurious `ide:removed` events and downstream auto-unbind churn.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Comment thread web/server/ide-discovery.ts Outdated
Comment thread web/server/ws-bridge.ts Outdated
Comment thread web/server/ide-discovery.ts Outdated
…ures

Addresses cubic round-4 review on PR The-Vibe-Company#652.

ide-discovery (P1):
- Distinguish missing vs transient lockfile read failures via discriminated
  ReadResult union. On transient errors for a previously-known path, carry
  the prior entry forward instead of spuriously emitting ide:removed (which
  caused auto-unbind churn).
- Bound carry-forward via per-path transientCounts counter
  (MAX_CONSECUTIVE_TRANSIENT_READS = 5). After the threshold, the entry is
  evicted so stale credentials can't be served indefinitely. Counter resets
  on any ok/missing read and on reconcile eviction.
- Counter reset on ok-reads happens BEFORE PID/port validation (structural
  hardening — functionally equivalent under current control flow since
  reconcile clears on remove, but future-proofs against refactors).

ide-discovery (P2):
- Track consecutive readdirSync failures via readdirFailureStreak. After
  READDIR_FAILURE_THRESHOLD (3) persistent failures, evict all known
  entries so stale IDEs can't linger when the dir becomes unreachable.
  Streak resets on successful readdir.

ws-bridge (P2):
- Auto-unbind on ide:removed now awaits unbindIde; on { ok: false } (e.g.
  backend disconnected) invokes new private forceClearDeadIdeBinding that
  clears ideBinding, purges companion-ide-* from dynamicMcpServers,
  persists, and broadcasts session_update — without touching the adapter.
  Session state reflects reality even when the backend is dead.

Tests: DISC-05a/b/c/d/e/f/g/h (9 in new ide-discovery-resilience.test.ts)
+ DISC-06a/b/c + BIND-10a/b. 5151/5151 pass; typecheck clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

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

1 issue found across 4 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="web/server/ide-discovery.ts">

<violation number="1" location="web/server/ide-discovery.ts:261">
P2: `readdirFailureStreak` is not reset on normal stop/start, so a new discovery session can inherit old failure history and evict IDEs too early.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Comment thread web/server/ide-discovery.ts
Addresses cubic round-5 P2: module-level readdirFailureStreak inherited
old failure history across discovery restarts, causing premature eviction
when a new session hit its first readdir failure.

Reset at three points: stopCurrent(), startIdeDiscovery() entry, and
resetIdeDiscoveryForTests(). Added _getReaddirFailureStreakForTests
accessor. DISC-06d pins the invariant.

5152/5152 tests pass; typecheck clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@edwinhu
Copy link
Copy Markdown
Contributor Author

edwinhu commented Apr 17, 2026

@cubic-dev-ai please re-review

@cubic-dev-ai
Copy link
Copy Markdown

cubic-dev-ai Bot commented Apr 17, 2026

@cubic-dev-ai please re-review

@edwinhu I have started the AI code review. It will take a few minutes to complete.

Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

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

2 issues found across 62 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="web/src/components/Composer.tsx">

<violation number="1" location="web/src/components/Composer.tsx:192">
P2: Raw-text `/ide` interception hijacks any real backend slash command named `ide`, preventing it from being sent.</violation>
</file>

<file name="web/server/ide-discovery.ts">

<violation number="1" location="web/server/ide-discovery.ts:577">
P2: `listAvailableIdes()` leaks mutable references to internal `known` entries instead of returning an immutable/copy snapshot, allowing callers to corrupt discovery state and diffing.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Comment thread web/src/components/Composer.tsx
Comment thread web/server/ide-discovery.ts Outdated
@edwinhu
Copy link
Copy Markdown
Contributor Author

edwinhu commented Apr 18, 2026

@cubic-dev-ai please re-review — 966db2b addresses codex full-PR adversarial P2 #2 (bind/unbind fire-and-forget):

  • Added optional applyMcpSetServers(servers, deleteKeys): Promise<{ok}> to IBackendAdapter.
  • Codex: refactored handleOutgoingMcpSetServers to delegate to private runMcpSetServers that throws on failure; applyMcpSetServers awaits + maps throw → {ok: false, error}. Fire-and-forget send() path preserved for browser-initiated edits.
  • Claude: wraps sendControlRequest in a Promise that resolves on matching control_response (10s timeout).
  • bindIde/unbindIde prefer applyMcpSetServers when available, fall back to send(). UI state only commits after backend ack.

Typecheck clean, 5165/5165 tests pass. One codex P2 remains (session rehydration of ideBinding on restart) — investigating next.

@cubic-dev-ai
Copy link
Copy Markdown

cubic-dev-ai Bot commented Apr 18, 2026

@cubic-dev-ai please re-review — 966db2b addresses codex full-PR adversarial P2 #2 (bind/unbind fire-and-forget):

  • Added optional applyMcpSetServers(servers, deleteKeys): Promise<{ok}> to IBackendAdapter.
  • Codex: refactored handleOutgoingMcpSetServers to delegate to private runMcpSetServers that throws on failure; applyMcpSetServers awaits + maps throw → {ok: false, error}. Fire-and-forget send() path preserved for browser-initiated edits.
  • Claude: wraps sendControlRequest in a Promise that resolves on matching control_response (10s timeout).
    ...

@edwinhu I have started the AI code review. It will take a few minutes to complete.

Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

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

1 issue found across 4 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="web/server/claude-adapter.ts">

<violation number="1" location="web/server/claude-adapter.ts:428">
P2: `applyMcpSetServers` times out locally but does not unregister its pending control-request callback, which can leave stale entries in `pendingControlRequests` when no response arrives.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Comment thread web/server/claude-adapter.ts
…meout

Cubic P2 (identified by cubic) on 966db2b: `applyMcpSetServers` sets a
10s local timeout but did not remove the pending entry from
`pendingControlRequests` when the timeout fired. A non-responding CLI
would leak entries until the map reset on disconnect.

Change `sendControlRequest` to return the generated request ID so
callers with local timeouts can unregister the pending entry. The
timeout closure now captures the id and `.delete()`s it before
resolving `{ok: false}`.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@edwinhu
Copy link
Copy Markdown
Contributor Author

edwinhu commented Apr 18, 2026

@cubic-dev-ai please re-review — 7c24679 addresses the cubic-identified P2 on 966db2b:

  • P2 (applyMcpSetServers timeout leaks pendingControlRequests entry)sendControlRequest now returns the generated request ID. The timeout closure captures it and pendingControlRequests.delete()s the entry before resolving {ok: false, error: "mcp_set_servers timeout"}.

Typecheck clean, 5165/5165 tests pass.

@cubic-dev-ai
Copy link
Copy Markdown

cubic-dev-ai Bot commented Apr 18, 2026

@cubic-dev-ai please re-review — 7c24679 addresses the cubic-identified P2 on 966db2b:

  • P2 (applyMcpSetServers timeout leaks pendingControlRequests entry)sendControlRequest now returns the generated request ID. The timeout closure captures it and pendingControlRequests.delete()s the entry before resolving {ok: false, error: "mcp_set_servers timeout"}.

Typecheck clean, 5165/5165 tests pass.

@edwinhu I have started the AI code review. It will take a few minutes to complete.

The FolderPicker manual input tests waited only for the pencil button
to render, not for the async loadDirs to resolve. On Ubuntu CI the
browsePath was still empty when clicked, causing toHaveValue to fail.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@edwinhu
Copy link
Copy Markdown
Contributor Author

edwinhu commented Apr 18, 2026

@cubic-dev Please review this PR.

@cubic-dev-ai
Copy link
Copy Markdown

cubic-dev-ai Bot commented Apr 18, 2026

@cubic-dev Please review this PR.

@edwinhu I have started the AI code review. It will take a few minutes to complete.

@edwinhu
Copy link
Copy Markdown
Contributor Author

edwinhu commented Apr 18, 2026

Adversarial Code Review -- PR #652

Scope: ~11,900 lines added across 67 files. Adds IDE lockfile discovery, session-IDE binding/unbinding, live picker UI, and mcp_set_servers wiring for both Claude and Codex backends.

Verdict: WARNING -- No blocking security issues found. The authToken handling is thorough and correct. Several medium-priority issues warrant attention before or shortly after merge.


CRITICAL Issues

None found. The security posture is solid -- see Security section below.


Security Assessment (PASS)

The PR handles IDE authToken (a bearer credential for the MCP server) across five trust boundaries. All five are correctly defended:

Boundary Defense Verified in
REST API (GET /api/ide/available) Explicit field projection -- authToken never appears in response body ide-routes.test.ts string-level + key-level assertions
Browser WebSocket (session_update) stripAuthToken() before broadcast ws-bridge.ts:1436, ws-bridge.test.ts BIND-03 tests
Disk persistence (session-store) sanitizeForDisk() via structuredClone + delete authToken session-store.test.ts raw-file scan for token string
Protocol recordings redactAuthTokens() regex on every recorder.appendEntry recorder.test.ts multi-depth + escaped-char tests
Diagnostic script (probe-ide.ts) authToken redacted from console.log output probe-ide.ts:902 -- map(({ authToken: _, ...rest }) => rest)

No hardcoded credentials, no injection risks, no path traversal -- the cwd query param in GET /api/ide/available is passed through path.resolve() but only used as a string comparison operand against workspace folders, never for filesystem access.

MCP server key namespace isolation (companion-ide- prefix with hyphen separator, disjoint from [a-z0-9]+ sanitizer output) correctly prevents user-controlled mcp_set_servers payloads from colliding with or overwriting IDE bindings. stripReservedIdeKeys enforces this at the routing layer and again at queue-flush replay.


HIGH Priority

H1. applyMcpSetServers timeout leaks the pending Promise on success
web/server/claude-adapter.ts:1110-1141

The 10-second timeout in applyMcpSetServers creates a timer that calls resolve({ok: false, error: "mcp_set_servers timeout"}). If the CLI responds successfully before the timeout, the resolve inside sendControlRequest's onResponse fires first, which is correct -- but clearTimeout(timer) is called and the pendingControlRequests entry is removed by the response handler. The logic is sound except: if sendControlRequest returns undefined (which currently cannot happen since you changed the return type to string), the timeout closure's this.pendingControlRequests.delete(requestId) would operate on undefined. This is currently safe but fragile -- a future refactor that makes sendControlRequest return string | undefined would silently leak entries.

Recommendation: Add an assertion or guard: if (requestId === undefined) { clearTimeout(timer); resolve({ok: false, error: "failed to send control request"}); return; }.

H2. Race between bindIde and concurrent routeBrowserMessage for mcp_set_servers
web/server/ws-bridge.ts:1316-1335

bindIde drains the pending message queue to ensure no stale mcp_set_servers replays after it. However, between the drain completing and the adapter.applyMcpSetServers() call, a concurrent browser WebSocket message could enqueue a new mcp_set_servers. Since routeBrowserMessage runs synchronously on the event loop while applyMcpSetServers is awaited, this new message would be enqueued after the drain but before the bind's write lands -- and on Claude's full-replace semantics, the next flush of that queued message would overwrite the IDE entry.

Mitigation: The risk is low in practice (requires a user editing MCP servers in the exact same event-loop tick as a bind), but consider adding a per-session mutex or "bind-in-progress" flag that makes routeBrowserMessage queue mcp_set_servers messages during an active bind operation.


MEDIUM Priority

M1. ide-discovery.ts watches with persistent: false -- process may exit early
web/server/ide-discovery.ts line ~3243

watch(ideDir, { persistent: false }) means the watcher does not keep the Node process alive. This is correct for the setInterval rescan timer (which also has .unref()), but means that if all other references are garbage-collected, the process will exit even though IDE discovery is nominally "running". In the Companion server context this is fine (the HTTP server holds the process open), but if this module is ever used in a standalone context (tests, scripts), it could cause confusing behavior.

M2. scanDirSync does not emit bus events for initial lockfiles found at startup
web/server/ide-discovery.ts lines ~3151-3188

scanDirSync calls reconcileKnown(nextKnown, gen) which does emit ide:added events. This is correct. However, any bus listener registered after startIdeDiscovery() returns will miss these initial events. The code mitigates this with listAvailableIdes() for REST callers, but the event-driven ide:removed auto-unbind path in ws-bridge.ts (line ~234) requires bus subscription to happen before startIdeDiscovery(). The current boot sequence in index.ts should be verified to ensure WsBridge constructor (which subscribes to ide:removed) runs before startIdeDiscovery().

M3. redactAuthTokens regex may miss non-standard JSON whitespace
web/server/recorder.ts line ~4121

The regex /"authToken"\s*:\s*"[^"\\]*(?:\\.[^"\\]*)*"/g handles standard JSON but would fail on a payload with a numeric or object-valued authToken (e.g. "authToken": 12345 or "authToken": {"nested": "value"}). The lockfile contract defines authToken as a string, but a defense-in-depth approach would also catch non-string values. Consider broadening the regex or using a JSON-parse-based approach for the recording path.

M4. No rate limiting on GET /api/ide/available or POST /api/sessions/:id/ide
The REST endpoints have no throttling. A misbehaving or compromised browser tab could hammer these endpoints. GET /api/ide/available calls listAvailableIdes() (cheap, in-memory) followed by matchIdesForCwd (O(n*m) where n=candidates, m=workspace folders) -- acceptable. POST .../ide calls bindIde which does an async adapter.applyMcpSetServers round-trip -- hammering this could stack up pending control requests. The busyRef guard in IdePicker.tsx prevents double-clicks from the same picker, but not from multiple tabs or programmatic callers.

M5. lockfilePath exposed in REST responses and browser broadcasts
web/server/routes/system-routes.ts line ~65

lockfilePath (an absolute filesystem path like /Users/x/.claude/ide/38630.lock) is included in both the GET /api/ide/available response and the session_update broadcast to browsers. While not a credential, it reveals the server's filesystem layout (username, home directory structure). Consider stripping it from browser-facing responses and using port as the canonical identifier.

M6. supportsSamplingParams hardcodes model name prefixes
web/server/settings-manager.ts lines ~608-615

The function checks for "opus" and "claude-opus-4-7" / "claude-opus-4.7" by prefix. When claude-opus-4-8 ships (or any future model that also lacks sampling params), this will silently regress. Consider a deny-list that is explicitly versioned or a feature-detection approach.


SUGGESTIONS (Low Priority)

S1. Large file: ws-bridge.ts
With the IDE binding additions, ws-bridge.ts is now well over 2000 lines. The bindIde / unbindIde / forceClearDeadIdeBinding / updateDynamicMcpServers / stripReservedIdeKeys cluster (~400 lines) is a natural extraction target into an ide-binding.ts module.

S2. Large file: ws-bridge.test.ts
The test file now has ~7000+ lines with the 2155-line IDE binding test block appended. Consider extracting the IDE binding tests into ws-bridge-ide.test.ts to keep test files navigable.

S3. derivePortAndTransport is duplicated between probe-ide.ts and ide-discovery.ts
Both files independently implement the same JSON-port-preferred-over-stem logic. ide-discovery.ts imports parseLockfile and isPidAlive from probe-ide.ts but reimplements derivePortAndTransport locally. Consider importing the shared implementation from probe-ide.ts to avoid drift.

S4. Test-only exports gated by runtime check, not build-time
ide-discovery.ts exports ~8 test-only functions (_scanDirForTests, _resetTransientCountsForTests, etc.) guarded by assertTestOnly() which checks NODE_ENV / VITEST at runtime. These increase the public API surface and could be accidentally called in production (the runtime guard throws, which is correct but noisy). Consider using conditional re-exports from a separate ide-discovery.testing.ts barrel that the test files import.

S5. Magic numbers

  • MAX_CONSECUTIVE_TRANSIENT_READS = 5 (ide-discovery.ts)
  • READDIR_FAILURE_THRESHOLD = 3 (ide-discovery.ts)
  • 10_000 ms timeout for applyMcpSetServers (claude-adapter.ts)
  • 2000 ms delay for handleOutgoingMcpGetStatus refresh (claude-adapter.ts)

These are well-commented but would benefit from being configurable via environment variables (matching the pattern of COMPANION_DISCONNECT_DEBOUNCE_MS).


Test Coverage Assessment

Exceptionally thorough. The PR includes:

  • Contract-pinning tests (lockfile shape, type assertions)
  • Resilience tests (transient EBUSY, readdir failures, bounded carry-forward)
  • Race condition tests (stale scan generation, concurrent bind/pick)
  • Security tests (authToken never in REST response, never on disk, never in recordings)
  • Integration tests (real WsBridge + real ide-discovery + Hono app.request)
  • Adversarial tests (PID=0, negative ports, all-punctuation IDE names, NaN ports)

One gap: no test for the H2 race (concurrent routeBrowserMessage during bindIde await). This is difficult to test without a per-session lock, which reinforces the recommendation to add one.


Summary

Category Count
Critical 0
High 2
Medium 6
Suggestions 5

The security story is excellent -- the authToken handling is the most carefully defended credential flow I have seen in a PR of this size. The architecture (lockfile discovery -> event bus -> ws-bridge bind/unbind -> adapter -> CLI) is clean and well-documented. The two HIGH items are edge cases rather than exploitable bugs. This PR can merge with the H1 guard added and H2 documented as a known limitation.

H1: Guard applyMcpSetServers against sendControlRequest returning
undefined in a future refactor — early-return with error instead of
leaking pendingControlRequests entries.

H2: Add ideBindInProgress flag to prevent concurrent browser
mcp_set_servers from racing bindIde/unbindIde. While the flag is set,
routeBrowserMessage queues mcp_set_servers messages instead of
forwarding them. Queued messages are flushed after the bind/unbind
completes. Prevents split-brain on Claude's full-replace wire
semantics.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@edwinhu
Copy link
Copy Markdown
Contributor Author

edwinhu commented Apr 18, 2026

@cubic-dev Please re-review — fixed both HIGH issues (H1: applyMcpSetServers guard, H2: ideBindInProgress race prevention).

@cubic-dev-ai
Copy link
Copy Markdown

cubic-dev-ai Bot commented Apr 18, 2026

@cubic-dev Please re-review — fixed both HIGH issues (H1: applyMcpSetServers guard, H2: ideBindInProgress race prevention).

@edwinhu I have started the AI code review. It will take a few minutes to complete.

@edwinhu
Copy link
Copy Markdown
Contributor Author

edwinhu commented Apr 18, 2026

Adversarial Review -- Round 2 (H1/H2 Fixes)

Commit reviewed: ed08578 — fix(ide): close race between bind/unbind and concurrent mcp_set_servers


[CRITICAL] C1: Postdrain flush of queued mcp_set_servers skips IDE re-injection (Claude full-replace data loss)

File: web/server/ws-bridge.ts (lines 1447-1451, 1572-1576 -- finally blocks)

Issue: The H2 race guard (line 1800) queues incoming browser mcp_set_servers messages BEFORE the IDE entry injection runs (lines 1849-1884 in routeBrowserMessage). The queued message therefore contains the raw browser payload -- which does NOT include the companion-ide-* server entry.

When the finally block calls flushQueuedBrowserMessages, that function:

  1. Strips reserved IDE keys (correct)
  2. Updates the dynamic MCP mirror (correct)
  3. Sends via adapter.send(queuedMsg) (problem!)

But it does NOT re-inject the IDE entry the way routeBrowserMessage does at lines 1849-1884. On Claude's full-replace wire semantics, the flushed payload is missing the IDE server entry, so the CLI silently drops the IDE MCP server from its configuration. Result: split-brain where the UI shows "IDE bound" but the CLI has lost the IDE server.

Scenario:

  1. bindIde sets ideBindInProgress = true, awaits applyMcpSetServers
  2. User configures a new MCP server from the browser UI -> mcp_set_servers({myServer: ...}) arrives
  3. H2 guard queues it (raw, without IDE entry)
  4. applyMcpSetServers resolves, bind completes, finally runs
  5. ideBindInProgress = false, then flushQueuedBrowserMessages fires
  6. Flushed message sent to Claude: {myServer: ...} -- missing companion-ide-vscode
  7. Claude full-replace drops IDE server

Fix: The postdrain flush site in flushQueuedBrowserMessages (lines 2035-2055) needs the same IDE re-injection logic that exists in routeBrowserMessage at lines 1849-1884. Alternatively, queue the message AFTER the IDE injection + stripping have run in routeBrowserMessage, not before. The latter approach is simpler and avoids duplicating the injection logic.

// Instead of queueing at line 1800 (before injection), move the H2 guard
// to AFTER the mcp_set_servers processing block (after line 1886), so the
// queued message already has the IDE entry injected:

if (msg.type === "mcp_set_servers") {
  // ... stripping, injection, mirror update all run first ...
  this.updateDynamicMcpServers(session, msg.servers, msg.deleteKeys);
  
  // THEN check the race guard
  if (session.ideBindInProgress) {
    const serialized = JSON.stringify(msg);  // now includes IDE entry
    this.enqueuePendingMessage(session, serialized);  // use proper queue method
    return;
  }
}

[HIGH] H1: sendControlRequest guard is dead code (return type is string, never undefined)

File: web/server/claude-adapter.ts (lines 447-451)

Issue: The H1 fix adds a guard for sendControlRequest returning a falsy value:

if (!requestId) {
  clearTimeout(timer);
  resolve({ ok: false, error: "failed to send control request" });
  return;
}

However, sendControlRequest (line 877) has return type string and always returns randomUUID() -- it can never return undefined, null, or empty string. The guard is unreachable dead code.

The original concern (H1 from prior review) was about sendControlRequest failing to send and returning undefined. But the actual implementation unconditionally generates a UUID, registers the callback, calls sendToBackend, and returns the ID -- even if sendToBackend silently fails to write (the message gets queued internally).

Severity rationale: This is HIGH not because it breaks anything today, but because:

  • It provides a false sense of safety: a reviewer might think "sendControlRequest failure is handled" when in fact the real failure mode (backend silently queuing the message) is not addressed here
  • The requestId variable is typed string | undefined only because of the let declaration before assignment, not because sendControlRequest can actually return undefined

Fix: Remove the dead guard. The real fragility gap is that sendControlRequest always succeeds (queues internally) even when the WebSocket is disconnected, so applyMcpSetServers will always proceed to waiting for a response that may never come. The existing 10-second timeout handles that case adequately.


[MEDIUM] M1: H2 guard uses direct push instead of enqueuePendingMessage

File: web/server/ws-bridge.ts (lines 1802-1803)

Issue: The H2 race guard pushes directly to session.pendingMessages with a silent drop when full:

if (session.pendingMessages.length < WsBridge.PENDING_MESSAGES_LIMIT) {
  session.pendingMessages.push(serialized);
}
// silently drops if full -- no log, no error broadcast

Every other enqueue site uses this.enqueuePendingMessage(session, serialized) which:

  • Drops the OLDEST message (FIFO eviction, not tail-drop)
  • Logs a warning
  • Broadcasts an error to the browser

The inconsistency means a user whose MCP config change is silently dropped during a bind gets zero feedback.

Fix: Replace with this.enqueuePendingMessage(session, serialized).


[MEDIUM] M2: No test coverage for the ideBindInProgress mechanism

Issue: The new race guard introduces a queueing mechanism, a flag, and postdrain flush behavior across bindIde, unbindIde, routeBrowserMessage, and flushQueuedBrowserMessages. None of these interactions have test coverage. The existing test suite has no assertions on ideBindInProgress.

Given that C1 above demonstrates the mechanism has a real data-loss bug, this underscores the need for tests that exercise the concurrent-message scenario end-to-end.

Fix: Add tests that:

  1. Call bindIde with a mock adapter whose applyMcpSetServers resolves after a delay
  2. During the delay, inject an mcp_set_servers via routeBrowserMessage
  3. Assert the message is queued (not sent)
  4. After bind completes, assert the queued message is flushed WITH the IDE entry injected (Claude) or without (Codex)

[LOW] L1: forceClearDeadIdeBinding does not check ideBindInProgress

File: web/server/ws-bridge.ts (line 1598)

Issue: If the ide:removed bus event fires while bindIde is awaiting applyMcpSetServers, forceClearDeadIdeBinding will set session.state.ideBinding = null and purge dynamicMcpServers. When bindIde resumes, it overwrites the binding back, so the net effect is probably correct. But the intermediate state (null binding + purged mirror) is visible to other event-loop tasks during the interleaving.

This is low priority because the event sequence (IDE removed during active bind to the same IDE) is extremely unlikely and the overwrite restores consistency.


Summary

ID Severity Status Description
C1 CRITICAL Must fix Postdrain flush drops IDE entry on Claude full-replace wire
H1 HIGH Should fix Dead-code guard on infallible sendControlRequest return
M1 MEDIUM Should fix Silent tail-drop instead of enqueuePendingMessage
M2 MEDIUM Should fix Zero test coverage for ideBindInProgress mechanism
L1 LOW Informational forceClearDeadIdeBinding concurrent interaction

Verdict: BLOCK -- C1 is a real data-loss path on Claude backends. The H2 mechanism correctly identifies the race window but the fix is incomplete because the queued message bypasses the IDE re-injection that routeBrowserMessage performs. The simplest fix is to move the queueing guard to after the IDE injection + stripping logic, so the serialized message is already complete when queued.

Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

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

1 issue found across 3 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="web/server/ws-bridge.ts">

<violation number="1" location="web/server/ws-bridge.ts:1802">
P2: `mcp_set_servers` updates can be silently dropped during IDE bind/unbind when pending queue is full.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Comment thread web/server/ws-bridge.ts Outdated
…e position

Container sessions now keep bypassPermissions — the container IS the
sandbox. Only host sessions running as root are downgraded.

Move the ideBindInProgress queueing guard in routeBrowserMessage to
AFTER IDE entry injection and mirror update. Previously the guard
queued the raw user message before injection, so flushed messages on
Claude's full-replace wire would be missing the IDE entry (C1 from
adversarial review round 2).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@edwinhu
Copy link
Copy Markdown
Contributor Author

edwinhu commented Apr 18, 2026

@cubic-dev Please re-review — fixed C1 (queueing guard position) and removed container permission downgrade (containers should have bypass by default).

@cubic-dev-ai
Copy link
Copy Markdown

cubic-dev-ai Bot commented Apr 18, 2026

@cubic-dev Please re-review — fixed C1 (queueing guard position) and removed container permission downgrade (containers should have bypass by default).

@edwinhu I have started the AI code review. It will take a few minutes to complete.

@edwinhu
Copy link
Copy Markdown
Contributor Author

edwinhu commented Apr 18, 2026

Adversarial Review -- Round 3

Reviewer: Claude Opus 4.6 (adversarial)
Scope: commits db41f3b and ed08578 (C1 fix for H2 queue position + container bypass removal)


CRITICAL: C2 -- flushQueuedBrowserMessages strips the IDE entry from queued messages, causing split-brain on Claude full-replace wire

Severity: CRITICAL -- data loss / split-brain
Files: web/server/ws-bridge.ts lines 1889-1896 (enqueue) vs lines 2031-2050 (flush strip)

The bug: The C1 fix correctly moved the ideBindInProgress queueing guard to AFTER IDE injection and mirror update (line 1893), so the enqueued JSON.stringify(msg) payload now correctly includes the companion-ide-* entry. However, flushQueuedBrowserMessages (called from the finally postdrain at line 1449) runs stripReservedIdeKeys on every queued mcp_set_servers message (line 2032) -- this strips the very IDE entry that was injected by routeBrowserMessage.

Concrete scenario:

  1. bindIde starts, sets ideBindInProgress = true, sends its own mcp_set_servers via applyMcpSetServers -- Claude acks, IDE entry is live
  2. Meanwhile a browser mcp_set_servers arrives in routeBrowserMessage. IDE injection at lines 1835-1871 adds companion-ide-vscode to msg.servers. Mirror is updated at line 1872. The H2 guard queues the message at line 1894
  3. bindIde completes, finally block clears ideBindInProgress and calls flushQueuedBrowserMessages
  4. In flush, stripReservedIdeKeys (line 2032) strips companion-ide-vscode from the queued payload
  5. adapter.send() sends {servers: {user-server-only: ...}} to Claude
  6. Claude full-replace: IDE entry is dropped. Split-brain -- UI shows bound, CLI lost the IDE MCP server

Root cause: The strip in flushQueuedBrowserMessages was authored as a defensive invariant under the assumption that "today routeBrowserMessage is the only enqueue path and it already strips before enqueue, so this is a no-op in practice" (comment at line 2021-2022). This was true before the C1 fix -- messages were enqueued BEFORE IDE injection. Now they're enqueued AFTER injection, and the "no-op" strip is destructive.

Fix options:

(A) Re-inject IDE entry in flush -- after stripping reserved keys, check if an IDE binding is active and re-inject the entry, mirroring the logic in routeBrowserMessage lines 1835-1871. This is the safest fix since it makes flush self-contained.

(B) Skip the strip for postdrain flushes -- add a parameter to flushQueuedBrowserMessages (e.g., skipIdeStrip: boolean) and have the ide_bind_postdrain / ide_unbind_postdrain callers pass true. Fragile -- future callers must know about the parameter.

(C) Don't strip in flush at all for messages that already went through routeBrowserMessage -- tag enqueued messages with a stripped: true metadata marker so flush knows not to re-strip. This requires changing the queue format from raw strings to structured entries.

I recommend (A) for correctness.


WARNING: W1 -- Postdrain flush after failed bind/unbind can send stale messages

Severity: MEDIUM
Files: web/server/ws-bridge.ts lines 1447-1452, 1572-1577

The finally block calls flushQueuedBrowserMessages unconditionally (modulo non-empty queue and connected adapter). If applyMcpSetServers returned {ok: false} (step 1399/1544), the try body returned early with an error. The finally block then flushes queued messages even though the bind/unbind operation failed.

In the bindIde failure case: the IDE entry was never committed to session state (session.state.ideBinding was never set). But the queued message (per C2 analysis above, after the strip) would be sent anyway. This is mostly harmless if C2 is fixed (the IDE entry would be correctly present or absent), but the semantic mismatch -- "bind failed yet we're flushing messages that were held specifically because bind was in progress" -- is worth reviewing. The queued messages might reference an IDE state that doesn't exist.

Suggested fix: Consider guarding the postdrain: only flush if the bind/unbind succeeded (track success with a local boolean).


INFO: I1 -- Dead code guard in applyMcpSetServers

Severity: LOW (no bug, cosmetic)
File: web/server/claude-adapter.ts lines 447-451

The if (!requestId) guard can never fire. sendControlRequest always returns a UUID string (line 881: return requestId where requestId = randomUUID()). The guard is defensive programming against a hypothetical future change to sendControlRequest's return type, which is fine, but the removed comment ("Forward-declare so the timeout closure can unregister...") actually documented the real reason for the let requestId: string | undefined pattern and its removal makes the code slightly less self-documenting.


PASS: Container bypass removal

The removal of shouldDowngradeContainerBypass in cli-launcher.ts is clean and correct:

  • The container IS the sandbox -- bypassPermissions is the right default
  • The COMPANION_FORCE_BYPASS_IN_CONTAINER env var is fully removed (no dangling references)
  • The root-process downgrade (shouldDowngradeRootBypass) is correctly preserved for non-container host sessions
  • Tests are updated symmetrically (assertion now expects bypassPermissions to pass through)

PASS: ideBindInProgress guard position

The guard at line 1893 is correctly placed AFTER:

  • Reserved key stripping (lines 1821-1832)
  • IDE entry injection (lines 1835-1871)
  • Mirror update via updateDynamicMcpServers (line 1872)
  • permission_response handling (lines 1876-1887)

And BEFORE the adapter.send() dispatch (line 1914). This is the right position to ensure queued messages carry the correct payload. The C1 fix is structurally correct -- the C2 issue above is in the FLUSH path, not the ENQUEUE path.


Missing test coverage

There are no tests for ideBindInProgress behavior. Given the complexity of the race condition being guarded (and the C2 bug found above), a test that exercises the interleaving of bindIde + concurrent mcp_set_servers -> queue -> postdrain flush would significantly reduce regression risk.


Verdict: BLOCK on C2

The C1 fix is correct at the enqueue site but introduces a new split-brain via the flush-side strip. This must be fixed before merge.

flushQueuedBrowserMessages runs stripReservedIdeKeys on queued
mcp_set_servers messages, which removes bridge-injected IDE entries.
On Claude's full-replace wire this causes the CLI to silently drop
the IDE MCP server.

Add IDE entry re-injection in the flush path, mirroring the logic
from routeBrowserMessage. The injection reads session.state.ideBinding
at flush time, so it correctly handles both successful binds (entry
re-injected) and failed binds (no injection, no ideBinding set).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@edwinhu
Copy link
Copy Markdown
Contributor Author

edwinhu commented Apr 18, 2026

@cubic-dev Please re-review — fixed C2 (IDE entry re-injection in flush path).

@cubic-dev-ai
Copy link
Copy Markdown

cubic-dev-ai Bot commented Apr 18, 2026

@cubic-dev Please re-review — fixed C2 (IDE entry re-injection in flush path).

@edwinhu I have started the AI code review. It will take a few minutes to complete.

@edwinhu
Copy link
Copy Markdown
Contributor Author

edwinhu commented Apr 18, 2026

Adversarial Review -- Round 4 (C2 Fix)

Commit: 802d1b7 fix(ws-bridge): re-inject IDE entry in flush path after strip (C2)

Verdict: APPROVE with one suggestion

The fix is correct and complete. It addresses the C2 split-brain scenario where flushQueuedBrowserMessages stripped bridge-injected IDE entries via stripReservedIdeKeys but never re-injected them, causing Claude's full-replace wire semantics to drop the IDE MCP server.


What I verified

1. Injection logic correctness vs routeBrowserMessage

The flush-path injection (lines 2052-2065) correctly mirrors the routeBrowserMessage injection (lines 1835-1871) with one intentional simplification: it omits the !alreadyPresent guard. This is safe because stripReservedIdeKeys runs immediately before (line 2033) and guarantees no companion-ide-* key survives in queuedMsg.servers. The comment on lines 1844-1850 in routeBrowserMessage even acknowledges this: "Post-strip invariant: ... alreadyPresent can therefore only be true if a prior adapter-level caller (which bypasses the stripper) set them -- and that never happens on this path."

2. Edge cases

Scenario Behavior Correct?
No binding (session.state.ideBinding is falsy) if guard fails, injection skipped Yes
Empty sanitized name (e.g. ideName: "!?") ideKey is "", ideKey.length > 0 fails, injection skipped Yes
ideEntry not in mirror (cold restore, mirror is {}) ideEntry !== undefined fails, injection skipped Yes
User explicitly deletes IDE key via deleteKeys Strip removes it from deleteKeys first (line 2033), so explicitlyDeleted is always false after strip. Injection proceeds. This matches the reserved-namespace contract: users cannot delete bridge-authored keys via the public path Yes
Pre-drain flush during bindIde (BIND-08h test) session.state.ideBinding is not yet set at pre-drain time, injection skipped Yes
Post-drain flush during bindIde finally block Binding is set, mirror has the entry, injection fires correctly Yes
Flush failure + re-queue (lines 2070-2072) remaining = queued.slice(i) re-queues raw (pre-injection) strings. Next flush cycle re-strips and re-injects. Idempotent Yes

3. Mirror consistency

updateDynamicMcpServers at line 2066 runs on the post-injection queuedMsg, so the mirror includes the IDE entry. This is correct -- the mirror reflects what the CLI received.

4. No remaining split-brain scenarios

The two paths that send mcp_set_servers to the CLI (routeBrowserMessage direct send, and flushQueuedBrowserMessages replay) now both strip-then-inject. The only other send paths (bindIde/unbindIde) bypass routeBrowserMessage entirely and construct their own payloads with full mirror merges. All paths converge on a consistent wire payload.


Suggestion (LOW priority)

Missing test for the core C2 scenario. The existing BIND-08h test (line 6850) covers stripping of spoofed keys during flush, but the C2 injection doesn't fire in that test because session.state.ideBinding is not set at pre-drain time. There is no test that exercises the actual C2 fix: a queued mcp_set_servers flushed after a successful bind, where the strip removes the bridge-injected IDE entry and the C2 re-injection restores it.

A test for this would:

  1. Create session, attach adapter, bind IDE (so session.state.ideBinding and session.dynamicMcpServers[ideKey] are populated)
  2. Disconnect the adapter (so messages queue instead of sending)
  3. Send a user mcp_set_servers from the browser (queued because adapter is disconnected)
  4. Reconnect the adapter, triggering flush
  5. Assert the flushed mcp_set_servers payload contains both the user's servers AND the IDE entry

Without this test, the C2 fix is only validated by manual reasoning (which checks out), not by automated regression coverage.


Review provenance: Adversarial review by AI agent (Claude Opus 4.6). No human review.

@edwinhu
Copy link
Copy Markdown
Contributor Author

edwinhu commented Apr 18, 2026

Re: Cubic P2 — mcp_set_servers dropped when queue full during bind

This is handled by enqueuePendingMessage (ws-bridge.ts:1963-1977), which uses FIFO eviction when the queue hits the 200-message limit:

  • Drops the oldest message (not the incoming one)
  • Logs a warning with the dropped message preview
  • Broadcasts an error to the browser so the user sees the drop

The bind window is at most ~10s (the applyMcpSetServers timeout). Accumulating 200 mcp_set_servers messages in that window would require the user to spam MCP config changes at 20/s — not a realistic scenario.

No code change needed.

@edwinhu
Copy link
Copy Markdown
Contributor Author

edwinhu commented Apr 18, 2026

@cubic-dev-ai Please re-review the latest commit (802d1b7 — C2 fix: IDE entry re-injection in flush path).

@cubic-dev-ai
Copy link
Copy Markdown

cubic-dev-ai Bot commented Apr 18, 2026

@cubic-dev-ai Please re-review the latest commit (802d1b7 — C2 fix: IDE entry re-injection in flush path).

@edwinhu I have started the AI code review. It will take a few minutes to complete.

Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

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

5 issues found across 68 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="web/server/ide-discovery-resilience.test.ts">

<violation number="1" location="web/server/ide-discovery-resilience.test.ts:1">
P2: DISC-05g/05h are non-red regression tests: the asserted counter cleanup is masked by reconcile cleanup, so the targeted transient-counter bug can regress without failing the test.</violation>
</file>

<file name="web/server/ide-discovery.ts">

<violation number="1" location="web/server/ide-discovery.ts:405">
P2: Transport-only lockfile updates are not treated as changes, so `ide:changed` is not emitted and clients may keep stale IDE transport mode.</violation>
</file>

<file name="web/server/codex-adapter.ts">

<violation number="1" location="web/server/codex-adapter.ts:1574">
P2: `applyMcpSetServers` can return `{ok:true}` even when requested server deletions fail, because delete errors are caught and not propagated in `runMcpSetServers`.</violation>
</file>

<file name="web/src/components/IdePicker.tsx">

<violation number="1" location="web/src/components/IdePicker.tsx:253">
P2: Retry after a disconnect failure can become a no-op when `currentBinding` turns null asynchronously, leaving a persistent error with a non-functional action.</violation>
</file>

<file name="web/server/claude-adapter.ts">

<violation number="1" location="web/server/claude-adapter.ts:442">
P2: `applyMcpSetServers` cannot surface explicit CLI control errors because `handleControlResponse` swallows `subtype === "error"` responses, causing a 10s timeout and misleading error message.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Comment thread web/server/ide-discovery-resilience.test.ts
Comment thread web/server/ide-discovery.ts
Comment thread web/server/codex-adapter.ts Outdated
Comment thread web/src/components/IdePicker.tsx Outdated
Comment thread web/server/claude-adapter.ts
1. DISC-05g/05h test isolation: restructured tests so transient
   counter cleanup is not masked by reconcile eviction cleanup.
2. Transport-only lockfile changes now emit ide:changed so clients
   see updated transport mode.
3. codex-adapter: applyMcpSetServers now returns {ok:false} when
   server deletions fail instead of silently swallowing errors.
4. IdePicker: retry after disconnect failure works even when
   currentBinding becomes null asynchronously.
5. claude-adapter: handleControlResponse now resolves pending
   promises on error responses instead of swallowing them,
   surfacing actual CLI errors instead of misleading timeouts.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@edwinhu
Copy link
Copy Markdown
Contributor Author

edwinhu commented Apr 18, 2026

@cubic-dev-ai Please re-review — all 5 P2 issues from your latest review are fixed in commit adcb543.

@cubic-dev-ai
Copy link
Copy Markdown

cubic-dev-ai Bot commented Apr 18, 2026

@cubic-dev-ai Please re-review — all 5 P2 issues from your latest review are fixed in commit adcb543.

@edwinhu I have started the AI code review. It will take a few minutes to complete.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant