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/backend/internal/daemon/daemon.go b/backend/internal/daemon/daemon.go index 0e31f06399..4facb17dd3 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,11 @@ func Run() error { stop() <-previewDone lcStack.Stop() + 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 { 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..d636b4e3ea --- /dev/null +++ b/backend/internal/daemon/mobile_restore.go @@ -0,0 +1,30 @@ +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 (no rotation — the paired phone keeps working), deriving the +// auth hash in memory, 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(mobilebridge.HashPassword(state.Password)) + 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..523c3e7a7f --- /dev/null +++ b/backend/internal/daemon/mobile_restore_test.go @@ -0,0 +1,65 @@ +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 (f *fakeLAN) PasswordHash() string { return f.hash } + +func TestRestoreEnabledStartsListener(t *testing.T) { + dir := t.TempDir() + path := mobilebridge.Path(dir) + if err := mobilebridge.Save(path, mobilebridge.State{Enabled: true, Password: "secret12", LastPort: 3011}); err != nil { + t.Fatalf("save state: %v", err) + } + lan := &fakeLAN{} + 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") + } + // Restore derives the auth hash from the persisted plaintext password (no + // rotation), so the fake must have received HashPassword(persisted password). + if want := mobilebridge.HashPassword("secret12"); lan.hash != want { + t.Fatalf("expected hash derived from persisted password %q, got %q", want, 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{} + 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") + } +} 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/auth.go b/backend/internal/httpd/auth.go new file mode 100644 index 0000000000..ca7a554c6e --- /dev/null +++ b/backend/internal/httpd/auth.go @@ -0,0 +1,111 @@ +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] + if !ok { + return false + } + if l.now().Before(t) { + return true + } + // Cooldown elapsed: clear the lockout AND the fail counter so the source + // starts a fresh window. Without this the counter stays at the limit and the + // very next failure would immediately re-lock for another full cooldown — + // and a client that keeps polling would stay locked out forever. This also + // bounds map growth, since expired entries are pruned on the next request. + delete(l.until, src) + delete(l.fails, src) + return false +} + +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..59129e128f --- /dev/null +++ b/backend/internal/httpd/auth_test.go @@ -0,0 +1,138 @@ +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 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 TestAuthLockoutResetsAfterCooldown(t *testing.T) { + nowP := time.Now() + h, _ := newAuthUnderTest("secret12", func() time.Time { return nowP }) + // Lock the source with 5 failures. + for i := 0; i < 5; i++ { + w := httptest.NewRecorder() + h.ServeHTTP(w, req("Bearer wrong")) + } + // Still within cooldown → 429 even with the right password. + w := httptest.NewRecorder() + h.ServeHTTP(w, req("Bearer secret12")) + if w.Code != http.StatusTooManyRequests { + t.Fatalf("during cooldown: got %d want 429", w.Code) + } + // Advance past the 1-minute cooldown. + nowP = nowP.Add(time.Minute + time.Second) + // A single WRONG attempt must NOT immediately re-lock — it starts a fresh + // window and returns 401, not 429. + w = httptest.NewRecorder() + h.ServeHTTP(w, req("Bearer wrong")) + if w.Code != http.StatusUnauthorized { + t.Fatalf("first attempt after cooldown: got %d want 401 (fresh window, not re-locked)", w.Code) + } + // And the correct password now succeeds. + w = httptest.NewRecorder() + h.ServeHTTP(w, req("Bearer secret12")) + if w.Code != http.StatusOK { + t.Fatalf("correct password after cooldown: got %d want 200", w.Code) + } +} + +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) + } +} + +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) + } +} 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..fdef167860 --- /dev/null +++ b/backend/internal/httpd/controllers/mobile.go @@ -0,0 +1,154 @@ +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) + PasswordHash() 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) + enabled := st.Enabled && b.LAN.Running() + res := MobileStatusResponse{ + Enabled: enabled, + Host: b.currentHost(), + Port: b.LAN.BoundPort(), + Warning: mobileUnencryptedWarning, + } + // Only surface the password while the bridge is actually enabled. This route + // is reachable only on the loopback listener (the LAN listener 404s + // /api/v1/mobile via lanControlBlock), so the plaintext never reaches a phone. + if enabled { + res.Password = st.Password + } + return res +} + +func (b *BridgeService) enableWithPassword(pw string) (MobileStatusResponse, error) { + // Snapshot state so we can roll back the in-memory side effects (armed hash, + // running listener) if we fail before durable state is written. Otherwise a + // failed enable would leave a LAN listener open on 0.0.0.0 with the new + // password while persisted state/UI still say the bridge is off. + prevHash := b.LAN.PasswordHash() + wasRunning := b.LAN.Running() + + // The persisted password is plaintext; the auth hash is derived in memory. + b.LAN.SetPasswordHash(mobilebridge.HashPassword(pw)) + port, err := b.LAN.Start(b.DefaultPort) + if err != nil { + b.LAN.SetPasswordHash(prevHash) // Start failed: undo the hash swap. + return MobileStatusResponse{}, err + } + if err := mobilebridge.Save(b.ConfigPath, mobilebridge.State{Enabled: true, Password: pw, LastPort: port}); err != nil { + // Persist failed after the listener came up. Roll back so reality matches + // the unchanged persisted state (and the UI's "enable failed"). A rotate on + // an already-running listener (wasRunning) keeps serving on the prior hash; + // a fresh enable tears the listener back down. + if !wasRunning { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = b.LAN.Stop(ctx) + } + b.LAN.SetPasswordHash(prevHash) + return MobileStatusResponse{}, err + } + return b.Status(), 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..6535421eea --- /dev/null +++ b/backend/internal/httpd/controllers/mobile_test.go @@ -0,0 +1,88 @@ +package controllers + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "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 +} + +// fakeLAN is a minimal LANController for exercising BridgeService directly. +type fakeLAN struct { + running bool + hash string + stopCalls int +} + +func (f *fakeLAN) Start(port int) (int, error) { f.running = true; return port, nil } +func (f *fakeLAN) Stop(ctx context.Context) error { + f.stopCalls++ + f.running = false + return nil +} +func (f *fakeLAN) Running() bool { return f.running } +func (f *fakeLAN) BoundPort() int { return 3011 } +func (f *fakeLAN) SetPasswordHash(h string) { f.hash = h } +func (f *fakeLAN) PasswordHash() string { return f.hash } + +// When Save fails during a fresh enable, the listener that Start already opened +// must be torn back down and the armed hash rolled back — otherwise a LAN +// listener stays live on 0.0.0.0 while persisted state/UI say enable failed. +func TestMobileEnableRollsBackListenerWhenSaveFails(t *testing.T) { + // A ConfigPath whose parent is a regular file makes mobilebridge.Save's + // MkdirAll (and thus Save) fail deterministically. + blocker := filepath.Join(t.TempDir(), "not-a-dir") + if err := os.WriteFile(blocker, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + lan := &fakeLAN{} + b := &BridgeService{LAN: lan, ConfigPath: filepath.Join(blocker, "mobile", "config.json"), DefaultPort: 3011} + + if _, err := b.Enable(); err == nil { + t.Fatal("expected enable to fail on Save error") + } + if lan.Running() { + t.Fatal("listener still running after failed enable; must be stopped") + } + if lan.stopCalls == 0 { + t.Fatal("expected Stop to be called on rollback") + } + if lan.hash != "" { + t.Fatalf("expected hash rolled back to empty, got %q", lan.hash) + } +} + +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 new file mode 100644 index 0000000000..65e5604f79 --- /dev/null +++ b/backend/internal/httpd/lan_listener.go @@ -0,0 +1,167 @@ +package httpd + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net" + "net/http" + "strings" + "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 + state *authState // shared with authMiddleware; SetPasswordHash writes through here + + 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: lanControlBlock(authMiddleware(state, lock)(handler)), + defaultPort: defaultPort, + log: loggerOrDefault(log), + state: state, + } +} + +// lanControlBlockedPrefixes are the loopback-only daemon-control route +// prefixes that must never be reachable through the LAN listener: /shutdown, +// the telemetry routes under /internal/, and the Connect Mobile control +// surface under /api/v1/mobile. These routes are gated in the shared router +// by localControlRequest, which trusts the client-supplied Host header (and +// RealIP, which trusts X-Forwarded-For/X-Real-IP) — both spoofable by any LAN +// client. The LAN listener is the one thing a caller cannot spoof: it is the +// physical socket the request arrived on. So the block below is applied only +// to the LAN-served handler, outermost (wrapping authMiddleware), independent +// of any header. +var lanControlBlockedPrefixes = []string{ + "/shutdown", + "/internal/", + "/api/v1/mobile", +} + +// lanControlBlock returns 404 for any request whose path is, or is nested +// under, a loopback-only control-route prefix, before it ever reaches auth or +// the shared router. It answers as if the route were never mounted at all — +// no 403/401 that would confirm the path exists. +func lanControlBlock(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if isLANControlBlockedPath(r.URL.Path) { + notFoundJSON(w, r) + return + } + next.ServeHTTP(w, r) + }) +} + +// isLANControlBlockedPath reports whether path matches a blocked prefix on an +// exact segment boundary: "/api/v1/mobile" blocks itself and everything +// beneath it ("/api/v1/mobile/status") but must not catch unrelated siblings +// such as "/api/v1/mobileapp". +func isLANControlBlockedPath(path string) bool { + for _, prefix := range lanControlBlockedPrefixes { + trimmed := prefix + if len(trimmed) > 1 && trimmed[len(trimmed)-1] == '/' { + trimmed = trimmed[:len(trimmed)-1] + } + if path == trimmed || strings.HasPrefix(path, trimmed+"/") { + return true + } + } + return false +} + +// 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. +func (m *LANManager) SetPasswordHash(hash string) { + m.state.setHash(hash) +} + +// PasswordHash returns the current connection password hash. Used to snapshot the +// prior hash before an enable/regenerate so a failed persist can be rolled back. +// Satisfies controllers.LANController. +func (m *LANManager) PasswordHash() string { + return m.state.currentHash() +} + +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..c408c9bb79 --- /dev/null +++ b/backend/internal/httpd/lan_listener_test.go @@ -0,0 +1,110 @@ +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) + } +} + +// TestLANManagerBlocksLoopbackOnlyControlRoutes proves the LAN listener never +// serves /shutdown, /internal/*, or /api/v1/mobile* — even when the request +// carries a spoofed Host: 127.0.0.1 and valid LAN auth, since gating on Host +// alone (localControlRequest) is what let a LAN client reach these routes. +func TestLANManagerBlocksLoopbackOnlyControlRoutes(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, err := m.Start(0) + if err != nil { + t.Fatalf("start: %v", err) + } + defer m.Stop(context.Background()) + + blocked := []string{ + "/shutdown", + "/internal/telemetry/cli-invoked", + "/api/v1/mobile/status", + } + for _, path := range blocked { + req, _ := http.NewRequest(http.MethodGet, fmt.Sprintf("http://127.0.0.1:%d%s", port, path), nil) + req.Host = "127.0.0.1" // spoofed loopback Host + req.Header.Set("Authorization", "Bearer secret12") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("%s: request failed: %v", path, err) + } + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("%s: got %d want 404 (Host-spoof + valid auth must not reach control routes)", path, resp.StatusCode) + } + } + + // A normal app route must still be reachable through the LAN listener + // (not swallowed by the control-route filter). Auth-gating, not the + // control filter, decides its fate. + req, _ := http.NewRequest(http.MethodGet, fmt.Sprintf("http://127.0.0.1:%d/api/v1/sessions", port), nil) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("sessions: request failed: %v", err) + } + if resp.StatusCode == http.StatusNotFound { + t.Fatalf("/api/v1/sessions: got 404, should not be blocked by the control-route filter") + } +} + +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 +} diff --git a/backend/internal/httpd/mobile_routes_test.go b/backend/internal/httpd/mobile_routes_test.go new file mode 100644 index 0000000000..16c4849447 --- /dev/null +++ b/backend/internal/httpd/mobile_routes_test.go @@ -0,0 +1,60 @@ +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. +func newTestRouterWithMobile(t *testing.T) chi.Router { + t.Helper() + r := chi.NewRouter() + mountMobile(r, &controllers.MobileController{Bridge: fakeMobileBridge{}}) + return r +} + +// The mobile control routes are served on the loopback router without a +// Host/Origin gate: the desktop renderer is a browser context that always +// sends an Origin, so a localControlRequest-style gate would (wrongly) 403 the +// very client meant to call them. The "phone cannot toggle its own access" +// invariant is enforced on the LAN listener by lanControlBlock instead — see +// TestLANManagerBlocksLoopbackOnlyControlRoutes. This test pins that the +// loopback route is reachable and NOT Host-gated. +func TestMobileStatusRouteServedOnLoopbackRouter(t *testing.T) { + r := newTestRouterWithMobile(t) + req := httptest.NewRequest(http.MethodGet, "/api/v1/mobile/status", nil) + // A non-loopback Host used to force a 403; it must no longer matter here, + // because Host-based gating is not how the phone is blocked. + req.Host = "192.168.1.9:3011" + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("mobile status on loopback router: got %d want 200", w.Code) + } +} diff --git a/backend/internal/httpd/router.go b/backend/internal/httpd/router.go index 0bd84af733..2380a240a2 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,26 @@ 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. They must be reachable from the desktop renderer — a browser +// context that always sends an Origin header — so they are NOT gated by +// localControlRequest (which rejects any Origin-bearing request and is meant for +// the CLI). The "phone must never toggle its own access" invariant is enforced +// on the LAN listener instead, by lanControlBlock, which 404s /api/v1/mobile on +// the 0.0.0.0 socket the phone reaches — a transport-based check that cannot be +// spoofed with a forged Host header. On the loopback listener these routes are +// protected by the same CORS allowlist as every other app route. +func mountMobile(r chi.Router, c *controllers.MobileController) { + if c == nil { + return + } + r.Get("/api/v1/mobile/status", c.Status) + r.Post("/api/v1/mobile/enable", c.Enable) + r.Post("/api/v1/mobile/disable", c.Disable) + r.Post("/api/v1/mobile/regenerate", c.Regenerate) +} + type cliInvokedRequest struct { Command string `json:"command"` CommandPath string `json:"commandPath"` 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 new file mode 100644 index 0000000000..9e10be93fc --- /dev/null +++ b/backend/internal/mobilebridge/config.go @@ -0,0 +1,99 @@ +// 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" +) + +// 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 + +// State is the persisted Connect Mobile bridge config in ~/.ao/mobile/config.json. +// Password is stored in plaintext by deliberate decision: it is a low-value, +// rotating LAN enabler that already travels in plaintext over the LAN and is +// shown on the desktop screen, so persisting it (in a 0600 file under ~/.ao) +// lets the desktop redisplay it while the bridge is enabled. The daemon derives +// the auth hash from it in memory (HashPassword) — see BridgeService. +type State struct { + Enabled bool `json:"enabled"` + Password string `json:"password"` + 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..084d16d6d8 --- /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, Password: "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") + } +} 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 +} 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/architecture.md b/docs/architecture.md index 4d6ea99018..f35aab7552 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -722,6 +722,15 @@ flowchart TD ``` +### Multi-Listener Architecture (Loopback + LAN) + +The daemon runs two independent HTTP listeners sharing the same chi router: + +1. **Primary (Loopback) Listener** — binds `127.0.0.1:3001` with no authentication. All existing daemon operations (CLI, desktop app) use this listener. +2. **LAN Listener** (Connect Mobile) — an opt-in second listener that binds `0.0.0.0:3011` (or ephemeral fallback) **only when explicitly enabled** by the user through the desktop app's Settings. It wraps the shared router in bearer-password authentication middleware, serves app API routes to mobile clients, but never exposes loopback-gated control routes (`/shutdown`, telemetry, mobile control commands). All traffic is plaintext HTTP on a home network only, by deliberate security decision — see `docs/adr/0001-lan-listener-for-mobile.md` for rationale and threat model. Auth state (hashed password, per-source lockout) is persisted to `~/.ao/mobile/config.json` and restored on daemon boot. + +For implementation details and security model, consult `docs/adr/0001-lan-listener-for-mobile.md` and the glossary in `CONTEXT.md`. + ### Request Flow ```mermaid 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. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 2f03ac7239..701fe3316b 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", @@ -9496,6 +9497,31 @@ "devOptional": true, "license": "MIT" }, + "node_modules/encoding": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", + "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "iconv-lite": "^0.6.2" + } + }, + "node_modules/encoding/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/end-of-stream": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", @@ -13478,6 +13504,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/ConnectMobileModal.test.tsx b/frontend/src/renderer/components/ConnectMobileModal.test.tsx new file mode 100644 index 0000000000..936bb004f5 --- /dev/null +++ b/frontend/src/renderer/components/ConnectMobileModal.test.tsx @@ -0,0 +1,7 @@ +import { expect, test } from "vitest"; +import { pairingPayload } from "./ConnectMobileModal"; + +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 new file mode 100644 index 0000000000..7be7de0f68 --- /dev/null +++ b/frontend/src/renderer/components/ConnectMobileModal.tsx @@ -0,0 +1,192 @@ +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, DialogHeader, DialogTitle } from "./ui/dialog"; +import { Switch } from "./ui/switch"; + +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 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 { + 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. 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); + + 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 enabled = status?.enabled ?? false; + 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); + }; + + 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 ( + + + + 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 ? ( +
+ {/* 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 && } + +
+
+ + {actionError &&

{actionError}

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

+ {status.warning} +

+ )} + +
+ +
+
+ )} +
+ ) : 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..6f8a0fd03b 100644 --- a/frontend/src/renderer/components/GlobalSettingsForm.tsx +++ b/frontend/src/renderer/components/GlobalSettingsForm.tsx @@ -4,7 +4,8 @@ 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). +// Migration (re-run the legacy-AO import, #2205). Connect Mobile lives in the +// sidebar Settings menu, not here. export function GlobalSettingsForm() { return (
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!)}>