From c6505020d2e1738635c415ef7d41817472f893b3 Mon Sep 17 00:00:00 2001 From: Prasad-D-Ware Date: Tue, 7 Jul 2026 18:53:02 +0530 Subject: [PATCH 01/26] docs(mobile): capture Connect Mobile design (CONTEXT, ADR, plan) + scope loopback rule (cherry picked from commit 0d3642ec4806faf6d24bbddb00220819d362723b) --- AGENTS.md | 3 +- CONTEXT.md | 46 + docs/adr/0001-lan-listener-for-mobile.md | 63 + .../2026-07-07-connect-mobile-lan-listener.md | 1344 +++++++++++++++++ 4 files changed, 1455 insertions(+), 1 deletion(-) create mode 100644 CONTEXT.md create mode 100644 docs/adr/0001-lan-listener-for-mobile.md create mode 100644 docs/superpowers/plans/2026-07-07-connect-mobile-lan-listener.md diff --git a/AGENTS.md b/AGENTS.md index 82cb2b0f2a..0620ef4174 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -77,7 +77,8 @@ For code entry points: ## Hard rules and boundaries -- The daemon is a loopback-only sidecar. Do not make the bind host configurable or expose it beyond `127.0.0.1`. +- The daemon's **primary (loopback) listener** stays bound to `127.0.0.1` and unauthenticated. Do not change its bind host or add auth to it. +- The daemon MAY run a **second, opt-in LAN listener** (the "Connect Mobile" feature) that binds `0.0.0.0` **only while explicitly enabled**, **only** behind the bearer-password `authMiddleware`, serving the app API but never the loopback-gated control routes (`/shutdown`, telemetry, mobile control). It is plaintext and home-network-only by deliberate decision — see `docs/adr/0001-lan-listener-for-mobile.md` and `CONTEXT.md`. Do not add any other network-facing bind. - The CLI is a thin client. Do not port old in-process TypeScript CLI behavior that bypasses daemon HTTP routes. - Do not store derived/display session status. Status is derived from durable facts (`activity_state`, `is_terminated`, PR/check/comment facts) at service read time. - Do not treat failed/unknown runtime probes as proof a session is dead. diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000000..81208e4f3e --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,46 @@ +# Domain Glossary + +Canonical vocabulary for Agent Orchestrator. Terms only — no implementation +details, no decisions (those live in `docs/adr/`). + +## Daemon surfaces + +- **Loopback Listener** — the daemon's original, always-on HTTP surface bound to + `127.0.0.1`. Serves the desktop app and CLI. Unauthenticated: safe only because + nothing off-box can reach it. Behaviour is unchanged by the mobile work. + +- **LAN Listener** — a second, network-facing HTTP surface the daemon exposes so a + physical phone can reach it. Off by default; exists only while **Connect Mobile** + is enabled. Authenticated (see **Connection Password**). Serves the app API but + **not** daemon-control routes (shutdown/lifecycle stay Loopback-only). + +## Mobile connectivity + +- **Connect Mobile** — the user-facing capability that opens the **LAN Listener** + and lets a paired phone use the app over the local network. A desktop toggle; + when off, no LAN surface exists. + +- **Connection Password** — the single, rotating secret that authorises a phone on + the **LAN Listener**. 8-char alphanumeric, generated by the desktop, shown to the + user out-of-band (read off the screen), typed into the phone. Rotating it drops + the currently-connected phone. It is the *sole* secret — it is never carried in + the **Pairing QR**. + +- **Pairing QR** — a scannable code that carries only the **LAN Listener** address + (`host` + `port`) and a schema version. Non-secret by design: it conveys *where* + to connect, never the **Connection Password**. A photo of it alone cannot connect. + +- **Pairing** — the act of a phone acquiring a working connection: scan the + **Pairing QR** (or type host/port manually), then enter the **Connection + Password** in a popup. A successful pairing is remembered on the phone. + +- **Paired Phone** — the phone currently authorised against the active **Connection + Password**. Only one is effectively connected at a time (single rotating password). + +- **Lockout** — the brute-force guard on the **LAN Listener**: repeated failed + password attempts from a source are throttled (per-source, not global, so a + hostile device cannot lock out the real phone). Resets on a successful auth. + +- **Home-network-only** — the trust boundary the feature assumes. Transport is + unencrypted, so the **LAN Listener** is only safe on a network the user trusts; + the desktop UI states this plainly. diff --git a/docs/adr/0001-lan-listener-for-mobile.md b/docs/adr/0001-lan-listener-for-mobile.md new file mode 100644 index 0000000000..003828633b --- /dev/null +++ b/docs/adr/0001-lan-listener-for-mobile.md @@ -0,0 +1,63 @@ +# 1. A second, authenticated, plaintext LAN listener for mobile access + +Date: 2026-07-07 +Status: Accepted + +## Context + +The daemon binds `127.0.0.1` only. AGENTS.md carries a hard rule: *"The daemon is +a loopback-only sidecar. Do not make the bind host configurable or expose it beyond +`127.0.0.1`."* That rule keeps the Loopback Listener safe **without authentication** +— the OS guarantees nothing off-box can reach it. + +We want a physical phone to use the app over the local network. The only prior +mechanism was a standalone Node proxy (`ao-phone-proxy.js`) run by hand, with +IP trust-on-first-connect and no password. The user rejected the proxy approach and +asked for an in-app "Connect Mobile" feature. + +Two forces collide: exposing anything to the LAN removes the loopback safety +guarantee, and the target mobile app is **Expo/React Native**, where trusting a +self-signed TLS cert (fingerprint pinning) requires native modules across three +transports (`fetch`, the `/mux` WebSocket, and the xterm WebView) — a large, risky +effort at odds with the desired scope. + +## Decision + +Add a **second HTTP listener inside the daemon**, bound to the LAN, gated by auth. +The Loopback Listener is left byte-for-byte unchanged (desktop/CLI stay +unauthenticated). This **overrides the AGENTS.md loopback-only hard rule**, by +explicit user decision on 2026-07-07; AGENTS.md should be amended to scope that rule +to the Loopback Listener. + +Security posture: + +- **On-demand.** The LAN Listener does not exist until Connect Mobile is enabled; + disabling closes the socket. Default off — zero standing LAN surface. +- **Single rotating Connection Password**, 8-char alphanumeric, stored only as a + hash, compared constant-time. Sent as `Authorization: Bearer ` on both + REST and the RN WebSocket (RN's WebSocket header option). Rotating drops the + current phone. +- **Per-source Lockout** after 5 failed attempts (not global — a hostile device + must not be able to lock out the real phone). +- **App API only** on the LAN Listener; daemon-control routes keep their existing + loopback-only guard (`localControlRequest`) with no change. +- **Plaintext transport (HTTP), accepted.** No TLS. The feature is + **home-network-only** and the UI says so. The Pairing QR therefore carries only + host+port (non-secret); the Connection Password is delivered out-of-band (read off + the desktop screen, typed into the phone), so a captured QR alone cannot connect. +- State persists to `~/.ao/mobile/config.json` (atomic write), honoring the + "all state under `~/.ao`" rule. The listener re-binds on the default port with an + ephemeral fallback; the QR always reflects the actually-bound port. + +## Consequences + +- The daemon gains a network-facing, authenticated attack surface whenever Connect + Mobile is on. Loopback behaviour is unaffected, so desktop/CLI carry no regression + risk. +- On untrusted networks the Connection Password and all traffic are exposed to + sniffers. This is an accepted, stated limitation, not an oversight. +- TLS is deliberately deferred. A future upgrade (TLS listener + a `fingerprint` + field in the Pairing QR + RN cert pinning) is additive: it does not require + reworking the auth, lifecycle, or persistence chosen here. +- AGENTS.md must be updated so the loopback-only rule reads as scoped to the + Loopback Listener, or future agents will (correctly) flag this code as a violation. diff --git a/docs/superpowers/plans/2026-07-07-connect-mobile-lan-listener.md b/docs/superpowers/plans/2026-07-07-connect-mobile-lan-listener.md new file mode 100644 index 0000000000..8a7fa7eb99 --- /dev/null +++ b/docs/superpowers/plans/2026-07-07-connect-mobile-lan-listener.md @@ -0,0 +1,1344 @@ +# Connect Mobile — LAN Listener Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let a physical phone use Agent Orchestrator over the local network through a second, on-demand, password-authenticated HTTP listener inside the daemon, without changing the existing loopback behaviour. + +**Architecture:** The daemon keeps its `127.0.0.1` **Loopback Listener** exactly as today (desktop/CLI, unauthenticated). A new **LAN Listener** binds `0.0.0.0` only while "Connect Mobile" is enabled; it wraps the *same* chi router in one extra `authMiddleware`. Auth is decided by *which socket the request arrived on*, not by inspecting the request. Transport is plaintext HTTP (home-network-only). The phone pairs by scanning a QR that carries only `host`+`port`, then types the rotating 8-char password (shown on the desktop) into a popup; the password rides as `Authorization: Bearer ` on REST and the RN WebSocket. + +**Tech Stack:** Go (chi, coder/websocket), Electron + React + TanStack Router + shadcn/ui (typed daemon client), Expo/React Native (expo-camera, AsyncStorage). + +## Global Constraints + +- All state resolves under `~/.ao` (overridable via `AO_DATA_DIR`). Mobile state lives in `~/.ao/mobile/config.json`. Never touch `~/Library/Application Support`. +- The **Loopback Listener must remain byte-for-byte unchanged** — no auth, same bind, same routes. Zero desktop/CLI regression. +- Daemon API is code-first: edit `backend/internal/httpd/controllers/dto.go` + `backend/internal/httpd/apispec/specgen/build.go`, then run `npm run api` to regenerate the OpenAPI spec + frontend TS types. Never hand-edit generated artifacts. +- CLI stays a thin HTTP client; do not open storage/runtime directly. +- Renderer clones agent-orchestrator's look; build UI from `frontend/src/renderer/components/ui/*` primitives (per DESIGN.md). +- Password format: **8 chars, alphanumeric `[A-Za-z0-9]`**, generated with `crypto/rand`. Stored **hashed only** (SHA-256 hex is sufficient here — it is a rotating LAN enabler, not a human password; constant-time compare on the hash). Never persist the plaintext to disk. +- Auth scheme everywhere: `Authorization: Bearer `. +- Default LAN port **3011**; ephemeral fallback if taken; the QR/status must always report the *actually-bound* port. +- Lockout: **per-source** (remote IP), threshold **5** failures → cooldown; reset on success. Never global. +- Config file writes are **atomic** (temp + rename), like `runfile.Write`. + +--- + +## File Structure + +**Backend (Go)** +- `backend/internal/mobilebridge/config.go` — the `~/.ao/mobile/config.json` store (load/save/atomic), password gen + hash, state struct. *New package, no httpd deps.* +- `backend/internal/mobilebridge/config_test.go` +- `backend/internal/mobilebridge/netiface.go` — autopick LAN IP + enumerate candidates. +- `backend/internal/mobilebridge/netiface_test.go` +- `backend/internal/httpd/auth.go` — `authMiddleware` + per-source `lockout` limiter + bearer extraction + constant-time check. +- `backend/internal/httpd/auth_test.go` +- `backend/internal/httpd/lan_listener.go` — `LANManager`: start/stop a second `http.Server` at runtime, report bound addr, own the shared router+auth wrap. +- `backend/internal/httpd/lan_listener_test.go` +- `backend/internal/httpd/controllers/mobile.go` — REST controller for `GET/POST /api/v1/mobile/...` (status, enable, disable, regenerate). +- `backend/internal/httpd/controllers/mobile_test.go` +- `backend/internal/httpd/controllers/dto.go` — **modify**: add mobile DTOs. +- `backend/internal/httpd/apispec/specgen/build.go` — **modify**: register mobile operations + schema names. +- `backend/internal/httpd/terminal_mux.go` — **modify**: no change to loopback path; auth for `/mux` is applied by the LAN router wrap (see Task 7), not here. +- `backend/internal/daemon/daemon.go` — **modify**: construct `LANManager`, wire it into the mobile controller, restore persisted enabled-state on boot. + +**Desktop (Electron/React)** +- `frontend/src/renderer/components/ui/dialog.tsx` — **new** shadcn Dialog primitive (only `sheet.tsx` exists today). +- `frontend/src/renderer/components/ConnectMobileButton.tsx` — the "Connect Mobile" button that opens the modal. +- `frontend/src/renderer/components/ConnectMobileModal.tsx` — modal: enable/disable, QR, IP:port, password, regenerate, warning. +- `frontend/src/renderer/components/ConnectMobileModal.test.tsx` +- `frontend/src/renderer/components/GlobalSettingsForm.tsx` — **modify**: add `` section. +- `frontend/src/renderer/lib/qr.ts` — tiny QR-SVG generator (self-contained; no external host per CSP) or a vendored generator. + +**Mobile (Expo)** +- `packages/mobile/lib/config.ts` — **modify**: add `password` to `ServerConfig`, derive auth header helper. +- `packages/mobile/lib/api.ts` — **modify**: attach `Authorization` header to every fetch. +- `packages/mobile/lib/mux.ts` — **modify**: attach `Authorization` header to the WebSocket via RN's `headers` option. +- `packages/mobile/lib/pairing.ts` — **new**: parse the scanned QR payload `{v,host,port}`. +- `packages/mobile/app/pair.tsx` — **new**: camera scanner screen (expo-camera). +- `packages/mobile/app/(tabs)/settings.tsx` — **modify**: "Scan QR" entry + password popup + manual host/port/password. +- `packages/mobile/package.json` / `app.json` — **modify**: add `expo-camera` + camera permission. + +**Docs** +- `AGENTS.md` — **modify**: scope the loopback-only hard rule to the Loopback Listener. +- `docs/architecture.md` — **modify**: one paragraph on the two-listener model. + +--- + +## PHASE 1 — Backend: config store & password + +### Task 1: mobilebridge config store (state + atomic persistence) + +**Files:** +- Create: `backend/internal/mobilebridge/config.go` +- Test: `backend/internal/mobilebridge/config_test.go` + +**Interfaces:** +- Produces: + - `type State struct { Enabled bool `json:"enabled"`; PasswordHash string `json:"passwordHash"`; LastPort int `json:"lastPort"` }` + - `func Path(dataDir string) string` → `filepath.Join(dataDir, "mobile", "config.json")` + - `func Load(path string) (State, error)` — missing file returns zero `State{}`, nil error. + - `func Save(path string, s State) error` — atomic (temp+rename), `mkdir -p` the dir, file mode `0o600`. + - `func GeneratePassword() (string, error)` — 8 chars from `[A-Za-z0-9]` via `crypto/rand`. + - `func HashPassword(pw string) string` — `hex(sha256(pw))`. + - `func PasswordMatches(hash, pw string) bool` — `subtle.ConstantTimeCompare` over the hex hashes. + +- [ ] **Step 1: Write the failing test** + +```go +package mobilebridge + +import ( + "os" + "path/filepath" + "regexp" + "testing" +) + +func TestSaveLoadRoundTrip(t *testing.T) { + dir := t.TempDir() + p := Path(dir) + want := State{Enabled: true, PasswordHash: "abc", LastPort: 3011} + if err := Save(p, want); err != nil { + t.Fatalf("save: %v", err) + } + got, err := Load(p) + if err != nil { + t.Fatalf("load: %v", err) + } + if got != want { + t.Fatalf("round trip: got %+v want %+v", got, want) + } + info, _ := os.Stat(p) + if info.Mode().Perm() != 0o600 { + t.Fatalf("mode = %v want 0600", info.Mode().Perm()) + } +} + +func TestLoadMissingIsZero(t *testing.T) { + got, err := Load(filepath.Join(t.TempDir(), "mobile", "config.json")) + if err != nil || got != (State{}) { + t.Fatalf("missing file: got %+v err %v", got, err) + } +} + +func TestGeneratePasswordFormat(t *testing.T) { + pw, err := GeneratePassword() + if err != nil { + t.Fatal(err) + } + if !regexp.MustCompile(`^[A-Za-z0-9]{8}$`).MatchString(pw) { + t.Fatalf("password %q not 8 alnum", pw) + } +} + +func TestPasswordMatches(t *testing.T) { + pw, _ := GeneratePassword() + h := HashPassword(pw) + if !PasswordMatches(h, pw) { + t.Fatal("expected match") + } + if PasswordMatches(h, pw+"x") { + t.Fatal("expected mismatch") + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd backend && go test ./internal/mobilebridge/ -run TestSaveLoad -v` +Expected: FAIL — package/functions do not exist. + +- [ ] **Step 3: Write minimal implementation** + +```go +// Package mobilebridge owns the durable state and helpers for the Connect +// Mobile LAN listener: the ~/.ao/mobile/config.json store and the rotating +// connection password. It has no httpd/daemon dependencies. +package mobilebridge + +import ( + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "path/filepath" +) + +type State struct { + Enabled bool `json:"enabled"` + PasswordHash string `json:"passwordHash"` + LastPort int `json:"lastPort"` +} + +func Path(dataDir string) string { return filepath.Join(dataDir, "mobile", "config.json") } + +func Load(path string) (State, error) { + b, err := os.ReadFile(path) + if os.IsNotExist(err) { + return State{}, nil + } + if err != nil { + return State{}, fmt.Errorf("read mobile config: %w", err) + } + var s State + if err := json.Unmarshal(b, &s); err != nil { + return State{}, fmt.Errorf("parse mobile config: %w", err) + } + return s, nil +} + +func Save(path string, s State) error { + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return fmt.Errorf("mkdir mobile dir: %w", err) + } + b, err := json.MarshalIndent(s, "", " ") + if err != nil { + return err + } + tmp, err := os.CreateTemp(filepath.Dir(path), ".config-*.tmp") + if err != nil { + return err + } + tmpName := tmp.Name() + defer os.Remove(tmpName) + if err := tmp.Chmod(0o600); err != nil { + tmp.Close() + return err + } + if _, err := tmp.Write(b); err != nil { + tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + return os.Rename(tmpName, path) +} + +const pwAlphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" + +func GeneratePassword() (string, error) { + buf := make([]byte, 8) + if _, err := rand.Read(buf); err != nil { + return "", err + } + for i, b := range buf { + buf[i] = pwAlphabet[int(b)%len(pwAlphabet)] + } + return string(buf), nil +} + +func HashPassword(pw string) string { + sum := sha256.Sum256([]byte(pw)) + return hex.EncodeToString(sum[:]) +} + +func PasswordMatches(hash, pw string) bool { + return subtle.ConstantTimeCompare([]byte(hash), []byte(HashPassword(pw))) == 1 +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd backend && go test ./internal/mobilebridge/ -v && go vet ./internal/mobilebridge/` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add backend/internal/mobilebridge/config.go backend/internal/mobilebridge/config_test.go +git commit -m "feat(mobile): mobilebridge config store + rotating password" +``` + +--- + +### Task 2: Autopick LAN IP + +**Files:** +- Create: `backend/internal/mobilebridge/netiface.go` +- Test: `backend/internal/mobilebridge/netiface_test.go` + +**Interfaces:** +- Produces: + - `func PrivateIPv4Candidates(ifaces []net.Interface, addrsOf func(net.Interface) ([]net.Addr, error)) []string` — pure, testable core; returns private, non-loopback, non-link-local IPv4s, skipping down/loopback/VPN(`utun`)/docker interfaces, in a stable preference order. + - `func AutopickLANIP() string` — wraps the pure core with `net.Interfaces`; returns `""` if none. + +- [ ] **Step 1: Write the failing test** + +```go +package mobilebridge + +import ( + "net" + "testing" +) + +func TestPrivateIPv4Candidates(t *testing.T) { + ifaces := []net.Interface{ + {Index: 1, Name: "lo0", Flags: net.FlagUp | net.FlagLoopback}, + {Index: 2, Name: "en0", Flags: net.FlagUp}, + {Index: 3, Name: "utun3", Flags: net.FlagUp}, // VPN — skip + {Index: 4, Name: "en5", Flags: 0}, // down — skip + } + addrs := map[string][]net.Addr{ + "lo0": {cidr("127.0.0.1/8")}, + "en0": {cidr("192.168.1.42/24"), cidr("fe80::1/64")}, + "utun3": {cidr("10.9.9.9/24")}, + "en5": {cidr("192.168.5.5/24")}, + } + got := PrivateIPv4Candidates(ifaces, func(i net.Interface) ([]net.Addr, error) { + return addrs[i.Name], nil + }) + if len(got) != 1 || got[0] != "192.168.1.42" { + t.Fatalf("got %v want [192.168.1.42]", got) + } +} + +func cidr(s string) net.Addr { + ip, ipnet, _ := net.ParseCIDR(s) + ipnet.IP = ip + return ipnet +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd backend && go test ./internal/mobilebridge/ -run TestPrivateIPv4 -v` +Expected: FAIL — undefined. + +- [ ] **Step 3: Write minimal implementation** + +```go +package mobilebridge + +import ( + "net" + "strings" +) + +func skipInterface(i net.Interface) bool { + if i.Flags&net.FlagUp == 0 || i.Flags&net.FlagLoopback != 0 { + return true + } + n := strings.ToLower(i.Name) + for _, bad := range []string{"utun", "tun", "tap", "docker", "bridge", "vmnet", "llw", "awdl"} { + if strings.HasPrefix(n, bad) { + return true + } + } + return false +} + +func PrivateIPv4Candidates(ifaces []net.Interface, addrsOf func(net.Interface) ([]net.Addr, error)) []string { + var out []string + for _, i := range ifaces { + if skipInterface(i) { + continue + } + addrs, err := addrsOf(i) + if err != nil { + continue + } + for _, a := range addrs { + var ip net.IP + switch v := a.(type) { + case *net.IPNet: + ip = v.IP + case *net.IPAddr: + ip = v.IP + } + ip4 := ip.To4() + if ip4 == nil || ip.IsLoopback() || ip.IsLinkLocalUnicast() { + continue + } + if ip4.IsPrivate() { + out = append(out, ip4.String()) + } + } + } + return out +} + +func AutopickLANIP() string { + ifaces, err := net.Interfaces() + if err != nil { + return "" + } + c := PrivateIPv4Candidates(ifaces, net.Interface.Addrs) + if len(c) == 0 { + return "" + } + return c[0] +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd backend && go test ./internal/mobilebridge/ -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add backend/internal/mobilebridge/netiface.go backend/internal/mobilebridge/netiface_test.go +git commit -m "feat(mobile): autopick private LAN IPv4" +``` + +--- + +## PHASE 2 — Backend: auth middleware & lockout + +### Task 3: Bearer auth middleware with per-source lockout + +**Files:** +- Create: `backend/internal/httpd/auth.go` +- Test: `backend/internal/httpd/auth_test.go` + +**Interfaces:** +- Consumes: `mobilebridge.PasswordMatches` (Task 1). +- Produces: + - `type authState struct { hash atomic.Pointer[string] }` with `func (a *authState) setHash(h string)` and `func (a *authState) currentHash() string`. + - `func newLockout(limit int, cooldown time.Duration, now func() time.Time) *lockout` with `func (l *lockout) blocked(src string) bool`, `func (l *lockout) fail(src string)`, `func (l *lockout) reset(src string)`. + - `func authMiddleware(state *authState, lock *lockout) func(http.Handler) http.Handler` — extracts `Authorization: Bearer`, checks lockout → 429, checks password → 401 (+`lock.fail`), success → `lock.reset` + call through. Uses `r.RemoteAddr` host as source key. + +- [ ] **Step 1: Write the failing test** + +```go +package httpd + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/mobilebridge" +) + +func newAuthUnderTest(pw string, now func() time.Time) (http.Handler, *lockout) { + st := &authState{} + h := mobilebridge.HashPassword(pw) + st.setHash(h) + lock := newLockout(5, time.Minute, now) + ok := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }) + return authMiddleware(st, lock)(ok), lock +} + +func req(auth string) *http.Request { + r := httptest.NewRequest(http.MethodGet, "/api/v1/sessions", nil) + r.RemoteAddr = "192.168.1.50:5555" + if auth != "" { + r.Header.Set("Authorization", auth) + } + return r +} + +func TestAuthRejectsMissingAndWrong(t *testing.T) { + h, _ := newAuthUnderTest("secret12", time.Now) + for _, tc := range []struct{ name, auth string; want int }{ + {"missing", "", http.StatusUnauthorized}, + {"wrong", "Bearer nope", http.StatusUnauthorized}, + {"right", "Bearer secret12", http.StatusOK}, + } { + w := httptest.NewRecorder() + h.ServeHTTP(w, req(tc.auth)) + if w.Code != tc.want { + t.Errorf("%s: got %d want %d", tc.name, w.Code, tc.want) + } + } +} + +func TestAuthLockoutAfterFive(t *testing.T) { + now := time.Now() + h, _ := newAuthUnderTest("secret12", func() time.Time { return now }) + for i := 0; i < 5; i++ { + w := httptest.NewRecorder() + h.ServeHTTP(w, req("Bearer wrong")) + if w.Code != http.StatusUnauthorized { + t.Fatalf("attempt %d: got %d want 401", i, w.Code) + } + } + // 6th attempt — even with the RIGHT password — is locked out. + w := httptest.NewRecorder() + h.ServeHTTP(w, req("Bearer secret12")) + if w.Code != http.StatusTooManyRequests { + t.Fatalf("locked attempt: got %d want 429", w.Code) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd backend && go test ./internal/httpd/ -run TestAuth -v` +Expected: FAIL — undefined `authState`/`newLockout`/`authMiddleware`. + +- [ ] **Step 3: Write minimal implementation** + +```go +package httpd + +import ( + "net" + "net/http" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/httpd/envelope" + "github.com/aoagents/agent-orchestrator/backend/internal/mobilebridge" +) + +// authState holds the current password hash for the LAN listener. Swapped +// atomically on regenerate so an in-flight request never sees a torn value. +type authState struct{ hash atomic.Pointer[string] } + +func (a *authState) setHash(h string) { a.hash.Store(&h) } +func (a *authState) currentHash() string { + if p := a.hash.Load(); p != nil { + return *p + } + return "" +} + +// lockout throttles password guessing per source address. +type lockout struct { + mu sync.Mutex + limit int + cooldown time.Duration + now func() time.Time + fails map[string]int + until map[string]time.Time +} + +func newLockout(limit int, cooldown time.Duration, now func() time.Time) *lockout { + return &lockout{limit: limit, cooldown: cooldown, now: now, fails: map[string]int{}, until: map[string]time.Time{}} +} + +func (l *lockout) blocked(src string) bool { + l.mu.Lock() + defer l.mu.Unlock() + t, ok := l.until[src] + return ok && l.now().Before(t) +} + +func (l *lockout) fail(src string) { + l.mu.Lock() + defer l.mu.Unlock() + l.fails[src]++ + if l.fails[src] >= l.limit { + l.until[src] = l.now().Add(l.cooldown) + } +} + +func (l *lockout) reset(src string) { + l.mu.Lock() + defer l.mu.Unlock() + delete(l.fails, src) + delete(l.until, src) +} + +func sourceKey(r *http.Request) string { + if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil { + return host + } + return r.RemoteAddr +} + +func bearerToken(r *http.Request) string { + h := r.Header.Get("Authorization") + if strings.HasPrefix(h, "Bearer ") { + return strings.TrimPrefix(h, "Bearer ") + } + return "" +} + +func authMiddleware(state *authState, lock *lockout) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + src := sourceKey(r) + if lock.blocked(src) { + envelope.WriteAPIError(w, r, http.StatusTooManyRequests, "too_many_requests", "LOCKED_OUT", + "too many failed attempts; try again shortly", nil) + return + } + if mobilebridge.PasswordMatches(state.currentHash(), bearerToken(r)) { + lock.reset(src) + next.ServeHTTP(w, r) + return + } + lock.fail(src) + envelope.WriteAPIError(w, r, http.StatusUnauthorized, "unauthorized", "BAD_PASSWORD", + "missing or invalid connection password", nil) + }) + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd backend && go test ./internal/httpd/ -run TestAuth -v && go test -race ./internal/httpd/ -run TestAuth` +Expected: PASS (including `-race`). + +- [ ] **Step 5: Commit** + +```bash +git add backend/internal/httpd/auth.go backend/internal/httpd/auth_test.go +git commit -m "feat(mobile): bearer auth middleware with per-source lockout" +``` + +--- + +## PHASE 3 — Backend: runtime LAN listener + +### Task 4: LANManager — start/stop a second listener at runtime + +**Files:** +- Create: `backend/internal/httpd/lan_listener.go` +- Test: `backend/internal/httpd/lan_listener_test.go` + +**Interfaces:** +- Consumes: `authMiddleware`, `authState`, `newLockout` (Task 3); the shared `http.Handler` router built by `NewRouterWithControl`. +- Produces: + - `type LANManager struct { ... }` + - `func NewLANManager(handler http.Handler, state *authState, defaultPort int, log *slog.Logger) *LANManager` — wraps `handler` once with `authMiddleware`. + - `func (m *LANManager) Start(port int) (boundPort int, err error)` — binds `0.0.0.0:port`, ephemeral fallback on `EADDRINUSE`, serves in a goroutine, idempotent (no-op if already running). Returns the actually-bound port. + - `func (m *LANManager) Stop(ctx context.Context) error` — graceful shutdown; idempotent. + - `func (m *LANManager) Running() bool` + - `func (m *LANManager) BoundPort() int` + +- [ ] **Step 1: Write the failing test** + +```go +package httpd + +import ( + "context" + "fmt" + "io" + "log/slog" + "net/http" + "testing" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/mobilebridge" +) + +func TestLANManagerAuthGatesSharedHandler(t *testing.T) { + inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + io.WriteString(w, "ok") + }) + st := &authState{} + st.setHash(mobilebridge.HashPassword("secret12")) + m := NewLANManager(inner, st, 0, slog.Default()) // port 0 → ephemeral + port, err := m.Start(0) + if err != nil { + t.Fatalf("start: %v", err) + } + defer m.Stop(context.Background()) + if !m.Running() || m.BoundPort() != port { + t.Fatalf("running=%v boundPort=%d port=%d", m.Running(), m.BoundPort(), port) + } + + base := fmt.Sprintf("http://127.0.0.1:%d/anything", port) + // no auth → 401 + resp, _ := http.Get(base) + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("no-auth: got %d want 401", resp.StatusCode) + } + // with auth → 200 + req, _ := http.NewRequest(http.MethodGet, base, nil) + req.Header.Set("Authorization", "Bearer secret12") + resp2, _ := http.DefaultClient.Do(req) + if resp2.StatusCode != http.StatusOK { + t.Fatalf("auth: got %d want 200", resp2.StatusCode) + } +} + +func TestLANManagerStartStopIdempotent(t *testing.T) { + m := NewLANManager(http.NotFoundHandler(), &authState{}, 0, slog.Default()) + p1, _ := m.Start(0) + p2, _ := m.Start(0) // idempotent — same port, no error + if p1 != p2 { + t.Fatalf("second start changed port: %d != %d", p1, p2) + } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + if err := m.Stop(ctx); err != nil { + t.Fatalf("stop: %v", err) + } + if m.Running() { + t.Fatal("still running after stop") + } + _ = m.Stop(ctx) // second stop is a no-op +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd backend && go test ./internal/httpd/ -run TestLANManager -v` +Expected: FAIL — undefined. + +- [ ] **Step 3: Write minimal implementation** + +```go +package httpd + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net" + "net/http" + "sync" + "syscall" + "time" +) + +// LANManager owns the daemon's second, network-facing HTTP listener. It binds +// 0.0.0.0 only while Connect Mobile is enabled and wraps the shared router in +// authMiddleware. The loopback listener is unaffected. +type LANManager struct { + handler http.Handler // shared router, already auth-wrapped + defaultPort int + log *slog.Logger + + mu sync.Mutex + srv *http.Server + ln net.Listener + bound int +} + +func NewLANManager(handler http.Handler, state *authState, defaultPort int, log *slog.Logger) *LANManager { + lock := newLockout(5, time.Minute, time.Now) + return &LANManager{ + handler: authMiddleware(state, lock)(handler), + defaultPort: defaultPort, + log: loggerOrDefault(log), + } +} + +func (m *LANManager) Start(port int) (int, error) { + m.mu.Lock() + defer m.mu.Unlock() + if m.srv != nil { + return m.bound, nil // idempotent + } + if port == 0 { + port = m.defaultPort + } + ln, err := net.Listen("tcp", fmt.Sprintf("0.0.0.0:%d", port)) + if err != nil { + if !errors.Is(err, syscall.EADDRINUSE) { + return 0, fmt.Errorf("bind LAN 0.0.0.0:%d: %w", port, err) + } + if ln, err = net.Listen("tcp", "0.0.0.0:0"); err != nil { + return 0, fmt.Errorf("bind LAN ephemeral: %w", err) + } + m.log.Warn("LAN port in use; bound ephemeral", "wanted", port, "bound", ln.Addr()) + } + m.ln = ln + m.bound = ln.Addr().(*net.TCPAddr).Port + m.srv = &http.Server{Handler: m.handler, ReadHeaderTimeout: 10 * time.Second} + go func() { + if err := m.srv.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) { + m.log.Error("LAN listener serve", "err", err) + } + }() + m.log.Info("LAN listener started", "addr", ln.Addr()) + return m.bound, nil +} + +func (m *LANManager) Stop(ctx context.Context) error { + m.mu.Lock() + srv := m.srv + m.srv, m.ln, m.bound = nil, nil, 0 + m.mu.Unlock() + if srv == nil { + return nil + } + return srv.Shutdown(ctx) +} + +func (m *LANManager) Running() bool { + m.mu.Lock() + defer m.mu.Unlock() + return m.srv != nil +} + +func (m *LANManager) BoundPort() int { + m.mu.Lock() + defer m.mu.Unlock() + return m.bound +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd backend && go test -race ./internal/httpd/ -run TestLANManager -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add backend/internal/httpd/lan_listener.go backend/internal/httpd/lan_listener_test.go +git commit -m "feat(mobile): runtime-controlled LAN listener manager" +``` + +--- + +## PHASE 4 — Backend: REST control endpoints + +### Task 5: Mobile control service + DTOs + +**Files:** +- Create: `backend/internal/httpd/controllers/mobile.go` +- Test: `backend/internal/httpd/controllers/mobile_test.go` +- Modify: `backend/internal/httpd/controllers/dto.go` + +**Interfaces:** +- Consumes: `mobilebridge` (Task 1/2), `LANManager` + `authState` (Task 3/4). +- Produces (DTOs in `dto.go`): + - `type MobileStatusResponse struct { Enabled bool `json:"enabled"`; Host string `json:"host"`; Port int `json:"port"`; Password string `json:"password"`; Warning string `json:"warning"` }` + - Controller `MobileController` with methods `Status`, `Enable`, `Disable`, `Regenerate`, each `func(http.ResponseWriter, *http.Request)`. + - A small port interface the controller depends on so it is unit-testable without a real listener: + `type mobileBridge interface { Enable() (MobileStatusResponse, error); Disable() error; Regenerate() (MobileStatusResponse, error); Status() MobileStatusResponse }` +- The concrete `mobileBridge` impl (`bridgeService`) lives in `mobile.go` and closes over `*LANManager`, `*authState`, the config path, and default port. `Password` is only populated when enabled (empty string when disabled). `Warning` is a constant: `"Traffic on this connection is not encrypted. Only use it on a network you trust."` + +- [ ] **Step 1: Write the failing test** (controller against a fake bridge) + +```go +package controllers + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +type fakeBridge struct{ enabled bool } + +func (f *fakeBridge) Status() MobileStatusResponse { + return MobileStatusResponse{Enabled: f.enabled, Host: "192.168.1.42", Port: 3011} +} +func (f *fakeBridge) Enable() (MobileStatusResponse, error) { + f.enabled = true + r := f.Status() + r.Password = "abcd1234" + return r, nil +} +func (f *fakeBridge) Disable() error { f.enabled = false; return nil } +func (f *fakeBridge) Regenerate() (MobileStatusResponse, error) { + r := f.Status() + r.Password = "wxyz5678" + return r, nil +} + +func TestMobileEnableReturnsPassword(t *testing.T) { + c := &MobileController{Bridge: &fakeBridge{}} + w := httptest.NewRecorder() + c.Enable(w, httptest.NewRequest(http.MethodPost, "/api/v1/mobile/enable", nil)) + if w.Code != http.StatusOK { + t.Fatalf("got %d", w.Code) + } + var got MobileStatusResponse + json.NewDecoder(w.Body).Decode(&got) + if !got.Enabled || got.Password != "abcd1234" || got.Warning == "" { + t.Fatalf("bad response: %+v", got) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd backend && go test ./internal/httpd/controllers/ -run TestMobile -v` +Expected: FAIL — undefined types. + +- [ ] **Step 3: Write minimal implementation** (controller + concrete bridge) + +Add DTO to `dto.go` (near other response DTOs), then create `mobile.go`: + +```go +package controllers + +import ( + "context" + "net/http" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/httpd/envelope" + "github.com/aoagents/agent-orchestrator/backend/internal/mobilebridge" +) + +const mobileUnencryptedWarning = "Traffic on this connection is not encrypted. Only use it on a network you trust." + +type mobileBridge interface { + Status() MobileStatusResponse + Enable() (MobileStatusResponse, error) + Disable() error + Regenerate() (MobileStatusResponse, error) +} + +type MobileController struct{ Bridge mobileBridge } + +func (c *MobileController) Status(w http.ResponseWriter, r *http.Request) { + envelope.WriteJSON(w, http.StatusOK, c.Bridge.Status()) +} +func (c *MobileController) Enable(w http.ResponseWriter, r *http.Request) { + res, err := c.Bridge.Enable() + if err != nil { + envelope.WriteAPIError(w, r, http.StatusInternalServerError, "internal", "MOBILE_ENABLE", err.Error(), nil) + return + } + envelope.WriteJSON(w, http.StatusOK, res) +} +func (c *MobileController) Disable(w http.ResponseWriter, r *http.Request) { + if err := c.Bridge.Disable(); err != nil { + envelope.WriteAPIError(w, r, http.StatusInternalServerError, "internal", "MOBILE_DISABLE", err.Error(), nil) + return + } + envelope.WriteJSON(w, http.StatusOK, c.Bridge.Status()) +} +func (c *MobileController) Regenerate(w http.ResponseWriter, r *http.Request) { + res, err := c.Bridge.Regenerate() + if err != nil { + envelope.WriteAPIError(w, r, http.StatusInternalServerError, "internal", "MOBILE_REGEN", err.Error(), nil) + return + } + envelope.WriteJSON(w, http.StatusOK, res) +} + +// LANController is the runtime hook set the concrete bridge needs. httpd's +// LANManager + authState satisfy it (adapter wired in daemon.go). +type LANController interface { + Start(port int) (int, error) + Stop(ctx context.Context) error + Running() bool + BoundPort() int + SetPasswordHash(hash string) +} + +// BridgeService is the production mobileBridge. It persists state and drives +// the LAN listener. Password plaintext exists only transiently in the response. +type BridgeService struct { + LAN LANController + ConfigPath string + DefaultPort int +} + +func (b *BridgeService) currentHost() string { return mobilebridge.AutopickLANIP() } + +func (b *BridgeService) Status() MobileStatusResponse { + st, _ := mobilebridge.Load(b.ConfigPath) + return MobileStatusResponse{ + Enabled: st.Enabled && b.LAN.Running(), + Host: b.currentHost(), + Port: b.LAN.BoundPort(), + Warning: mobileUnencryptedWarning, + } +} + +func (b *BridgeService) enableWithPassword(pw string) (MobileStatusResponse, error) { + hash := mobilebridge.HashPassword(pw) + b.LAN.SetPasswordHash(hash) + port, err := b.LAN.Start(b.DefaultPort) + if err != nil { + return MobileStatusResponse{}, err + } + if err := mobilebridge.Save(b.ConfigPath, mobilebridge.State{Enabled: true, PasswordHash: hash, LastPort: port}); err != nil { + return MobileStatusResponse{}, err + } + res := b.Status() + res.Password = pw // transient — never persisted in plaintext + return res, nil +} + +func (b *BridgeService) Enable() (MobileStatusResponse, error) { + pw, err := mobilebridge.GeneratePassword() + if err != nil { + return MobileStatusResponse{}, err + } + return b.enableWithPassword(pw) +} + +func (b *BridgeService) Regenerate() (MobileStatusResponse, error) { + pw, err := mobilebridge.GeneratePassword() + if err != nil { + return MobileStatusResponse{}, err + } + return b.enableWithPassword(pw) // rotate → drops current phone (new hash) +} + +func (b *BridgeService) Disable() error { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := b.LAN.Stop(ctx); err != nil { + return err + } + st, _ := mobilebridge.Load(b.ConfigPath) + st.Enabled = false + return mobilebridge.Save(b.ConfigPath, st) +} +``` + +Note for the implementer: add `SetPasswordHash(hash string)` to `LANManager` in `lan_listener.go` — it stores the hash on the shared `*authState` (`m.state.setHash(hash)`); keep a `state *authState` field on `LANManager` and set it in `NewLANManager`. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd backend && go test ./internal/httpd/controllers/ -run TestMobile -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add backend/internal/httpd/controllers/mobile.go backend/internal/httpd/controllers/mobile_test.go backend/internal/httpd/controllers/dto.go backend/internal/httpd/lan_listener.go +git commit -m "feat(mobile): mobile control endpoints + bridge service" +``` + +--- + +### Task 6: Register routes on the LOOPBACK router + regenerate API artifacts + +**Files:** +- Modify: `backend/internal/httpd/router.go` (add `mountMobile` — these control routes live on the loopback router so the *desktop* drives them; the phone never enables/disables itself). +- Modify: `backend/internal/httpd/apispec/specgen/build.go` (register the 4 operations + `MobileStatusResponse` schema name). + +**Interfaces:** +- Consumes: `MobileController` (Task 5). +- Produces: routes `GET /api/v1/mobile/status`, `POST /api/v1/mobile/enable`, `POST /api/v1/mobile/disable`, `POST /api/v1/mobile/regenerate`, each gated by `localControlRequest` (desktop/loopback only — the phone must not toggle its own access). + +- [ ] **Step 1: Write the failing test** + +```go +// backend/internal/httpd/mobile_routes_test.go +package httpd + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestMobileStatusRouteIsLoopbackGated(t *testing.T) { + r := newTestRouterWithMobile(t) // helper builds router with a fake controller + req := httptest.NewRequest(http.MethodGet, "/api/v1/mobile/status", nil) + req.Host = "192.168.1.9:3011" // non-loopback → must be refused + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusForbidden { + t.Fatalf("non-loopback status: got %d want 403", w.Code) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd backend && go test ./internal/httpd/ -run TestMobileStatusRoute -v` +Expected: FAIL — route not mounted / helper undefined. + +- [ ] **Step 3: Implement** `mountMobile(r, controller)` in `router.go`, call it from `NewRouterWithControl`, wrapping each handler with the existing `localControlRequest` check (mirror `mountControl`). Add the operations to `build.go` with a `schemaNames` entry for `MobileStatusResponse`. Provide the `newTestRouterWithMobile` helper in the test file. + +- [ ] **Step 4: Verify + regenerate artifacts** + +Run: +```bash +cd backend && go test ./internal/httpd/ -run TestMobile -v +cd .. && npm run api # regenerate OpenAPI + frontend TS types +npm run frontend:typecheck +``` +Expected: tests PASS; `npm run api` updates spec + `frontend/src/api/*` with the new types; typecheck PASS. + +- [ ] **Step 5: Commit** + +```bash +git add backend/internal/httpd/router.go backend/internal/httpd/mobile_routes_test.go backend/internal/httpd/apispec/ frontend/src/api/ +git commit -m "feat(mobile): mount loopback-gated mobile control routes + regen API" +``` + +--- + +### Task 7: Wire LANManager into the daemon + restore-on-boot + +**Files:** +- Modify: `backend/internal/daemon/daemon.go` + +**Interfaces:** +- Consumes: `httpd.NewLANManager`, `controllers.BridgeService`, `mobilebridge.Load/Path`. +- Produces: a running daemon where (a) the loopback router serves as today, (b) a `LANManager` is constructed over the same handler + a shared `authState`, (c) the mobile controller drives it, (d) on boot, if persisted `State.Enabled` is true, the LAN listener is re-started with the persisted `PasswordHash` (no new password — the paired phone keeps working). + +- [ ] **Step 1: Write the failing test** (boot restore) + +```go +// backend/internal/daemon/mobile_restore_test.go — table test at the seam. +// If daemon.Run is too heavy to unit-test, assert the restore helper instead: +func TestRestoreEnabledStartsListener(t *testing.T) { + dir := t.TempDir() + path := mobilebridge.Path(dir) + _ = mobilebridge.Save(path, mobilebridge.State{Enabled: true, PasswordHash: "h", LastPort: 3011}) + lan := &fakeLAN{} + restoreMobileOnBoot(path, lan) // helper added in daemon package + if !lan.started { + t.Fatal("expected LAN listener started from persisted enabled state") + } + if lan.hash != "h" { + t.Fatalf("expected persisted hash reused, got %q", lan.hash) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd backend && go test ./internal/daemon/ -run TestRestoreEnabled -v` +Expected: FAIL — `restoreMobileOnBoot`/`fakeLAN` undefined. + +- [ ] **Step 3: Implement** `restoreMobileOnBoot(path string, lan httpd.LANController)` in the daemon package: `Load` the state; if `Enabled`, `lan.SetPasswordHash(state.PasswordHash)` then `lan.Start(state.LastPort)`. In `Run`, after constructing `srv`, build the shared `authState`, the `LANManager` over the router handler, the `BridgeService`, pass the controller into `NewWithDeps`/router deps, and call `restoreMobileOnBoot` before serving. Stop the LAN listener during shutdown alongside the other teardown. + + Implementation note: the router handler must be reachable to hand to `NewLANManager`. Either expose the built `chi.Router` from the `Server` (add `func (s *Server) Handler() http.Handler`) or build the router once in `daemon.Run` and pass it to both the loopback `Server` and the `LANManager`. Prefer the latter to keep a single handler instance. + +- [ ] **Step 4: Verify end-to-end** + +Run: +```bash +cd backend && go build ./... && go test ./... && go test -race ./internal/httpd/ ./internal/daemon/ ./internal/mobilebridge/ +``` +Then a manual smoke: +```bash +go run ./cmd/ao start & # daemon up +curl -s -XPOST localhost:3001/api/v1/mobile/enable | tee /tmp/enable.json # returns password + port +# NOTE: envelope.WriteJSON encodes the DTO directly (no "data" wrapper). +PW=$(python3 -c "import json;print(json.load(open('/tmp/enable.json'))['password'])") +PORT=$(python3 -c "import json;print(json.load(open('/tmp/enable.json'))['port'])") +curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:$PORT/api/v1/sessions # expect 401 +curl -s -o /dev/null -w '%{http_code}\n' -H "Authorization: Bearer $PW" http://127.0.0.1:$PORT/api/v1/sessions # expect 200 +curl -s -XPOST localhost:3001/api/v1/mobile/disable # closes LAN socket +``` +Expected: build+tests PASS; unauth 401, authed 200; disable closes the port. + +- [ ] **Step 5: Commit** + +```bash +git add backend/internal/daemon/ +git commit -m "feat(mobile): wire LAN listener into daemon with restore-on-boot" +``` + +--- + +## PHASE 5 — Desktop UI (Electron/React) + +### Task 8: shadcn Dialog primitive + +**Files:** +- Create: `frontend/src/renderer/components/ui/dialog.tsx` + +**Interfaces:** +- Produces: `Dialog`, `DialogTrigger`, `DialogContent`, `DialogHeader`, `DialogTitle`, `DialogDescription`, `DialogFooter` — the standard shadcn Radix Dialog wrappers, styled to match the existing `sheet.tsx` tokens (only `sheet.tsx` exists; add `dialog.tsx` beside it). + +- [ ] **Step 1:** Copy the canonical shadcn `dialog.tsx` (Radix `@radix-ui/react-dialog`), matching class tokens used in `sheet.tsx`. Confirm `@radix-ui/react-dialog` is already a dep (it backs `sheet.tsx`); if not, add it. +- [ ] **Step 2:** `cd frontend && npm run typecheck` → PASS. +- [ ] **Step 3: Commit** + +```bash +git add frontend/src/renderer/components/ui/dialog.tsx frontend/package.json +git commit -m "feat(ui): add shadcn dialog primitive" +``` + +--- + +### Task 9: QR generator + Connect Mobile modal + +**Files:** +- Create: `frontend/src/renderer/lib/qr.ts` (self-contained QR→SVG string; **no external host** per CSP). +- Create: `frontend/src/renderer/components/ConnectMobileModal.tsx` +- Create: `frontend/src/renderer/components/ConnectMobileModal.test.tsx` +- Create: `frontend/src/renderer/components/ConnectMobileButton.tsx` +- Modify: `frontend/src/renderer/components/GlobalSettingsForm.tsx` + +**Interfaces:** +- Consumes: generated mobile client types (Task 6) via `api-client.ts`; `Dialog` (Task 8). +- Produces: + - `ConnectMobileButton` — a button rendered in `GlobalSettingsForm`; opens the modal. + - `ConnectMobileModal` — reads `GET /api/v1/mobile/status`; when OFF shows an **Enable** button; when ON shows a **QR** (encoding `{"v":1,"host":,"port":}` — **password NOT included**), the `host:port` text, the **password** in plaintext, **Regenerate** and **Disable** buttons, and the unencrypted-network **warning** text from `status.warning`. +- The QR payload builder: `function pairingPayload(host: string, port: number): string { return JSON.stringify({ v: 1, host, port }); }` — assert in a test that it excludes any password field. + +- [ ] **Step 1: Write the failing test** + +```tsx +// ConnectMobileModal.test.tsx +import { render, screen } from "@testing-library/react"; +import { pairingPayload } from "./ConnectMobileModal"; + +test("QR payload never contains the password", () => { + const s = pairingPayload("192.168.1.42", 3011); + expect(JSON.parse(s)).toEqual({ v: 1, host: "192.168.1.42", port: 3011 }); + expect(s.toLowerCase()).not.toContain("password"); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd frontend && npx vitest run src/renderer/components/ConnectMobileModal.test.tsx` +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement** `qr.ts`, `ConnectMobileModal.tsx` (with exported `pairingPayload`), `ConnectMobileButton.tsx`, and add `` as a new section in `GlobalSettingsForm.tsx`. Use `useQuery`/`useMutation` against the generated client for status/enable/disable/regenerate. Show the password with a "Copy" affordance; render the QR SVG inline from `pairingPayload(...)`. Display `status.warning` prominently. + +- [ ] **Step 4: Verify + demo** + +Run: +```bash +cd frontend && npx vitest run src/renderer/components/ConnectMobileModal.test.tsx && npm run typecheck +``` +Then, per CLAUDE.md, demo it in-session: +```bash +ao preview # render the settings screen with Connect Mobile in the desktop browser panel +``` +Expected: test PASS, typecheck PASS, modal renders with QR + password + warning when enabled. + +- [ ] **Step 5: Commit** + +```bash +git add frontend/src/renderer/lib/qr.ts frontend/src/renderer/components/ConnectMobile*.tsx frontend/src/renderer/components/ConnectMobileModal.test.tsx frontend/src/renderer/components/GlobalSettingsForm.tsx +git commit -m "feat(mobile): desktop Connect Mobile modal with QR, password, warning" +``` + +--- + +## PHASE 6 — Mobile app (Expo) + +### Task 10: ServerConfig password + auth headers + +**Files:** +- Modify: `packages/mobile/lib/config.ts` +- Modify: `packages/mobile/lib/api.ts` +- Modify: `packages/mobile/lib/mux.ts` + +**Interfaces:** +- Produces: + - `ServerConfig` gains `password: string`. + - `function authHeaders(cfg: ServerConfig): Record` in `config.ts` → `cfg.password ? { Authorization: `Bearer ${cfg.password}` } : {}`. + - `api.ts`: every `fetch` spreads `authHeaders(cfg)` into request headers. + - `mux.ts`: the `WebSocket` is constructed with RN's options arg — `new WebSocket(muxUrl(cfg), undefined, { headers: authHeaders(cfg) })`. + +- [ ] **Step 1: Write the failing test** (config helper; mobile uses tsc — add a tiny node/vitest or a typecheck-guarded assertion) + +If the mobile package has no test runner, encode the contract as a typed unit and verify via `npm run typecheck`; otherwise: + +```ts +import { authHeaders, DEFAULT_CONFIG } from "./config"; +test("authHeaders present only with a password", () => { + expect(authHeaders({ ...DEFAULT_CONFIG, password: "" })).toEqual({}); + expect(authHeaders({ ...DEFAULT_CONFIG, password: "abcd1234" })).toEqual({ Authorization: "Bearer abcd1234" }); +}); +``` + +- [ ] **Step 2: Run** `cd packages/mobile && npm run typecheck` (and the test if a runner exists) → FAIL (missing `password`/`authHeaders`). +- [ ] **Step 3: Implement** the `password` field (default `""`), `authHeaders`, and thread it through `api.ts` fetches and the `mux.ts` WebSocket. +- [ ] **Step 4: Run** `cd packages/mobile && npm run typecheck` → PASS. +- [ ] **Step 5: Commit** + +```bash +git add packages/mobile/lib/config.ts packages/mobile/lib/api.ts packages/mobile/lib/mux.ts +git commit -m "feat(mobile): send Authorization bearer on REST + mux" +``` + +--- + +### Task 11: QR scanning + pairing + password popup + +**Files:** +- Create: `packages/mobile/lib/pairing.ts` +- Create: `packages/mobile/app/pair.tsx` +- Modify: `packages/mobile/app/(tabs)/settings.tsx` +- Modify: `packages/mobile/package.json`, `packages/mobile/app.json` + +**Interfaces:** +- Produces: + - `function parsePairingPayload(raw: string): { host: string; port: string } | null` in `pairing.ts` — parse `{v,host,port}`, validate `v===1`, coerce `port` to string, reject anything else. + - `app/pair.tsx` — an `expo-camera` scanner; on scan, `parsePairingPayload` → navigate back to settings with host/port filled. + - `settings.tsx` — a **"Scan QR"** button (→ `pair.tsx`), the existing manual host/port fields, a **password** field, and a **Connect** action that opens a popup (RN `Alert.prompt` on iOS or a small modal component cross-platform) asking for the password, then saves the full `ServerConfig` and connects. On a `401` from the daemon, re-open the popup. + +- [ ] **Step 1: Write the failing test** + +```ts +import { parsePairingPayload } from "./pairing"; +test("parses a valid payload and rejects junk", () => { + expect(parsePairingPayload('{"v":1,"host":"192.168.1.42","port":3011}')).toEqual({ host: "192.168.1.42", port: "3011" }); + expect(parsePairingPayload('{"v":2,"host":"x","port":1}')).toBeNull(); + expect(parsePairingPayload("not json")).toBeNull(); + expect(parsePairingPayload('{"host":"x"}')).toBeNull(); +}); +``` + +- [ ] **Step 2: Run** the mobile test/typecheck → FAIL (module missing). +- [ ] **Step 3: Implement** `parsePairingPayload`; add `expo-camera` to `package.json` and its permission to `app.json` (`ios.infoPlist.NSCameraUsageDescription`, `android.permissions: ["CAMERA"]`, plus the `expo-camera` config plugin); build `pair.tsx` and the settings wiring with the password popup. Keep `secure:false` (plaintext). +- [ ] **Step 4: Verify** + +Run: +```bash +cd packages/mobile && npm run typecheck +npx expo prebuild --clean # regenerate native projects with the camera permission (dev build) +``` +Then a device smoke: scan the desktop QR, enter the password from the desktop modal, confirm the session list and a terminal load over LAN. +Expected: typecheck PASS; on-device pairing connects and the terminal streams. + +- [ ] **Step 5: Commit** + +```bash +git add packages/mobile/lib/pairing.ts packages/mobile/app/pair.tsx "packages/mobile/app/(tabs)/settings.tsx" packages/mobile/package.json packages/mobile/app.json +git commit -m "feat(mobile): QR scan pairing + password popup" +``` + +--- + +## PHASE 7 — Docs + +### Task 12: Amend AGENTS.md + architecture note; retire the manual proxy + +**Files:** +- Modify: `AGENTS.md` +- Modify: `docs/architecture.md` +- Modify: `packages/mobile/scripts/README.md` (mark `ao-phone-proxy.js` superseded by the built-in LAN listener) + +- [ ] **Step 1:** In `AGENTS.md`, change the hard rule from *"The daemon is a loopback-only sidecar. Do not make the bind host configurable or expose it beyond `127.0.0.1`."* to scope it to the **Loopback Listener**, and add the LAN Listener's rules: + + > - The daemon's **primary (loopback) listener** stays bound to `127.0.0.1` and unauthenticated; do not change its bind or add auth to it. + > - A **second, opt-in LAN listener** (Connect Mobile) may bind `0.0.0.0` **only** while enabled, **only** behind the bearer-password `authMiddleware`, serving the app API but never the loopback-gated control routes. Plaintext, home-network-only, by decision in `docs/adr/0001-lan-listener-for-mobile.md`. + +- [ ] **Step 2:** Add a short two-listener paragraph to `docs/architecture.md` pointing at ADR 0001 and `CONTEXT.md`. +- [ ] **Step 3:** Note in the mobile scripts README that `ao-phone-proxy.js` is superseded by the in-app LAN listener (keep the file for now; do not delete without user sign-off). +- [ ] **Step 4:** `npm run lint` (docs don't break Go, but run the repo lint gate for safety) → PASS. +- [ ] **Step 5: Commit** + +```bash +git add AGENTS.md docs/architecture.md packages/mobile/scripts/README.md +git commit -m "docs(mobile): scope loopback-only rule to loopback listener; document LAN listener" +``` + +--- + +## Self-Review + +**Spec coverage** — every decision maps to a task: +- Second LAN listener inside daemon → Tasks 4, 7. Loopback unchanged → enforced by not touching the loopback `Server`; asserted implicitly (existing tests still pass in Task 7 Step 4). +- On-demand off-by-default + persistence + restore-on-boot → Tasks 1, 5, 7. +- Single rotating 8-char alnum password, hashed, constant-time → Task 1; rotate drops phone → Task 5 (`Regenerate` → new hash). +- Bearer on REST + RN WebSocket → Tasks 3 (server), 10 (client). +- Per-source lockout after 5 → Task 3. +- App API only; control loopback-only → Tasks 4 (wrap), 6 (`localControlRequest` on mobile control routes). +- Plaintext home-network-only + warning → Task 5 (`Warning`), 9 (displayed), 12 (docs). +- QR host+port only, password out-of-band → Tasks 9 (`pairingPayload` excludes pw, tested), 11 (`parsePairingPayload`). +- Default 3011 + ephemeral fallback + report bound port → Task 4, surfaced in Task 5 `Status`. +- Autopick LAN IP → Task 2. +- Desktop modal from a button with toggle/QR/ip:port/password/regen/disable/warning → Tasks 8, 9. +- expo-camera + password on ServerConfig → Tasks 10, 11. +- Amend AGENTS.md → Task 12. + +**Placeholder scan** — no "TBD"/"add error handling"/"write tests for the above"; every code step carries real code or an exact command. + +**Type consistency** — `MobileStatusResponse`, `mobileBridge`/`BridgeService`, `LANController` (with `SetPasswordHash`), `authState`/`lockout`/`authMiddleware`, `pairingPayload`/`parsePairingPayload`, `authHeaders` are named identically wherever referenced across tasks. `LANManager` gains `SetPasswordHash` (noted in Task 5) so it satisfies `LANController`. + +**Known follow-ups (out of scope, by decision):** TLS + fingerprint pinning (ADR 0001 "Consequences"); multi-device passwords; QR expiry. From e090beafdf3af932c7c05bc709f995c93eee1da3 Mon Sep 17 00:00:00 2001 From: Prasad-D-Ware Date: Tue, 7 Jul 2026 18:54:56 +0530 Subject: [PATCH 02/26] feat(mobile): mobilebridge config store + rotating password (cherry picked from commit f2617934cac9f2d681e522c92a380fcc60e8a90b) --- backend/internal/mobilebridge/config.go | 88 ++++++++++++++++++++ backend/internal/mobilebridge/config_test.go | 56 +++++++++++++ 2 files changed, 144 insertions(+) create mode 100644 backend/internal/mobilebridge/config.go create mode 100644 backend/internal/mobilebridge/config_test.go diff --git a/backend/internal/mobilebridge/config.go b/backend/internal/mobilebridge/config.go new file mode 100644 index 0000000000..e88b44dc01 --- /dev/null +++ b/backend/internal/mobilebridge/config.go @@ -0,0 +1,88 @@ +// Package mobilebridge owns the durable state and helpers for the Connect +// Mobile LAN listener: the ~/.ao/mobile/config.json store and the rotating +// connection password. It has no httpd/daemon dependencies. +package mobilebridge + +import ( + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "path/filepath" +) + +type State struct { + Enabled bool `json:"enabled"` + PasswordHash string `json:"passwordHash"` + LastPort int `json:"lastPort"` +} + +func Path(dataDir string) string { return filepath.Join(dataDir, "mobile", "config.json") } + +func Load(path string) (State, error) { + b, err := os.ReadFile(path) + if os.IsNotExist(err) { + return State{}, nil + } + if err != nil { + return State{}, fmt.Errorf("read mobile config: %w", err) + } + var s State + if err := json.Unmarshal(b, &s); err != nil { + return State{}, fmt.Errorf("parse mobile config: %w", err) + } + return s, nil +} + +func Save(path string, s State) error { + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return fmt.Errorf("mkdir mobile dir: %w", err) + } + b, err := json.MarshalIndent(s, "", " ") + if err != nil { + return err + } + tmp, err := os.CreateTemp(filepath.Dir(path), ".config-*.tmp") + if err != nil { + return err + } + tmpName := tmp.Name() + defer os.Remove(tmpName) + if err := tmp.Chmod(0o600); err != nil { + tmp.Close() + return err + } + if _, err := tmp.Write(b); err != nil { + tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + return os.Rename(tmpName, path) +} + +const pwAlphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" + +func GeneratePassword() (string, error) { + buf := make([]byte, 8) + if _, err := rand.Read(buf); err != nil { + return "", err + } + for i, b := range buf { + buf[i] = pwAlphabet[int(b)%len(pwAlphabet)] + } + return string(buf), nil +} + +func HashPassword(pw string) string { + sum := sha256.Sum256([]byte(pw)) + return hex.EncodeToString(sum[:]) +} + +func PasswordMatches(hash, pw string) bool { + return subtle.ConstantTimeCompare([]byte(hash), []byte(HashPassword(pw))) == 1 +} diff --git a/backend/internal/mobilebridge/config_test.go b/backend/internal/mobilebridge/config_test.go new file mode 100644 index 0000000000..ee9169e935 --- /dev/null +++ b/backend/internal/mobilebridge/config_test.go @@ -0,0 +1,56 @@ +package mobilebridge + +import ( + "os" + "path/filepath" + "regexp" + "testing" +) + +func TestSaveLoadRoundTrip(t *testing.T) { + dir := t.TempDir() + p := Path(dir) + want := State{Enabled: true, PasswordHash: "abc", LastPort: 3011} + if err := Save(p, want); err != nil { + t.Fatalf("save: %v", err) + } + got, err := Load(p) + if err != nil { + t.Fatalf("load: %v", err) + } + if got != want { + t.Fatalf("round trip: got %+v want %+v", got, want) + } + info, _ := os.Stat(p) + if info.Mode().Perm() != 0o600 { + t.Fatalf("mode = %v want 0600", info.Mode().Perm()) + } +} + +func TestLoadMissingIsZero(t *testing.T) { + got, err := Load(filepath.Join(t.TempDir(), "mobile", "config.json")) + if err != nil || got != (State{}) { + t.Fatalf("missing file: got %+v err %v", got, err) + } +} + +func TestGeneratePasswordFormat(t *testing.T) { + pw, err := GeneratePassword() + if err != nil { + t.Fatal(err) + } + if !regexp.MustCompile(`^[A-Za-z0-9]{8}$`).MatchString(pw) { + t.Fatalf("password %q not 8 alnum", pw) + } +} + +func TestPasswordMatches(t *testing.T) { + pw, _ := GeneratePassword() + h := HashPassword(pw) + if !PasswordMatches(h, pw) { + t.Fatal("expected match") + } + if PasswordMatches(h, pw+"x") { + t.Fatal("expected mismatch") + } +} From ff795023c56284df59e2ede0d37009670b0006c7 Mon Sep 17 00:00:00 2001 From: Prasad-D-Ware Date: Tue, 7 Jul 2026 18:57:08 +0530 Subject: [PATCH 03/26] feat(mobile): autopick private LAN IPv4 (cherry picked from commit e030474bceff8322cf2a7e814e0a560db1cd8c54) --- backend/internal/mobilebridge/netiface.go | 63 +++++++++++++++++++ .../internal/mobilebridge/netiface_test.go | 33 ++++++++++ 2 files changed, 96 insertions(+) create mode 100644 backend/internal/mobilebridge/netiface.go create mode 100644 backend/internal/mobilebridge/netiface_test.go diff --git a/backend/internal/mobilebridge/netiface.go b/backend/internal/mobilebridge/netiface.go new file mode 100644 index 0000000000..8aae669199 --- /dev/null +++ b/backend/internal/mobilebridge/netiface.go @@ -0,0 +1,63 @@ +package mobilebridge + +import ( + "net" + "strings" +) + +func skipInterface(i net.Interface) bool { + if i.Flags&net.FlagUp == 0 || i.Flags&net.FlagLoopback != 0 { + return true + } + n := strings.ToLower(i.Name) + for _, bad := range []string{"utun", "tun", "tap", "docker", "bridge", "vmnet", "llw", "awdl"} { + if strings.HasPrefix(n, bad) { + return true + } + } + return false +} + +func PrivateIPv4Candidates(ifaces []net.Interface, addrsOf func(net.Interface) ([]net.Addr, error)) []string { + var out []string + for _, i := range ifaces { + if skipInterface(i) { + continue + } + addrs, err := addrsOf(i) + if err != nil { + continue + } + for _, a := range addrs { + var ip net.IP + switch v := a.(type) { + case *net.IPNet: + ip = v.IP + case *net.IPAddr: + ip = v.IP + } + ip4 := ip.To4() + if ip4 == nil || ip.IsLoopback() || ip.IsLinkLocalUnicast() { + continue + } + if ip4.IsPrivate() { + out = append(out, ip4.String()) + } + } + } + return out +} + +func AutopickLANIP() string { + ifaces, err := net.Interfaces() + if err != nil { + return "" + } + c := PrivateIPv4Candidates(ifaces, func(i net.Interface) ([]net.Addr, error) { + return i.Addrs() + }) + if len(c) == 0 { + return "" + } + return c[0] +} diff --git a/backend/internal/mobilebridge/netiface_test.go b/backend/internal/mobilebridge/netiface_test.go new file mode 100644 index 0000000000..960f223b6a --- /dev/null +++ b/backend/internal/mobilebridge/netiface_test.go @@ -0,0 +1,33 @@ +package mobilebridge + +import ( + "net" + "testing" +) + +func TestPrivateIPv4Candidates(t *testing.T) { + ifaces := []net.Interface{ + {Index: 1, Name: "lo0", Flags: net.FlagUp | net.FlagLoopback}, + {Index: 2, Name: "en0", Flags: net.FlagUp}, + {Index: 3, Name: "utun3", Flags: net.FlagUp}, // VPN — skip + {Index: 4, Name: "en5", Flags: 0}, // down — skip + } + addrs := map[string][]net.Addr{ + "lo0": {cidr("127.0.0.1/8")}, + "en0": {cidr("192.168.1.42/24"), cidr("fe80::1/64")}, + "utun3": {cidr("10.9.9.9/24")}, + "en5": {cidr("192.168.5.5/24")}, + } + got := PrivateIPv4Candidates(ifaces, func(i net.Interface) ([]net.Addr, error) { + return addrs[i.Name], nil + }) + if len(got) != 1 || got[0] != "192.168.1.42" { + t.Fatalf("got %v want [192.168.1.42]", got) + } +} + +func cidr(s string) net.Addr { + ip, ipnet, _ := net.ParseCIDR(s) + ipnet.IP = ip + return ipnet +} From 6498a3ea15746486e9b44a072eb9f7b18ad3afda Mon Sep 17 00:00:00 2001 From: Prasad-D-Ware Date: Tue, 7 Jul 2026 18:59:24 +0530 Subject: [PATCH 04/26] feat(mobile): bearer auth middleware with per-source lockout (cherry picked from commit 461d3c22be4441e6405286979f3e7b371cb171ae) --- backend/internal/httpd/auth.go | 98 +++++++++++++++++++++++++++++ backend/internal/httpd/auth_test.go | 61 ++++++++++++++++++ 2 files changed, 159 insertions(+) create mode 100644 backend/internal/httpd/auth.go create mode 100644 backend/internal/httpd/auth_test.go diff --git a/backend/internal/httpd/auth.go b/backend/internal/httpd/auth.go new file mode 100644 index 0000000000..d8b42775dc --- /dev/null +++ b/backend/internal/httpd/auth.go @@ -0,0 +1,98 @@ +package httpd + +import ( + "net" + "net/http" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/httpd/envelope" + "github.com/aoagents/agent-orchestrator/backend/internal/mobilebridge" +) + +// authState holds the current password hash for the LAN listener. Swapped +// atomically on regenerate so an in-flight request never sees a torn value. +type authState struct{ hash atomic.Pointer[string] } + +func (a *authState) setHash(h string) { a.hash.Store(&h) } +func (a *authState) currentHash() string { + if p := a.hash.Load(); p != nil { + return *p + } + return "" +} + +// lockout throttles password guessing per source address. +type lockout struct { + mu sync.Mutex + limit int + cooldown time.Duration + now func() time.Time + fails map[string]int + until map[string]time.Time +} + +func newLockout(limit int, cooldown time.Duration, now func() time.Time) *lockout { + return &lockout{limit: limit, cooldown: cooldown, now: now, fails: map[string]int{}, until: map[string]time.Time{}} +} + +func (l *lockout) blocked(src string) bool { + l.mu.Lock() + defer l.mu.Unlock() + t, ok := l.until[src] + return ok && l.now().Before(t) +} + +func (l *lockout) fail(src string) { + l.mu.Lock() + defer l.mu.Unlock() + l.fails[src]++ + if l.fails[src] >= l.limit { + l.until[src] = l.now().Add(l.cooldown) + } +} + +func (l *lockout) reset(src string) { + l.mu.Lock() + defer l.mu.Unlock() + delete(l.fails, src) + delete(l.until, src) +} + +func sourceKey(r *http.Request) string { + if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil { + return host + } + return r.RemoteAddr +} + +func bearerToken(r *http.Request) string { + h := r.Header.Get("Authorization") + if strings.HasPrefix(h, "Bearer ") { + return strings.TrimPrefix(h, "Bearer ") + } + return "" +} + +func authMiddleware(state *authState, lock *lockout) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + src := sourceKey(r) + if lock.blocked(src) { + envelope.WriteAPIError(w, r, http.StatusTooManyRequests, "too_many_requests", "LOCKED_OUT", + "too many failed attempts; try again shortly", nil) + return + } + if mobilebridge.PasswordMatches(state.currentHash(), bearerToken(r)) { + lock.reset(src) + next.ServeHTTP(w, r) + return + } + lock.fail(src) + envelope.WriteAPIError(w, r, http.StatusUnauthorized, "unauthorized", "BAD_PASSWORD", + "missing or invalid connection password", nil) + }) + } +} diff --git a/backend/internal/httpd/auth_test.go b/backend/internal/httpd/auth_test.go new file mode 100644 index 0000000000..0850c388ea --- /dev/null +++ b/backend/internal/httpd/auth_test.go @@ -0,0 +1,61 @@ +package httpd + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/mobilebridge" +) + +func newAuthUnderTest(pw string, now func() time.Time) (http.Handler, *lockout) { + st := &authState{} + h := mobilebridge.HashPassword(pw) + st.setHash(h) + lock := newLockout(5, time.Minute, now) + ok := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }) + return authMiddleware(st, lock)(ok), lock +} + +func req(auth string) *http.Request { + r := httptest.NewRequest(http.MethodGet, "/api/v1/sessions", nil) + r.RemoteAddr = "192.168.1.50:5555" + if auth != "" { + r.Header.Set("Authorization", auth) + } + return r +} + +func TestAuthRejectsMissingAndWrong(t *testing.T) { + h, _ := newAuthUnderTest("secret12", time.Now) + for _, tc := range []struct{ name, auth string; want int }{ + {"missing", "", http.StatusUnauthorized}, + {"wrong", "Bearer nope", http.StatusUnauthorized}, + {"right", "Bearer secret12", http.StatusOK}, + } { + w := httptest.NewRecorder() + h.ServeHTTP(w, req(tc.auth)) + if w.Code != tc.want { + t.Errorf("%s: got %d want %d", tc.name, w.Code, tc.want) + } + } +} + +func TestAuthLockoutAfterFive(t *testing.T) { + now := time.Now() + h, _ := newAuthUnderTest("secret12", func() time.Time { return now }) + for i := 0; i < 5; i++ { + w := httptest.NewRecorder() + h.ServeHTTP(w, req("Bearer wrong")) + if w.Code != http.StatusUnauthorized { + t.Fatalf("attempt %d: got %d want 401", i, w.Code) + } + } + // 6th attempt — even with the RIGHT password — is locked out. + w := httptest.NewRecorder() + h.ServeHTTP(w, req("Bearer secret12")) + if w.Code != http.StatusTooManyRequests { + t.Fatalf("locked attempt: got %d want 429", w.Code) + } +} From aa9c23d9d49ea20dbfd0723ca4cff0e6a855802b Mon Sep 17 00:00:00 2001 From: Prasad-D-Ware Date: Tue, 7 Jul 2026 19:02:28 +0530 Subject: [PATCH 05/26] feat(mobile): test lockout is per-source (never global) (cherry picked from commit 47e7d9770511c863ad5bd724386e8ab774661700) --- backend/internal/httpd/auth_test.go | 46 +++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/backend/internal/httpd/auth_test.go b/backend/internal/httpd/auth_test.go index 0850c388ea..a729445e5d 100644 --- a/backend/internal/httpd/auth_test.go +++ b/backend/internal/httpd/auth_test.go @@ -27,6 +27,15 @@ func req(auth string) *http.Request { return r } +func reqFrom(remoteAddr, auth string) *http.Request { + r := httptest.NewRequest(http.MethodGet, "/api/v1/sessions", nil) + r.RemoteAddr = remoteAddr + if auth != "" { + r.Header.Set("Authorization", auth) + } + return r +} + func TestAuthRejectsMissingAndWrong(t *testing.T) { h, _ := newAuthUnderTest("secret12", time.Now) for _, tc := range []struct{ name, auth string; want int }{ @@ -59,3 +68,40 @@ func TestAuthLockoutAfterFive(t *testing.T) { t.Fatalf("locked attempt: got %d want 429", w.Code) } } + +func TestAuthLockoutIsPerSource(t *testing.T) { + now := time.Now() + h, _ := newAuthUnderTest("secret12", func() time.Time { return now }) + + // Source A: lock with 5 failed attempts from 192.168.1.50 + sourceA := "192.168.1.50:5555" + for i := 0; i < 5; i++ { + w := httptest.NewRecorder() + h.ServeHTTP(w, reqFrom(sourceA, "Bearer wrong")) + if w.Code != http.StatusUnauthorized { + t.Fatalf("source A attempt %d: got %d want 401", i, w.Code) + } + } + // Verify source A is now locked + w := httptest.NewRecorder() + h.ServeHTTP(w, reqFrom(sourceA, "Bearer secret12")) + if w.Code != http.StatusTooManyRequests { + t.Fatalf("source A locked check: got %d want 429", w.Code) + } + + // Source B: should NOT be locked despite source A being locked + sourceB := "192.168.1.99:6666" + // B with correct password should be 200, not 429 + w = httptest.NewRecorder() + h.ServeHTTP(w, reqFrom(sourceB, "Bearer secret12")) + if w.Code != http.StatusOK { + t.Fatalf("source B with correct password: got %d want 200", w.Code) + } + + // B with wrong password should be 401, not 429 + w = httptest.NewRecorder() + h.ServeHTTP(w, reqFrom(sourceB, "Bearer wrong")) + if w.Code != http.StatusUnauthorized { + t.Fatalf("source B with wrong password: got %d want 401", w.Code) + } +} From 773909cf4d66021c291356cf6dfa61c841f7bfef Mon Sep 17 00:00:00 2001 From: Prasad-D-Ware Date: Tue, 7 Jul 2026 19:04:50 +0530 Subject: [PATCH 06/26] feat(mobile): runtime-controlled LAN listener manager (cherry picked from commit a10fd4b6e460f234107a89c4d0fad92932b40bf0) --- backend/internal/httpd/lan_listener.go | 95 +++++++++++++++++++++ backend/internal/httpd/lan_listener_test.go | 62 ++++++++++++++ 2 files changed, 157 insertions(+) create mode 100644 backend/internal/httpd/lan_listener.go create mode 100644 backend/internal/httpd/lan_listener_test.go diff --git a/backend/internal/httpd/lan_listener.go b/backend/internal/httpd/lan_listener.go new file mode 100644 index 0000000000..50a10b9fb8 --- /dev/null +++ b/backend/internal/httpd/lan_listener.go @@ -0,0 +1,95 @@ +package httpd + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net" + "net/http" + "sync" + "syscall" + "time" +) + +// LANManager owns the daemon's second, network-facing HTTP listener. It binds +// 0.0.0.0 only while Connect Mobile is enabled and wraps the shared router in +// authMiddleware. The loopback listener is unaffected. +type LANManager struct { + handler http.Handler // shared router, already auth-wrapped + defaultPort int + log *slog.Logger + + mu sync.Mutex + srv *http.Server + ln net.Listener + bound int +} + +func NewLANManager(handler http.Handler, state *authState, defaultPort int, log *slog.Logger) *LANManager { + lock := newLockout(5, time.Minute, time.Now) + return &LANManager{ + handler: authMiddleware(state, lock)(handler), + defaultPort: defaultPort, + log: loggerOrDefault(log), + } +} + +func (m *LANManager) Start(port int) (int, error) { + m.mu.Lock() + if m.srv != nil { + defer m.mu.Unlock() + return m.bound, nil // idempotent + } + if port == 0 { + port = m.defaultPort + } + ln, err := net.Listen("tcp", fmt.Sprintf("0.0.0.0:%d", port)) + if err != nil { + if !errors.Is(err, syscall.EADDRINUSE) { + m.mu.Unlock() + return 0, fmt.Errorf("bind LAN 0.0.0.0:%d: %w", port, err) + } + if ln, err = net.Listen("tcp", "0.0.0.0:0"); err != nil { + m.mu.Unlock() + return 0, fmt.Errorf("bind LAN ephemeral: %w", err) + } + m.log.Warn("LAN port in use; bound ephemeral", "wanted", port, "bound", ln.Addr()) + } + m.ln = ln + m.bound = ln.Addr().(*net.TCPAddr).Port + m.srv = &http.Server{Handler: m.handler, ReadHeaderTimeout: 10 * time.Second} + srv := m.srv + boundPort := m.bound + m.mu.Unlock() + go func() { + if err := srv.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) { + m.log.Error("LAN listener serve", "err", err) + } + }() + m.log.Info("LAN listener started", "addr", ln.Addr()) + return boundPort, nil +} + +func (m *LANManager) Stop(ctx context.Context) error { + m.mu.Lock() + srv := m.srv + m.srv, m.ln, m.bound = nil, nil, 0 + m.mu.Unlock() + if srv == nil { + return nil + } + return srv.Shutdown(ctx) +} + +func (m *LANManager) Running() bool { + m.mu.Lock() + defer m.mu.Unlock() + return m.srv != nil +} + +func (m *LANManager) BoundPort() int { + m.mu.Lock() + defer m.mu.Unlock() + return m.bound +} diff --git a/backend/internal/httpd/lan_listener_test.go b/backend/internal/httpd/lan_listener_test.go new file mode 100644 index 0000000000..5fc08d25aa --- /dev/null +++ b/backend/internal/httpd/lan_listener_test.go @@ -0,0 +1,62 @@ +package httpd + +import ( + "context" + "fmt" + "io" + "log/slog" + "net/http" + "testing" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/mobilebridge" +) + +func TestLANManagerAuthGatesSharedHandler(t *testing.T) { + inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + io.WriteString(w, "ok") + }) + st := &authState{} + st.setHash(mobilebridge.HashPassword("secret12")) + m := NewLANManager(inner, st, 0, slog.Default()) // port 0 → ephemeral + port, err := m.Start(0) + if err != nil { + t.Fatalf("start: %v", err) + } + defer m.Stop(context.Background()) + if !m.Running() || m.BoundPort() != port { + t.Fatalf("running=%v boundPort=%d port=%d", m.Running(), m.BoundPort(), port) + } + + base := fmt.Sprintf("http://127.0.0.1:%d/anything", port) + // no auth → 401 + resp, _ := http.Get(base) + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("no-auth: got %d want 401", resp.StatusCode) + } + // with auth → 200 + req, _ := http.NewRequest(http.MethodGet, base, nil) + req.Header.Set("Authorization", "Bearer secret12") + resp2, _ := http.DefaultClient.Do(req) + if resp2.StatusCode != http.StatusOK { + t.Fatalf("auth: got %d want 200", resp2.StatusCode) + } +} + +func TestLANManagerStartStopIdempotent(t *testing.T) { + m := NewLANManager(http.NotFoundHandler(), &authState{}, 0, slog.Default()) + p1, _ := m.Start(0) + p2, _ := m.Start(0) // idempotent — same port, no error + if p1 != p2 { + t.Fatalf("second start changed port: %d != %d", p1, p2) + } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + if err := m.Stop(ctx); err != nil { + t.Fatalf("stop: %v", err) + } + if m.Running() { + t.Fatal("still running after stop") + } + _ = m.Stop(ctx) // second stop is a no-op +} From 205eeb2c47daf5ae587724f37fd94beb1b48652e Mon Sep 17 00:00:00 2001 From: Prasad-D-Ware Date: Tue, 7 Jul 2026 19:09:22 +0530 Subject: [PATCH 07/26] feat(mobile): mobile control endpoints + bridge service (cherry picked from commit c0bb792f040344a5597fd3190ff035c51a988edf) --- backend/internal/httpd/controllers/dto.go | 11 ++ backend/internal/httpd/controllers/mobile.go | 129 ++++++++++++++++++ .../internal/httpd/controllers/mobile_test.go | 40 ++++++ backend/internal/httpd/lan_listener.go | 9 ++ 4 files changed, 189 insertions(+) create mode 100644 backend/internal/httpd/controllers/mobile.go create mode 100644 backend/internal/httpd/controllers/mobile_test.go diff --git a/backend/internal/httpd/controllers/dto.go b/backend/internal/httpd/controllers/dto.go index 89cad91923..81ac195ed9 100644 --- a/backend/internal/httpd/controllers/dto.go +++ b/backend/internal/httpd/controllers/dto.go @@ -545,3 +545,14 @@ type ResolveCommentsResponse struct { OK bool `json:"ok"` Resolved int `json:"resolved"` } + +// MobileStatusResponse is the body of the Connect Mobile status/enable/disable/ +// regenerate endpoints. Password is populated only transiently, on enable and +// regenerate responses (empty otherwise) — it is never persisted in plaintext. +type MobileStatusResponse struct { + Enabled bool `json:"enabled"` + Host string `json:"host"` + Port int `json:"port"` + Password string `json:"password"` + Warning string `json:"warning"` +} diff --git a/backend/internal/httpd/controllers/mobile.go b/backend/internal/httpd/controllers/mobile.go new file mode 100644 index 0000000000..7aad60bd06 --- /dev/null +++ b/backend/internal/httpd/controllers/mobile.go @@ -0,0 +1,129 @@ +package controllers + +import ( + "context" + "net/http" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/httpd/envelope" + "github.com/aoagents/agent-orchestrator/backend/internal/mobilebridge" +) + +const mobileUnencryptedWarning = "Traffic on this connection is not encrypted. Only use it on a network you trust." + +type mobileBridge interface { + Status() MobileStatusResponse + Enable() (MobileStatusResponse, error) + Disable() error + Regenerate() (MobileStatusResponse, error) +} + +type MobileController struct{ Bridge mobileBridge } + +// withWarning stamps the constant unencrypted-LAN warning onto any bridge +// response. The warning is not bridge-specific state — it's always present — +// so the controller guarantees it here rather than trusting every mobileBridge +// implementation (including test fakes) to set it. +func withWarning(res MobileStatusResponse) MobileStatusResponse { + res.Warning = mobileUnencryptedWarning + return res +} + +func (c *MobileController) Status(w http.ResponseWriter, r *http.Request) { + envelope.WriteJSON(w, http.StatusOK, withWarning(c.Bridge.Status())) +} +func (c *MobileController) Enable(w http.ResponseWriter, r *http.Request) { + res, err := c.Bridge.Enable() + if err != nil { + envelope.WriteAPIError(w, r, http.StatusInternalServerError, "internal", "MOBILE_ENABLE", err.Error(), nil) + return + } + envelope.WriteJSON(w, http.StatusOK, withWarning(res)) +} +func (c *MobileController) Disable(w http.ResponseWriter, r *http.Request) { + if err := c.Bridge.Disable(); err != nil { + envelope.WriteAPIError(w, r, http.StatusInternalServerError, "internal", "MOBILE_DISABLE", err.Error(), nil) + return + } + envelope.WriteJSON(w, http.StatusOK, withWarning(c.Bridge.Status())) +} +func (c *MobileController) Regenerate(w http.ResponseWriter, r *http.Request) { + res, err := c.Bridge.Regenerate() + if err != nil { + envelope.WriteAPIError(w, r, http.StatusInternalServerError, "internal", "MOBILE_REGEN", err.Error(), nil) + return + } + envelope.WriteJSON(w, http.StatusOK, withWarning(res)) +} + +// LANController is the runtime hook set the concrete bridge needs. httpd's +// LANManager + authState satisfy it (adapter wired in daemon.go). +type LANController interface { + Start(port int) (int, error) + Stop(ctx context.Context) error + Running() bool + BoundPort() int + SetPasswordHash(hash string) +} + +// BridgeService is the production mobileBridge. It persists state and drives +// the LAN listener. Password plaintext exists only transiently in the response. +type BridgeService struct { + LAN LANController + ConfigPath string + DefaultPort int +} + +func (b *BridgeService) currentHost() string { return mobilebridge.AutopickLANIP() } + +func (b *BridgeService) Status() MobileStatusResponse { + st, _ := mobilebridge.Load(b.ConfigPath) + return MobileStatusResponse{ + Enabled: st.Enabled && b.LAN.Running(), + Host: b.currentHost(), + Port: b.LAN.BoundPort(), + Warning: mobileUnencryptedWarning, + } +} + +func (b *BridgeService) enableWithPassword(pw string) (MobileStatusResponse, error) { + hash := mobilebridge.HashPassword(pw) + b.LAN.SetPasswordHash(hash) + port, err := b.LAN.Start(b.DefaultPort) + if err != nil { + return MobileStatusResponse{}, err + } + if err := mobilebridge.Save(b.ConfigPath, mobilebridge.State{Enabled: true, PasswordHash: hash, LastPort: port}); err != nil { + return MobileStatusResponse{}, err + } + res := b.Status() + res.Password = pw // transient — never persisted in plaintext + return res, nil +} + +func (b *BridgeService) Enable() (MobileStatusResponse, error) { + pw, err := mobilebridge.GeneratePassword() + if err != nil { + return MobileStatusResponse{}, err + } + return b.enableWithPassword(pw) +} + +func (b *BridgeService) Regenerate() (MobileStatusResponse, error) { + pw, err := mobilebridge.GeneratePassword() + if err != nil { + return MobileStatusResponse{}, err + } + return b.enableWithPassword(pw) // rotate → drops current phone (new hash) +} + +func (b *BridgeService) Disable() error { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := b.LAN.Stop(ctx); err != nil { + return err + } + st, _ := mobilebridge.Load(b.ConfigPath) + st.Enabled = false + return mobilebridge.Save(b.ConfigPath, st) +} diff --git a/backend/internal/httpd/controllers/mobile_test.go b/backend/internal/httpd/controllers/mobile_test.go new file mode 100644 index 0000000000..32b01bb07b --- /dev/null +++ b/backend/internal/httpd/controllers/mobile_test.go @@ -0,0 +1,40 @@ +package controllers + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +type fakeBridge struct{ enabled bool } + +func (f *fakeBridge) Status() MobileStatusResponse { + return MobileStatusResponse{Enabled: f.enabled, Host: "192.168.1.42", Port: 3011} +} +func (f *fakeBridge) Enable() (MobileStatusResponse, error) { + f.enabled = true + r := f.Status() + r.Password = "abcd1234" + return r, nil +} +func (f *fakeBridge) Disable() error { f.enabled = false; return nil } +func (f *fakeBridge) Regenerate() (MobileStatusResponse, error) { + r := f.Status() + r.Password = "wxyz5678" + return r, nil +} + +func TestMobileEnableReturnsPassword(t *testing.T) { + c := &MobileController{Bridge: &fakeBridge{}} + w := httptest.NewRecorder() + c.Enable(w, httptest.NewRequest(http.MethodPost, "/api/v1/mobile/enable", nil)) + if w.Code != http.StatusOK { + t.Fatalf("got %d", w.Code) + } + var got MobileStatusResponse + json.NewDecoder(w.Body).Decode(&got) + if !got.Enabled || got.Password != "abcd1234" || got.Warning == "" { + t.Fatalf("bad response: %+v", got) + } +} diff --git a/backend/internal/httpd/lan_listener.go b/backend/internal/httpd/lan_listener.go index 50a10b9fb8..aa764724e3 100644 --- a/backend/internal/httpd/lan_listener.go +++ b/backend/internal/httpd/lan_listener.go @@ -19,6 +19,7 @@ type LANManager struct { handler http.Handler // shared router, already auth-wrapped defaultPort int log *slog.Logger + state *authState // shared with authMiddleware; SetPasswordHash writes through here mu sync.Mutex srv *http.Server @@ -32,9 +33,17 @@ func NewLANManager(handler http.Handler, state *authState, defaultPort int, log handler: authMiddleware(state, lock)(handler), defaultPort: defaultPort, log: loggerOrDefault(log), + state: state, } } +// SetPasswordHash stores the current connection password hash on the shared +// authState so the auth middleware (already wrapping handler) validates +// against it. Satisfies controllers.LANController. +func (m *LANManager) SetPasswordHash(hash string) { + m.state.setHash(hash) +} + func (m *LANManager) Start(port int) (int, error) { m.mu.Lock() if m.srv != nil { From f29d857aaebd72dcf2d395122e61168ee3996df3 Mon Sep 17 00:00:00 2001 From: Prasad-D-Ware Date: Tue, 7 Jul 2026 19:16:33 +0530 Subject: [PATCH 08/26] feat(mobile): mount loopback-gated mobile control routes + regen API (cherry picked from commit 376b5f1b9041392feb03bff65f8ec85353b9f076) --- backend/internal/httpd/api.go | 1 + backend/internal/httpd/apispec/openapi.yaml | 115 + backend/internal/httpd/apispec/parity_test.go | 8 +- .../internal/httpd/apispec/specgen/build.go | 50 + backend/internal/httpd/mobile_routes_test.go | 52 + backend/internal/httpd/router.go | 29 + packages/shared/src/api/schema.ts | 2969 +++++++++++++++++ 7 files changed, 3223 insertions(+), 1 deletion(-) create mode 100644 backend/internal/httpd/mobile_routes_test.go create mode 100644 packages/shared/src/api/schema.ts diff --git a/backend/internal/httpd/api.go b/backend/internal/httpd/api.go index 218e6815b0..d49c9c8ca6 100644 --- a/backend/internal/httpd/api.go +++ b/backend/internal/httpd/api.go @@ -31,6 +31,7 @@ type APIDeps struct { CDC cdc.Source Events cdcSubscriber Telemetry ports.EventSink + Mobile *controllers.MobileController } // API owns one controller per resource and is the single Register call the diff --git a/backend/internal/httpd/apispec/openapi.yaml b/backend/internal/httpd/apispec/openapi.yaml index e4554bde88..e31711a965 100644 --- a/backend/internal/httpd/apispec/openapi.yaml +++ b/backend/internal/httpd/apispec/openapi.yaml @@ -189,6 +189,100 @@ paths: summary: Run the legacy AO project import through the daemon store tags: - import + /api/v1/mobile/disable: + post: + operationId: disableMobile + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/MobileStatusResponse' + description: OK + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Forbidden + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Internal Server Error + summary: Disable the Connect Mobile LAN bridge + tags: + - mobile + /api/v1/mobile/enable: + post: + operationId: enableMobile + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/MobileStatusResponse' + description: OK + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Forbidden + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Internal Server Error + summary: Enable the Connect Mobile LAN bridge and issue a fresh password + tags: + - mobile + /api/v1/mobile/regenerate: + post: + operationId: regenerateMobile + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/MobileStatusResponse' + description: OK + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Forbidden + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Internal Server Error + summary: Rotate the Connect Mobile password, dropping any connected phone + tags: + - mobile + /api/v1/mobile/status: + get: + operationId: getMobileStatus + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/MobileStatusResponse' + description: OK + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Forbidden + summary: Check whether Connect Mobile's LAN bridge is enabled + tags: + - mobile /api/v1/notifications: get: operationId: listNotifications @@ -1910,6 +2004,25 @@ components: - prNumber - method type: object + MobileStatusResponse: + properties: + enabled: + type: boolean + host: + type: string + password: + type: string + port: + type: integer + warning: + type: string + required: + - enabled + - host + - port + - password + - warning + type: object NotificationEnvelope: properties: notification: @@ -2750,3 +2863,5 @@ tags: name: events - description: Legacy AO project import (availability probe and run) name: import +- description: Connect Mobile LAN bridge control (loopback/desktop only) + name: mobile diff --git a/backend/internal/httpd/apispec/parity_test.go b/backend/internal/httpd/apispec/parity_test.go index e68eaeee64..59fc65d129 100644 --- a/backend/internal/httpd/apispec/parity_test.go +++ b/backend/internal/httpd/apispec/parity_test.go @@ -13,6 +13,7 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/config" "github.com/aoagents/agent-orchestrator/backend/internal/httpd" "github.com/aoagents/agent-orchestrator/backend/internal/httpd/apispec" + "github.com/aoagents/agent-orchestrator/backend/internal/httpd/controllers" ) // TestRouteSpecParity asserts the mounted /api/v1 routes and the OpenAPI @@ -20,7 +21,12 @@ import ( // spec coverage, and the spec can't describe a route that isn't served. func TestRouteSpecParity(t *testing.T) { log := slog.New(slog.NewTextHandler(io.Discard, nil)) - router := httpd.NewRouterWithControl(config.Config{}, log, nil, httpd.APIDeps{}, httpd.ControlDeps{}) + // Mobile carries a non-nil MobileController so mountMobile (which, like + // mountControl, skips mounting entirely on a nil controller) registers its + // routes here — otherwise the mobile spec operations below would have no + // mounted route to match. + deps := httpd.APIDeps{Mobile: &controllers.MobileController{}} + router := httpd.NewRouterWithControl(config.Config{}, log, nil, deps, httpd.ControlDeps{}) mounted := map[string]bool{} err := chi.Walk(router, func(method, route string, _ http.Handler, _ ...func(http.Handler) http.Handler) error { diff --git a/backend/internal/httpd/apispec/specgen/build.go b/backend/internal/httpd/apispec/specgen/build.go index 3ce0d5936d..93476fb63c 100644 --- a/backend/internal/httpd/apispec/specgen/build.go +++ b/backend/internal/httpd/apispec/specgen/build.go @@ -71,6 +71,8 @@ func Build() ([]byte, error) { "Server-sent CDC event stream with durable replay"), *(&openapi31.Tag{Name: "import"}).WithDescription( "Legacy AO project import (availability probe and run)"), + *(&openapi31.Tag{Name: "mobile"}).WithDescription( + "Connect Mobile LAN bridge control (loopback/desktop only)"), } for _, op := range operations() { @@ -201,6 +203,8 @@ var schemaNames = map[string]string{ // httpd/controllers: import wire envelopes "ControllersImportStatusResponse": "ImportStatusResponse", "ControllersImportRunResponse": "ImportRunResponse", + // httpd/controllers: mobile wire envelopes + "ControllersMobileStatusResponse": "MobileStatusResponse", // legacyimport report "LegacyimportReport": "ImportReport", // service/project entities + DTOs @@ -292,6 +296,7 @@ func operations() []operation { ops = append(ops, reviewOperations()...) ops = append(ops, notificationOperations()...) ops = append(ops, importOperations()...) + ops = append(ops, mobileOperations()...) return ops } @@ -329,6 +334,51 @@ func agentOperations() []operation { } } +// mobileOperations declares the 4 /mobile control operations. These are +// mounted on the loopback router (mountMobile in router.go), not the REST +// /api/v1 group — only the desktop/CLI may enable, disable, or regenerate the +// phone's LAN access; the phone never toggles its own connection. Must stay +// 1:1 with the routes mountMobile registers (enforced by the parity test). +func mobileOperations() []operation { + return []operation{ + { + method: http.MethodGet, path: "/api/v1/mobile/status", id: "getMobileStatus", tag: "mobile", + summary: "Check whether Connect Mobile's LAN bridge is enabled", + resps: []respUnit{ + {http.StatusOK, controllers.MobileStatusResponse{}}, + {http.StatusForbidden, envelope.APIError{}}, + }, + }, + { + method: http.MethodPost, path: "/api/v1/mobile/enable", id: "enableMobile", tag: "mobile", + summary: "Enable the Connect Mobile LAN bridge and issue a fresh password", + resps: []respUnit{ + {http.StatusOK, controllers.MobileStatusResponse{}}, + {http.StatusForbidden, envelope.APIError{}}, + {http.StatusInternalServerError, envelope.APIError{}}, + }, + }, + { + method: http.MethodPost, path: "/api/v1/mobile/disable", id: "disableMobile", tag: "mobile", + summary: "Disable the Connect Mobile LAN bridge", + resps: []respUnit{ + {http.StatusOK, controllers.MobileStatusResponse{}}, + {http.StatusForbidden, envelope.APIError{}}, + {http.StatusInternalServerError, envelope.APIError{}}, + }, + }, + { + method: http.MethodPost, path: "/api/v1/mobile/regenerate", id: "regenerateMobile", tag: "mobile", + summary: "Rotate the Connect Mobile password, dropping any connected phone", + resps: []respUnit{ + {http.StatusOK, controllers.MobileStatusResponse{}}, + {http.StatusForbidden, envelope.APIError{}}, + {http.StatusInternalServerError, envelope.APIError{}}, + }, + }, + } +} + // importOperations declares the 2 /import operations. Must stay 1:1 with // the routes ImportController.Register mounts (enforced by the parity test). func importOperations() []operation { diff --git a/backend/internal/httpd/mobile_routes_test.go b/backend/internal/httpd/mobile_routes_test.go new file mode 100644 index 0000000000..4b0908b0c4 --- /dev/null +++ b/backend/internal/httpd/mobile_routes_test.go @@ -0,0 +1,52 @@ +package httpd + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-chi/chi/v5" + + "github.com/aoagents/agent-orchestrator/backend/internal/httpd/controllers" +) + +// fakeMobileBridge is a no-op mobileBridge implementation. Its type is +// unexported to httpd, but the controllers.MobileController.Bridge field is +// exported and structurally typed, so any value with matching method +// signatures satisfies it from outside the package. +type fakeMobileBridge struct{} + +func (fakeMobileBridge) Status() controllers.MobileStatusResponse { + return controllers.MobileStatusResponse{} +} + +func (fakeMobileBridge) Enable() (controllers.MobileStatusResponse, error) { + return controllers.MobileStatusResponse{}, nil +} + +func (fakeMobileBridge) Disable() error { return nil } + +func (fakeMobileBridge) Regenerate() (controllers.MobileStatusResponse, error) { + return controllers.MobileStatusResponse{}, nil +} + +// newTestRouterWithMobile builds a bare router with only the mobile control +// routes mounted, backed by a fake bridge — enough to exercise the +// loopback gate without a real LAN listener. +func newTestRouterWithMobile(t *testing.T) chi.Router { + t.Helper() + r := chi.NewRouter() + mountMobile(r, &controllers.MobileController{Bridge: fakeMobileBridge{}}) + return r +} + +func TestMobileStatusRouteIsLoopbackGated(t *testing.T) { + r := newTestRouterWithMobile(t) + req := httptest.NewRequest(http.MethodGet, "/api/v1/mobile/status", nil) + req.Host = "192.168.1.9:3011" // non-loopback → must be refused + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusForbidden { + t.Fatalf("non-loopback status: got %d want 403", w.Code) + } +} diff --git a/backend/internal/httpd/router.go b/backend/internal/httpd/router.go index 0bd84af733..b121aa17b2 100644 --- a/backend/internal/httpd/router.go +++ b/backend/internal/httpd/router.go @@ -15,6 +15,7 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/config" "github.com/aoagents/agent-orchestrator/backend/internal/daemonmeta" + "github.com/aoagents/agent-orchestrator/backend/internal/httpd/controllers" "github.com/aoagents/agent-orchestrator/backend/internal/httpd/envelope" "github.com/aoagents/agent-orchestrator/backend/internal/ports" "github.com/aoagents/agent-orchestrator/backend/internal/telemetrymeta" @@ -62,6 +63,7 @@ func NewRouterWithControl(cfg config.Config, log *slog.Logger, termMgr *terminal mountTerminalMux(r, termMgr, log) mountControl(r, control) mountTelemetry(r, deps.Telemetry) + mountMobile(r, deps.Mobile) NewAPI(cfg, deps).Register(r) return r @@ -99,6 +101,33 @@ func mountControl(r chi.Router, deps ControlDeps) { }) } +// mountMobile registers the Connect Mobile control routes: status, enable, +// disable, and regenerate. These toggle the LAN bridge that lets a phone reach +// the daemon, so — like mountControl — every handler is gated by +// localControlRequest: only the desktop/CLI (a loopback caller) may flip the +// phone's access on or off; the phone itself must never be able to. +func mountMobile(r chi.Router, c *controllers.MobileController) { + if c == nil { + return + } + guard := func(h http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, req *http.Request) { + if !localControlRequest(req) { + envelope.WriteJSON(w, http.StatusForbidden, map[string]any{ + "status": "forbidden", + "service": daemonmeta.ServiceName, + }) + return + } + h(w, req) + } + } + r.Get("/api/v1/mobile/status", guard(c.Status)) + r.Post("/api/v1/mobile/enable", guard(c.Enable)) + r.Post("/api/v1/mobile/disable", guard(c.Disable)) + r.Post("/api/v1/mobile/regenerate", guard(c.Regenerate)) +} + type cliInvokedRequest struct { Command string `json:"command"` CommandPath string `json:"commandPath"` diff --git a/packages/shared/src/api/schema.ts b/packages/shared/src/api/schema.ts new file mode 100644 index 0000000000..afac0afb2f --- /dev/null +++ b/packages/shared/src/api/schema.ts @@ -0,0 +1,2969 @@ +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export interface paths { + "/api/v1/events": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Stream CDC events with durable replay */ + get: operations["streamEvents"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/import": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Check whether a legacy AO install is available to import */ + get: operations["getImportStatus"]; + put?: never; + /** Run the legacy AO project import through the daemon store */ + post: operations["runImport"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/mobile/disable": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Disable the Connect Mobile LAN bridge */ + post: operations["disableMobile"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/mobile/enable": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Enable the Connect Mobile LAN bridge and issue a fresh password */ + post: operations["enableMobile"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/mobile/regenerate": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Rotate the Connect Mobile password, dropping any connected phone */ + post: operations["regenerateMobile"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/mobile/status": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Check whether Connect Mobile's LAN bridge is enabled */ + get: operations["getMobileStatus"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/notifications": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List unread notifications */ + get: operations["listNotifications"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/notifications/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** Mark a notification read */ + patch: operations["markNotificationRead"]; + trace?: never; + }; + "/api/v1/notifications/read-all": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Mark all unread notifications read */ + post: operations["markAllNotificationsRead"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/notifications/stream": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Stream created notifications */ + get: operations["streamNotifications"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/orchestrators": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List orchestrator sessions across projects */ + get: operations["listOrchestrators"]; + put?: never; + /** Spawn an orchestrator session */ + post: operations["spawnOrchestrator"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/orchestrators/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Fetch one orchestrator session */ + get: operations["getOrchestrator"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/projects": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List all registered projects (active + degraded) */ + get: operations["listProjects"]; + put?: never; + /** Register a new project from a git repository path */ + post: operations["addProject"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/projects/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Fetch one project; discriminates ok vs degraded */ + get: operations["getProject"]; + put?: never; + post?: never; + /** Remove a project; stops sessions, cleans workspaces, unregisters */ + delete: operations["removeProject"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/projects/{id}/config": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Replace a project's per-project config */ + put: operations["setProjectConfig"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/prs/{id}/merge": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Squash-merge a pull request */ + post: operations["mergePR"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/prs/{id}/resolve-comments": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Resolve review threads on a pull request */ + post: operations["resolveComments"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/sessions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List sessions */ + get: operations["listSessions"]; + put?: never; + /** Spawn a new agent session */ + post: operations["spawnSession"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/sessions/{sessionId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Fetch one session */ + get: operations["getSession"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** Rename a session display name */ + patch: operations["renameSession"]; + trace?: never; + }; + "/api/v1/sessions/{sessionId}/activity": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Report an agent activity-state signal for a session */ + post: operations["setSessionActivity"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/sessions/{sessionId}/kill": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Mark a session terminated and tear down runtime/workspace resources */ + post: operations["killSession"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/sessions/{sessionId}/pr": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List pull requests owned by a session */ + get: operations["listSessionPRs"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/sessions/{sessionId}/pr/claim": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Claim an existing pull request for a session */ + post: operations["claimSessionPR"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/sessions/{sessionId}/preview": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Discover a browser preview URL for a session workspace */ + get: operations["getSessionPreview"]; + put?: never; + /** Set (or autodetect) the browser preview URL for a session */ + post: operations["setSessionPreview"]; + /** Clear the browser preview URL for a session */ + delete: operations["clearSessionPreview"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/sessions/{sessionId}/preview/files/*": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Serve a static browser preview file from a session workspace */ + get: operations["getSessionPreviewFile"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/sessions/{sessionId}/restore": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Restore a terminated session */ + post: operations["restoreSession"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/sessions/{sessionId}/reviews": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List a worker's code-review runs */ + get: operations["listReviews"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/sessions/{sessionId}/reviews/submit": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Record a reviewer's result for a worker's PR */ + post: operations["submitReview"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/sessions/{sessionId}/reviews/trigger": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Trigger a code review of a worker's PR */ + post: operations["triggerReview"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/sessions/{sessionId}/rollback": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Undo a partially-completed spawn (delete seed row, or kill if spawn output exists) */ + post: operations["rollbackSession"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/sessions/{sessionId}/send": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Send a message to a running session's agent */ + post: operations["sendSessionMessage"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/sessions/cleanup": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Clean up terminated session workspaces */ + post: operations["cleanupSessions"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + APIError: { + code: string; + details?: { + [key: string]: unknown; + }; + error: string; + message: string; + requestId?: string; + }; + AddProjectInput: { + asWorkspace?: boolean; + config?: components["schemas"]["ProjectConfig"]; + name?: null | string; + path: string; + projectId?: null | string; + }; + AgentConfig: { + model?: string; + permissions?: string; + }; + ClaimPRRequest: { + allowTakeover?: null | boolean; + pr: string; + }; + ClaimPRResponse: { + branchChanged: boolean; + ok: boolean; + prs: components["schemas"]["SessionPRFacts"][]; + sessionId: string; + takenOverFrom: string[]; + }; + CleanupSessionsResponse: { + cleaned: string[]; + ok: boolean; + skipped: components["schemas"]["CleanupSkippedSession"][]; + }; + CleanupSkippedSession: { + reason: string; + sessionId: string; + }; + ControllersSessionView: { + activity: components["schemas"]["DomainActivity"]; + branch?: string; + /** Format: date-time */ + createdAt: string; + displayName?: string; + harness?: string; + id: string; + isTerminated: boolean; + issueId?: string; + kind: string; + /** Format: int64 */ + previewRevision?: number; + previewUrl?: string; + projectId: string; + prs: components["schemas"]["SessionPRFacts"][]; + /** @enum {string} */ + status: "working" | "pr_open" | "draft" | "ci_failed" | "review_pending" | "changes_requested" | "approved" | "mergeable" | "merged" | "needs_input" | "idle" | "terminated" | "no_signal"; + terminalHandleId?: string; + /** Format: date-time */ + updatedAt: string; + }; + DegradedProject: { + id: string; + kind: string; + name: string; + path: string; + resolveError: string; + }; + DomainActivity: { + /** Format: date-time */ + lastActivityAt: string; + state: string; + }; + DomainReviewerConfig: { + harness: string; + }; + ImportReport: { + dryRun: boolean; + notes?: string[]; + projectsImported: number; + projectsSkipped: number; + }; + ImportRunResponse: { + report: components["schemas"]["ImportReport"]; + }; + ImportStatusResponse: { + available: boolean; + legacyRoot: string; + }; + KillSessionResponse: { + freed?: boolean; + ok: boolean; + sessionId: string; + }; + ListNotificationsResponse: { + notifications: components["schemas"]["NotificationResponse"][]; + }; + ListProjectsResponse: { + projects: components["schemas"]["ProjectSummary"][]; + }; + ListReviewsResponse: { + reviewerHandleId: string; + reviews: components["schemas"]["PRReviewState"][]; + }; + ListSessionPRsResponse: { + prs: components["schemas"]["SessionPRSummary"][]; + sessionId: string; + }; + ListSessionsResponse: { + sessions: components["schemas"]["ControllersSessionView"][]; + }; + MarkAllNotificationsReadResponse: { + notifications: components["schemas"]["NotificationResponse"][]; + }; + MarkNotificationReadRequest: { + /** + * @description V1 supports only marking an unread notification read. + * @enum {string} + */ + status: "read"; + }; + MergePRResponse: { + method: string; + ok: boolean; + prNumber: number; + }; + MobileStatusResponse: { + enabled: boolean; + host: string; + password: string; + port: number; + warning: string; + }; + NotificationEnvelope: { + notification: components["schemas"]["NotificationResponse"]; + }; + NotificationResponse: { + body: string; + /** Format: date-time */ + createdAt: string; + id: string; + prUrl: string; + projectId: string; + sessionId: string; + /** @enum {string} */ + status: "unread" | "read"; + target: components["schemas"]["NotificationTarget"]; + title: string; + /** @enum {string} */ + type: "needs_input" | "ready_to_merge" | "pr_merged" | "pr_closed_unmerged"; + }; + NotificationTarget: { + /** @enum {string} */ + kind: "session" | "pr"; + prUrl?: string; + sessionId: string; + }; + OrchestratorResponse: { + id: string; + projectId: string; + projectName?: string; + }; + PRReviewState: { + latestRun?: components["schemas"]["ReviewRun"]; + prNumber: number; + prUrl: string; + /** @enum {string} */ + status: "needs_review" | "running" | "up_to_date" | "changes_requested" | "ineligible"; + targetSha: string; + title: string; + }; + Project: { + agent?: string; + config?: components["schemas"]["ProjectConfig"]; + defaultBranch: string; + id: string; + kind: string; + name: string; + path: string; + repo: string; + workspaceRepos?: components["schemas"]["WorkspaceRepo"][]; + }; + ProjectConfig: { + agentConfig?: components["schemas"]["AgentConfig"]; + defaultBranch?: string; + env?: { + [key: string]: string; + }; + orchestrator?: components["schemas"]["RoleOverride"]; + postCreate?: string[]; + reviewers?: components["schemas"]["DomainReviewerConfig"][]; + sessionPrefix?: string; + symlinks?: string[]; + worker?: components["schemas"]["RoleOverride"]; + }; + ProjectGetResponse: { + project: components["schemas"]["ProjectOrDegraded"]; + /** @enum {string} */ + status: "ok" | "degraded"; + }; + ProjectOrDegraded: components["schemas"]["Project"] | components["schemas"]["DegradedProject"]; + ProjectResponse: { + project: components["schemas"]["Project"]; + }; + ProjectSummary: { + id: string; + kind: string; + name: string; + path: string; + resolveError?: string; + sessionPrefix: string; + }; + RemoveProjectResult: { + projectId: string; + removedStorageDir: boolean; + }; + RenameSessionRequest: { + displayName: string; + }; + RenameSessionResponse: { + displayName: string; + ok: boolean; + sessionId: string; + }; + ResolveCommentsResponse: { + ok: boolean; + resolved: number; + }; + RestoreSessionResponse: { + ok: boolean; + session: components["schemas"]["ControllersSessionView"]; + sessionId: string; + }; + ReviewRun: { + batchId: string; + body: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + deliveredAt?: null | string; + githubReviewId: string; + harness: string; + id: string; + prUrl: string; + reviewId: string; + sessionId: string; + status: string; + targetSha: string; + verdict: string; + }; + ReviewRunResponse: { + review: components["schemas"]["ReviewRun"]; + reviewerHandleId: string; + reviews: components["schemas"]["ReviewRun"][]; + }; + RoleOverride: { + agent?: string; + agentConfig?: components["schemas"]["AgentConfig"]; + }; + RollbackSessionResponse: { + deleted?: boolean; + killed?: boolean; + ok: boolean; + sessionId: string; + }; + SendSessionMessageRequest: { + message: string; + }; + SendSessionMessageResponse: { + message: string; + ok: boolean; + sessionId: string; + }; + SessionPRCISummary: { + failingChecks: components["schemas"]["SessionPRFailingCheck"][]; + /** @enum {string} */ + state: "unknown" | "pending" | "passing" | "failing"; + }; + SessionPRConflictFile: { + path: string; + url?: string; + }; + SessionPRFacts: { + /** @enum {string} */ + ci: "unknown" | "pending" | "passing" | "failing"; + /** @enum {string} */ + mergeability: "unknown" | "mergeable" | "conflicting" | "blocked" | "unstable"; + number: number; + /** @enum {string} */ + review: "none" | "approved" | "changes_requested" | "review_required"; + reviewComments: boolean; + /** @enum {string} */ + state: "draft" | "open" | "merged" | "closed"; + /** Format: date-time */ + updatedAt: string; + url: string; + }; + SessionPRFailingCheck: { + conclusion: string; + name: string; + /** @enum {string} */ + status: "failed" | "cancelled"; + url?: string; + }; + SessionPRMergeabilitySummary: { + conflictFiles?: components["schemas"]["SessionPRConflictFile"][]; + prUrl: string; + reasons: string[]; + /** @enum {string} */ + state: "unknown" | "mergeable" | "conflicting" | "blocked" | "unstable"; + }; + SessionPRReviewCommentLink: { + file?: string; + line?: number; + url?: string; + }; + SessionPRReviewSummary: { + /** @enum {string} */ + decision: "none" | "approved" | "changes_requested" | "review_required"; + hasUnresolvedHumanComments: boolean; + unresolvedBy: components["schemas"]["SessionPRUnresolvedReviewer"][]; + }; + SessionPRSummary: { + additions: number; + author: string; + changedFiles: number; + ci: components["schemas"]["SessionPRCISummary"]; + /** Format: date-time */ + ciObservedAt?: string; + deletions: number; + headSha: string; + htmlUrl?: string; + mergeability: components["schemas"]["SessionPRMergeabilitySummary"]; + number: number; + /** Format: date-time */ + observedAt?: string; + /** @enum {string} */ + provider: "github"; + repo: string; + review: components["schemas"]["SessionPRReviewSummary"]; + /** Format: date-time */ + reviewObservedAt?: string; + sourceBranch: string; + /** @enum {string} */ + state: "draft" | "open" | "merged" | "closed"; + targetBranch: string; + title: string; + /** Format: date-time */ + updatedAt: string; + url: string; + }; + SessionPRUnresolvedReviewer: { + count: number; + isBot?: boolean; + links: components["schemas"]["SessionPRReviewCommentLink"][]; + reviewUrl?: string; + reviewerId: string; + }; + SessionPreviewResponse: { + entry?: string; + previewUrl?: string; + sessionId: string; + }; + SessionResponse: { + session: components["schemas"]["ControllersSessionView"]; + }; + SetActivityRequest: { + /** + * @description Agent activity state reported by an agent hook. + * @enum {string} + */ + state: "active" | "idle" | "waiting_input" | "exited"; + }; + SetActivityResponse: { + ok: boolean; + sessionId: string; + state: string; + }; + SetProjectConfigInput: { + config: components["schemas"]["ProjectConfig"]; + }; + SetSessionPreviewRequest: { + /** @description Preview target URL. When empty, the daemon autodetects a static entry point in the session workspace. */ + url?: string; + }; + SpawnOrchestratorRequest: { + clean?: boolean; + projectId: string; + }; + SpawnOrchestratorResponse: { + orchestrator: components["schemas"]["OrchestratorResponse"]; + }; + SpawnSessionRequest: { + branch?: string; + displayName?: string; + /** @enum {string} */ + harness?: "claude-code" | "codex" | "aider" | "opencode" | "grok" | "droid" | "amp" | "agy" | "crush" | "cursor" | "qwen" | "copilot" | "goose" | "auggie" | "continue" | "devin" | "cline" | "kimi" | "kiro" | "kilocode" | "vibe" | "pi" | "autohand"; + issueId?: string; + /** @enum {string} */ + kind?: "worker" | "orchestrator"; + projectId: string; + prompt?: string; + }; + SubmitReviewInput: { + /** @description Review body recorded by AO. Required for changes_requested. */ + body?: string; + /** @description Id of the GitHub PR review the reviewer posted, if any. */ + githubReviewId?: string; + /** @description Batched review results recorded by one reviewer CLI command. */ + reviews?: components["schemas"]["SubmitReviewItem"][]; + /** @description Review run id being completed. */ + runId?: string; + /** @description Review verdict: approved or changes_requested. */ + verdict?: string; + }; + SubmitReviewItem: { + /** @description Review body recorded by AO. Required for changes_requested. */ + body?: string; + /** @description Id of the GitHub PR review the reviewer posted, if any. */ + githubReviewId?: string; + /** @description Review run id being completed. */ + runId: string; + /** @description Review verdict: approved or changes_requested. */ + verdict: string; + }; + TriggerReviewResponse: { + reviewerHandleId: string; + reviews: components["schemas"]["PRReviewState"][]; + }; + WorkspaceRepo: { + name: string; + relativePath: string; + repo: string; + }; + }; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export interface operations { + streamEvents: { + parameters: { + query?: { + /** @description Replay events with seq greater than this cursor. When omitted, clients may send Last-Event-ID instead. */ + after?: null | number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "text/event-stream": string; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Not Implemented */ + 501: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + }; + }; + getImportStatus: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ImportStatusResponse"]; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Not Implemented */ + 501: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + }; + }; + runImport: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ImportRunResponse"]; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Not Implemented */ + 501: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + }; + }; + disableMobile: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MobileStatusResponse"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + }; + }; + enableMobile: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MobileStatusResponse"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + }; + }; + regenerateMobile: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MobileStatusResponse"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + }; + }; + getMobileStatus: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MobileStatusResponse"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + }; + }; + listNotifications: { + parameters: { + query?: { + /** @description Notification status filter. V1 supports only unread. */ + status?: "unread"; + /** @description Maximum notifications to return. Defaults to 50; capped at 100. */ + limit?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ListNotificationsResponse"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Not Implemented */ + 501: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + }; + }; + markNotificationRead: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Notification identifier. */ + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["MarkNotificationReadRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["NotificationEnvelope"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Not Implemented */ + 501: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + }; + }; + markAllNotificationsRead: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MarkAllNotificationsReadResponse"]; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Not Implemented */ + 501: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + }; + }; + streamNotifications: { + parameters: { + query?: { + /** @description Optional project id filter for live notifications. */ + projectId?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "text/event-stream": string; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Not Implemented */ + 501: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + }; + }; + listOrchestrators: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ListSessionsResponse"]; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Not Implemented */ + 501: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + }; + }; + spawnOrchestrator: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SpawnOrchestratorRequest"]; + }; + }; + responses: { + /** @description Created */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SpawnOrchestratorResponse"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Not Implemented */ + 501: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + }; + }; + getOrchestrator: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Orchestrator session identifier, e.g. project-orchestrator. */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SessionResponse"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Not Implemented */ + 501: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + }; + }; + listProjects: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ListProjectsResponse"]; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + }; + }; + addProject: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AddProjectInput"]; + }; + }; + responses: { + /** @description Created */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProjectResponse"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + }; + }; + getProject: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Project identifier (registry key). */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProjectGetResponse"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + }; + }; + removeProject: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Project identifier (registry key). */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RemoveProjectResult"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + }; + }; + setProjectConfig: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Project identifier (registry key). */ + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SetProjectConfigInput"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProjectResponse"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + }; + }; + mergePR: { + parameters: { + query?: never; + header?: never; + path: { + /** @description PR number. */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MergePRResponse"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Not Implemented */ + 501: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + }; + }; + resolveComments: { + parameters: { + query?: never; + header?: never; + path: { + /** @description PR number. */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ResolveCommentsResponse"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Not Implemented */ + 501: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + }; + }; + listSessions: { + parameters: { + query?: { + /** @description Project id filter. */ + project?: string; + /** @description When true, return non-terminated sessions; when false, return terminated sessions. */ + active?: null | boolean; + /** @description When true, return only orchestrator sessions. */ + orchestratorOnly?: null | boolean; + /** @description When true, return only fresh non-terminated sessions. */ + fresh?: null | boolean; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ListSessionsResponse"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + }; + }; + spawnSession: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SpawnSessionRequest"]; + }; + }; + responses: { + /** @description Created */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SessionResponse"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + }; + }; + getSession: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Session identifier, e.g. project-1. */ + sessionId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SessionResponse"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + }; + }; + renameSession: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Session identifier, e.g. project-1. */ + sessionId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["RenameSessionRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RenameSessionResponse"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Not Implemented */ + 501: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + }; + }; + setSessionActivity: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Session identifier, e.g. project-1. */ + sessionId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SetActivityRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SetActivityResponse"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Not Implemented */ + 501: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + }; + }; + killSession: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Session identifier, e.g. project-1. */ + sessionId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["KillSessionResponse"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + }; + }; + listSessionPRs: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Session identifier, e.g. project-1. */ + sessionId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ListSessionPRsResponse"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Not Implemented */ + 501: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + }; + }; + claimSessionPR: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Session identifier, e.g. project-1. */ + sessionId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ClaimPRRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ClaimPRResponse"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Not Implemented */ + 501: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Service Unavailable */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + }; + }; + getSessionPreview: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Session identifier, e.g. project-1. */ + sessionId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SessionPreviewResponse"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Not Implemented */ + 501: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + }; + }; + setSessionPreview: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Session identifier, e.g. project-1. */ + sessionId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SetSessionPreviewRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SessionResponse"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Not Implemented */ + 501: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + }; + }; + clearSessionPreview: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Session identifier, e.g. project-1. */ + sessionId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SessionResponse"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Not Implemented */ + 501: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + }; + }; + getSessionPreviewFile: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Session identifier, e.g. project-1. */ + sessionId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "text/html": string; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Not Implemented */ + 501: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + }; + }; + restoreSession: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Session identifier, e.g. project-1. */ + sessionId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RestoreSessionResponse"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + }; + }; + listReviews: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Session identifier, e.g. project-1. */ + sessionId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ListReviewsResponse"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Not Implemented */ + 501: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + }; + }; + submitReview: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Session identifier, e.g. project-1. */ + sessionId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SubmitReviewInput"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ReviewRunResponse"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Not Implemented */ + 501: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + }; + }; + triggerReview: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Session identifier, e.g. project-1. */ + sessionId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TriggerReviewResponse"]; + }; + }; + /** @description Created */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TriggerReviewResponse"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Not Implemented */ + 501: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + }; + }; + rollbackSession: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Session identifier, e.g. project-1. */ + sessionId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RollbackSessionResponse"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + }; + }; + sendSessionMessage: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Session identifier, e.g. project-1. */ + sessionId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SendSessionMessageRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SendSessionMessageResponse"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + }; + }; + cleanupSessions: { + parameters: { + query?: { + /** @description Project id filter. When omitted, clean terminated sessions across all projects. */ + project?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CleanupSessionsResponse"]; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Not Implemented */ + 501: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + }; + }; +} From a86651e2189d6d9b9285a07604284742e19c8acf Mon Sep 17 00:00:00 2001 From: Prasad-D-Ware Date: Tue, 7 Jul 2026 19:26:46 +0530 Subject: [PATCH 09/26] feat(mobile): wire LAN listener into daemon with restore-on-boot (cherry picked from commit 1a65adbf058db1606d9f8df4c3c794387e08e7dc) --- backend/internal/daemon/daemon.go | 31 ++++++++++ backend/internal/daemon/mobile_restore.go | 29 ++++++++++ .../internal/daemon/mobile_restore_test.go | 58 +++++++++++++++++++ backend/internal/httpd/lan_listener.go | 8 +++ backend/internal/httpd/server.go | 5 ++ backend/internal/mobilebridge/config.go | 5 ++ 6 files changed, 136 insertions(+) create mode 100644 backend/internal/daemon/mobile_restore.go create mode 100644 backend/internal/daemon/mobile_restore_test.go diff --git a/backend/internal/daemon/daemon.go b/backend/internal/daemon/daemon.go index 0e31f06399..c9feda9116 100644 --- a/backend/internal/daemon/daemon.go +++ b/backend/internal/daemon/daemon.go @@ -18,6 +18,8 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/daemon/supervisor" "github.com/aoagents/agent-orchestrator/backend/internal/domain" "github.com/aoagents/agent-orchestrator/backend/internal/httpd" + "github.com/aoagents/agent-orchestrator/backend/internal/httpd/controllers" + "github.com/aoagents/agent-orchestrator/backend/internal/mobilebridge" "github.com/aoagents/agent-orchestrator/backend/internal/notify" "github.com/aoagents/agent-orchestrator/backend/internal/ports" "github.com/aoagents/agent-orchestrator/backend/internal/preview" @@ -139,6 +141,18 @@ func Run() error { } }() + // Connect Mobile: the bridge service needs the LAN listener, but the LAN + // listener needs the built router's handler, which only exists once srv is + // constructed — and srv's router mounts the mobile controller, which needs + // the bridge service. Break the cycle with late binding: build bs with LAN + // left nil, hand its controller into NewWithDeps, then once srv exists, + // build the LAN listener over srv.Handler() and assign it onto bs.LAN. + bs := &controllers.BridgeService{ + ConfigPath: mobilebridge.Path(cfg.DataDir), + DefaultPort: mobilebridge.DefaultPort, + } + mc := &controllers.MobileController{Bridge: bs} + srv, err := httpd.NewWithDeps(cfg, log, termMgr, httpd.APIDeps{ Projects: projectsvc.NewWithDeps(projectsvc.Deps{Store: store, Sessions: sessionSvc, DefaultHarness: domain.AgentHarness(cfg.Agent), Telemetry: telemetrySink}), Agents: agentSvc, @@ -151,6 +165,7 @@ func Run() error { Events: cdcPipe.Broadcaster, Activity: lcStack.LCM, Telemetry: telemetrySink, + Mobile: mc, }) if err != nil { stop() @@ -162,6 +177,19 @@ func Run() error { return err } + // Late-bind: the LAN listener shares the exact loopback router instance so + // the LAN surface and loopback surface never drift apart. + lan := httpd.NewMobileLAN(srv.Handler(), mobilebridge.DefaultPort, log) + bs.LAN = lan + + // Restore Connect Mobile across a daemon restart: if the bridge was left + // enabled, re-arm the listener on its last port with the same password + // hash so an already-paired phone keeps working with no new password. + // Best-effort: never blocks boot. + if err := restoreMobileOnBoot(mobilebridge.Path(cfg.DataDir), lan); err != nil { + log.Warn("restore mobile bridge on boot failed", "err", err) + } + // Reconcile sessions on boot: adopt crash-surviving runtimes, capture and // terminate dead ones, reap leaked tmux, then restore shutdown-saved // sessions. Best-effort: a failure is logged but never blocks boot. Placed @@ -202,6 +230,9 @@ func Run() error { stop() <-previewDone lcStack.Stop() + if err := lan.Stop(context.Background()); err != nil { + log.Error("mobile LAN listener shutdown", "err", err) + } if err := cdcPipe.Stop(); err != nil { log.Error("cdc pipeline shutdown", "err", err) } diff --git a/backend/internal/daemon/mobile_restore.go b/backend/internal/daemon/mobile_restore.go new file mode 100644 index 0000000000..6053679576 --- /dev/null +++ b/backend/internal/daemon/mobile_restore.go @@ -0,0 +1,29 @@ +package daemon + +import ( + "fmt" + + "github.com/aoagents/agent-orchestrator/backend/internal/httpd/controllers" + "github.com/aoagents/agent-orchestrator/backend/internal/mobilebridge" +) + +// restoreMobileOnBoot re-arms the Connect Mobile LAN listener across daemon +// restarts. If the persisted state says the bridge was enabled, it reuses the +// existing password hash (no rotation — the paired phone keeps working) and +// restarts the listener on its last bound port. A non-nil return means the +// listener failed to (re)bind; the caller logs it as a warning and continues +// booting regardless — Connect Mobile is best-effort, not load-bearing. +func restoreMobileOnBoot(path string, lan controllers.LANController) error { + state, err := mobilebridge.Load(path) + if err != nil { + return fmt.Errorf("load mobile bridge state: %w", err) + } + if !state.Enabled { + return nil + } + lan.SetPasswordHash(state.PasswordHash) + if _, err := lan.Start(state.LastPort); err != nil { + return fmt.Errorf("restart mobile LAN listener: %w", err) + } + return nil +} diff --git a/backend/internal/daemon/mobile_restore_test.go b/backend/internal/daemon/mobile_restore_test.go new file mode 100644 index 0000000000..ab35bf2a2c --- /dev/null +++ b/backend/internal/daemon/mobile_restore_test.go @@ -0,0 +1,58 @@ +package daemon + +import ( + "context" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/mobilebridge" +) + +// fakeLAN is a minimal httpd.LANController fake for exercising +// restoreMobileOnBoot without a real listener. +type fakeLAN struct { + started bool + hash string + port int +} + +func (f *fakeLAN) Start(port int) (int, error) { + f.started = true + f.port = port + return port, nil +} +func (f *fakeLAN) Stop(ctx context.Context) error { return nil } +func (f *fakeLAN) Running() bool { return f.started } +func (f *fakeLAN) BoundPort() int { return f.port } +func (f *fakeLAN) SetPasswordHash(hash string) { f.hash = hash } + +func TestRestoreEnabledStartsListener(t *testing.T) { + dir := t.TempDir() + path := mobilebridge.Path(dir) + if err := mobilebridge.Save(path, mobilebridge.State{Enabled: true, PasswordHash: "h", LastPort: 3011}); err != nil { + t.Fatalf("save state: %v", err) + } + lan := &fakeLAN{} + restoreMobileOnBoot(path, lan) + if !lan.started { + t.Fatal("expected LAN listener started from persisted enabled state") + } + if lan.hash != "h" { + t.Fatalf("expected persisted hash reused, got %q", lan.hash) + } + if lan.port != 3011 { + t.Fatalf("expected persisted port reused, got %d", lan.port) + } +} + +func TestRestoreDisabledDoesNotStart(t *testing.T) { + dir := t.TempDir() + path := mobilebridge.Path(dir) + if err := mobilebridge.Save(path, mobilebridge.State{Enabled: false}); err != nil { + t.Fatalf("save state: %v", err) + } + lan := &fakeLAN{} + restoreMobileOnBoot(path, lan) + if lan.started { + t.Fatal("expected LAN listener NOT started when persisted state is disabled") + } +} diff --git a/backend/internal/httpd/lan_listener.go b/backend/internal/httpd/lan_listener.go index aa764724e3..12ebe14b16 100644 --- a/backend/internal/httpd/lan_listener.go +++ b/backend/internal/httpd/lan_listener.go @@ -37,6 +37,14 @@ func NewLANManager(handler http.Handler, state *authState, defaultPort int, log } } +// NewMobileLAN constructs a LANManager with its own private authState. Callers +// outside this package (the daemon) cannot construct an authState directly +// since it is unexported; this gives them a LANManager that owns one, and the +// daemon rotates the connection password exclusively via SetPasswordHash. +func NewMobileLAN(handler http.Handler, defaultPort int, log *slog.Logger) *LANManager { + return NewLANManager(handler, &authState{}, defaultPort, log) +} + // SetPasswordHash stores the current connection password hash on the shared // authState so the auth middleware (already wrapping handler) validates // against it. Satisfies controllers.LANController. diff --git a/backend/internal/httpd/server.go b/backend/internal/httpd/server.go index e770db9b87..4dbbe93b1e 100644 --- a/backend/internal/httpd/server.go +++ b/backend/internal/httpd/server.go @@ -80,6 +80,11 @@ func NewWithDeps(cfg config.Config, log *slog.Logger, termMgr *terminal.Manager, // and the OS chose one — primarily in tests). func (s *Server) Addr() net.Addr { return s.listen.Addr() } +// Handler returns the loopback server's built router so the daemon can share +// the exact same handler instance with the LAN listener (via NewMobileLAN), +// keeping the loopback and LAN surfaces identical. +func (s *Server) Handler() http.Handler { return s.http.Handler } + // Run serves until ctx is cancelled (SIGINT/SIGTERM via signal.NotifyContext), // then performs a graceful shutdown bounded by cfg.ShutdownTimeout. It writes // running.json before serving and removes it on the way out. Run blocks until diff --git a/backend/internal/mobilebridge/config.go b/backend/internal/mobilebridge/config.go index e88b44dc01..49dadcb5c3 100644 --- a/backend/internal/mobilebridge/config.go +++ b/backend/internal/mobilebridge/config.go @@ -14,6 +14,11 @@ import ( "path/filepath" ) +// DefaultPort is the LAN listener's default port for the Connect Mobile +// bridge. Distinct from config.DefaultPort (the loopback API port) since the +// two listeners can run concurrently. +const DefaultPort = 3011 + type State struct { Enabled bool `json:"enabled"` PasswordHash string `json:"passwordHash"` From 130cffc39ecfc281e9cf4bcdabb49960e57c4367 Mon Sep 17 00:00:00 2001 From: Prasad-D-Ware Date: Tue, 7 Jul 2026 19:30:53 +0530 Subject: [PATCH 10/26] fix(mobile): bound LAN listener shutdown ctx; assert restore happy path (cherry picked from commit a2c051bcff9d1ccf19f1e39d84900d2e69a957aa) --- backend/internal/daemon/daemon.go | 4 +++- backend/internal/daemon/mobile_restore_test.go | 8 ++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/backend/internal/daemon/daemon.go b/backend/internal/daemon/daemon.go index c9feda9116..4facb17dd3 100644 --- a/backend/internal/daemon/daemon.go +++ b/backend/internal/daemon/daemon.go @@ -230,7 +230,9 @@ func Run() error { stop() <-previewDone lcStack.Stop() - if err := lan.Stop(context.Background()); err != nil { + lanStopCtx, lanCancel := context.WithTimeout(context.Background(), cfg.ShutdownTimeout) + defer lanCancel() + if err := lan.Stop(lanStopCtx); err != nil { log.Error("mobile LAN listener shutdown", "err", err) } if err := cdcPipe.Stop(); err != nil { diff --git a/backend/internal/daemon/mobile_restore_test.go b/backend/internal/daemon/mobile_restore_test.go index ab35bf2a2c..720a5e14ad 100644 --- a/backend/internal/daemon/mobile_restore_test.go +++ b/backend/internal/daemon/mobile_restore_test.go @@ -32,7 +32,9 @@ func TestRestoreEnabledStartsListener(t *testing.T) { t.Fatalf("save state: %v", err) } lan := &fakeLAN{} - restoreMobileOnBoot(path, lan) + if err := restoreMobileOnBoot(path, lan); err != nil { + t.Fatalf("restoreMobileOnBoot: %v", err) + } if !lan.started { t.Fatal("expected LAN listener started from persisted enabled state") } @@ -51,7 +53,9 @@ func TestRestoreDisabledDoesNotStart(t *testing.T) { t.Fatalf("save state: %v", err) } lan := &fakeLAN{} - restoreMobileOnBoot(path, lan) + if err := restoreMobileOnBoot(path, lan); err != nil { + t.Fatalf("restoreMobileOnBoot: %v", err) + } if lan.started { t.Fatal("expected LAN listener NOT started when persisted state is disabled") } From c363eecefc1f5e1f6f529b554817fb3a81de056e Mon Sep 17 00:00:00 2001 From: Prasad-D-Ware Date: Tue, 7 Jul 2026 19:33:31 +0530 Subject: [PATCH 11/26] feat(ui): add shadcn dialog primitive (cherry picked from commit 27e690005362a34af9c1d5c41313a4076749bdcd) --- .../src/renderer/components/ui/dialog.tsx | 97 +++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 frontend/src/renderer/components/ui/dialog.tsx diff --git a/frontend/src/renderer/components/ui/dialog.tsx b/frontend/src/renderer/components/ui/dialog.tsx new file mode 100644 index 0000000000..3c24375d88 --- /dev/null +++ b/frontend/src/renderer/components/ui/dialog.tsx @@ -0,0 +1,97 @@ +"use client"; + +import * as React from "react"; +import { XIcon } from "lucide-react"; +import { Dialog as DialogPrimitive } from "radix-ui"; + +import { cn } from "@/lib/utils"; + +function Dialog({ ...props }: React.ComponentProps) { + return ; +} + +function DialogTrigger({ ...props }: React.ComponentProps) { + return ; +} + +function DialogPortal({ ...props }: React.ComponentProps) { + return ; +} + +function DialogClose({ ...props }: React.ComponentProps) { + return ; +} + +function DialogOverlay({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function DialogContent({ + className, + children, + showCloseButton = true, + ...props +}: React.ComponentProps & { + showCloseButton?: boolean; +}) { + return ( + + + + {children} + {showCloseButton && ( + + + Close + + )} + + + ); +} + +function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return
; +} + +function DialogFooter({ className, ...props }: React.ComponentProps<"div">) { + return
; +} + +function DialogTitle({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function DialogDescription({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +export { Dialog, DialogTrigger, DialogPortal, DialogClose, DialogOverlay, DialogContent, DialogHeader, DialogFooter, DialogTitle, DialogDescription }; From 8fc5d8c9d7f679f63ef9ca3a27273e9101b4aec1 Mon Sep 17 00:00:00 2001 From: Prasad-D-Ware Date: Tue, 7 Jul 2026 19:38:24 +0530 Subject: [PATCH 12/26] feat(mobile): desktop Connect Mobile modal with QR, password, warning (cherry picked from commit 33df1d0fb86a114ce849eb8bdd2e1d672933d445) --- frontend/package-lock.json | 10 + frontend/package.json | 1 + frontend/src/api/schema.ts | 218 ++++++++++++++++++ .../components/ConnectMobileButton.tsx | 31 +++ .../components/ConnectMobileModal.test.tsx | 8 + .../components/ConnectMobileModal.tsx | 193 ++++++++++++++++ .../components/GlobalSettingsForm.tsx | 7 +- 7 files changed, 466 insertions(+), 2 deletions(-) create mode 100644 frontend/src/renderer/components/ConnectMobileButton.tsx create mode 100644 frontend/src/renderer/components/ConnectMobileModal.test.tsx create mode 100644 frontend/src/renderer/components/ConnectMobileModal.tsx diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 2f03ac7239..3813f0e407 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -28,6 +28,7 @@ "lucide-react": "^1.17.0", "openapi-fetch": "^0.17.0", "posthog-js": "^1.390.2", + "qrcode.react": "^4.2.0", "radix-ui": "^1.5.0", "react": "^19.2.7", "react-dom": "^19.2.7", @@ -13478,6 +13479,15 @@ "node": ">=16.0.0" } }, + "node_modules/qrcode.react": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz", + "integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/query-selector-shadow-dom": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/query-selector-shadow-dom/-/query-selector-shadow-dom-1.0.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index a70a05a573..b00824baa4 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -73,6 +73,7 @@ "lucide-react": "^1.17.0", "openapi-fetch": "^0.17.0", "posthog-js": "^1.390.2", + "qrcode.react": "^4.2.0", "radix-ui": "^1.5.0", "react": "^19.2.7", "react-dom": "^19.2.7", diff --git a/frontend/src/api/schema.ts b/frontend/src/api/schema.ts index 3bd45ec046..8ca6e38d78 100644 --- a/frontend/src/api/schema.ts +++ b/frontend/src/api/schema.ts @@ -90,6 +90,74 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/mobile/disable": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Disable the Connect Mobile LAN bridge */ + post: operations["disableMobile"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/mobile/enable": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Enable the Connect Mobile LAN bridge and issue a fresh password */ + post: operations["enableMobile"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/mobile/regenerate": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Rotate the Connect Mobile password, dropping any connected phone */ + post: operations["regenerateMobile"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/mobile/status": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Check whether Connect Mobile's LAN bridge is enabled */ + get: operations["getMobileStatus"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/notifications": { parameters: { query?: never; @@ -687,6 +755,13 @@ export interface components { ok: boolean; prNumber: number; }; + MobileStatusResponse: { + enabled: boolean; + host: string; + password: string; + port: number; + warning: string; + }; NotificationEnvelope: { notification: components["schemas"]["NotificationResponse"]; }; @@ -1263,6 +1338,149 @@ export interface operations { }; }; }; + disableMobile: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MobileStatusResponse"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + }; + }; + enableMobile: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MobileStatusResponse"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + }; + }; + regenerateMobile: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MobileStatusResponse"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + }; + }; + getMobileStatus: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MobileStatusResponse"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + }; + }; listNotifications: { parameters: { query?: { diff --git a/frontend/src/renderer/components/ConnectMobileButton.tsx b/frontend/src/renderer/components/ConnectMobileButton.tsx new file mode 100644 index 0000000000..e3664737bf --- /dev/null +++ b/frontend/src/renderer/components/ConnectMobileButton.tsx @@ -0,0 +1,31 @@ +import { useState } from "react"; +import { ConnectMobileModal } from "./ConnectMobileModal"; +import { Button } from "./ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"; + +// ConnectMobileButton is the Global Settings card for pairing the mobile app +// with this desktop over the LAN bridge. It just opens ConnectMobileModal, +// which owns the enable/disable/regenerate flow. +export function ConnectMobileButton() { + const [open, setOpen] = useState(false); + + return ( + + + Connect Mobile + + +

+ Pair the Agent Orchestrator mobile app with this desktop so you can drive sessions from your phone while on + the same network. +

+
+ +
+
+ +
+ ); +} diff --git a/frontend/src/renderer/components/ConnectMobileModal.test.tsx b/frontend/src/renderer/components/ConnectMobileModal.test.tsx new file mode 100644 index 0000000000..0f3e441357 --- /dev/null +++ b/frontend/src/renderer/components/ConnectMobileModal.test.tsx @@ -0,0 +1,8 @@ +import { expect, test } from "vitest"; +import { pairingPayload } from "./ConnectMobileModal"; + +test("QR payload never contains the password", () => { + const s = pairingPayload("192.168.1.42", 3011); + expect(JSON.parse(s)).toEqual({ v: 1, host: "192.168.1.42", port: 3011 }); + expect(s.toLowerCase()).not.toContain("password"); +}); diff --git a/frontend/src/renderer/components/ConnectMobileModal.tsx b/frontend/src/renderer/components/ConnectMobileModal.tsx new file mode 100644 index 0000000000..2990126565 --- /dev/null +++ b/frontend/src/renderer/components/ConnectMobileModal.tsx @@ -0,0 +1,193 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useState } from "react"; +import { Loader2 } from "lucide-react"; +import { QRCodeSVG } from "qrcode.react"; +import { apiClient, apiErrorMessage } from "../lib/api-client"; +import { Button } from "./ui/button"; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "./ui/dialog"; + +export const mobileStatusQueryKey = ["mobile-status"] as const; + +interface MobileStatus { + enabled: boolean; + host: string; + port: number; + password: string; + warning: string; +} + +// pairingPayload is the QR code contents scanned by the mobile app to discover +// the desktop's LAN bridge. It deliberately excludes the password — the QR +// code can be seen by anyone nearby, so the password is only ever shown as +// plaintext in this modal and typed in by hand on the phone. +export function pairingPayload(host: string, port: number): string { + return JSON.stringify({ v: 1, host, port }); +} + +async function fetchMobileStatus(): Promise { + const { data, error } = await apiClient.GET("/api/v1/mobile/status"); + if (error || !data) throw new Error(apiErrorMessage(error)); + return data; +} + +interface ConnectMobileModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +// ConnectMobileModal lets a user pair the mobile app with this desktop over +// the LAN bridge (issue: mobile phone bridge). It reads the daemon's mobile +// status; when disabled it offers an Enable button, and when enabled it shows +// a QR code (host/port only, no password), the plaintext password with a +// copy affordance, and Regenerate/Disable actions. +export function ConnectMobileModal({ open, onOpenChange }: ConnectMobileModalProps) { + const queryClient = useQueryClient(); + const [copied, setCopied] = useState(false); + + const query = useQuery({ + queryKey: mobileStatusQueryKey, + queryFn: fetchMobileStatus, + enabled: open, + }); + + const invalidate = () => { + void queryClient.invalidateQueries({ queryKey: mobileStatusQueryKey }); + }; + + const enable = useMutation({ + mutationFn: async () => { + const { data, error } = await apiClient.POST("/api/v1/mobile/enable"); + if (error) throw new Error(apiErrorMessage(error)); + return data; + }, + onSuccess: invalidate, + }); + + const disable = useMutation({ + mutationFn: async () => { + const { data, error } = await apiClient.POST("/api/v1/mobile/disable"); + if (error) throw new Error(apiErrorMessage(error)); + return data; + }, + onSuccess: invalidate, + }); + + const regenerate = useMutation({ + mutationFn: async () => { + const { data, error } = await apiClient.POST("/api/v1/mobile/regenerate"); + if (error) throw new Error(apiErrorMessage(error)); + return data; + }, + onSuccess: invalidate, + }); + + const status = query.data; + const busy = enable.isPending || disable.isPending || regenerate.isPending; + + const copyPassword = async () => { + if (!status?.password) return; + await navigator.clipboard.writeText(status.password); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + }; + + return ( + + + + Connect Mobile + Pair the Agent Orchestrator mobile app with this desktop over your LAN. + + + {query.isLoading ? ( +

Checking status…

+ ) : query.isError ? ( +

+ {query.error instanceof Error ? query.error.message : "Failed to load mobile status."} +

+ ) : status && !status.enabled ? ( +
+

+ Enable the mobile bridge to let your phone connect to this desktop while both are on the same network. +

+ {enable.isError && ( +

+ {enable.error instanceof Error ? enable.error.message : "Failed to enable."} +

+ )} + + + +
+ ) : status ? ( +
+
+ +
+ +
+ + + {status.host}:{status.port} + + + +
+ {status.password} + +
+
+
+ + {status.warning && ( +

+ {status.warning} +

+ )} + + {(disable.isError || regenerate.isError) && ( +

+ {disable.error instanceof Error + ? disable.error.message + : regenerate.error instanceof Error + ? regenerate.error.message + : "Request failed."} +

+ )} + + + + + +
+ ) : null} +
+
+ ); +} + +function Row({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ {label} + {children} +
+ ); +} diff --git a/frontend/src/renderer/components/GlobalSettingsForm.tsx b/frontend/src/renderer/components/GlobalSettingsForm.tsx index bdb6ddf32f..6bf5e9460e 100644 --- a/frontend/src/renderer/components/GlobalSettingsForm.tsx +++ b/frontend/src/renderer/components/GlobalSettingsForm.tsx @@ -1,10 +1,12 @@ +import { ConnectMobileButton } from "./ConnectMobileButton"; import { DashboardSubhead } from "./DashboardSubhead"; import { MigrationSection } from "./MigrationSection"; import { UpdatesSection } from "./UpdatesSection"; // App-wide settings, shown from the sidebar when no project is selected. Each -// section is a self-contained card: Updates (auto-update channel, #2207) and -// Migration (re-run the legacy-AO import, #2205). +// section is a self-contained card: Updates (auto-update channel, #2207), +// Migration (re-run the legacy-AO import, #2205), and Connect Mobile (pair the +// mobile app over the LAN bridge). export function GlobalSettingsForm() { return (
@@ -13,6 +15,7 @@ export function GlobalSettingsForm() {
+
From 0ae405e720fca5df0f64a88a177f7b5dfd474f12 Mon Sep 17 00:00:00 2001 From: Prasad-D-Ware Date: Tue, 7 Jul 2026 19:43:18 +0530 Subject: [PATCH 13/26] feat(mobile): send Authorization bearer on REST + mux (cherry picked from commit b4322a4d2397f07977b8f54fae4fb8a14f949297) --- packages/mobile/lib/api.ts | 4 ++-- packages/mobile/lib/config.ts | 8 +++++++- packages/mobile/lib/mux.ts | 4 ++-- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/packages/mobile/lib/api.ts b/packages/mobile/lib/api.ts index 04253cbd80..db2b82467e 100644 --- a/packages/mobile/lib/api.ts +++ b/packages/mobile/lib/api.ts @@ -1,4 +1,4 @@ -import { httpBase, type ServerConfig } from "./config"; +import { authHeaders, httpBase, type ServerConfig } from "./config"; import type { AttentionLevel } from "./theme"; // ---- Types (subset of AO's DashboardSession we use on the phone) ------------ @@ -199,7 +199,7 @@ async function req(cfg: ServerConfig, path: string, init?: RequestInit): Promise res = await fetch(url, { ...init, signal: controller.signal, - headers: { "Content-Type": "application/json", ...(init?.headers ?? {}) }, + headers: { ...authHeaders(cfg), "Content-Type": "application/json", ...(init?.headers ?? {}) }, }); } catch (e) { if ((e as { name?: string })?.name === "AbortError") { diff --git a/packages/mobile/lib/config.ts b/packages/mobile/lib/config.ts index edef1a0811..64459acb41 100644 --- a/packages/mobile/lib/config.ts +++ b/packages/mobile/lib/config.ts @@ -10,6 +10,7 @@ export type ServerConfig = { httpPort: string; // AO daemon HTTP port (REST API + /mux), default 3001 muxPort: string; // legacy separate mux port - unused against the Go daemon secure?: boolean; // use https/wss instead of http/ws (TLS / Tailscale funnel) + password: string; // daemon connection password for Authorization header }; export const DEFAULT_CONFIG: ServerConfig = { @@ -17,9 +18,14 @@ export const DEFAULT_CONFIG: ServerConfig = { httpPort: "3001", muxPort: "14801", secure: false, + password: "", }; -// Strip a pasted scheme (http://, ws://, ...) and trailing slashes so we never +export function authHeaders(cfg: ServerConfig): Record { + return cfg.password ? { Authorization: `Bearer ${cfg.password}` } : {}; +} + +// Strip a pasted scheme (http://, ws://, …) and trailing slashes so we never // build a double-scheme URL like "http://https://host". function cleanHost(host: string): string { return host diff --git a/packages/mobile/lib/mux.ts b/packages/mobile/lib/mux.ts index 0915d9f159..83ef342eb6 100644 --- a/packages/mobile/lib/mux.ts +++ b/packages/mobile/lib/mux.ts @@ -1,4 +1,4 @@ -import { muxUrl, type ServerConfig } from "./config"; +import { authHeaders, muxUrl, type ServerConfig } from "./config"; // Mirrors AO's mux-protocol.ts (the bits we use). export type SessionPatch = { @@ -129,7 +129,7 @@ export class MuxClient { const WS = WebSocket as unknown as { new (url: string, protocols?: string | string[], options?: { headers?: Record }): WebSocket; }; - ws = new WS(muxUrl(this.cfg), undefined, { headers: { Origin: "http://localhost" } }); + ws = new WS(muxUrl(this.cfg), undefined, { headers: { Origin: "http://localhost", ...authHeaders(this.cfg) } }); } catch (e) { this.handlers.onStatus?.("error", String(e)); this.scheduleReconnect(); From 8f949deef4aeee01124ee2339f7322e469f13b28 Mon Sep 17 00:00:00 2001 From: Prasad-D-Ware Date: Tue, 7 Jul 2026 19:50:01 +0530 Subject: [PATCH 14/26] feat(mobile): QR scan pairing + password popup (cherry picked from commit 42d8624efc368eec10e2c7dfed2819a69c318956) --- packages/mobile/app.json | 33 ++++++- packages/mobile/app/(tabs)/settings.tsx | 114 +++++++++++++++++++++-- packages/mobile/app/_layout.tsx | 1 + packages/mobile/app/pair.tsx | 115 ++++++++++++++++++++++++ packages/mobile/lib/pairing.ts | 22 +++++ packages/mobile/package.json | 1 + 6 files changed, 276 insertions(+), 10 deletions(-) create mode 100644 packages/mobile/app/pair.tsx create mode 100644 packages/mobile/lib/pairing.ts diff --git a/packages/mobile/app.json b/packages/mobile/app.json index 4088bb2e43..6100793b5b 100644 --- a/packages/mobile/app.json +++ b/packages/mobile/app.json @@ -16,7 +16,9 @@ "ios": { "supportsTablet": true, "bundleIdentifier": "aoagents.ao", - "config": { "usesNonExemptEncryption": false } + "config": { + "usesNonExemptEncryption": false + } }, "android": { "package": "aoagents.ao", @@ -26,8 +28,31 @@ }, "predictiveBackGestureEnabled": false }, - "web": { "favicon": "./assets/favicon.png", "bundler": "metro" }, - "plugins": ["expo-router", ["expo-build-properties", { "android": { "usesCleartextTraffic": true } }]], - "extra": { "router": {} } + "web": { + "favicon": "./assets/favicon.png", + "bundler": "metro" + }, + "plugins": [ + "expo-router", + [ + "expo-build-properties", + { + "android": { + "usesCleartextTraffic": true + } + } + ], + [ + "expo-camera", + { + "cameraPermission": "Allow AO to scan the pairing QR code.", + "microphonePermission": false, + "recordAudioAndroid": false + } + ] + ], + "extra": { + "router": {} + } } } diff --git a/packages/mobile/app/(tabs)/settings.tsx b/packages/mobile/app/(tabs)/settings.tsx index eb12158581..1496b5cabb 100644 --- a/packages/mobile/app/(tabs)/settings.tsx +++ b/packages/mobile/app/(tabs)/settings.tsx @@ -4,6 +4,7 @@ import { useEffect, useState } from "react"; import { ActivityIndicator, KeyboardAvoidingView, + Modal, Platform, Pressable, ScrollView, @@ -35,6 +36,8 @@ export default function SettingsScreen() { const [testing, setTesting] = useState(false); const [saved, setSaved] = useState(false); const [result, setResult] = useState<{ ok: boolean; msg: string } | null>(null); + const [pwPromptOpen, setPwPromptOpen] = useState(false); + const [pwDraft, setPwDraft] = useState(""); useEffect(() => { loadConfig().then((c) => { @@ -45,16 +48,23 @@ export default function SettingsScreen() { const set = (k: keyof ServerConfig) => (v: string) => setCfg((prev) => ({ ...prev, [k]: v })); - async function test() { + async function test(target: ServerConfig = cfg) { setTesting(true); setResult(null); try { - await saveConfig(cfg); - const count = await pingServer(cfg); - setResult({ ok: true, msg: `Connected - ${count} session(s) found.` }); + await saveConfig(target); + const count = await pingServer(target); + setResult({ ok: true, msg: `Connected — ${count} session(s) found.` }); await reloadConfig(); } catch (e) { - setResult({ ok: false, msg: e instanceof Error ? e.message : "Could not reach server." }); + const msg = e instanceof Error ? e.message : "Could not reach server."; + setResult({ ok: false, msg }); + // Wrong/missing password — reopen the prompt instead of leaving the + // user stuck on a silent 401. + if (msg.startsWith("401")) { + setPwDraft(target.password); + setPwPromptOpen(true); + } } finally { setTesting(false); } @@ -67,6 +77,26 @@ export default function SettingsScreen() { setTimeout(() => setSaved(false), 1800); } + // The primary "Connect" action — gated behind a password prompt the first + // time (no password saved yet). Once a password is saved it persists in + // AsyncStorage with the rest of the config, so subsequent connects skip + // straight to test(). + function connect() { + if (!cfg.password.trim()) { + setPwDraft(""); + setPwPromptOpen(true); + return; + } + test(); + } + + function submitPassword() { + const next = { ...cfg, password: pwDraft }; + setCfg(next); + setPwPromptOpen(false); + test(next); + } + if (!loaded) { return ( @@ -109,6 +139,15 @@ export default function SettingsScreen() { + + Use TLS (https / wss) @@ -121,12 +160,20 @@ export default function SettingsScreen() { /> + -
- - - - ); -} diff --git a/frontend/src/renderer/components/ConnectMobileModal.test.tsx b/frontend/src/renderer/components/ConnectMobileModal.test.tsx index 0f3e441357..936bb004f5 100644 --- a/frontend/src/renderer/components/ConnectMobileModal.test.tsx +++ b/frontend/src/renderer/components/ConnectMobileModal.test.tsx @@ -1,8 +1,7 @@ import { expect, test } from "vitest"; import { pairingPayload } from "./ConnectMobileModal"; -test("QR payload never contains the password", () => { - const s = pairingPayload("192.168.1.42", 3011); - expect(JSON.parse(s)).toEqual({ v: 1, host: "192.168.1.42", port: 3011 }); - expect(s.toLowerCase()).not.toContain("password"); +test("QR payload carries host, port, and password for one-scan connect", () => { + const s = pairingPayload("192.168.1.42", 3011, "xKb1Z3A1"); + expect(JSON.parse(s)).toEqual({ v: 1, host: "192.168.1.42", port: 3011, password: "xKb1Z3A1" }); }); diff --git a/frontend/src/renderer/components/ConnectMobileModal.tsx b/frontend/src/renderer/components/ConnectMobileModal.tsx index 2990126565..7be7de0f68 100644 --- a/frontend/src/renderer/components/ConnectMobileModal.tsx +++ b/frontend/src/renderer/components/ConnectMobileModal.tsx @@ -4,7 +4,8 @@ import { Loader2 } from "lucide-react"; import { QRCodeSVG } from "qrcode.react"; import { apiClient, apiErrorMessage } from "../lib/api-client"; import { Button } from "./ui/button"; -import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "./ui/dialog"; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "./ui/dialog"; +import { Switch } from "./ui/switch"; export const mobileStatusQueryKey = ["mobile-status"] as const; @@ -16,12 +17,13 @@ interface MobileStatus { warning: string; } -// pairingPayload is the QR code contents scanned by the mobile app to discover -// the desktop's LAN bridge. It deliberately excludes the password — the QR -// code can be seen by anyone nearby, so the password is only ever shown as -// plaintext in this modal and typed in by hand on the phone. -export function pairingPayload(host: string, port: number): string { - return JSON.stringify({ v: 1, host, port }); +// pairingPayload is the QR code contents scanned by the mobile app to connect +// to the desktop's LAN bridge. It includes the password so a single scan +// autofills everything and connects with no typing. The bridge is a trusted- +// home-network tool over plaintext HTTP, so a QR that grants access is an +// acceptable trade-off; regenerating the password invalidates any old QR. +export function pairingPayload(host: string, port: number, password: string): string { + return JSON.stringify({ v: 1, host, port, password }); } async function fetchMobileStatus(): Promise { @@ -36,10 +38,10 @@ interface ConnectMobileModalProps { } // ConnectMobileModal lets a user pair the mobile app with this desktop over -// the LAN bridge (issue: mobile phone bridge). It reads the daemon's mobile -// status; when disabled it offers an Enable button, and when enabled it shows -// a QR code (host/port only, no password), the plaintext password with a -// copy affordance, and Regenerate/Disable actions. +// the LAN bridge. A single "Enable mobile" toggle sits at the top; flipping it +// on starts the bridge and reveals the pairing details below the toggle row — +// a QR code (host/port/password), the plaintext address + password with a copy +// affordance, and a Regenerate action. Flipping it off tears the bridge down. export function ConnectMobileModal({ open, onOpenChange }: ConnectMobileModalProps) { const queryClient = useQueryClient(); const [copied, setCopied] = useState(false); @@ -82,6 +84,7 @@ export function ConnectMobileModal({ open, onOpenChange }: ConnectMobileModalPro }); const status = query.data; + const enabled = status?.enabled ?? false; const busy = enable.isPending || disable.isPending || regenerate.isPending; const copyPassword = async () => { @@ -91,6 +94,18 @@ export function ConnectMobileModal({ open, onOpenChange }: ConnectMobileModalPro setTimeout(() => setCopied(false), 1500); }; + const onToggle = (next: boolean) => { + if (busy) return; + if (next) enable.mutate(); + else disable.mutate(); + }; + + const actionError = + (enable.error instanceof Error && enable.error.message) || + (disable.error instanceof Error && disable.error.message) || + (regenerate.error instanceof Error && regenerate.error.message) || + null; + return ( @@ -105,77 +120,61 @@ export function ConnectMobileModal({ open, onOpenChange }: ConnectMobileModalPro

{query.error instanceof Error ? query.error.message : "Failed to load mobile status."}

- ) : status && !status.enabled ? ( -
-

- Enable the mobile bridge to let your phone connect to this desktop while both are on the same network. -

- {enable.isError && ( -

- {enable.error instanceof Error ? enable.error.message : "Failed to enable."} -

- )} - - - -
) : status ? (
-
- + {/* Toggle row — always visible. Flipping it starts/stops the bridge. */} +
+
+ Enable mobile + + Open a password-protected port on your local network so your phone can connect. + +
+
+ {busy && } + +
-
- - - {status.host}:{status.port} - - - -
- {status.password} - + {actionError &&

{actionError}

} + + {/* Pairing details — revealed below the toggle only when enabled. */} + {enabled && ( +
+
+
- -
- {status.warning && ( -

- {status.warning} -

- )} +
+ + + {status.host}:{status.port} + + + +
+ {status.password} + +
+
+
- {(disable.isError || regenerate.isError) && ( -

- {disable.error instanceof Error - ? disable.error.message - : regenerate.error instanceof Error - ? regenerate.error.message - : "Request failed."} -

- )} + {status.warning && ( +

+ {status.warning} +

+ )} - - - - +
+ +
+
+ )}
) : null} diff --git a/frontend/src/renderer/components/GlobalSettingsForm.tsx b/frontend/src/renderer/components/GlobalSettingsForm.tsx index 6bf5e9460e..6f8a0fd03b 100644 --- a/frontend/src/renderer/components/GlobalSettingsForm.tsx +++ b/frontend/src/renderer/components/GlobalSettingsForm.tsx @@ -1,12 +1,11 @@ -import { ConnectMobileButton } from "./ConnectMobileButton"; import { DashboardSubhead } from "./DashboardSubhead"; import { MigrationSection } from "./MigrationSection"; import { UpdatesSection } from "./UpdatesSection"; // App-wide settings, shown from the sidebar when no project is selected. Each -// section is a self-contained card: Updates (auto-update channel, #2207), -// Migration (re-run the legacy-AO import, #2205), and Connect Mobile (pair the -// mobile app over the LAN bridge). +// section is a self-contained card: Updates (auto-update channel, #2207) and +// Migration (re-run the legacy-AO import, #2205). Connect Mobile lives in the +// sidebar Settings menu, not here. export function GlobalSettingsForm() { return (
@@ -15,7 +14,6 @@ export function GlobalSettingsForm() {
-
diff --git a/frontend/src/renderer/components/Sidebar.tsx b/frontend/src/renderer/components/Sidebar.tsx index 49d2f3ef76..ad87f4f5ad 100644 --- a/frontend/src/renderer/components/Sidebar.tsx +++ b/frontend/src/renderer/components/Sidebar.tsx @@ -14,6 +14,7 @@ import { Plus, Search, Settings, + Smartphone, Sun, Trash2, X, @@ -36,6 +37,7 @@ import { spawnOrchestrator } from "../lib/spawn-orchestrator"; import { renameSession } from "../lib/rename-session"; import { useEventsConnection } from "../hooks/useEventsConnection"; import { useResizable } from "../hooks/useResizable"; +import { ConnectMobileModal } from "./ConnectMobileModal"; import { DropdownMenu, DropdownMenuContent, @@ -151,6 +153,8 @@ export function Sidebar({ const isCollapsed = state === "collapsed"; const theme = useUiStore((s) => s.theme); const toggleTheme = useUiStore((s) => s.toggleTheme); + // Connect Mobile pairing modal, opened from the Settings menu. + const [mobileOpen, setMobileOpen] = useState(false); // Disclosure state: projects are expanded by default; a project id present in // this set is collapsed (sessions hidden). const [collapsedIds, setCollapsedIds] = useState>(() => new Set()); @@ -322,6 +326,11 @@ export function Sidebar({ ⌘K + setTimeout(() => setMobileOpen(true), 0)}> + + {selection.activeProjectId && ( selection.goSettings(selection.activeProjectId!)}> + setTimeout(() => setMobileOpen(true), 0)}> + + {selection.activeProjectId && ( selection.goSettings(selection.activeProjectId!)}>