Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
c650502
docs(mobile): capture Connect Mobile design (CONTEXT, ADR, plan) + sc…
Prasad-D-Ware Jul 7, 2026
e090bea
feat(mobile): mobilebridge config store + rotating password
Prasad-D-Ware Jul 7, 2026
ff79502
feat(mobile): autopick private LAN IPv4
Prasad-D-Ware Jul 7, 2026
6498a3e
feat(mobile): bearer auth middleware with per-source lockout
Prasad-D-Ware Jul 7, 2026
aa9c23d
feat(mobile): test lockout is per-source (never global)
Prasad-D-Ware Jul 7, 2026
773909c
feat(mobile): runtime-controlled LAN listener manager
Prasad-D-Ware Jul 7, 2026
205eeb2
feat(mobile): mobile control endpoints + bridge service
Prasad-D-Ware Jul 7, 2026
f29d857
feat(mobile): mount loopback-gated mobile control routes + regen API
Prasad-D-Ware Jul 7, 2026
a86651e
feat(mobile): wire LAN listener into daemon with restore-on-boot
Prasad-D-Ware Jul 7, 2026
130cffc
fix(mobile): bound LAN listener shutdown ctx; assert restore happy path
Prasad-D-Ware Jul 7, 2026
c363eec
feat(ui): add shadcn dialog primitive
Prasad-D-Ware Jul 7, 2026
8fc5d8c
feat(mobile): desktop Connect Mobile modal with QR, password, warning
Prasad-D-Ware Jul 7, 2026
0ae405e
feat(mobile): send Authorization bearer on REST + mux
Prasad-D-Ware Jul 7, 2026
8f949de
feat(mobile): QR scan pairing + password popup
Prasad-D-Ware Jul 7, 2026
eacfec7
docs(mobile): scope loopback-only rule to loopback listener; document…
Prasad-D-Ware Jul 7, 2026
37e39fe
fix(mobile): block loopback-only control routes on the LAN listener
Prasad-D-Ware Jul 7, 2026
59839e8
fix(mobile): let the desktop renderer reach the mobile control routes
Prasad-D-Ware Jul 8, 2026
5bfd473
feat(mobile): persist the connection password so the desktop can show it
Prasad-D-Ware Jul 8, 2026
459e86c
fix(mobile): reset the per-source lockout counter when the cooldown e…
Prasad-D-Ware Jul 8, 2026
55f2bcf
feat(mobile): move Connect Mobile to the sidebar Settings menu; toggl…
Prasad-D-Ware Jul 8, 2026
35dc68c
feat(mobile): scan-to-connect and hardened connection UX
Prasad-D-Ware Jul 8, 2026
ad1ffe8
chore(mobile): drop fork-only shared API schema (upstream generates t…
Prasad-D-Ware Jul 8, 2026
de5ceef
chore(mobile): sync lockfiles for qrcode.react (desktop) and expo-cam…
Prasad-D-Ware Jul 8, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
46 changes: 46 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
@@ -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.
33 changes: 33 additions & 0 deletions backend/internal/daemon/daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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,
Expand All @@ -151,6 +165,7 @@ func Run() error {
Events: cdcPipe.Broadcaster,
Activity: lcStack.LCM,
Telemetry: telemetrySink,
Mobile: mc,
})
if err != nil {
stop()
Expand All @@ -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
Expand Down Expand Up @@ -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)
}
Expand Down
30 changes: 30 additions & 0 deletions backend/internal/daemon/mobile_restore.go
Original file line number Diff line number Diff line change
@@ -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
}
64 changes: 64 additions & 0 deletions backend/internal/daemon/mobile_restore_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package daemon

import (
"context"
"testing"

"github.com/aoagents/agent-orchestrator/backend/internal/mobilebridge"
)

// fakeLAN is a minimal httpd.LANController fake for exercising
// restoreMobileOnBoot without a real listener.
type fakeLAN struct {
started bool
hash string
port int
}

func (f *fakeLAN) Start(port int) (int, error) {
f.started = true
f.port = port
return port, nil
}
func (f *fakeLAN) Stop(ctx context.Context) error { return nil }
func (f *fakeLAN) Running() bool { return f.started }
func (f *fakeLAN) BoundPort() int { return f.port }
func (f *fakeLAN) SetPasswordHash(hash string) { f.hash = hash }

func TestRestoreEnabledStartsListener(t *testing.T) {
dir := t.TempDir()
path := mobilebridge.Path(dir)
if err := mobilebridge.Save(path, mobilebridge.State{Enabled: true, 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")
}
}
1 change: 1 addition & 0 deletions backend/internal/httpd/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
115 changes: 115 additions & 0 deletions backend/internal/httpd/apispec/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
8 changes: 7 additions & 1 deletion backend/internal/httpd/apispec/parity_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,20 @@ 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
// operations are in 1:1 correspondence — so a route can't be added without
// 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 {
Expand Down
Loading