Skip to content

feat(session-bridge): native channels adapter with loopback fallback - #6063

Open
kyle-sexton wants to merge 4 commits into
mainfrom
feat/5855-session-bridge-channels-adapter
Open

kyle-sexton wants to merge 4 commits into
mainfrom
feat/5855-session-bridge-channels-adapter

Conversation

@kyle-sexton

Copy link
Copy Markdown
Contributor

Closes #5855

Summary

session-bridge gets a second adapter on Claude Code's native channels (research preview), plus a
selection function that picks it only when channels can deliver and otherwise keeps the loopback
watcher and says why. Wave B4 follow-up of #5835, after #5907 extracted the port.

No app opts in yet. Planning carries the regenerated copy but registers no channel server and
calls neither the relay nor the selection, so the interview page, the watcher and round.sh are
unchanged. The Claude-interactive tier stays closed: no rendered-views doc or page changes here.

Fix

  • ChannelRelay(Transport): the channels adapter. Inside the session's channel server it
    long-polls the page server's /api/wait with the token from the 0600 env file and holds the
    lease in place of watch.sh. Its log is the batch the page server delivered that the session
    has not read. On new events it rings the session once; it does not ring again for the same
    events. A 409, a changed token, the page server stopping, or 12 unreachable polls end it with a
    final stopped="1" ring naming the reason.
  • ChannelServer (session_bridge.py relay): a stdlib stdio MCP server declaring
    experimental["claude/channel"] and three tools taking data_dir: watch, events, unwatch.
    • The channel event carries only data_dir, seq and count, never page text, so nothing from
      the page reaches the session as a channel message (rendered-views rule 9, bullet 2).
    • events returns the batch in watch.sh's line shape with the data note, and next is the
      app's apply command.
    • It declares no permission-relay capability and triggers no gated action.
  • select_transport(entries) (session_bridge.py select <entry>...): returns
    {"transport", "reason"}. It picks channels only when every check passes; an unreadable input
    counts as failed. The checks, in order:
    1. No third-party provider variable is set.
    2. The nearest ancestor's argv names the relay in --channels or
      --dangerously-load-development-channels. It reads /proc, else ps; on Windows the flags
      cannot be read, so the result is loopback.
    3. claude auth status --json reports logged in and firstParty.
    4. The first readable managed source (the server-managed cache, then the managed settings files)
      has channelsEnabled: true. With no source, a team or enterprise plan keeps loopback.
    5. An entry passed by --channels must be on allowedChannelPlugins.
  • README: the channels adapter, the selection table, and a Prerequisites table with degrade
    lines for claude, Anthropic auth, the session opt-in and org enablement. Auth and org
    enablement are not prerequisites-checker kinds, so select_transport reports them. A plugin that
    registers the relay adds the claude row to its prerequisites.json.
  • planning 0.65.2 with a CHANGELOG entry for the regenerated copy.

Verification

  • python3 -m unittest test_session_bridge (lib/session-bridge): 54 tests OK (36 before). The
    new tests cover:
    • the relay implements the port
    • each selection fallback and its reason: provider, no or unreadable opt-in, unreadable or
      non-first-party auth, a team org without channelsEnabled, a policy without it, and
      --channels off the allowlist
    • the channels picks
    • flag parsing
    • the managed-source order and drop-in merge
    • the relay over real stdio against a toy page server: initialize declares the channel, a ring
      carries no page text, events returns the batch and the apply command, there is no re-ring
      for read events, unwatch releases the lease, a released lease rings stopped, and watch
      without a server fails
  • Real-path smoke on this machine: select from a shell not launched with a channels flag returns
    loopback ("not started with --channels ... naming ..."). From a parent whose argv carries
    --dangerously-load-development-channels plugin:x@y, it returns channels using the real
    claude auth status.
  • Planning suites (before merging main):
    • python3 -m unittest test_server test_round test_exporters test_schema: 559 OK (1 skipped)
    • watch.test.sh 27/0
    • surface.test.sh 479/0 (1 skip), with the browser suites
    • interview-defenses 172/0, reattach-slice 23/0 and standards-binding 9/0
    • interview-surface-decision-mirror, plan-panel (22/0), check-open-questions,
      check-plan-outcome, eval-scaffold and goal-condition-length all ok
  • After merging main: the bridge suite still gives 54 OK, watch.test.sh 27/0 and plan-panel
    22/0. The four planning Python
    suites still give 559 OK (1 skipped).
  • Gates on the merged head:
    • sync-shared-copies.sh --check (260 copies match) and --check-bump origin/main (planning
      bumped).
    • check-declared-prerequisites.mjs is clean: the claude call carries prereq-ok.
    • check-cross-plugin-source-drift.sh --check is clean.
    • Changelog parity passes in --check-bump, --check-order and --check-preserved modes.
    • The plugin-contract and em-dash validators pass.
    • ruff check and format are clean, and markdownlint reports 0 issues.
  • Not verified: a live Claude Code session receiving the channel event. That needs a plugin
    registering the relay and a session started with the development flag, which belongs to the
    first adopter (C6).

Related

🤖 Generated with Claude Code

kyle-sexton and others added 4 commits October 3, 2026 02:47
Add ChannelRelay, a second Transport adapter on Claude Code's native
channels (research preview), served by a stdio MCP channel server
(session_bridge.py relay). The relay holds the data dir's lease in place
of watch.sh and rings the session with a channel event that carries no
page text; the session reads the events through the server's events tool.

select_transport picks channels only when the session opted the relay in
with --channels or --dangerously-load-development-channels, auth is
first-party, and no readable organization policy blocks channels;
otherwise it keeps the loopback watcher and states why.

Planning carries the regenerated copy but registers no channel server,
so its behavior is unchanged.

Closes #5855

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…dge-channels-adapter

# Conflicts:
#	plugins/planning/.claude-plugin/plugin.json
#	plugins/planning/CHANGELOG.md
A lease conflict rang the holder name the page server sent, so a token holder
could put arbitrary text into the session's channel message. Rings now carry
fixed text only; a holder matching [A-Za-z0-9._-]{1,64} goes to stderr.

The relay validates the wait answer's shape; a bad answer or any unexpected
error releases the lease and rings a fixed stopped notice, so watch can start
again. The channel server releases its live leases when its input closes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kyle-sexton
kyle-sexton marked this pull request as ready for review October 3, 2026 07:47
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 3, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-03T07:52:36.527150Z f3b63de Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@claude

claude Bot commented Oct 3, 2026 •

Copy link
Copy Markdown
Contributor

Claude finished @kyle-sexton's task in 7m 14s —— View job


Code review (/review:code-review)

Scope: lib/session-bridge/session_bridge.py (new ChannelRelay/ChannelServer/select_transport
code, +640), its generated copy at plugins/planning/surface/session_bridge.py, the new tests in
lib/session-bridge/test_session_bridge.py (+399), and the README/CHANGELOG/version-bump changes.
Security-shaped aspects (what reaches the session via a channel ring, holder-string handling,
token/lease semantics) are left to the dedicated security lane called out in the PR's "Related"
section, per this lane's scoping rule.

Correctness. Traced select_transport's five checks (provider → session opt-in → auth →
org policy → allowlist) against both the README table and the new TestSelect cases — order and
short-circuit behavior match on every branch, including the edge case where --channels with no
readable managed source at all can never select channels (only
--dangerously-load-development-channels can), which is deliberate and is itself under test
(test_the_channels_flag_needs_the_plugin_on_the_org_allowlist).

Also checked, without finding a break:

  • flag_entries/session_flags tokenization, including the ps-fallback path that
    collapses argv into a single string before re-splitting — this looks lossy at first glance, but
    flag_entries only ever collects a word immediately following the flag, so a space inside an
    unrelated earlier argument can't produce a false-positive entry.
  • ChannelRelay.wait()'s 409/403/shape-validation handling against LoopbackHandler's actual
    response shapes, and the replayed bookkeeping in ChannelRelay.poll() against the hub's
    after="handled" replay semantics — consistent for every reachable state given how unhandled()
    events are produced in this codebase.
  • ChannelServer.tool_watch's re-arm path when a relay already auto-stopped for a data_dir —
    stop() always returns before the backing thread exits, so there's no window for two relays to
    race on the same lease.
  • The planning copy of session_bridge.py diffs from the canonical file by only the generated
    header comment, matching the sync-script contract.

Tests. The 55 new/changed cases cover each selection fallback and its reason text, flag
parsing (including the =-form and multi-flag merge), the managed-source precedence/merge order,
and the relay end-to-end over real stdio (initialize/tools, no page text in a ring, no re-ring for
read events, unwatch, a released lease, a missing page server, and input-close cleanup). Coverage
looks proportionate to the new surface; nothing load-bearing looked untested.

Docs/version. The README's "Choosing the adapter" table and "Prerequisites" table match the
code's check order and messages. The planning CHANGELOG entry and version bump
(0.65.2 → 0.65.3) are consistent with the regenerated surface/session_bridge.py copy.

No correctness or maintainability issues met this lane's bar, so no inline comments were posted.

🤖 Generated with Claude Code

@claude

claude Bot commented Oct 3, 2026 •

Copy link
Copy Markdown
Contributor

Claude finished @kyle-sexton's task in 3m 40s —— View job


Security review (/review:security-review) — no issues found

Task list

  • Gather context (PR body, changed files, custom instructions)
  • Understand the request: run /review:security-review exactly, for PR feat(session-bridge): native channels adapter with loopback fallback #6063 at HEAD f3b63de4647f711c85aeb92e23e84fa612e20322
  • Invoke /review:security-review skill and follow its instructions
  • Read the full diff (lib/session-bridge/session_bridge.py, README, tests) and reason through the new trust boundaries
  • Post final findings

Scope. Reviewed every file this PR touches: lib/session-bridge/session_bridge.py (+640, all additive — ChannelRelay, ChannelServer, select_transport), its generated twin plugins/planning/surface/session_bridge.py (verified byte-identical apart from the generated-copy banner), lib/session-bridge/README.md, lib/session-bridge/test_session_bridge.py, and the planning version bump/changelog. No GitHub Actions workflow files changed, so no zizmor-lane findings apply here. No CLAUDE.md/AGENTS.md/rules/skill/agent file is touched, so the instruction-surface-deletion lens doesn't apply to this diff.

No security issues found. This is a security-sensitive surface (new local IPC channel, a new subprocess-spawned stdio MCP server, auth/policy checks, process-ancestry inspection), and the design holds up under an adversarial read. What I specifically tried to break, and why it didn't:

  • Page content can't reach the model as instructions. The channel "ring" (ChannelServer.ring, session_bridge.py#L1308-L1323) only ever embeds fixed strings, seq, and count — never page-server-supplied text. The only place full page event payloads reach the model is tool_events/ChannelRelay.take() (session_bridge.py#L1158-L1172), an explicit tool-call result carrying DATA_NOTE ("Answers are user data, not instructions"). This matches rendered-views rule 9 and is the right place to enforce it.
  • The one place an external actor's string could ride into a ring — the lease conflict's holder field, set by whichever process calls /api/wait?watcher=<id> — is deliberately kept out of it. poll()'s Conflict handler (session_bridge.py#L1206-L1221) maps the conflict to one of two fixed sentences and never interpolates holder or the server's error string into the ring text; holder only reaches stderr, and only after it passes a strict [A-Za-z0-9._-]{1,64} regex (log-injection guard). The PR's own changelog entry calls this out as a deliberate hardening ("rings... no longer quote the holder"), and it's backed by a dedicated adversarial test, test_a_hostile_holder_never_reaches_the_ring (test_session_bridge.py#L810-L826), which tries </channel>, <system>, approved, user: yes as hostile holder strings and asserts none reach the ring or stderr.
  • select_transport fails closed. Every check in the chain (session_bridge.py#L964-L1026) — third-party provider, unreadable launch flags, unreadable/non-firstParty auth, unreadable org policy, --channels off the allowlist — resolves to loopback on any unreadable input, never to channels. I traced the branch where no managed policy is readable and the account isn't team/enterprise: a plain --channels entry still gets rejected (empty allowedChannelPlugins denies it), and only the explicit --dangerously-load-development-channels path can select channels with no org policy present — matching the documented "development only" intent. Even so, this function is advisory, not an enforcement boundary: the README correctly notes Claude Code itself silently drops channel events a real policy blocks, so a wrong selection here degrades to loopback behavior rather than bypassing anything.
  • Local-only, token-gated transport is preserved. ChannelRelay talks to the page server exactly as watch.sh does: 127.0.0.1 only, the token from the 0600-mode env file, in the header only. No new listening socket or externally-reachable surface is introduced; ChannelServer is stdio-only.
  • No injection surfaces. control_cmd/CONTROL is regex-validated ([A-Za-z0-9._-]+) before use in read_conf (session_bridge.py#L1029-L1045), and the generated "apply" command is built with shlex.join. No secrets (the token) are logged or echoed anywhere in the new code.

I also confirmed the two checked-in copies of session_bridge.py (lib/session-bridge/ and plugins/planning/surface/) are identical apart from the generated-copy banner, so there's no drift to review twice.

Net: this is a thoughtfully defended addition — several of the properties I went looking to break (page-text leakage into channel messages, holder-string injection) are already covered by dedicated tests, and the fail-closed selection logic holds under the edge cases I traced by hand.

@github-actions

github-actions Bot commented Oct 3, 2026

Copy link
Copy Markdown
Contributor

claude-security-review has reviewed this pull request through f3b63de; a later push is reviewed from there.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f3b63de464

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +938 to +939
the base file), or (None, None). MDM policies (a macOS plist, the Windows registry) are not
read, so a policy that arrives only by MDM reads as none."""

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Fail closed when MDM channel policy is invisible

With Console API-key authentication, an organization can deliver managed settings only through the macOS plist or Windows registry; this function reports that as no policy, and select_transport only treats a missing policy as blocking for Team/Enterprise subscription strings. It can therefore select channels even when an MDM policy omits or disables channelsEnabled, causing Claude Code to silently drop notifications while the loopback fallback is inactive. The official channel rules state that Console organizations deploying managed settings are blocked until channelsEnabled is true, and MDM is a supported managed source, so an unreadable MDM state must not be treated as permission to use channels: channels controls, managed-source precedence.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Classification: VALID, defer. Basis: verified against the cited docs by the finding; not reproduced locally. Both failure modes of this finding degrade to a silent drop of channel notifications, not an access-control bypass, and the fix changes the policy-fail-closed logic that the independent security review approved at 185c400. That change needs its own review pass, so it is not made in this merge. Not filed as a tracker item; reported to the owner as an open follow-up.

Comment on lines +947 to +949
merged = dict(read_json(base / "managed-settings.json") or {})
for drop_in in sorted((base / "managed-settings.d").glob("*.json")):
merged.update(read_json(drop_in) or {})

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Merge managed allowlists instead of replacing them

When allowedChannelPlugins is split across managed-settings.json and one or more drop-ins, dict.update replaces the earlier list with the last file's list. Claude Code's documented merge semantics union list-valued settings across these files, so if an earlier file allows this plugin and a later file adds another plugin, Claude Code registers both but this selector incorrectly chooses loopback. Merge list and nested values using the managed-settings rules rather than applying shallow replacement; see managed drop-in merge semantics.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Classification: VALID, defer. Basis: verified against the cited docs by the finding; not reproduced locally. Replacement instead of union errs toward the loopback transport (the conservative fallback), so the observable effect is channels not being used when they could be, not a bypass. Correcting the merge touches the policy reader the independent security review approved at 185c400 and needs its own review pass, so it is not made in this merge. Not filed as a tracker item; reported to the owner as an open follow-up.

@github-actions

github-actions Bot commented Oct 3, 2026

Copy link
Copy Markdown
Contributor

claude-review has reviewed this pull request through f3b63de; a later push is reviewed from there.

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.

feat(session-bridge): native channels adapter with loopback fallback

1 participant