server

package
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 13, 2026 License: Apache-2.0 Imports: 56 Imported by: 0

Documentation

Overview

Package server is the daemon's HTTP surface. P1 implements only the unauthenticated /api/health discovery route. Later phases register the authenticated routes (skills, sessions, hooks, usage) and the WS endpoint.

Auth model (matches Mac server): /api/health is unauthenticated so iOS can probe it during discovery BEFORE fetching the secret. All other routes require the per-install secret via Bearer header. The secret lives at ~/.rmote/agent/secret (mode 0600) — see package auth.

File-browser API for the iOS file browser. Two read-only routes:

GET /api/files/list?path=&session=  — directory listing (one level)
GET /api/files/get?path=&session=   — raw file bytes (preview / download)

This is NOT a copy of /api/plan/preview. Preview serves only .md/.html/image files, so its threat model ("serving any md/html/image under $HOME adds no new exposure") leans on that extension allowlist. /api/files/get serves ANY regular file in the allowlist, which includes credential files (~/.ssh/id_rsa, ~/.aws/credentials, the daemon's own bearer ~/.rmote/agent/secret). A leaked bearer could otherwise bulk-read credentials — and read the live bearer (self-escalating, defeats rotation). So this surface carries defenses preview does not: a sensitive-subtree blocklist, stat-first size cap, O_NOFOLLOW open, regular-files-only, rate limiting + a concurrency semaphore, and audit logging.

Path resolution copies ONLY the EvalSymlinks-then-PathAllowlist.Validate idiom from server_preview.go:197-208 — it does NOT reuse preview's extension gate, its 8-level ancestor walk, or its plan.html/plan.md directory pick (those are preview-specific and would silently serve the wrong file).

Local-site preview reverse proxy.

Lets the iOS app view a dev server running on the daemon host's loopback (e.g. http://localhost:5173 that Claude Code just printed, or a DDEV site http://myproject.ddev.site whose *.ddev.site wildcard resolves to 127.0.0.1) through the existing daemon + cloudflared connection — iOS can't reach the Mac's loopback directly, and there is no in-app browser that could.

Route: GET /api/localpreview/{token}/{exp}/{host}/{port}/{path...} Mounted BARE on s.mux (NOT behind authMiddleware), the same way /api/health and /ws are: WKWebView sub-requests (<img>/<script>/<css>) can't attach an "Authorization: Bearer" header, so the route carries a short-lived HMAC token in the PATH instead. The handler recomputes the MAC from the on-disk secret (auth.Read, per request) so `rmoted rotate-secret` still takes effect without a restart.

Canonical token string (iOS mints this with CryptoKit HMAC<SHA256>):

mac   = HMAC-SHA256(secret, "localpreview|" + host + "|" + port + "|" + exp)
token = base64url(mac)            // raw (no padding)
exp   = unix-seconds when the token expires (now + 30m)
host  = virtual-host Host header to send upstream (e.g. "myproject.ddev.site",
        or "localhost" for a plain loopback server)

SSRF guard: the upstream DIAL is ALWAYS 127.0.0.1:<port> — `host` only sets the Host: header (so a vhost router like DDEV's can route by name). A crafted `host` can never make the daemon dial a non-loopback address.

HTTPS: port 443 is dialed as TLS (DDEV https). Because the dial is loopback, TLS verification is intentionally skipped — a network MITM is impossible on a loopback connection, so verification would add no security; the cert just needs to let the handshake complete so the router can terminate TLS + route by Host. ServerName is set to the vhost so SNI matches the router's cert.

WebSocket/HMR is intentionally NOT proxied (MVP: reload-on-tap). Bodies of text/html + JS + CSS are rewritten so absolute http(s)://<host>[:port] URLs the dev server emits point back at this proxy path; relative URLs already resolve correctly against the proxy origin.

server_notify.go: MSG_NOTIFY (0x05) push for hook events.

The daemon's hook ingest (handleHookIngest in server.go) already fans events to the long-poll bus (subscribeEvents, used by SSH-tab clients) and pushes MSG_CLAUDE_STATE (0x08) for the per-tab skills bar. Mac-style profile connections, however, drive the Live Activity fleet via MSG_NOTIFY (0x05) → iOS onNotify → applyEvent — the same path the Mac server uses (its emitNotify → WS sink). Without a 0x05 push, those connections never receive LA phase transitions and the activity stays frozen. pushNotify closes that protocol-parity gap.

The event name is collapsed to the canonical Rmote set before framing because iOS LiveActivityCoordinator.applyEvent returns early (no transition) on any unrecognized event name, and raw hook names ("UserPromptSubmit", "PostToolUse", "Stop") are not in its switch. collapseHookEvent mirrors the Mac server's ClaudeHooksManager.mapEvent so daemon and Mac frames drive identical LA behavior.

Rich fields (task/tool_target/todos/project/last_assistant_message) are extracted from the raw hook POST body — the lean hooks.Event drops them, but iOS applyEvent uses them for the mission one-liner, the "Editing X" action line, the todo footer, and the turn-end answer preview. The extraction mirrors the Mac hook script (ClaudeHooksManager.swift: normalizePrompt, toolTarget, todos, basename) so a daemon-sourced LA matches a Mac-sourced LA.

File-preview handlers for the command-link "Preview" action (ck:cook plan output + general selection preview of .md/.html + image files under $HOME).

Ported from the Swift Mac server's PlanPreviewResolver + the two routes in ServerExtendedRoutes.swift. The daemon serves SSH-host $HOME, which is the right context for these previews: a user tapping a path in their shell sees the file from the host they're connected to, not their phone.

Security envelope (must match the Mac exactly — an authenticated peer has full PTY access to $HOME, so serving any md/html/image under $HOME adds no new exposure; the gates exist to block enumeration + symlink escape):

  • the realpath (symlinks resolved) must sit under $HOME (or the allowlist);
  • extension must be .md/.html (text) or in the image set (image);
  • directories are rejected for image, accepted for text (dir mode picks plan.html then plan.md);
  • every resolution failure collapses to a single generic 404 so the endpoint cannot enumerate files; only a missing `path` yields 400.

server_session_stats.go: Line-3 PER-SESSION stats (model · msgs · turns · tokens · updated). Distinct from server_stats.go, which is the host-level CPU/RAM sampler. Agent-agnostic counting from the hook stream — every catalog agent emits UserPromptSubmit-ish and Stop/AfterModel-ish events, so counting those yields correct message/turn tallies without parsing each agent's token-schema JSON (a per-agent refinement tracked in phase-05). Tokens stay 0 until a usage path lands; Model is the active agent. See sessions.SessionStats.

server_summarize.go: live "what's it doing now" one-liner per agent session. The backend is selected at startup from config (see config/summarize.go + summarize/): the on-device Apple Foundation Model when available (macOS, free), otherwise a hosted/local agent CLI in print mode (claude/pi/codex — works on Linux), else disabled. The daemon feeds the backend the cleaned tail of a session's PTY ring and reads a single summary line; the result is stored on Meta.Summary and broadcast on MSG_SESSIONS_UPDATE (0x04) via markLiveSessionsChanged.

Why a model and not the hook fields: the daemon already pushes rich per-turn data (task/tool_target/todos/last_assistant_message) via 0x05 for the Live Activity, but the persistent panel label still comes from OSC titles, which are low-signal (user@host:cwd, braille spinners, harness boilerplate). Reading the actual terminal tail through the model yields an accurate present-tense activity ("Editing Bridge.swift to fix a PTY resize bug") that no hook field or self-summary matches.

Recursion guard: every helper subprocess is tagged with RMOTE_SUMMARIZER_HELPER (see summarize/helper.go). handleHookIngest skips events whose originating process carries that env, so a helper invocation can never register as a session or re-trigger summarize (advisory (b)).

server_title.go: model-derived session TITLE (Line 1 of the session card). Distinct from server_summarize.go (Line 2, live activity): a title names the session's TOPIC, generated rarely and kept stable. Both share the same summarizer backend (s.summary) via different instructions.

Lifecycle (user-driven design):

A. Placeholder (immediate, no model call): the first user prompt is shown
   truncated as the name — something honest before the task is clear.

B. Meaningful name (self-gated, AI judges): on each turn_done while the
   name is still the placeholder, the model returns a ≤6-word title or
   UNCLEAR; the first confident answer LOCKS. N is emergent. After
   maxAttempts UNCLEARs a best-effort call forces a name.

Once locked the title is intentionally STABLE — it never re-derives on later prompts. Auto topic-change detection was removed: comparing a 6-word title to a full prompt made the model cry "new topic" on nearly every prompt and the title churned. The reset paths are explicit: a user /rename (permanently suppresses B) or a new agent session (/clear, detected in SetAgentSessionID).

Durable state (prompt buffer, Meaningful flag, Attempts) lives on Meta.Title and persists to state.json, so a daemon restart mid-session keeps the naming context. Only the per-session `inflight` guard is in-memory here.

Precedence (advisory (a)): displayed name = custom(/name) > ModelTitle > OSC AutoTitle > first-msg. SetModelTitle never touches Name/NameIsCustom.

Index

Constants

This section is empty.

Variables

View Source
var ValidEffortPattern = regexp.MustCompile(`^(low|medium|high)$`)

ValidEffortPattern validates effort level strings.

View Source
var ValidModelPattern = regexp.MustCompile(`^[a-zA-Z0-9._\[\]-]{1,64}$`)

ValidModelPattern validates model strings at API boundaries. Allows alphanumerics, dots, hyphens, underscores, and brackets (e.g. glm-5.2[1m]). Defense-in-depth alongside ShellQuote in the agent closures.

Functions

func HTTPServerConfig

func HTTPServerConfig(h http.Handler) *http.Server

HTTPServerConfig returns a sensible *http.Server config. Timeouts prevent slowloris-style resource exhaustion from any local process; since the daemon is localhost-only the risk is low but the cost is zero.

func ListenAddr

func ListenAddr() string

ListenAddr returns the address the server should bind. Default 127.0.0.1 (localhost-only — iOS reaches via SSH local-forward or Tailscale). Override via RMOTE_AGENT_BIND=0.0.0.0 for LAN-attached iOS (QR pairing). The bearer auth + rate limiting are the security boundary for non-loopback binds.

Types

type Server

type Server struct {
	// contains filtered or unexported fields
}

Server owns the HTTP routing tree. The auth secret is intentionally NOT cached here: authMiddleware re-reads it from disk on every request so `rmote-agent rotate-secret` takes effect without a daemon restart. Future phases that want in-memory caching MUST add a fsnotify-based invalidation or this rotation property breaks silently.

func New

func New(log *slog.Logger) (*Server, error)

New builds the server. Generates the secret if missing (first run).

func (*Server) Close

func (s *Server) Close()

Close flushes session state before stopping every owned PTY. Tests must call this before removing an isolated RMOTE_AGENT_DIR so foreground callbacks cannot schedule a debounced write after the directory cleanup starts.

func (*Server) ConfigureRelay

func (s *Server) ConfigureRelay(c *relayclient.Client)

ConfigureRelay enables relay-mode alert fanout: background alerts for devices that carry relay installation material (InstallationID+E2EPubKey) are E2E-sealed and submitted to the relay instead of sent direct to APNs. Devices without installation material still use the direct path. A nil client (or no call) keeps the legacy direct-APNs-only behavior. The daemon opts in via RMOTE_APNS_RELAY_URL + RMOTE_APNS_RELAY_CREDENTIAL in main.

func (*Server) Handler

func (s *Server) Handler() http.Handler

Handler returns the root http.Handler (routing + middleware composed). Caller (cmd/rmote-agent) wraps it in an *http.Server.

func (*Server) SaveStateNow

func (s *Server) SaveStateNow()

SaveStateNow flushes state.json synchronously. Called by cmd/rmoted's SIGINT/SIGTERM handler so brm never drops the latest batch of session mutations to the 200 ms debounced-save window. Thin delegator — the lock logic lives in sessions.Manager.SaveStateNow. Mirrors the Handler() facade pattern: main.go holds *Server, not *sessions.Manager.

type ServerMemorySample

type ServerMemorySample struct {
	Timestamp              float64 `json:"timestamp"`
	ResidentBytes          uint64  `json:"residentBytes"`
	PhysicalFootprintBytes *uint64 `json:"physicalFootprintBytes,omitempty"`
	SessionCount           int     `json:"sessionCount"`
	ClientCount            int     `json:"clientCount"`
	DiskReadBytes          *uint64 `json:"diskReadBytes,omitempty"`
	DiskWriteBytes         *uint64 `json:"diskWriteBytes,omitempty"`
	NetInBytes             *uint64 `json:"netInBytes,omitempty"`
	NetOutBytes            *uint64 `json:"netOutBytes,omitempty"`
}

ServerMemorySample is one point in the memory chart. JSON tags match iOS's APIClient.ServerMemorySample Codable struct (snake_case keys).

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL