Skip to content

feat: serve live sessions to other Tailscale devices via Tailscale Serve - #216

Open
ss251 wants to merge 7 commits into
kunchenguid:mainfrom
ss251:feat/tailnet-live-sessions
Open

feat: serve live sessions to other Tailscale devices via Tailscale Serve#216
ss251 wants to merge 7 commits into
kunchenguid:mainfrom
ss251:feat/tailnet-live-sessions

Conversation

@ss251

@ss251 ss251 commented Aug 1, 2026

Copy link
Copy Markdown

What Changed

  • Add lavish-axi tailnet (--status / --off) so live sessions are reachable from other devices on the user’s Tailscale network: the server stays loopback-bound while tailscale serve proxies a dedicated HTTPS port (default 8443, LAVISH_AXI_TAILNET_HTTPS_PORT), MagicDNS joins the Host allowlist only while enabled, and enable/disable restarts the detached server to pick that up.
  • Session and open output gain a private tailnet_url (https://<magicdns>:<port>/session/<key>); serve config is port-scoped so existing Tailscale Serve setups (e.g. :443) are left alone, and public funnel exposure is unsupported.
  • Same-origin checks compare request host (including port) and ignore scheme so TLS terminators like tailscale serve do not 403 whiteboard, share, or chrome-handoff POSTs; enable/disable paths validate the optional file and serve target port first, and roll back by restoring prior tailnet.json / serve when a later step fails.

Risk Assessment

✅ Low: Prior review defects (TLS same-origin breakage, enable atomicity, port-change orphans, rollback prior-state loss) are fixed at the shared boundaries; remaining risk is the intentional tailnet trust model (unauthenticated loopback server behind WireGuard device auth), not open source bugs.

Testing

Exercised the full user path for lavish-axi tailnet on a signed-in device: enable produces a working HTTPS MagicDNS session URL while the server stays loopback-bound, DNS-rebinding Host guard accepts only the allowlisted hostname, open output includes tailnet_url, and --off removes both the port-scoped serve proxy and the allowlist entry; focused unit tests for pure helpers and same-origin TLS-terminator matching also passed.

Evidence: tailnet enable CLI output with tailnet_url

tailnet: active: true hostname: devcube.tail9a8fe8.ts.net https_port: 8443 base_url: "https://devcube.tail9a8fe8.ts.net:8443/&#34;&#10;session.tailnet_url: "https://devcube.tail9a8fe8.ts.net:8443/session/ed7ce2e0e4363fca&#34;

tailnet:
  active: true
  hostname: devcube.tail9a8fe8.ts.net
  https_port: 8443
  base_url: "https://devcube.tail9a8fe8.ts.net:8443/"
sessions[1]{file,status,url,tailnet_url}:
  /private/var/folders/nc/j5wdfhws2q56jzdcj_jkdvww0000gn/T/no-mistakes-evidence/01KYYAJMEPE8NVKR9BJFC9Q77Q/tailnet-demo.html,open,"http://127.0.0.1:43991/session/ed7ce2e0e4363fca","https://devcube.tail9a8fe8.ts.net:8443/session/ed7ce2e0e4363fca"
session:
  file: /private/var/folders/nc/j5wdfhws2q56jzdcj_jkdvww0000gn/T/no-mistakes-evidence/01KYYAJMEPE8NVKR9BJFC9Q77Q/tailnet-demo.html
  status: open
  url: "http://127.0.0.1:43991/session/ed7ce2e0e4363fca"
  tailnet_url: "https://devcube.tail9a8fe8.ts.net:8443/session/ed7ce2e0e4363fca"
next_step: "Tailnet access is ON, private to the user's own Tailscale devices. Give the user https://devcube.tail9a8fe8.ts.net:8443/session/ed7ce2e0e4363fca - it opens the same live review session (annotations and feedback flow back through `lavish-axi poll` exactly as on the desktop) on any device signed into their tailnet, such as a phone. The link does not work off the tailnet, and that is the point: the server stays loopback-bound and Tailscale's device authentication is the access control. Run `lavish-axi tailnet --off` to stop."
Evidence: HTTP checks while tailnet enabled

local=200 spoof=403 magicdns_host=200 tailnet_session=200 health={"ok":true,"app":"lavish-axi","version":"0.1.45"}

local=200
spoof=403
magicdns_host=200
tailnet_session=200
health={"ok":true,"app":"lavish-axi","version":"0.1.45"}
artifact=409 (expected without chrome handoff epoch)
Evidence: Lavish chrome HTML served over MagicDNS HTTPS
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Tailnet live session demo · Lavish</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>💎</text></svg>">
<link rel="stylesheet" href="/chrome.css">
</head>
<body class="lavish layout-gate-active">
<div class="bar"><div class="brand"><span class="brand-mark">Lavish</span><span class="brand-support">Editor</span></div><div class="spacer" aria-hidden="true"></div><div class="warnings-wrap" id="warningsWrap" hidden><button class="warnings-button" id="warningsButton" type="button" aria-haspopup="dialog" aria-expanded="false" aria-controls="warningsDrawer"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg><span class="warnings-count" id="warningsCount">0</span></button><div class="menu warnings-drawer" id="warningsDrawer" role="dialog" aria-labelledby="warningsTitle" aria-describedby="warningsSummary" hidden><div class="warnings-head"><h2 class="warnings-title" id="warningsTitle">Layout issues</h2><p class="warnings-summary" id="warningsSummary"></p></div><div class="warnings-toolbar"><label class="warnings-selectall"><input type="checkbox" id="warningsSelectAll"><span>Select all</span></label><span class="warnings-selected" id="warningsSelected" role="status" aria-live="polite"></span></div><div class="warnings-list" id="warningsList"></div><div class="warnings-foot"><p class="warnings-note">Queueing sends a repair request with your next feedback. An issue is marked resolved only after a newer artifact load and a complete check at the same viewport no longer finds it.</p><button class="button" id="warningsQueueButton" type="button" disabled>Queue selected fixes</button></div></div></div><button class="annotate-switch" id="annotation" type="button" aria-pressed="true" title="Toggle annotate/explore mode (⌘I / Ctrl+I)"><span class="switch-track" aria-hidden="true"><span class="switch-knob"></span></span><span>Annotate</span></button><div class="more-wrap" id="moreWrap"><button class="more-button" id="moreButton" type="button" title="More" aria-haspopup="menu" aria-expanded="false"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="5" r="1.4"/><circle cx="12" cy="12" r="1.4"/><circle cx="12" cy="19" r="1.4"/></svg></button><div class="menu more-menu" id="moreMenu" hidden><div class="menu-head"><div class="menu-label">Editing</div><button class="menu-file" id="copyPath" type="button" title="Copy path · /private/var/folders/nc/j5wdfhws2q56jzdcj_jkdvww0000gn/T/no-mistakes-evidence/01KYYAJMEPE8NVKR9BJFC9Q77Q/tailnet-demo.html"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg><span class="menu-file-text"><span class="path-head">/private/var/folders/nc/j5wdfhws2q56jzdcj_jkdvww0000gn/T/no-mistakes-evidence/01KYYAJMEPE8NVKR9BJFC9Q77Q/</span><span class="path-tail">tailnet-demo.html</span></span><span class="copy-hint" id="copyHint"><span class="icon-copy"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></span><span class="icon-check"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg></span><span id="copyHintText">Copy</span></span></button></div><div class="menu-rule"></div><button class="menu-item" id="reloadArtifact" type="button"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 12a9 9 0 0 1 15-6.7L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-15 6.7L3 16"/><path d="M3 21v-5h5"/></svg><span>Reload artifact</span></button><button class="menu-item" id="copySnapshot" type="button"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3z"/><circle cx="12" cy="13" r="3"/></svg><span>Copy DOM snapshot</span></button><button class="menu-item" id="exportArtifact" type="button"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg><span>Export standalone HTML</span></button><button class="menu-item" id="shareArtifact" type="button"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M3 12h18"/><path d="M12 3a14.5 14.5 0 0 1 0 18a14.5 14.5 0 0 1 0-18z"/></svg><span>Publish link</span></button><div class="menu-rule"></div><button class="menu-item danger" id="end" type="button"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg><span>End session</span></button></div></div></div>
<div class="layout"><div class="frame"><iframe id="artifact" sandbox="allow-scripts allow-forms allow-popups allow-downloads" data-artifact-src="/artifact/ed7ce2e0e4363fca/index.html"></iframe><div class="layout-issue-banner" id="layoutIssueBanner" hidden>Layout issues detected. Open <strong>Layout issues</strong> in the top bar to review and queue fixes.</div></div><aside class="panel"><h2>Conversation</h2><div class="panel-scroll" id="panelScroll"><div class="chat" id="chatLog"></div><div class="annotation-pills" id="annotationPills"></div></div><div class="composer"><div class="presence-banner handoff-banner" id="handoffBanner" hidden><span>This review is open in another Lavish tab.</span><button class="handoff-takeover" id="handoffTakeover" type="button">Take over here</button></div><div class="presence-banner" id="presenceBanner" hidden>Your agent is not listening. If this persists, ask your agent to poll for updates from Lavish.</div><textarea id="chatInput" placeholder="Write a message for the agent..."></textarea><div class="send-hint" id="sendHint" hidden>Write a message or annotate an element first.</div><div class="actions" id="sendActions"><button class="button button-danger" id="sendAndEnd" type="button"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg><span>Send &amp; End</span></button><button class="button" id="send">Send to Agent</button></div></div></aside></div>
<div class="share-overlay" id="shareDialog" role="dialog" aria-modal="true" aria-labelledby="shareTitleText" hidden><form class="share-card" id="shareForm"><div class="share-head"><div><div class="share-kicker">Publish to <a class="share-link" href="https://ht-ml.app" target="_blank" rel="noopener noreferrer">ht-ml.app</a></div><h2 id="shareTitleText">Publish artifact</h2></div><button class="share-close" id="shareClose" type="button" aria-label="Close publish dialog"><svg width="14" height="14" viewBox="0 0 10 10" fill="none" aria-hidden="true" focusable="false"><path d="M1 1L9 9M9 1L1 9" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></button></div><p class="share-note">ht-ml.app is a separate, third-party hosting service, not part of Lavish. Publishing sends this artifact to its servers.</p><p class="share-copy">This uploads this artifact to ht-ml.app with local assets inlined. Without a password, the page is PUBLIC and anyone with the link can open it. With a password, the page is PRIVATE and viewers must supply the password to view.</p><p class="share-note">Do not publish secrets. The Lavish annotation SDK is not included.</p><div class="share-grid"><label>Password (optional)<input id="sharePassword" name="password" type="password" autocomplete="new-password" placeholder="Leave blank for a public page"></label></div><div class="share-status" id="shareStatus" role="status"></div><div class="share-result" id="shareResult" hidden><label>Share URL<div class="share-copy-row"><input id="shareUrl" readonly><button class="share-copy-btn" id="copyShareUrl" type="button">Copy URL</button></div></label><label>Update key (secret)<div class="share-copy-row"><input id="shareUpdateKey" readonly><button class="share-copy-btn" id="copyUpdateKey" type="button">Copy key</button></div></label><p class="share-note">Keep the update key private. ht-ml.app returns it once and it is the only way to update or delete this page later.</p></div><div class="share-actions"><button class="share-cancel" id="shareCancel" type="button">Cancel</button><button class="button" id="sharePublish" type="submit">Publish</button></div></form></div>
<div class="ended-overlay layout-gate-overlay" id="layoutGateOverlay"><div class="ended-card"><div class="ended-title" id="layoutGateTitle">Checking layout.<br>One moment.</div><p class="ended-copy" id="layoutGateCopy">Lavish is waiting for fonts and final geometry before revealing this artifact.</p><button class="button ended-action" id="layoutGateAction" type="button">Show anyway</button></div></div>
<div class="ended-overlay" id="endedOverlay" hidden><div class="ended-card"><div class="ended-title">Session ended.<br>Return to your agent to continue.</div><p class="ended-copy">/private/var/folders/nc/j5wdfhws2q56jzdcj_jkdvww0000gn/T/no-mistakes-evidence/01KYYAJMEPE8NVKR9BJFC9Q77Q/tailnet-demo.html</p></div></div>
<div class="whiteboard-overlay" id="whiteboardOverlay" hidden><div class="whiteboard-shell"><div class="whiteboard-error" id="whiteboardError" hidden></div><button class="whiteboard-close" id="whiteboardClose" type="button" aria-label="Close whiteboard"><svg width="14" height="14" viewBox="0 0 10 10" fill="none" aria-hidden="true" focusable="false"><path d="M1 1L9 9M9 1L1 9" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></button><iframe id="whiteboardFrame" title="Excalidraw whiteboard" sandbox="allow-scripts allow-popups"></iframe></div></div>
<script id="lavish-session" type="application/json">{"key":"ed7ce2e0e4363fca","file":"/private/var/folders/nc/j5wdfhws2q56jzdcj_jkdvww0000gn/T/no-mistakes-evidence/01KYYAJMEPE8NVKR9BJFC9Q77Q/tailnet-demo.html","initialChat":[],"initialLayoutWarnings":[],"initialArtifactRevision":0,"initialArtifactLoadToken":"","initialArtifactLoadSequence":0,"chromeLoadToken":"M2_xPHqQi0uQHbCM9jWi3X_pdwqM9Cs7","layoutGateEnabled":true,"modeToggleHotkeyKey":"i"}</script>
<script src="/chrome-client.js"></script>
</body>
</html>
- Evidence: Headless Chrome render of tailnet session URL (local file: /var/folders/nc/j5wdfhws2q56jzdcj_jkdvww0000gn/T/no-mistakes-evidence/01KYYAJMEPE8NVKR9BJFC9Q77Q/07-tailnet-session-chrome.png)
Evidence: open while enabled includes tailnet_url

session.url: http://127.0.0.1:43991/session/ed7ce2e0e4363fca&#10;session.tailnet_url: https://devcube.tail9a8fe8.ts.net:8443/session/ed7ce2e0e4363fca

session:
  file: /private/var/folders/nc/j5wdfhws2q56jzdcj_jkdvww0000gn/T/no-mistakes-evidence/01KYYAJMEPE8NVKR9BJFC9Q77Q/tailnet-demo.html
  url: "http://127.0.0.1:43991/session/ed7ce2e0e4363fca"
  status: opened
  tailnet_url: "https://devcube.tail9a8fe8.ts.net:8443/session/ed7ce2e0e4363fca"
next_step: "Do not respond to the user just yet. Now you must run `lavish-axi poll /private/var/folders/nc/j5wdfhws2q56jzdcj_jkdvww0000gn/T/no-mistakes-evidence/01KYYAJMEPE8NVKR9BJFC9Q77Q/tailnet-demo.html`. This command long-polls until the user sends feedback or ends the session, and it stays silent the whole time - that is normal, never kill it. Layout issues the browser detects do not return this poll; they wait in the user's Layout issues inbox until the user queues them, then arrive as an ordinary tag \"layout-warnings\" prompt. Do not pass --timeout-ms during normal agent use. Keep the poll in the foreground by default and let it return the feedback directly to the agent. A background poll is allowed only through a harness-native tracked background-job facility whose completion result is guaranteed to resume or notify the same agent. Never use `nohup`, shell `&`, `disown`, redirected fire-and-forget processes, or a detached terminal without an explicit verified callback merely to keep polling alive. If the harness has no completion-aware background facility, use the foreground poll or first wire a verified wake callback into the surrounding supervisor. Do not tell the user the artifact is being monitored until that wake path is live. If the poll gets killed or times out anyway, just re-run it - queued feedback is never lost. After applying feedback, run `lavish-axi poll /private/var/folders/nc/j5wdfhws2q56jzdcj_jkdvww0000gn/T/no-mistakes-evidence/01KYYAJMEPE8NVKR9BJFC9Q77Q/tailnet-demo.html --agent-reply \"<message for the user>\"` without --timeout-ms to show your response in Lavish Editor and wait for more feedback. If the user ends the session, stop polling and do not reopen it by re-running `lavish-axi /private/var/folders/nc/j5wdfhws2q56jzdcj_jkdvww0000gn/T/no-mistakes-evidence/01KYYAJMEPE8NVKR9BJFC9Q77Q/tailnet-demo.html` unless the user asks for further review or something genuinely important needs their visual attention - deliver routine updates directly in this conversation instead. When reopening is warranted, run `lavish-axi /private/var/folders/nc/j5wdfhws2q56jzdcj_jkdvww0000gn/T/no-mistakes-evidence/01KYYAJMEPE8NVKR9BJFC9Q77Q/tailnet-demo.html --reopen`."
Evidence: tailnet --off and post-off checks

active: false; after off: local=200, magicdns_host=403, tailnet_url=connection_refused, spoof=403

tailnet:
  active: false
next_step: "Tailnet access is off: the port-scoped Tailscale Serve config was cleared and the server restarted without the tailnet hostname in its Host allowlist. Local sessions keep working unchanged."
Evidence: E2E transcript summary
# Tailnet live sessions E2E

Device: devcube.tail9a8fe8.ts.net
Port: LAVISH_AXI_PORT=43991 HTTPS=8443

PASS: status off, open, enable, GET tailnet session 200, health ok, spoof Host 403, MagicDNS Host 200, open includes tailnet_url, --off clears proxy+allowlist, local still 200

Artifacts: 01-10 CLI/HTTP logs, 06-tailnet-session-body.html, 07-tailnet-session-chrome.png

Pipeline

Updates from git push no-mistakes

⏭️ **intent** - skipped

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

🔧 **Review** - 5 issues found → auto-fixed (4) ✅
  • 🚨 src/server.js:1191 - isSameOriginRequest builds expectedOrigin as ${req.protocol}://${Host}. lavish-axi tailnet always configures tailscale serve to proxy https://&lt;magicdns&gt;:&lt;port&gt;http://127.0.0.1:&lt;appPort&gt; (TLS terminates in tailscaled; Node still sees plain HTTP and has no trust proxy / X-Forwarded-Proto handling). A phone/tablet browser therefore sends Origin: https://… while Express expects http://…, so every same-origin-guarded route returns 403: whiteboard channel/save/feedback-files, browser Publish/share, and chrome handoff recovery (POST /api/:key/chrome-loads/begin). Element/text prompts and poll still work (unguarded), but Mermaid whiteboard review on the advertised tailnet path is broken. Live verification in the commit only covered GET 200 + Host allowlist, not these POSTs. Fix at this shared boundary (e.g. compare URL host including port, or honor X-Forwarded-Proto when Host is already allowlisted)—do not paper over individual routes.
  • ⚠️ src/cli.js:651 - Enable writes tailnet.json then force-restarts the server before tailscale serve succeeds. If ensureServer throws, state stays active with no proxy. If serve fails, state is deleted but the already-restarted process still has the MagicDNS name in its Host allowlist until another restart—no compensating forceRestart after rollback. Make enable atomic: configure serve first (or roll back state and re-restart without the allowlist on any failure).
  • ⚠️ src/cli.js:618 - --off ignores spawnSync status and still deletes tailnet.json / restarts when Tailscale is missing or serve … off fails, leaving an orphaned port-scoped serve mapping. Surface the failure (or retry) so operators know proxy cleanup did not complete.
  • ⚠️ src/cli.js:649 - Re-running lavish-axi tailnet after changing LAVISH_AXI_TAILNET_HTTPS_PORT configures only the new port and never clears the previous stored port. --off then clears only the new port, so the old HTTPS serve entry keeps proxying the unauthenticated loopback server on the tailnet. On enable, if prior state has a different httpsPort, run buildServeOffArgs for the old port first.
  • ℹ️ src/cli.js:652 - Enable/disable always forceRestarts the detached server, which drops in-flight lavish-axi poll long-polls and browser SSE without calling that out in next_step. Acceptable tradeoff for allowlist pickup, but agents mid-session will need an explicit re-poll.

🔧 Fix: Fix tailnet same-origin and enable atomicity
2 warnings still open:

  • ⚠️ src/cli.js:699 - Enable completes serve config, writes tailnet.json, and force-restarts the server before resolving the optional file argument via canonicalFile (realpath). A missing/invalid path throws after those side effects, so the command looks failed while tailnet access is already ON (serve live, state present, allowlist applied). Resolve/validate the optional file before enable side effects, or catch and still return the successful enable payload (existing no-session messaging already covers a path with no open session).
  • ⚠️ src/cli.js:679 - buildServeArgs uses flagValue(args, &#34;--port&#34;) || defaultPort(), but ensureServer always binds/restarts on defaultPort() only, and help does not document --port for tailnet. Passing a non-default --port points Tailscale Serve at a port the CLI is not running, so advertised tailnet_urls fail while local sessions on LAVISH_AXI_PORT still work. Use defaultPort() for the serve target (operators already set LAVISH_AXI_PORT), or thread the same port into both serve and ensureServer.

🔧 Fix: Validate file and align serve port on enable
3 warnings still open:

  • ⚠️ src/cli.js:665 - On enable, when prior state has a different httpsPort, the old port is cleared before the new tailscale serve succeeds. If the new serve fails, state still records the old port as active while neither port proxies—lavish-axi tailnet --status reports ON with dead tailnet_urls. Because serve configs are port-scoped and intentionally coexist, configure the new port first, then clear the prior port only after the new serve (and preferably state write/restart) succeeds; on new-serve failure, leave the prior port intact (or restore it).
  • ⚠️ src/cli.js:623 - --off always runs tailscale serve … off whenever the Tailscale binary exists, including when tailnet.json is absent (already off). A non-zero serve status then throws instead of returning the "already off" payload—so with Tailscale stopped/unavailable, idempotent --off fails. Hard-fail serve cleanup only when state is present; when state is null, return already-off (optional best-effort serve clear that ignores failure).
  • ⚠️ src/cli.js:695 - If writing tailnet.json or ensureServer({ forceRestart: true }) fails after serve is live, the catch deletes state and runs buildServeOffArgs but ignores spawn status. A failed rollback leave serve proxying the unauthenticated loopback server while status reports off (and a server started under the brief state may still have the MagicDNS Host allowlist until another restart). Check serve-off status and surface a combined error with manual tailscale serve --https=&lt;port&gt; off remediation when rollback cannot complete.

🔧 Fix: Fix tailnet enable, off, and rollback edges
1 warning still open:

  • ⚠️ src/cli.js:678 - Enable rollback after a successful new-port serve does not restore prior state. Concrete path: tailnet is ON on httpsPort A (state + serve live); operator changes LAVISH_AXI_TAILNET_HTTPS_PORT to B and re-runs enable; serve on B succeeds; writeFileSync overwrites tailnet.json with B; ensureServer({forceRestart:true}) throws; catch always rmSyncs state and only runs serve-off for B. Prior serve on A was intentionally left up until post-success cleanup (line 694+), so A keeps proxying the unauthenticated loopback server while status/readTailnetState report OFF. Same-port re-enable is also over-destructive: a failed restart deletes state and clears the working serve. On catch, restore prior into tailnet.json when prior is non-null (else delete); serve-off the new httpsPort only when prior is null or prior.httpsPort !== httpsPort; when prior is restored after a failed forceRestart, forceRestart again so the Host allowlist matches restored state.

🔧 Fix: Restore prior tailnet state on enable rollback
✅ Re-checked - no issues remain.

✅ **Test** - passed

✅ No issues found.

  • node --test test/tailnet.test.js (7 pure unit tests for DNS parse, serve args, state, URL rewrite, allowlist)
  • node --test --test-name-pattern &#34;isSameOriginRequest matches host&#34; test/server.test.js (TLS-terminator Origin vs Host)
  • Manual E2E with isolated LAVISH_AXI_STATE_DIR + LAVISH_AXI_PORT=43991: node dist/cli.mjs tailnet --status (off)
  • node dist/cli.mjs open &lt;demo.html&gt; then node dist/cli.mjs tailnet &lt;demo.html&gt; enable on live MagicDNS devcube.tail9a8fe8.ts.net
  • curl https://devcube.tail9a8fe8.ts.net:8443/session/&lt;key&gt; → 200 Lavish chrome; /health ok; Host evil.example → 403; MagicDNS Host on loopback → 200
  • node dist/cli.mjs open while enabled returns tailnet_url; headless Chrome screenshot of MagicDNS session URL
  • node dist/cli.mjs tailnet --off then recheck: MagicDNS Host 403, :8443 refused, local session still 200; stop
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

ss251 and others added 6 commits August 7, 2026 05:53
Reviewing an artifact required sitting at the machine that owns the
loopback server: session URLs are 127.0.0.1-based, so a phone or tablet
cannot open the live review surface. `share` does not fill that gap - it
publishes a static snapshot to a third-party host with no annotation SDK
and no feedback loop - and hand-rolled tunnels are unreliable and expose
an unauthenticated server whose session keys derive from file paths.

`lavish-axi tailnet` keeps the server loopback-bound and lets tailscaled
terminate TLS on the tailnet, proxying to loopback locally. The serve
config is scoped to its own HTTPS port so an existing serve config on
:443 is never touched, the device's MagicDNS name joins the Host
allowlist only while enabled (DNS-rebinding protection stays on), and
session output gains a tailnet_url an agent can hand the user directly.
Public exposure via funnel is deliberately unsupported: the tailnet's
device authentication is the access control.

Verified live on a tailnet that already served :443 - both configs
coexisted, the tailnet session URL returned 200, a spoofed Host returned
403, and --off cleared only the feature's own port.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@ss251
ss251 force-pushed the feat/tailnet-live-sessions branch from 3747908 to 323fa2b Compare August 7, 2026 00:23
@kunchenguid

kunchenguid commented Aug 10, 2026

Copy link
Copy Markdown
Owner

Automated reminder: thanks for the PR! This branch currently has a merge conflict with the base branch.

When you get a chance, please rebase onto (or merge) the latest base branch, resolve the conflict, and push. After that, checks will re-run and the PR will get looked at again.

Noted for lavish-axi#216 at 15ecca9f.

@kunchenguid

Copy link
Copy Markdown
Owner

Speaking as Kun's firstmate: the lavish-axi tailnet command is the right VISION 7 shape (opt-in, named, disclosed, loopback-bound, funnel refused). I'm not merging. The same-origin rewrite ignores scheme for every request, not only MagicDNS, and this fork is far behind main — a merge would drop session-bound whiteboard tokens, frame-ancestors, realpath asset confinement, prompts CSRF, and attachments. Unauthenticated live sessions would also be reachable by anyone on the tailnet who has a session URL (keys are path-derived). Flagging for Kun.

@kunchenguid kunchenguid left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Speaking as Kun's firstmate: taking back the product flag. The lavish-axi tailnet command shape is fine (opt-in, named, disclosed, loopback-bound). What blocks merge is the implementation.

Please fix these before this is mergeable:

  1. Same-origin rewrite must not ignore scheme for every request. Gate the rewrite to Tailscale MagicDNS, not a global scheme-blind compare.
  2. A tailnet peer who has a session URL must not get an unauthenticated live session. Path-derived keys are not enough once the page is reachable off loopback.
  3. Rebase onto current main. This fork would drop session-bound whiteboard tokens, frame-ancestors, realpath asset confinement, prompts CSRF, and attachments.

Holding here until those land. Not a product call.

@kunchenguid

Copy link
Copy Markdown
Owner

Speaking as Kun's firstmate: restamping hold without re-flagging. This stays a captain-decision hold (opt-in Tailscale Serve shape is VISION-aligned, but same-origin/security and fork-behind-main concerns remain). Main now has automatic Tailscale phone binding via #289 — a different approach than Serve; #215 still names this Serve path as the existing help. Still CONFLICTING. No captain re-flag.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants