feat: operator-approved port previews (#3) #21

Merged
lz merged 35 commits from feat/port-previews into main 2026-05-26 18:18:07 +02:00
Owner

Summary

Implements issue #3: operator-approved L4 TCP forwarders for per-session container ports, with an agent-callable registration helper and a per-session UI.

  • Raw net.createServer + socket.pipe proxy in PreviewProxy; restored on Nexus boot.
  • POST /api/agent/previews is bridge-only (loopback excluded; source container IP must match session's worker).
  • Per-session <Previews> mounted under each session row, including the workspace shell.
  • notify-preview <port> [label] worker helper reads NEXUS_SESSION_ID injected by tmux -e on session create/respawn (multi-pane coworking works because -e scopes the env to the whole tmux window).
  • New instance setting preview_bind_interface (loopback | lan); rebinding all listeners synchronously on settings PUT.

Spec: docs/superpowers/specs/2026-05-24-operator-approved-port-previews-design.md
Plan: docs/superpowers/plans/2026-05-24-port-previews.md

How it hangs together

  • Schema: migration 0007_previews.ts (FK to sessions on cascade, status/source CHECKs).
  • Boot: singletons.ts constructs PreviewProxy once and stores it on the globalThis slot so both esbuild and Rollup bundles see the same instance. cachedBindHost lives on that slot too.
  • Lifecycle: deleteSession, removeWorker, closeWorkspaceSession, and reconcileSessions (the 10s tmux-window reconciler) all call previewProxy.stopAllForSession(sessionId) before dropping the DB row, so the FK cascade can't orphan TCP listeners.
  • Rebind: changing preview_bind_interface calls refreshBindHost + rebindAllPreviews — listeners are torn down and restarted on the new interface; any listener that can't rebind is demoted to pending.

Review status (transparency)

Per-task TDD + code-quality reviews were run during implementation. The pure helpers, proxy, service layer, singletons wiring, agent route, and session-injection task all got both spec and quality review with follow-up fix commits where reviewers flagged issues. Tasks 4 (1-line registry add), 8 (thin route wrappers), 10/12 (frontend types + UI), 11 (settings-side hook), 13/14 (worker helper + docs) skipped per-task quality review to keep the cadence — risk is low but it's not zero. A final cross-cutting review caught two listener-leak bugs in reconcileSessions and closeWorkspaceSession that are now fixed in 927647d.

Test plan

  • CI runs pnpm --dir nexus test on node:22-alpine — locally Windows sqlite tests auto-skip per AGENTS.md; CI exercises them in full (most importantly previews/service.test.ts).
  • pnpm --dir nexus exec tsc --noEmit — passes locally; CI re-runs.
  • Manual smoke per the spec's Verification section — not yet run:
    • Loopback happy path, manual add (python3 -m http.server, approve, open)
    • Loopback happy path, agent callback (notify-preview 8000 py inside a session pane)
    • Vite + HMR (server.allowedHosts: ['127.0.0.1'])
    • Multi-port concurrent (5173 + 8000 on the same session)
    • Container stop/start — listener stays bound, next request works
    • Worker remove → cleanup (netstat -an | grep <host_port> empty)
    • Session delete → cleanup (other sessions' previews unaffected)
    • Bind mode switch (loopback ↔ lan)
    • Negative: agent callback from host (curl localhost:3001/api/agent/previews → 403)
    • Negative: cross-worker session_id (403)
    • Negative: host port collision (mirror falls back to pool)
    • Negative: container port not listening (ECONNRESET on first request, listener stays bound)
## Summary Implements issue #3: operator-approved L4 TCP forwarders for per-session container ports, with an agent-callable registration helper and a per-session UI. - Raw `net.createServer` + `socket.pipe` proxy in `PreviewProxy`; restored on Nexus boot. - `POST /api/agent/previews` is bridge-only (loopback excluded; source container IP must match session's worker). - Per-session `<Previews>` mounted under each session row, including the workspace shell. - `notify-preview <port> [label]` worker helper reads `NEXUS_SESSION_ID` injected by `tmux -e` on session create/respawn (multi-pane coworking works because `-e` scopes the env to the whole tmux window). - New instance setting `preview_bind_interface` (`loopback` | `lan`); rebinding all listeners synchronously on settings PUT. Spec: `docs/superpowers/specs/2026-05-24-operator-approved-port-previews-design.md` Plan: `docs/superpowers/plans/2026-05-24-port-previews.md` ## How it hangs together - Schema: migration `0007_previews.ts` (FK to sessions on cascade, status/source CHECKs). - Boot: `singletons.ts` constructs `PreviewProxy` once and stores it on the `globalThis` slot so both esbuild and Rollup bundles see the same instance. `cachedBindHost` lives on that slot too. - Lifecycle: `deleteSession`, `removeWorker`, `closeWorkspaceSession`, and `reconcileSessions` (the 10s tmux-window reconciler) all call `previewProxy.stopAllForSession(sessionId)` before dropping the DB row, so the FK cascade can't orphan TCP listeners. - Rebind: changing `preview_bind_interface` calls `refreshBindHost` + `rebindAllPreviews` — listeners are torn down and restarted on the new interface; any listener that can't rebind is demoted to `pending`. ## Review status (transparency) Per-task TDD + code-quality reviews were run during implementation. The pure helpers, proxy, service layer, singletons wiring, agent route, and session-injection task all got both spec and quality review with follow-up fix commits where reviewers flagged issues. Tasks 4 (1-line registry add), 8 (thin route wrappers), 10/12 (frontend types + UI), 11 (settings-side hook), 13/14 (worker helper + docs) skipped per-task quality review to keep the cadence — risk is low but it's not zero. A final cross-cutting review caught two listener-leak bugs in `reconcileSessions` and `closeWorkspaceSession` that are now fixed in `927647d`. ## Test plan - [ ] CI runs `pnpm --dir nexus test` on `node:22-alpine` — locally Windows sqlite tests auto-skip per AGENTS.md; CI exercises them in full (most importantly `previews/service.test.ts`). - [ ] `pnpm --dir nexus exec tsc --noEmit` — passes locally; CI re-runs. - [ ] Manual smoke per the spec's Verification section — **not yet run**: - [ ] Loopback happy path, manual add (python3 -m http.server, approve, open) - [ ] Loopback happy path, agent callback (`notify-preview 8000 py` inside a session pane) - [ ] Vite + HMR (server.allowedHosts: ['127.0.0.1']) - [ ] Multi-port concurrent (5173 + 8000 on the same session) - [ ] Container stop/start — listener stays bound, next request works - [ ] Worker remove → cleanup (`netstat -an | grep <host_port>` empty) - [ ] Session delete → cleanup (other sessions' previews unaffected) - [ ] Bind mode switch (loopback ↔ lan) - [ ] Negative: agent callback from host (curl localhost:3001/api/agent/previews → 403) - [ ] Negative: cross-worker session_id (403) - [ ] Negative: host port collision (mirror falls back to pool) - [ ] Negative: container port not listening (ECONNRESET on first request, listener stays bound)
lz added 23 commits 2026-05-24 18:13:30 +02:00
Two specs from a single brainstorming session for issue #3.
Unification landed at 2caa986 — preview spec depends on it for
clean per-session FK scoping.
16 tasks, TDD-shaped where applicable. Plan is the source of truth for
the executor; spec is the why.
- 0007 migration: previews (session_id FK cascade, status/source CHECKs)
- partial unique index on (session_id, container_port)
- status index for restoreOnBoot
- PreviewsTable interface + PreviewStatus/PreviewSource literals
Per code review:
- move (session_id, container_port) uniqueness into addUniqueConstraint
  inside createTable to match 0002_sessions.ts (uq_ prefix now matches
  the actual constraint type, not a unique index masquerading)
- host_port CHECK: null or 1..65535, mirroring container_port
- types.ts: PreviewDTO + constants (pool 10000-10999, IDLE_TIMEOUT_MS)
- bind-host.ts: resolveBindHost + pickLanIp (os.networkInterfaces, injectable provider)
- agent-ip-guard.ts: isPrivateBridgeIPv4 + assertBridgeRequest (loopback excluded)
- host-port.ts: mirror-then-fallback allocator over a free-port set
- 19 tests total, all passing
- net.createServer + socket.pipe both ways (raw L4)
- resolveContainerIp injected so tests don't need dockerode
- per-conn teardown on client/upstream error/close/timeout
- stop, stopAllForSession, stopAll, boundHostPorts API
- 6 tests including EADDRINUSE + upstream-fail cases
Per code review:
- torn flag in handleConnection so teardown is one-shot
- guard before creating upstream in the resolveContainerIp .then() so a
  stop() racing the bridge-IP lookup can't leak an untracked upstream socket
- stop(): destroy sockets first, then server.close(cb) — removes the TOCTOU
  gap between the .listening check and the once('close') registration
- upstream-fail test uses a freshly-closed port instead of port 1 (firewall-
  agnostic ECONNREFUSED)
Editable, defaults to 'loopback'. Validated to ('loopback' | 'lan').
Auto-listed by InstancePanel via existing settings/instance API.
- listPreviews sorted pending-first, then approved, both ascending port
- addManualPreview + registerFromAgent (idempotent, preserves source)
- approvePreview: mirror-then-fallback host port, rolls back row on listener fail
- deletePreview: stops listener if approved, hard-deletes row
- restoreOnBoot reads approved rows + starts listeners; demotes to pending on failure
- testing/db.ts: include 0006 + 0007 migrations
- 9 service tests with fake proxy + in-memory db (auto-skip on Windows)
Per code review:
- restoreOnBoot warns with err + ids before demoting (parity with sessions/workers)
- rowToDTO param is now PreviewsTable so schema additions can't silently drift
- approvePreview wraps read+pick+write in a Kysely txn; proxy.start stays
  outside the lock
- registerFromAgent flips to optimistic INSERT + recover-on-collision (same
  pattern openWorkspaceSession uses)
- insertPending distinguishes UNIQUE collision from FK/other failures so the
  caller doesn't see 'already exists' on unrelated errors
- singletons.ts builds PreviewProxy with resolveContainerIp pulling bridge IP
  via dockerode.inspect() (compose-network path, no cache)
- cachedBindHost updated by refreshBindHost(); reads instance_settings
- previewServiceDeps() helper used by routes and the boot restore call
- server.ts calls refreshBindHost + restoreOnBoot after runMigrations,
  before the skills-installer sweeps
singletons.ts is bundled twice (esbuild for src/server.ts and Rollup for
the SvelteKit SSR build). The globalThis Symbol slot is shared across
bundles but module-level let vars are not — so refreshBindHost() updates
in the esbuild bundle but the SvelteKit bundle's previewServiceDeps()
keeps reading '127.0.0.1' for the DTO host_url.

Fix: cachedBindHost lives on the singletons object. refreshBindHost
writes through s.cachedBindHost; previewServiceDeps and the PreviewProxy
bindHost closure both read through it. The PreviewProxy is constructed
after the slot is seeded so its closure binds to the live shared object.
- hooks.server.ts: /api/agent/* bypasses the lock gate (routes self-validate)
- POST /api/agent/previews: Zod body, session lookup, bridge-IP cross-check,
  delegates to registerFromAgent
- 403 on any guard failure with structured warn log (reqIp + claimedSession)
Wraps the registerFromAgent call so the FK race (session deleted between
the lookup and the insert) returns 403 like every other guard failure on
this route, instead of emitting an unhandled 500.
- GET/POST /api/workers/[id]/sessions/[sessionId]/previews
- POST /api/previews/[previewId]/approve
- DELETE /api/previews/[previewId]
- All operator routes auth-gated by hooks.server.ts (no carve-out)
- 409 on duplicate manual add; 400 on other service errors
- tmux new-window in createSession passes -e NEXUS_SESSION_ID=<sessions.id>
- openWorkspaceSession pre-computes rowId so both tmux paths get -e
- deleteSession stops all preview listeners for the session first
- removeWorker stops all listeners across all owned sessions before remove
Per code review:
- deleteSession wraps stopAllForSession in try/catch matching removeWorker
- removeWorker switches to Promise.all for the per-session teardown loop
- singletons.ts exports rebindAllPreviews() — stop all approved, then
  restart on the (newly-cached) bind host; any listener that fails its
  restart is demoted back to pending
- settings PUT handler invokes refreshBindHost + rebindAllPreviews on a
  successful change to preview_bind_interface; failure is logged and
  doesn't fail the PUT (the row is already saved)
- types.ts + client.ts: add PreviewDTO, PreviewStatus, PreviewSource and
  the four API methods (listPreviews, addPreview, approvePreview, deletePreview).
- Previews.svelte: list pending-first, approve/copy/open/revoke actions,
  inline add form. Always expanded per spec — no collapse toggle.
- Sessions.svelte mounts Previews under each user session row and once
  for the workspace_shell row when present.
- POSIX sh, no bashisms. Reads NEXUS_SESSION_ID from env.
- Exit codes: 0 = pending/approved, 1 = Nexus unreachable, 2 = bad usage,
  3 = NEXUS_SESSION_ID unset.
- Worker image installs to /usr/local/bin/notify-preview.
fix(previews): stop listeners on closeWorkspaceSession + reconcile delete
Some checks failed
ci / nexus (pull_request) Failing after 1m15s
ci / images (./nexus, agent-nexus) (pull_request) Has been skipped
ci / images (./worker, nexus-worker) (pull_request) Has been skipped
927647db10
Per final code review:
- closeWorkspaceSession was dropping the workspace_shell row without first
  stopping its preview listeners — listeners would outlive the row until
  Nexus restarted
- reconcileSessions hard-deletes user-session rows after two missing-window
  polls (~20s); same shape, same leak

Both paths now mirror the deleteSession pattern: dynamic-import previewProxy
and call stopAllForSession before the SQL DELETE, wrapped in best-effort
try/catch so a proxy error can't block close/reconcile.
lz added 12 commits 2026-05-26 10:24:20 +02:00
Migration file existed but wasn't wired into the migrate runner, so the
previews table was never created and previews/restoreOnBoot fails at
startup with 'no such table: previews'.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
feat(previews): scheme (http/https) per preview + browser-aware URL composition
Some checks failed
ci / nexus (pull_request) Failing after 1m32s
ci / images (./worker, nexus-worker) (pull_request) Has been skipped
ci / images (./nexus, agent-nexus) (pull_request) Has been skipped
6c82b3ede9
The agent inside the workspace knows whether its dev server speaks http or
https — Nexus shouldn't guess. Add scheme as a first-class column on the
previews table (default 'http', check-constrained to http|https). Threads
through:

- notify-preview.sh: new --scheme http|https flag (POSIX arg parser).
- POST /api/agent/previews and POST .../sessions/:id/previews: optional
  body field 'scheme'.
- DB row + DTO + service insertPending all carry scheme.
- Server-side host_url uses the row's scheme (kept for non-browser
  consumers like notify-preview's 'already approved' message).

UI changes in Previews.svelte:
- Compose the displayed URL as ${p.scheme}://${window.location.hostname}:${p.host_port}.
  This makes the URL match how the operator reached Nexus — if they used
  http://devops.lan, the preview link is http://devops.lan:<port>, not
  http://<server-bind-ip>:<port>.
- Multi-line row layout (URL on its own line, word-break: break-all) so
  long URLs don't overflow the workspace card.
- Add http/https select in the manual-add form.
lz merged commit 8048e7d5ee into main 2026-05-26 18:18:07 +02:00
lz deleted branch feat/port-previews 2026-05-26 18:18:07 +02:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Reference
lz/agent-nexus!21
No description provided.