Add operator-approved port forwarding so dev servers running in worker containers can be opened from the host #3
Labels
No labels
bug
duplicate
enhancement
help wanted
invalid
question
wontfix
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
lz/agent-nexus#3
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Motivation
When an agent runs
npm run devinside a worker container, the operator currently has no way to open the dev server in a browser. This issue adds:Companion to #2 (Playwright MCP for agents). This issue is purely the operator-side preview channel; the agent's own browser access is independent.
Threat model
127.0.0.1only — anyone with loopback on the Nexus host already has equivalent access to the UI.pending→ operator approves → listener opens. No auto-forwarding ever./api/agent/*) are internal-only. They are reachable from the shared Docker bridge but are never published on a host port. Primary defense: don't expose them outside the bridge. Defense in depth: a Fastify route-prefix that returns 403 unlessreq.ipis in a Docker bridge subnet.die. Hook into the existing health-poll rather than subscribing to Docker events (avoids needingEVENTS=1on docker-socket-proxy).Verified facts (corrections to the original proposal)
NetworkSettings.IPAddress(top-level) is only populated on the legacy default bridge. Compose-managed networks needNetworkSettings.Networks[<networkName>].IPAddress. UseObject.values(info.NetworkSettings.Networks)[0].IPAddress. Don't cache the result — bridge IPs reassign on restart.CONTAINERS=1, which does coverGET /containers/{id}/json. No new socket-proxy permissions are needed.NETWORKS=1is only for the separate/networks/*endpoints, which we don't use.net.createServer+socket.pipe()is fully transparent at L4 — WebSocket upgrades, HMR, and long-lived connections all work. Don't reach forhttp-proxy/http-proxy-middleware; they parse HTTP and break Next.js HMR.dockerode5.0.0 is current anddocker.getContainer(id).inspect()returns the fullNetworkSettingswe need.Blocked request. This host is not allowed.) unlessserver.allowedHostslists it. Operator needs to setserver.allowedHosts: ['127.0.0.1']in theirvite.config.js. Next.js dev has no such issue.Suggested implementation
1. Worker-reachable Nexus API
Nexus and worker containers both attach to the
defaultbridge (docker-compose.yml#L46-L48), so workers can already reachagent-nexus:3001via Docker DNS. Two things to add:0.0.0.0:3001(or add a second listener bound to the bridge IP). The host publish in compose stays127.0.0.1:3001:3001— don't publish a second host port. This keeps the agent endpoints reachable from inside the bridge but not from outside./api/agent/*, norequireUnlockedguard. Add a smallpreHandlerthat returns 403 unlessreq.ipis in a private/bridge range (e.g.172.16.0.0/12,192.168.0.0/16). Defense in depth — primary defense is the network policy above.A small shell helper baked into the worker image (
worker/notify-preview.sh→/usr/local/bin/notify-preview):The worker spawn in nexus/src/workers/service.ts#L139 needs to add
WORKER_ID=<id>to the container'sEnv:so the helper has it. Not a secret — labels andinspectalready expose it.2. Schema
New migration
nexus/migrations/000N_preview_ports.ts:3. TCP proxy service
New
nexus/src/preview/tcp-proxy.ts:On Nexus boot, restore listeners for
status='approved'rows of running workers.4. Host port allocation
Pool 10000–10999 (configurable via env var). Allocate the lowest free port not in the DB. On
server.listenerror (EADDRINUSE), bump and retry. This handles the case where another host process is holding the port.5. API routes
Operator routes —
nexus/src/preview/routes.ts, all guarded byrequireUnlocked:GET/api/workers/:id/previewsPOST/api/workers/:id/previews{container_port, label?}pendingPOST/api/workers/:id/previews/:previewId/approveapproved, returns{host_port, url}POST/api/workers/:id/previews/:previewId/revokerevokedDELETE/api/workers/:id/previews/:previewIdAgent callback route —
nexus/src/preview/agent-routes.ts, bridge-network-only, norequireUnlocked:POST/api/agent/previews{worker_id, container_port, label?}req.ip; on match, upsertpendingrow (idempotent on(worker_id, container_port)); on mismatch, 403Both files registered in nexus/src/server.ts next to the existing route families.
6. Worker lifecycle integration
removeWorkerin nexus/src/workers/service.ts toawait tcpProxy.stopAllForWorker(workerId)before container removal.pendingrather than deleting them, so the next start can re-approve fast).7. UI
New "Previews" sub-section per workspace in nexus/web/src/views/workers.ts, sibling to the sessions mount:
target="_blank"link + a "Copy" button + a "Revoke" button.port+label+ "Add") for manual ports (skipping the agent callback).Out of scope
<port>-<workspace>.example.com). Not needed for single-operator localhost flow.Verification
docker execinto it, runpython3 -m http.server 8000. Manually add port 8000 via the UI form. Approve. Get a host URL likehttp://127.0.0.1:10000. Open in new tab → directory listing.notify-preview 8000 "test"from inside the container. Confirm a pending row appears in the UI; approve; URL works.npx vite). Confirm Vite respondsBlocked request. This host is not allowed.Setserver.allowedHosts: ['127.0.0.1']invite.config.js. Re-open URL — works.docker stop). Listener disappears within one health-poll cycle. Revoke the row afterwards — returns cleanly (404 or "already gone").curl http://localhost:3001/api/agent/previewsfrom the host (i.e. not from a bridge IP) returns 403.worker_id(a different worker's id) returns 403 because the IP-vs-container check fails.Files touched
nexus/src/preview/tcp-proxy.ts,nexus/src/preview/routes.ts,nexus/src/preview/agent-routes.ts,nexus/migrations/000N_preview_ports.ts,worker/notify-preview.sh.lz referenced this issue2026-05-30 10:05:48 +02:00
Closed by PR #21