A Remote access card on the dashboard's Settings > Advanced tab: a master toggle
(Allow remote access) plus a scope picker (Tailscale only, recommended/default, vs
All interfaces). Flipping it persists to the settings DB and immediately restarts the service
with the new bind. The settings DB becomes the single source of truth for the bind host; CLI
--host becomes a one-shot explicit override that is never persisted. Autostart artifacts stop
baking --host, so reboots and upgrades honor whatever the GUI last said.
| Decision | Choice |
|---|---|
| Exposure scope | Both, as a GUI choice: "Tailscale only" (loopback + 100.x, default, recommended badge) and "All interfaces" (0.0.0.0, loud warning styling). |
| When the bind change applies | Immediately: persist, then self-restart. "Restarting…" overlay, health-poll back in. |
| Source of truth | Settings DB. Precedence: explicit CLI --host (one-shot, never persisted) > DB setting > loopback default. |
| Placement | Settings > Advanced tab. NOT the Features tab: features (jacked/api/routes/features.py) toggle Claude Code assets on disk; this is jacked's own server runtime. |
| Confirm dialogs | Enable shows the real security story (no auth layer, Tailscale ACL is the access control). "All interfaces" gets a louder variant. Remote-origin + turning OFF gets a lockout warning. |
| No new dependencies | Tailscale detection is stdlib + optional tailscale CLI shell-out. No psutil/netifaces. |
jacked install has no --host flag and
unconditionally regenerates autostart with DEFAULT_HOST
(_setup_tray_autostart → _ensure_autostart_and_running(DEFAULT_HOST, DEFAULT_PORT),
cli.py:2692-2708). Both upgrade paths (system.py:_do_upgrade runs
jacked install --force; tray updater at updater.py:300-322 likewise) therefore
silently revert a user's 0.0.0.0 plist to loopback on every upgrade.service restart --host X is a silent no-op on macOS when a plist is loaded:
native_restart() runs launchctl kickstart -k (platform.py:21-60), which reuses
launchd's in-memory plist definition and ignores the new host; cli.py:3364-3377 returns early on
native-path success.install_autostart never unloads before launchctl load
(platform.py:245-261): rewriting the plist over an already-loaded label silently keeps the old
in-memory definition until reboot. Fix: bootout-then-bootstrap (or unload/load) so plist changes take effect.
flowchart TD
GUI[Settings > Advanced card] -- PUT /api/settings/remote-access --> DB[(settings table\nremote_access_enabled\nremote_access_scope)]
GUI -- POST /api/settings/remote-access/restart --> RS[restart: execv POSIX /\ndetached respawn Windows]
RS --> BOOT[service boot]
CLI[explicit --host flag\none-shot override] --> RES
BOOT --> RES[resolve_bind in jacked/service/bind.py\nflag > DB > loopback]
DB --> RES
RES --> DET[Tailscale IP detection\nUDP route trick + CLI fallback]
RES --> SOCK[pre-bound socket set\nSO_REUSEADDR]
SOCK --> UV[uvicorn Server.run sockets=...]
RES --> ENV[JACKED_HOST env\nCORS/CSRF/lifespan]
flowchart TD
GUI[Settings > Advanced card] -- PUT /api/settings/remote-access --> DB[(settings table)]
GUI -- POST /api/settings/remote-access/restart --> RS[restart]
RS --> BOOT[service boot]
CLI[explicit --host flag] --> RES
BOOT --> RES[resolve_bind: flag > DB > loopback]
DB --> RES
RES --> DET[Tailscale IP detection]
RES --> SOCK[pre-bound socket set]
SOCK --> UV[uvicorn Server.run sockets=...]
RES --> ENV[JACKED_HOST env]
jacked/service/bind.pyresolve_bind(cli_host: str | None, port: int) -> BindPlan
# precedence: explicit CLI --host (one-shot, never persisted)
# > DB setting (remote_access_enabled/scope) > loopback default
# BindPlan: mode ('loopback'|'tailscale'|'all'), sockets spec,
# primary_host (feeds JACKED_HOST), tailscale_ip | None,
# fallback_reason: str | None (tailscale requested but not detected)
[127.0.0.1]; tailscale → [127.0.0.1, 100.x]; all → [0.0.0.0] alone
(never 0.0.0.0 plus another socket on the same port).SO_REUSEADDR, created fresh per (re)start, handed to uvicorn.
Verified: uvicorn 0.46.0 Server.run(sockets=[...]) and Server.serve(sockets=[...])
both accept socket lists.ServiceRunner._start_uvicorn (tray.py:310-326, currently uvicorn.Config(host=...) +
server.run() in a daemon thread → becomes server.run(sockets=...), minimal change to the
threading model) and webux (cli.py:240-277, currently uvicorn.run(host=...) →
same Config/Server.run(sockets=...) shape). webux --reload stays single-host
CLI-only (uvicorn's reloader subprocess can't inherit pre-bound sockets); document in help text.ServiceRunner._wait_for_ready
(tray.py:336), _wait_for_port_free (tray.py:359), and
is_port_available(self.host, ...) (tray.py:1011) currently target self.host;
they must target the resolved sockets (and never hard-fail the boot on a vanished pinned IP; see detection fallback).
_service_http_ok (cli.py:280-309) already always probes loopback and stays as-is.JACKED_HOST env channel (read by lifespan main.py:214-218, CSRF guard
security.py:266-270, import-time CORS main.py:355): set to the primary non-loopback host
when remote is on (the 100.x IP, or 0.0.0.0), loopback otherwise. The lifespan refreshes
_cors_origins in place (main.py:226), so a restart picks everything up._spawn_service_detached (cli.py:334) currently bakes
--host into the service argv unconditionally (svc_args = ["service","start","--host",host,...]);
it must pass --host ONLY when the user explicitly typed one (thread explicit-ness through
jacked start, whose click default is already None, cli.py:385). This is what makes
os.execv(sys.argv[0], sys.argv) (system.py:805) re-resolve from the DB on upgrade restarts.
The webux click decorator's hardcoded default "127.0.0.1" (cli.py:241) becomes
None so "no flag" is distinguishable from "explicit loopback".100.100.100.100:80 (Tailscale's service IP; UDP connect sends no
packets), getsockname() yields the local address; accept only if inside 100.64.0.0/10
(use ipaddress).tailscale ip -4 (use winproc.NO_WINDOW on Windows; short timeout;
tolerate missing binary).fallback_reason so the GUI status
line shows "Remote access is on, but Tailscale wasn't detected at startup; currently loopback only."
Never silently widen to 0.0.0.0; never crash the boot. Re-detect on every restart. No live interface-watcher:
Tailscale node IPs are stable by design; the status line plus re-apply-on-restart covers it.remote_access_enabled, remote_access_scope
("tailscale" | "all") in the existing settings table
(jacked/web/database.py:239-243; get_setting/set_setting at 1444-1533 are sync,
usable at boot outside any request: Database().get_setting(...), DB path ~/.claude/jacked.db)._PROTECTED_SETTING_KEYS (system.py:902-916) so the generic
PUT /api/settings/{key} can't clobber them.jacked/api/routes/settings_remote.py copying the settings_swap.py pattern
(Pydantic model + validation). Register before system.router in main.py
(like swap_settings_router at main.py:465) or system's catch-all PUT steals the routes.GET /api/settings/remote-access → {enabled, scope, effective: {addresses: [...],
tailscale_ip, fallback_reason}}. effective reflects what the server ACTUALLY bound at startup
(BindPlan stored on app.state) so the UI never lies about live state.PUT /api/settings/remote-access → validates + persists, returns the same shape.POST /api/settings/remote-access/restart → broadcasts restart_started WS event,
sleeps ~1.5s to flush, restarts (below). Separate from PUT so save and apply stay distinct (mirrors upgrade)._on_restart handler, as above. No execv, no PID churn, no detached respawn needed.os.execv(sys.argv[0], sys.argv),
the upgrade flow's proven pattern (system.py:779-805); argv no longer carries --host,
so the fresh process re-resolves from the DB. Windows execv is spawn-and-exit emulation, acceptable here
because there is no tray to orphan._startHealthPolling (header.js:128-140:
"Restarting…", poll GET /api/health every 1.5s, location.reload() on ok, 30s deadline).
WS handler map (websocket.js:473-489) gains restart_started wired to the same modal
functions.window.location.hostname is not loopback
(127.0.0.1/localhost/::1) and the action disables remote access (or a scope
change drops the current origin), the confirm dialog warns the user is cutting themselves off, and after
confirming, the modal shows a terminal "reopen from the machine itself" message instead of health-polling forever.jacked/data/web/js/components/settings.js (tab bar at 40-46,
renderAdvancedTab at 1227). Copy the auto-swap settings pattern
(auto-swap.js:131-133, 753-799: checkbox → debounced save → api.put → toast), NOT the
features toggle (that PUTs /api/features/...).effective ("Listening on 127.0.0.1 +
100.64.12.7 (Tailscale)" / fallback warning state), and the URL hint (MagicDNS name / IP) when remote is active.GET /api/settings/remote-access on tab render).--host pair (keep --port) from _generate_launchd_plist
(platform.py:149-194, bake at 171-174) and _generate_windows_vbs
(platform.py:197-219, bakes at BOTH 213-214 and 217-218). install_autostart
(platform.py:231) loses its host param; update ALL bake sites:
tray.py:592 (_on_toggle_autostart passes self.host),
cli.py:3449 (service_install), _ensure_autostart_and_running, and
ensure_native_lifecycle's in-process install_autostart() call (platform.py:117).install_autostart becomes
bootout-then-bootstrap (tolerate "not loaded" on bootout) so a rewritten plist actually takes effect.jacked install --force (which every upgrade runs)
or service boot finds an existing plist/VBS with a baked --host: parse the host out FIRST and copy it
into the DB (0.0.0.0 → enabled+all; a 100.64.0.0/10 IP → enabled+tailscale; loopback → leave settings
untouched/disabled), THEN regenerate host-free and reload. Existing service install --host 0.0.0.0
users keep their bind instead of losing it.jacked service install --host X and
jacked service restart --host X now write the DB setting (X=0.0.0.0 → enabled+all;
X in 100.64/10 or X omitted-but-remote-was-on → respect scope; X=127.0.0.1 → disabled), then restart.
This makes them finally work reliably on macOS (native_restart's kickstart now picks up the DB on boot).
Update the flag help text: the GUI toggle is the primary interface.platform.py:222-228, 270-274); the DB read at
service start makes the toggle work there automatically. Docs: systemd-unit authors must not bake
--host in their own unit's ExecStart.security.py trust-model changes needed (verified):
host_header_allowed already accepts IP literals + *.ts.net + single-label MagicDNS names
(security.py:135-162); origin_allowed's same-origin fallthrough
(security.py:165-197) means pages served from http://100.x:8321 or MagicDNS FQDNs pass
WS origin (main.py:397-414) and CSRF (security.py:260-280) checks unchanged.
build_allowed_origins never wildcards (security.py:75-90).main.py:259-266) stays; add the
tailscale-fallback-to-loopback log line.| File | Change |
|---|---|
jacked/service/bind.py | NEW: resolve_bind, BindPlan, socket-set construction, tailscale detection. |
jacked/api/routes/settings_remote.py | NEW: GET/PUT remote-access, POST restart. |
jacked/api/main.py | Register new router before system router; store BindPlan on app.state. |
jacked/api/routes/system.py | Protected keys += remote_access_*. |
jacked/service/tray.py | _start_uvicorn → sockets; readiness checks follow socket set; _on_toggle_autostart drops host. |
jacked/service/platform.py | Generators drop --host; install_autostart bootout/bootstrap; migration parser. |
jacked/cli.py | webux/start/service host defaults → None + resolve_bind; _spawn_service_detached conditional --host; service install/restart write DB. |
jacked/data/web/js/components/settings.js | Remote access card on Advanced tab. |
jacked/data/web/js/websocket.js, header.js | restart_started event → restart modal/polling; lockout terminal state. |
tests/… | See Testing Plan. |
README.md | Remote Access section rewritten GUI-first; CLI as alternative. |
resolve_bind precedence matrix: flag > DB > default; explicit flag never persists.Server.serve(sockets=[two pre-bound loopback sockets on different ports]) against the real app; both answer /api/health (proves the serving model without a tailnet in CI).test_network_security.py extensions: Host/Origin cases for a concrete 100.x:8321 (some exist at :94/:152); effective-state endpoint.tests/unit/test_web_js_swap_ui.py): render, save flow, confirm-dialog branches, lockout branch.uv run python -m pytest) before push.bind.py: resolve_bind + BindPlan + socket sets + tailscale detection, fully unit-tested.Server.run(sockets=...); JACKED_HOST fed from BindPlan; readiness checks fixed; multi-socket integration test green.Generated with the jacked HTML artifact template. Edit freely.