Skip to content

feat(acpbridge): tailnet-only ACP-over-WebSocket bridge daemon - #9

Merged
enekos merged 18 commits into
masterfrom
feat/acp-bridge
May 2, 2026
Merged

enekos merged 18 commits into
masterfrom
feat/acp-bridge

Conversation

@enekos

@enekos enekos commented Apr 30, 2026

Copy link
Copy Markdown
Owner

Summary

  • New mairu acp-bridge daemon: tailnet-only WebSocket server that proxies ACP JSON-RPC frames between remote clients (mobile app, future tooling) and locally-spawned ACP agents (mairu acp, claude-code --acp, gemini --acp).
  • Per-session event ring buffer + Last-Event-ID replay so reconnects don't lose frames.
  • Server-initiated session/request_permission is fanned out to every attached client; first responder wins, 60s fallback synthesizes a denial so the agent never blocks indefinitely.
  • Pluggable PeerAuthorizer interface with AllowAll (dev) and TailscaleAuth (production-bound) implementations. tsnet.Server embedding is deferred — see plan note 1.
  • CLI: mairu acp-bridge --addr <host:port> --no-tailscale. The --no-tailscale flag is required until the Tailscale identity gate is wired; the daemon refuses to start without it so production deployments can't accidentally run open.

Implements the plan at docs/superpowers/plans/2026-04-28-mairu-acp-bridge.md and the design at docs/superpowers/specs/2026-04-28-mairu-mobile-design.md.

What's in here

Path Role
`mairu/internal/acpbridge/bridge.go` `Bridge` type, options, lifecycle
`mairu/internal/acpbridge/session.go` Subprocess pumps + WS subscriber set + event-id stamping
`mairu/internal/acpbridge/registry.go` Thread-safe session registry
`mairu/internal/acpbridge/ringbuffer.go` Bounded ring of stamped server→client frames
`mairu/internal/acpbridge/permission.go` Permission fan-out + synthetic denial on timeout
`mairu/internal/acpbridge/auth.go` `PeerAuthorizer` interface + AllowAll + TailscaleAuth
`mairu/internal/acpbridge/agentspec.go` Default agent exec specs (mairu/claude-code/gemini)
`mairu/internal/acpbridge/ws.go` WS upgrade + per-conn pumps + Last-Event-ID parsing
`mairu/internal/acpbridge/http.go` `GET/POST/DELETE /sessions` endpoints
`mairu/internal/cmd/acp_bridge_cmd.go` Cobra command + flag wiring

All packages have unit tests; e2e against real `mairu acp` lives in `acpbridge/e2e_test.go`.

Test plan

  • `go test ./mairu/internal/acpbridge/...` passes (full pre-commit suite green)
  • `go test ./mairu/internal/cmd/ -run TestACPBridgeCmd` passes (flag presence + refusal-without-flag)
  • Manual smoke on tailnet: `mairu acp-bridge --addr 0.0.0.0:7777 --no-tailscale` from desktop, `wscat` from another tailnet node, verify frames flow

Known deferrals (see plan self-review)

  1. `tsnet` embedding. Auth interface is wired; embedding the bridge inside its own tailnet identity is a follow-up. Until then the daemon must run on a host already on the tailnet and bind its tailnet IP, with `--no-tailscale` to bypass the gate.
  2. `x-mairu-event-id` placement. Sibling field on the JSON-RPC envelope. Verified non-breaking via the e2e test against `mairu acp`.

Pairs with

#$(date +%s) (`feat/mairu-mobile` — the phone client that consumes this bridge). The mobile branch's e2e auto-skips on branches without acpbridge and will activate once this PR lands.

🤖 Generated with Claude Code

enekos and others added 18 commits April 28, 2026 22:38
…pt-in

Daemon now refuses to start without --no-tailscale until the Tailscale
identity gate is wired (deferred per the acp-bridge plan). Match the
flag name to the spec/plan terminology so the future tsnet integration
slots in cleanly, and so the mobile e2e harness has a stable knob.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@enekos
enekos merged commit 9e6d0e3 into master May 2, 2026
1 check failed
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