From e5e01ca98623b2d05ae62d63cc65c5570fe3cb01 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 19 Aug 2026 20:48:37 -0700 Subject: [PATCH 1/3] Add the macOS selfhost server installer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One idempotent script installs the coordinating server as a per-login LaunchAgent fronted by `tailscale serve` on the node's own MagicDNS name. Re-running it updates the installed release from the current checkout; it never pulls, switches branches, or installs an updater. Each release is self-contained — production server tree, lib/dist-pocket, and a copy of the exact Node binary the build ran under — so the service depends on neither the source checkout, nor Homebrew/nvm, nor pnpm's store, nor the user's interactive PATH. State and config live outside the releases and survive updates, prunes, and uninstall. Two silent-failure traps are encoded because testing hit both: - `pnpm deploy --prod --legacy` rewrites the *root* workspace state file to production:true/dev:false. Every later pnpm command in that checkout then decides the workspace is stale and tries `pnpm install --production`, which would strip the developer's devDependencies. Snapshot and restore it from an EXIT trap, so even a failed install leaves the checkout as it found it. - `mv -f tmp link` follows a symlink to a directory: swapping `current` this way deposits the temp link inside the old release and leaves `current` unmoved, making every update a silent no-op whose prune then deletes the new release. Use rename(2) on the link path and assert the switch landed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GDNJHHA95nvRdo4Cv3rAoi --- deploy/local/install-macos.sh | 1186 +++++++++++++++++++++++++++++++++ docs/specs/server.md | 75 +++ 2 files changed, 1261 insertions(+) create mode 100755 deploy/local/install-macos.sh diff --git a/deploy/local/install-macos.sh b/deploy/local/install-macos.sh new file mode 100755 index 00000000..6bbdb68a --- /dev/null +++ b/deploy/local/install-macos.sh @@ -0,0 +1,1186 @@ +#!/bin/bash +# +# Install the Dormouse coordinating server on this Mac as a per-login +# LaunchAgent, fronted by `tailscale serve` on the node's own HTTPS name. +# +# Running this a second time updates the installed release from the current +# checkout. It never pulls, fetches, switches branches, or installs an updater: +# the checkout you are standing in is the release source. +# +# See SELF_HOST.md for the runbook and docs/specs/server.md for the runtime +# contract this installs. +# +# Usage: +# ./deploy/local/install-macos.sh [--yes] +# +# Environment: +# DORMOUSE_INSTALL_TEST=1 Build, stage, health-check and switch releases, +# but do not touch launchd or the Serve config. +# Honors an overridden HOME, so a throwaway install +# root can be exercised end to end. + +set -euo pipefail + +# macOS ships bash 3.2; nothing here may use bash 4+ syntax. + +LABEL="sh.dormouse.server" +INSTALL_ROOT="$HOME/Library/Application Support/Dormouse Server" +LOG_DIR="$HOME/Library/Logs/Dormouse Server" +PLIST="$HOME/Library/LaunchAgents/$LABEL.plist" +LOOPBACK_PORT=3100 +RELEASES_TO_KEEP=2 + +ASSUME_YES=0 +[ "${DORMOUSE_INSTALL_ASSUME_YES:-0}" = "1" ] && ASSUME_YES=1 +TEST_MODE=0 +[ "${DORMOUSE_INSTALL_TEST:-0}" = "1" ] && TEST_MODE=1 + +# A throwaway install root, for exercising path quoting, plist generation, +# release switching and cleanup without touching the real installation. Gated to +# test mode on purpose: a real install belongs in the documented location, and +# an overridden root would leave `manage` and the LaunchAgent disagreeing about +# where the service lives. Overriding HOME instead would break pnpm, whose store +# and downloaded runtime live under the real home. +if [ -n "${DORMOUSE_INSTALL_ROOT:-}" ]; then + if [ "$TEST_MODE" != "1" ]; then + echo "DORMOUSE_INSTALL_ROOT is only honored with DORMOUSE_INSTALL_TEST=1" >&2 + exit 64 + fi + INSTALL_ROOT="$DORMOUSE_INSTALL_ROOT" + LOG_DIR="$INSTALL_ROOT/logs" + PLIST="$INSTALL_ROOT/LaunchAgents/$LABEL.plist" +fi + +for arg in "$@"; do + case "$arg" in + --yes|-y) ASSUME_YES=1 ;; + --help|-h) sed -n '2,25p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + *) echo "unknown argument: $arg" >&2; exit 64 ;; + esac +done + +# ---------------------------------------------------------------- output ---- + +if [ -t 1 ]; then + C_DIM=$'\033[2m'; C_RED=$'\033[31m'; C_GRN=$'\033[32m' + C_YEL=$'\033[33m'; C_BLD=$'\033[1m'; C_OFF=$'\033[0m' +else + C_DIM=""; C_RED=""; C_GRN=""; C_YEL=""; C_BLD=""; C_OFF="" +fi + +step() { printf '\n%s==>%s %s%s%s\n' "$C_BLD" "$C_OFF" "$C_BLD" "$1" "$C_OFF"; } +info() { printf ' %s\n' "$1"; } +detail() { printf ' %s%s%s\n' "$C_DIM" "$1" "$C_OFF"; } +ok() { printf ' %s✓%s %s\n' "$C_GRN" "$C_OFF" "$1"; } +warn() { printf ' %s!%s %s\n' "$C_YEL" "$C_OFF" "$1" >&2; } +die() { printf '\n%serror:%s %s\n' "$C_RED" "$C_OFF" "$1" >&2; exit 1; } + +confirm() { + # $1 = prompt. Returns 0 for yes. + if [ "$ASSUME_YES" = "1" ]; then + detail "$1 [auto-yes]" + return 0 + fi + if [ ! -t 0 ]; then + die "$1 — refusing to assume an answer with no terminal. Re-run with --yes if that is what you want." + fi + printf ' %s [y/N] ' "$1" + local reply="" + read -r reply || true + case "$reply" in + y|Y|yes|YES) return 0 ;; + *) return 1 ;; + esac +} + +# ------------------------------------------------------------- preflight ---- + +[ "$(uname -s)" = "Darwin" ] || die "this installer is macOS-only (found $(uname -s)). See SELF_HOST.md Prerequisites — design the native service manager with the user rather than translating LaunchAgent commands." + +[ "$(id -u)" != "0" ] || die "do not run this as root. It installs only into \$HOME and needs no sudo." + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +[ -f "$REPO_ROOT/pnpm-workspace.yaml" ] || die "cannot locate the repository root from $SCRIPT_DIR" +cd "$REPO_ROOT" + +JSON_RUNNER="" +if command -v node >/dev/null 2>&1; then + JSON_RUNNER="node" +elif [ -x /usr/bin/python3 ]; then + JSON_RUNNER="python3" +else + die "need either node or /usr/bin/python3 to read package.json and the Tailscale status." +fi + +# json_query -> value on stdout, exit 1 if absent. +# Arrays are joined with commas. +json_query() { + case "$JSON_RUNNER" in + node) + node -e ' +const fs = require("fs"); +const j = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); +let v = j; +for (const k of process.argv[2].split(".")) { if (v == null) break; v = v[k]; } +if (v == null) process.exit(1); +process.stdout.write(Array.isArray(v) ? v.join(",") : String(v)); +' "$1" "$2" + ;; + python3) + /usr/bin/python3 -c ' +import json, sys +v = json.load(open(sys.argv[1])) +for k in sys.argv[2].split("."): + if v is None: break + v = v.get(k) if isinstance(v, dict) else None +if v is None: sys.exit(1) +sys.stdout.write(",".join(v) if isinstance(v, list) else str(v)) +' "$1" "$2" + ;; + esac +} + +# Replace a symlink atomically, without following it. +# +# `mv -f tmp link` FOLLOWS an existing symlink-to-directory: it moves the temp +# link *inside* the directory the old link points at, leaving `current` aimed +# where it already was. The update then silently becomes a no-op — and the +# prune, reading `current`, deletes the release nothing points at. rename(2) on +# the link path replaces the link itself and has no such behavior. +# $1 = target, $2 = link path, $3 = node binary +atomic_symlink() { + "$3" -e ' +const fs = require("fs"); +const target = process.argv[1]; +const link = process.argv[2]; +const tmp = link + ".swap." + process.pid; +try { fs.unlinkSync(tmp); } catch (e) { /* no stale temp link */ } +fs.symlinkSync(target, tmp); +fs.renameSync(tmp, link); +' "$1" "$2" +} + +# --------------------------------------------------------------- tailscale -- + +TS_BIN="" +TS_VIA_BUNDLE=0 +if command -v tailscale >/dev/null 2>&1; then + TS_BIN="$(command -v tailscale)" +else + for candidate in \ + "/Applications/Tailscale.app/Contents/MacOS/tailscale" \ + "/Applications/Tailscale.app/Contents/MacOS/Tailscale" \ + "$HOME/Applications/Tailscale.app/Contents/MacOS/tailscale" \ + "$HOME/Applications/Tailscale.app/Contents/MacOS/Tailscale"; do + if [ -x "$candidate" ]; then + TS_BIN="$candidate" + TS_VIA_BUNDLE=1 + break + fi + done +fi +[ -n "$TS_BIN" ] || die "tailscale CLI not found on PATH or in /Applications/Tailscale.app. Install Tailscale and sign in first — this installer will not install or reauthenticate it for you. https://tailscale.com/docs/install/mac" + +# TAILSCALE_BE_CLI=1 stops the bundled app executable from launching the GUI +# instead of acting as the CLI. Harmless for a real CLI binary. +ts() { + if [ "$TS_VIA_BUNDLE" = "1" ]; then + TAILSCALE_BE_CLI=1 "$TS_BIN" "$@" + else + "$TS_BIN" "$@" + fi +} + +# ------------------------------------------------------------------ start ---- + +printf '%sDormouse selfhost server — macOS installer%s\n' "$C_BLD" "$C_OFF" +[ "$TEST_MODE" = "1" ] && warn "DORMOUSE_INSTALL_TEST=1 — launchd and Serve will not be touched." + +step "Checking Tailscale" + +TS_STATUS_JSON="$(mktemp -t dormouse-ts-status)" +trap 'rm -f "$TS_STATUS_JSON"' EXIT +ts status --json > "$TS_STATUS_JSON" 2>/dev/null || die "\`tailscale status --json\` failed. Is Tailscale running and signed in?" + +TS_BACKEND="$(json_query "$TS_STATUS_JSON" "BackendState" || echo "")" +[ "$TS_BACKEND" = "Running" ] || die "Tailscale backend state is '${TS_BACKEND:-unknown}', expected 'Running'. Sign in and connect, then re-run." + +TS_DNS_RAW="$(json_query "$TS_STATUS_JSON" "Self.DNSName" || echo "")" +[ -n "$TS_DNS_RAW" ] || die "Tailscale reports no MagicDNS name for this node. Enable MagicDNS for the tailnet: https://login.tailscale.com/admin/dns" +# MagicDNS names arrive fully qualified with a trailing dot. +TS_DNS="${TS_DNS_RAW%.}" + +MAGIC_DNS_ENABLED="$(json_query "$TS_STATUS_JSON" "CurrentTailnet.MagicDNSEnabled" || echo "false")" +[ "$MAGIC_DNS_ENABLED" = "true" ] || warn "MagicDNS is not reported as enabled for this tailnet; the HTTPS name may not resolve for other devices." + +ORIGIN="https://$TS_DNS" +ok "node: $TS_DNS" +ok "external origin: $ORIGIN" + +CERT_DOMAINS="$(json_query "$TS_STATUS_JSON" "CertDomains" || echo "")" +case ",$CERT_DOMAINS," in + *",$TS_DNS,"*) ok "tailnet HTTPS certificates enabled for this name" ;; + *) + warn "tailnet HTTPS certificates do not list $TS_DNS." + warn "Enable HTTPS at https://login.tailscale.com/admin/dns — Serve cannot get a certificate without it." + warn "Tailscale may also prompt for consent the first time Serve requests one." + ;; +esac + +# --------------------------------------------------------- origin identity --- + +CONFIG_DIR="$INSTALL_ROOT/config" +ENV_FILE="$CONFIG_DIR/server.env" +STATE_DIR="$INSTALL_ROOT/state" +RELEASES_DIR="$INSTALL_ROOT/releases" +BIN_DIR="$INSTALL_ROOT/bin" +CURRENT_LINK="$INSTALL_ROOT/current" +PREVIOUS_LINK="$INSTALL_ROOT/previous" + +FIRST_INSTALL=1 +if [ -f "$ENV_FILE" ]; then + FIRST_INSTALL=0 + EXISTING_ORIGIN="$(sed -n 's/^DORMOUSE_ORIGIN=//p' "$ENV_FILE" | head -1 | sed 's/^"//; s/"$//')" + if [ -n "$EXISTING_ORIGIN" ] && [ "$EXISTING_ORIGIN" != "$ORIGIN" ]; then + printf '\n' >&2 + warn "This machine already has an installation bound to a DIFFERENT origin." + warn " installed: $EXISTING_ORIGIN" + warn " derived: $ORIGIN" + warn "" + warn "DORMOUSE_ORIGIN is durable WebAuthn identity: it is the source of the" + warn "passkey rpId and of the Host's ConnectionPolicy. Rewriting it invalidates" + warn "the registered passkey and every enrolled Host — they must be re-enrolled." + warn "" + warn "This usually means the Tailscale node was renamed or re-enrolled." + die "refusing to silently rewrite the origin. Decide deliberately: restore the old node name, or plan the passkey + Host re-enrollment and remove $ENV_FILE by hand." + fi +fi + +# ----------------------------------------------------------------- source ---- + +step "Checking the source checkout" + +GIT_SHA="$(git -C "$REPO_ROOT" rev-parse HEAD 2>/dev/null || echo "unknown")" +GIT_SHORT="$(git -C "$REPO_ROOT" rev-parse --short HEAD 2>/dev/null || echo "unknown")" +GIT_BRANCH="$(git -C "$REPO_ROOT" rev-parse --abbrev-ref HEAD 2>/dev/null || echo "unknown")" +GIT_DIRTY="false" +if [ -n "$(git -C "$REPO_ROOT" status --porcelain 2>/dev/null)" ]; then + GIT_DIRTY="true" +fi +ARCH="$(uname -m)" + +info "checkout: $REPO_ROOT" +info "branch: $GIT_BRANCH" +info "commit: $GIT_SHA" +info "arch: $ARCH" +if [ "$GIT_DIRTY" = "true" ]; then + warn "the worktree is DIRTY — the installed release will not be identified by its SHA alone." + git -C "$REPO_ROOT" status --short | sed 's/^/ /' + confirm "Install this dirty worktree?" || die "aborted at the user's request." +else + ok "worktree clean" +fi + +NODE_PIN="$(json_query "$REPO_ROOT/package.json" "devEngines.runtime.version" || echo "")" +[ -n "$NODE_PIN" ] || die "root package.json has no devEngines.runtime.version. SECURITY.md keys a mechanical FAIL IF to that exact field." +case "$NODE_PIN" in + *.*.*) : ;; + *) die "devEngines.runtime.version must be an exact MAJOR.MINOR.PATCH version, got '$NODE_PIN'." ;; +esac +PNPM_PIN="$(json_query "$REPO_ROOT/package.json" "packageManager" || echo "")" +[ -n "$PNPM_PIN" ] || die "root package.json has no packageManager field." +ok "node pin: $NODE_PIN" +ok "pnpm pin: $PNPM_PIN" + +command -v pnpm >/dev/null 2>&1 || die "pnpm is not on PATH. Install pnpm $PNPM_PIN (or enable Corepack) and re-run." +PNPM_ACTUAL="$(pnpm --version 2>/dev/null || echo "unknown")" +if [ "$PNPM_PIN" != "pnpm@$PNPM_ACTUAL" ]; then + warn "pnpm on PATH is $PNPM_ACTUAL but the repository pins $PNPM_PIN." + confirm "Continue with the mismatched pnpm?" || die "aborted at the user's request." +else + ok "pnpm on PATH matches the pin" +fi + +# --------------------------------------------------- workspace-state guard --- +# +# `pnpm deploy --prod --legacy` rewrites the ROOT workspace state file +# (node_modules/.pnpm-workspace-state-v1.json) to production:true / dev:false. +# Every later pnpm command in this checkout then decides the workspace is stale +# and tries to run `pnpm install --production`, which would strip the developer's +# devDependencies. Snapshot the file and restore it unconditionally on exit, so +# a failed install cannot leave the checkout poisoned either. + +WS_STATE="$REPO_ROOT/node_modules/.pnpm-workspace-state-v1.json" +WS_STATE_BACKUP="" +restore_workspace_state() { + if [ -n "$WS_STATE_BACKUP" ] && [ -f "$WS_STATE_BACKUP" ]; then + cp -p "$WS_STATE_BACKUP" "$WS_STATE" 2>/dev/null || true + rm -f "$WS_STATE_BACKUP" + fi +} +cleanup() { + restore_workspace_state + rm -f "$TS_STATUS_JSON" +} +trap cleanup EXIT + +# ------------------------------------------------------------------ build ---- + +step "Building the release from this checkout" + +info "pnpm install --frozen-lockfile" +pnpm install --frozen-lockfile >/dev/null 2>&1 || die "pnpm install --frozen-lockfile failed. Run it by hand to see why." +ok "dependencies installed" + +info "building lib/dist-pocket" +pnpm --filter dormouse-lib build:pocket >/dev/null 2>&1 || die "pocket build failed. Run: pnpm --filter dormouse-lib build:pocket" +[ -f "$REPO_ROOT/lib/dist-pocket/index.html" ] || die "lib/dist-pocket/index.html missing after the pocket build." +ok "pocket app built" + +info "building server (and server-lib-common)" +pnpm --filter server build >/dev/null 2>&1 || die "server build failed. Run: pnpm --filter server build" +[ -f "$REPO_ROOT/server/dist/index.js" ] || die "server/dist/index.js missing after the server build." +ok "server built" + +# Resolve the exact Node the build ran under. pnpm honors devEngines +# (onFail: download), so this is the pinned runtime, not whatever is on PATH. +# Write it to a file: pnpm can emit progress chatter on stdout, which would +# contaminate a command substitution. +EXECPATH_FILE="$(mktemp -t dormouse-execpath)" +pnpm exec node -e 'require("fs").writeFileSync(process.argv[1], process.execPath)' "$EXECPATH_FILE" >/dev/null 2>&1 \ + || die "could not resolve the pinned Node runtime via pnpm exec." +NODE_BIN="$(cat "$EXECPATH_FILE")" +rm -f "$EXECPATH_FILE" +[ -x "$NODE_BIN" ] || die "resolved Node runtime is not executable: $NODE_BIN" + +NODE_BUILD_VERSION="$("$NODE_BIN" -e 'process.stdout.write(process.version)')" +NODE_BUILD_ARCH="$("$NODE_BIN" -e 'process.stdout.write(process.arch)')" +[ "$NODE_BUILD_VERSION" = "v$NODE_PIN" ] || die "the build ran under Node $NODE_BUILD_VERSION but the repository pins v$NODE_PIN." +ok "pinned runtime: $NODE_BUILD_VERSION ($NODE_BUILD_ARCH)" + +# ------------------------------------------------------------- stage build --- + +step "Staging the new release" + +mkdir -p "$RELEASES_DIR" "$BIN_DIR" +mkdir -p "$CONFIG_DIR" "$STATE_DIR" +chmod 0700 "$CONFIG_DIR" "$STATE_DIR" +mkdir -p "$LOG_DIR" + +BUILT_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)" +RELEASE_ID="$(date -u +%Y%m%dT%H%M%SZ)-$GIT_SHORT" +[ "$GIT_DIRTY" = "true" ] && RELEASE_ID="$RELEASE_ID-dirty" +STAGE="$RELEASES_DIR/$RELEASE_ID" + +rm -rf "$STAGE" +mkdir -p "$STAGE/lib" "$STAGE/runtime" + +info "pnpm deploy --prod --legacy" +WS_STATE_BACKUP="" +if [ -f "$WS_STATE" ]; then + WS_STATE_BACKUP="$(mktemp -t dormouse-wsstate)" + cp -p "$WS_STATE" "$WS_STATE_BACKUP" +fi +pnpm --filter server deploy --prod --legacy "$STAGE/server" >/dev/null 2>&1 \ + || die "pnpm deploy failed. Run: pnpm --filter server deploy --prod --legacy /tmp/dormouse-deploy-probe" +restore_workspace_state +[ -f "$STAGE/server/dist/index.js" ] || die "the deployed server tree has no dist/index.js." +[ -d "$STAGE/server/node_modules/server-lib-common" ] || die "the deployed server tree is missing the injected server-lib-common workspace package." +ok "production server tree staged" + +# server/src/config.ts resolves the pocket dir two levels up from +# server/dist/config.js, i.e. /lib/dist-pocket. Match that layout so no +# DORMOUSE_POCKET_DIR override is needed. +cp -R "$REPO_ROOT/lib/dist-pocket" "$STAGE/lib/dist-pocket" +[ -f "$STAGE/lib/dist-pocket/index.html" ] || die "pocket app did not land in the release." +ok "pocket app staged" + +cp "$NODE_BIN" "$STAGE/runtime/node" +chmod 0755 "$STAGE/runtime/node" +STAGED_NODE_VERSION="$("$STAGE/runtime/node" -e 'process.stdout.write(process.version)')" +STAGED_NODE_ARCH="$("$STAGE/runtime/node" -e 'process.stdout.write(process.arch)')" +[ "$STAGED_NODE_VERSION" = "v$NODE_PIN" ] || die "the copied runtime reports $STAGED_NODE_VERSION, expected v$NODE_PIN." +case "$ARCH:$STAGED_NODE_ARCH" in + arm64:arm64|x86_64:x64) : ;; + *) die "the copied runtime is $STAGED_NODE_ARCH but this Mac is $ARCH." ;; +esac +ok "self-contained runtime staged ($STAGED_NODE_VERSION $STAGED_NODE_ARCH)" + +cat > "$STAGE/RELEASE" < "$ENV_FILE" < "$BIN_DIR/run-server" <<'RUNSERVER_EOF' +#!/bin/bash +# Installed by deploy/local/install-macos.sh. Stable across releases. +# +# launchd does not read interactive shell startup files, so this must not depend +# on the user's PATH, on Homebrew/nvm/Volta, on pnpm's store, or on the source +# checkout. It loads only the installer-owned env file and execs the runtime +# copied into the current release. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +ENV_FILE="$ROOT/config/server.env" + +[ -r "$ENV_FILE" ] || { echo "run-server: cannot read $ENV_FILE" >&2; exit 78; } + +# Parse KEY=VALUE lines. Deliberately not `source`/`eval`: this file holds the +# setup password, and a config file should not be able to execute code. +while IFS= read -r line || [ -n "$line" ]; do + case "$line" in ''|'#'*) continue ;; esac + case "$line" in *=*) ;; *) continue ;; esac + key="${line%%=*}" + value="${line#*=}" + case "$key" in + [A-Za-z_]*) ;; + *) continue ;; + esac + case "$value" in + '"'*'"') value="${value#\"}"; value="${value%\"}" ;; + esac + export "$key=$value" +done < "$ENV_FILE" + +NODE_BIN="$ROOT/current/runtime/node" +ENTRY="$ROOT/current/server/dist/index.js" +[ -x "$NODE_BIN" ] || { echo "run-server: missing runtime $NODE_BIN" >&2; exit 78; } +[ -f "$ENTRY" ] || { echo "run-server: missing entrypoint $ENTRY" >&2; exit 78; } + +exec "$NODE_BIN" "$ENTRY" +RUNSERVER_EOF +chmod 0700 "$BIN_DIR/run-server" +ok "bin/run-server" + +cat > "$BIN_DIR/manage" <<'MANAGE_EOF' +#!/bin/bash +# Installed by deploy/local/install-macos.sh. +set -euo pipefail + +LABEL="sh.dormouse.server" +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +ENV_FILE="$ROOT/config/server.env" +STATE_DIR="$ROOT/state" +LOG_DIR="$HOME/Library/Logs/Dormouse Server" +PLIST="$HOME/Library/LaunchAgents/$LABEL.plist" +# A test install (DORMOUSE_INSTALL_ROOT) keeps its logs and plist inside its own +# root, so `manage` must follow them there rather than at the real HOME paths. +[ -d "$ROOT/logs" ] && LOG_DIR="$ROOT/logs" +[ -f "$ROOT/LaunchAgents/$LABEL.plist" ] && PLIST="$ROOT/LaunchAgents/$LABEL.plist" + +if [ -t 1 ]; then + C_RED=$'\033[31m'; C_GRN=$'\033[32m'; C_YEL=$'\033[33m'; C_DIM=$'\033[2m'; C_OFF=$'\033[0m' +else + C_RED=""; C_GRN=""; C_YEL=""; C_DIM=""; C_OFF="" +fi + +pass() { printf ' %s✓%s %s\n' "$C_GRN" "$C_OFF" "$1"; } +fail() { printf ' %s✗%s %s\n' "$C_RED" "$C_OFF" "$1"; FAILURES=$((FAILURES + 1)); } +note() { printf ' %s%s%s\n' "$C_DIM" "$1" "$C_OFF"; } +warn() { printf ' %s!%s %s\n' "$C_YEL" "$C_OFF" "$1"; } + +env_value() { + [ -r "$ENV_FILE" ] || return 1 + sed -n "s/^$1=//p" "$ENV_FILE" | head -1 | sed 's/^"//; s/"$//' +} + +PORT="$(env_value PORT || echo 3100)" +ORIGIN="$(env_value DORMOUSE_ORIGIN || echo "")" + +TS_BIN="" +TS_VIA_BUNDLE=0 +if command -v tailscale >/dev/null 2>&1; then + TS_BIN="$(command -v tailscale)" +else + for candidate in \ + "/Applications/Tailscale.app/Contents/MacOS/tailscale" \ + "/Applications/Tailscale.app/Contents/MacOS/Tailscale" \ + "$HOME/Applications/Tailscale.app/Contents/MacOS/tailscale" \ + "$HOME/Applications/Tailscale.app/Contents/MacOS/Tailscale"; do + if [ -x "$candidate" ]; then TS_BIN="$candidate"; TS_VIA_BUNDLE=1; break; fi + done +fi +ts() { + [ -n "$TS_BIN" ] || return 127 + if [ "$TS_VIA_BUNDLE" = "1" ]; then TAILSCALE_BE_CLI=1 "$TS_BIN" "$@"; else "$TS_BIN" "$@"; fi +} + +# Replace a symlink atomically, without following it. `mv -f tmp link` follows +# an existing symlink-to-directory and would deposit the temp link inside the +# old release, leaving `current` unmoved. rename(2) on the link path does not. +# $1 = target, $2 = link path, $3 = node binary +atomic_symlink() { + "$3" -e ' +const fs = require("fs"); +const target = process.argv[1]; +const link = process.argv[2]; +const tmp = link + ".swap." + process.pid; +try { fs.unlinkSync(tmp); } catch (e) { /* no stale temp link */ } +fs.symlinkSync(target, tmp); +fs.renameSync(tmp, link); +' "$1" "$2" +} + +release_field() { + local target="$ROOT/current/RELEASE" + [ -f "$target" ] || return 1 + sed -n "s/^$1=//p" "$target" | head -1 +} + +wait_for_health() { + local deadline=$((SECONDS + ${1:-30})) + while [ $SECONDS -lt $deadline ]; do + if curl -sf -o /dev/null "http://127.0.0.1:$PORT/api/hello"; then return 0; fi + sleep 0.5 + done + return 1 +} + +cmd_status() { + printf '\nDormouse selfhost server\n' + printf ' install root : %s\n' "$ROOT" + printf ' origin : %s\n' "${ORIGIN:-}" + printf ' loopback : http://127.0.0.1:%s\n' "$PORT" + if [ -L "$ROOT/current" ]; then + printf ' release : %s\n' "$(basename "$(readlink "$ROOT/current")")" + printf ' commit : %s (dirty=%s)\n' "$(release_field git_sha || echo '?')" "$(release_field git_dirty || echo '?')" + printf ' built at : %s\n' "$(release_field built_at || echo '?')" + printf ' node : %s %s\n' "$(release_field node_version || echo '?')" "$(release_field node_arch || echo '')" + else + printf ' release : %s(none — current symlink missing)%s\n' "$C_RED" "$C_OFF" + fi + if [ -L "$ROOT/previous" ]; then + printf ' previous : %s\n' "$(basename "$(readlink "$ROOT/previous")")" + else + printf ' previous : (none — rollback unavailable)\n' + fi + printf '\nLaunchAgent\n' + if launchctl print "gui/$UID/$LABEL" >/dev/null 2>&1; then + launchctl print "gui/$UID/$LABEL" 2>/dev/null \ + | sed -n 's/^[[:space:]]*\(state\|pid\|last exit code\) = / &/p' \ + | sed 's/^ [[:space:]]*/ /' + else + printf ' %snot loaded%s\n' "$C_RED" "$C_OFF" + fi + printf '\nHealth\n' + if curl -sf "http://127.0.0.1:$PORT/api/hello" >/dev/null 2>&1; then + printf ' loopback /api/hello : %sok%s\n' "$C_GRN" "$C_OFF" + else + printf ' loopback /api/hello : %sunreachable%s\n' "$C_RED" "$C_OFF" + fi + printf '\nTailscale Serve\n' + ts serve status 2>&1 | sed 's/^/ /' || printf ' %stailscale CLI unavailable%s\n' "$C_RED" "$C_OFF" + printf '\nState files (%s)\n' "$STATE_DIR" + if [ -d "$STATE_DIR" ]; then + ls -la "$STATE_DIR" | sed 's/^/ /' + else + printf ' %smissing%s\n' "$C_RED" "$C_OFF" + fi + printf '\n' +} + +cmd_verify() { + FAILURES=0 + printf '\nVerifying the installed service\n\n' + + if launchctl print "gui/$UID/$LABEL" >/dev/null 2>&1; then + pass "LaunchAgent $LABEL is loaded in gui/$UID" + else + fail "LaunchAgent $LABEL is not loaded" + fi + + if [ -f "$PLIST" ] && plutil -lint "$PLIST" >/dev/null 2>&1; then + pass "LaunchAgent plist is valid" + if grep -q "RunAtLoad" "$PLIST" && grep -q "KeepAlive" "$PLIST"; then + pass "plist declares RunAtLoad and KeepAlive" + else + fail "plist is missing RunAtLoad or KeepAlive" + fi + if grep -q "DORMOUSE_SETUP_PASSWORD" "$PLIST"; then + fail "plist contains the setup password — it must live only in config/server.env" + else + pass "plist carries no credential" + fi + else + fail "LaunchAgent plist missing or invalid: $PLIST" + fi + + if curl -sf -o /dev/null "http://127.0.0.1:$PORT/api/hello"; then + pass "http://127.0.0.1:$PORT/api/hello responds" + else + fail "loopback /api/hello is unreachable" + fi + + if curl -sf -o /dev/null "http://127.0.0.1:$PORT/"; then + pass "Pocket app is served on loopback" + else + fail "Pocket index is not served — is lib/dist-pocket in the release?" + fi + + local listeners + listeners="$(lsof -nP -iTCP:"$PORT" -sTCP:LISTEN 2>/dev/null | tail -n +2 || true)" + if [ -z "$listeners" ]; then + fail "nothing is listening on port $PORT" + elif printf '%s\n' "$listeners" | grep -qv '127\.0\.0\.1:'"$PORT"; then + fail "port $PORT is bound off-loopback — fix DORMOUSE_BIND_HOST=127.0.0.1" + printf '%s\n' "$listeners" | sed 's/^/ /' + else + pass "port $PORT is bound only to 127.0.0.1" + fi + + local tsip + tsip="$(ts ip -4 2>/dev/null | head -1 || true)" + if [ -n "$tsip" ]; then + if curl -s --max-time 3 -o /dev/null "http://$tsip:$PORT/api/hello" 2>/dev/null; then + fail "plaintext port $PORT is reachable on the Tailscale IP $tsip" + else + pass "plaintext port $PORT is not reachable on the Tailscale IP" + fi + else + note "skipped the off-loopback probe (no Tailscale IPv4 address)" + fi + + local serve_out + serve_out="$(ts serve status 2>/dev/null || true)" + if [ -z "$serve_out" ]; then + fail "tailscale serve reports no configuration" + else + if printf '%s' "$serve_out" | grep -q "127.0.0.1:$PORT"; then + pass "Serve proxies to 127.0.0.1:$PORT" + else + fail "Serve does not proxy to 127.0.0.1:$PORT" + printf '%s\n' "$serve_out" | sed 's/^/ /' + fi + if [ -n "$ORIGIN" ] && printf '%s' "$serve_out" | grep -q "${ORIGIN#https://}"; then + pass "Serve origin matches DORMOUSE_ORIGIN ($ORIGIN)" + else + fail "Serve origin does not match DORMOUSE_ORIGIN ($ORIGIN)" + fi + fi + + local cfg_mode state_mode env_mode + cfg_mode="$(stat -f '%Lp' "$ROOT/config" 2>/dev/null || echo '???')" + state_mode="$(stat -f '%Lp' "$STATE_DIR" 2>/dev/null || echo '???')" + env_mode="$(stat -f '%Lp' "$ENV_FILE" 2>/dev/null || echo '???')" + [ "$cfg_mode" = "700" ] && pass "config/ is mode 0700" || fail "config/ is mode $cfg_mode, expected 700" + [ "$state_mode" = "700" ] && pass "state/ is mode 0700" || fail "state/ is mode $state_mode, expected 700" + [ "$env_mode" = "600" ] && pass "config/server.env is mode 0600" || fail "config/server.env is mode $env_mode, expected 600" + + if grep -q '^DORMOUSE_BIND_HOST=127\.0\.0\.1$' "$ENV_FILE" 2>/dev/null; then + pass "DORMOUSE_BIND_HOST=127.0.0.1" + else + fail "DORMOUSE_BIND_HOST is not pinned to 127.0.0.1" + fi + + if [ -L "$ROOT/current" ] && [ -f "$ROOT/current/RELEASE" ]; then + pass "current release: $(basename "$(readlink "$ROOT/current")")" + [ "$(release_field git_dirty)" = "true" ] && warn "this release was built from a DIRTY worktree" + else + fail "current release symlink or RELEASE metadata missing" + fi + + if [ -L "$ROOT/previous" ]; then + pass "a previous release is retained for rollback" + else + warn "no previous release retained yet — rollback is unavailable until the next update" + fi + + # The release must not depend on the source checkout. + local src + src="$(release_field source_checkout || echo '')" + if [ -n "$src" ]; then + if grep -q "$src" "$PLIST" 2>/dev/null || grep -q "$src" "$ROOT/bin/run-server" 2>/dev/null; then + fail "the LaunchAgent or wrapper references the source checkout ($src)" + else + pass "the installed service does not reference the source checkout" + fi + fi + + printf '\n' + if [ "$FAILURES" -eq 0 ]; then + printf '%sAll checks passed.%s\n\n' "$C_GRN" "$C_OFF" + return 0 + fi + printf '%s%s check(s) failed.%s\n\n' "$C_RED" "$FAILURES" "$C_OFF" + return 1 +} + +cmd_logs() { + mkdir -p "$LOG_DIR" + touch "$LOG_DIR/server.out.log" "$LOG_DIR/server.err.log" + printf 'tailing %s/{server.out.log,server.err.log} — ctrl-c to stop\n\n' "$LOG_DIR" + tail -n 50 -f "$LOG_DIR/server.out.log" "$LOG_DIR/server.err.log" +} + +cmd_restart() { + launchctl kickstart -k "gui/$UID/$LABEL" + printf 'restarted; waiting for health...\n' + if wait_for_health 30; then + printf '%shealthy%s\n' "$C_GRN" "$C_OFF" + else + printf '%sdid not become healthy within 30s — check: manage logs%s\n' "$C_RED" "$C_OFF" + return 1 + fi +} + +cmd_show_password() { + printf '\n%sWARNING%s the setup password gates account creation and Host enrollment.\n' "$C_YEL" "$C_OFF" + printf 'It is about to be printed to this terminal. Make sure nobody is looking\n' + printf 'over your shoulder and that this session is not being recorded or shared.\n\n' + if [ -t 0 ]; then + printf 'Print it? [y/N] ' + local reply="" + read -r reply || true + case "$reply" in y|Y|yes|YES) ;; *) printf 'aborted\n'; return 1 ;; esac + fi + printf '\n %s\n\n' "$(env_value DORMOUSE_SETUP_PASSWORD)" +} + +cmd_serve() { + # Re-apply the Serve mapping — e.g. after a dev session repointed / at :3000. + [ -n "$TS_BIN" ] || { printf 'tailscale CLI not found\n' >&2; return 1; } + ts serve --bg "$PORT" + ts serve status +} + +cmd_rollback() { + [ -L "$ROOT/previous" ] || { printf 'no previous release retained\n' >&2; return 1; } + local prev cur + prev="$(readlink "$ROOT/previous")" + cur="$(readlink "$ROOT/current" 2>/dev/null || echo '')" + [ -d "$ROOT/releases/$(basename "$prev")" ] || { printf 'previous release directory is gone: %s\n' "$prev" >&2; return 1; } + printf 'rolling back: %s -> %s\n' "$(basename "$cur")" "$(basename "$prev")" + local node_bin="" + for candidate in "$prev/runtime/node" "$ROOT/current/runtime/node"; do + if [ -x "$candidate" ]; then node_bin="$candidate"; break; fi + done + [ -n "$node_bin" ] || { printf 'no usable runtime found to swap the symlinks\n' >&2; return 1; } + atomic_symlink "$prev" "$ROOT/current" "$node_bin" + if [ -n "$cur" ]; then atomic_symlink "$cur" "$ROOT/previous" "$node_bin"; fi + if [ "$(readlink "$ROOT/current")" != "$prev" ]; then + printf 'current did not advance to %s\n' "$prev" >&2 + return 1 + fi + launchctl kickstart -k "gui/$UID/$LABEL" || true + if wait_for_health 30; then + printf '%srolled back and healthy%s\n' "$C_GRN" "$C_OFF" + else + printf '%srolled back but not healthy — check: manage logs%s\n' "$C_RED" "$C_OFF" + return 1 + fi +} + +cmd_uninstall() { + printf '\nThis removes the LaunchAgent and the installed code.\n' + printf 'It PRESERVES your configuration and state:\n' + printf ' config : %s\n' "$ROOT/config" + printf ' state : %s\n' "$STATE_DIR" + printf '\nUse "manage purge" separately to delete those irreversibly.\n\n' + if [ -t 0 ]; then + printf 'Uninstall? [y/N] ' + local reply="" + read -r reply || true + case "$reply" in y|Y|yes|YES) ;; *) printf 'aborted\n'; return 1 ;; esac + fi + launchctl bootout "gui/$UID/$LABEL" 2>/dev/null || true + rm -f "$PLIST" + # Turn off only the mapping this installer owns. + if ts serve status 2>/dev/null | grep -q "127.0.0.1:$PORT"; then + ts serve --bg off 2>/dev/null || ts serve reset 2>/dev/null || true + printf 'turned off the Serve mapping to 127.0.0.1:%s\n' "$PORT" + else + printf 'left the Serve config alone (it does not point at 127.0.0.1:%s)\n' "$PORT" + fi + rm -rf "$ROOT/releases" "$ROOT/current" "$ROOT/previous" "$ROOT/bin" + printf '\nuninstalled. config and state remain at:\n %s\n %s\n\n' "$ROOT/config" "$STATE_DIR" +} + +cmd_purge() { + printf '\n%sIRREVERSIBLE%s This deletes the account, enrolled Hosts, push\n' "$C_RED" "$C_OFF" + printf 'subscriptions, and the VAPID key:\n %s\n %s\n\n' "$STATE_DIR" "$ROOT/config" + printf 'Registered passkeys and enrolled Hosts will have to be set up again.\n\n' + printf 'Type exactly: DELETE DORMOUSE STATE\n> ' + local reply="" + read -r reply || true + if [ "$reply" != "DELETE DORMOUSE STATE" ]; then printf 'aborted\n'; return 1; fi + rm -rf "$STATE_DIR" "$ROOT/config" + printf 'purged.\n' +} + +case "${1:-status}" in + status) cmd_status ;; + verify) cmd_verify ;; + logs) cmd_logs ;; + restart) cmd_restart ;; + show-password) cmd_show_password ;; + serve) cmd_serve ;; + rollback) cmd_rollback ;; + uninstall) cmd_uninstall ;; + purge) cmd_purge ;; + *) + cat < + + status LaunchAgent, process, health, Serve origin, and release + verify run every acceptance check; exits nonzero on any failure + logs tail the local server logs + restart kickstart the LaunchAgent and wait for health + show-password warn, then display the setup password locally + serve re-apply the Tailscale Serve mapping for this server + rollback switch to the retained previous release, preserving state + uninstall remove LaunchAgent + code (keeps config and state) + purge irreversibly delete config and state +USAGE + exit 64 + ;; +esac +MANAGE_EOF +chmod 0700 "$BIN_DIR/manage" +ok "bin/manage" + +# --------------------------------------------------------- candidate check --- + +step "Health-checking the candidate release" + +# Disposable: a throwaway state dir, a throwaway password and an ephemeral port, +# so nothing touches the live service or the real state while we prove the new +# code boots and serves. +PROBE_PORT="$("$STAGE/runtime/node" -e 'const n=require("net");const s=n.createServer();s.listen(0,"127.0.0.1",()=>{const p=s.address().port;s.close(()=>process.stdout.write(String(p)))});')" +PROBE_STATE="$(mktemp -d -t dormouse-probe-state)" +PROBE_LOG="$(mktemp -t dormouse-probe-log)" +chmod 0700 "$PROBE_STATE" + +env -i HOME="$HOME" PATH=/usr/bin:/bin:/usr/sbin:/sbin \ + DORMOUSE_SETUP_PASSWORD="candidate-probe-$RELEASE_ID" \ + DORMOUSE_ORIGIN="$ORIGIN" \ + DORMOUSE_STATE_DIR="$PROBE_STATE" \ + DORMOUSE_BIND_HOST=127.0.0.1 \ + PORT="$PROBE_PORT" \ + NODE_ENV=production \ + "$STAGE/runtime/node" "$STAGE/server/dist/index.js" > "$PROBE_LOG" 2>&1 & +PROBE_PID=$! + +probe_cleanup() { + kill "$PROBE_PID" 2>/dev/null || true + wait "$PROBE_PID" 2>/dev/null || true + rm -rf "$PROBE_STATE" + rm -f "$PROBE_LOG" +} + +PROBE_OK=0 +i=0 +while [ $i -lt 60 ]; do + if curl -sf -o /dev/null "http://127.0.0.1:$PROBE_PORT/api/hello"; then PROBE_OK=1; break; fi + kill -0 "$PROBE_PID" 2>/dev/null || break + sleep 0.25 + i=$((i + 1)) +done + +if [ "$PROBE_OK" != "1" ]; then + echo "--- candidate output ---" >&2 + cat "$PROBE_LOG" >&2 + probe_cleanup + rm -rf "$STAGE" + die "the candidate release did not answer /api/hello. The live service was left untouched." +fi +ok "candidate answers /api/hello (scrubbed PATH, ephemeral port $PROBE_PORT)" + +if curl -sf -o /dev/null "http://127.0.0.1:$PROBE_PORT/"; then + ok "candidate serves the Pocket app" +else + probe_cleanup + rm -rf "$STAGE" + die "the candidate release did not serve the Pocket index. The live service was left untouched." +fi +probe_cleanup + +# ----------------------------------------------------------- switch release -- + +step "Switching to the new release" + +OLD_RELEASE="" +if [ -L "$CURRENT_LINK" ]; then + OLD_RELEASE="$(readlink "$CURRENT_LINK")" +fi + +if [ -n "$OLD_RELEASE" ]; then + atomic_symlink "$OLD_RELEASE" "$PREVIOUS_LINK" "$STAGE/runtime/node" + detail "previous -> $(basename "$OLD_RELEASE")" +fi +atomic_symlink "$STAGE" "$CURRENT_LINK" "$STAGE/runtime/node" + +# Prove the switch actually landed: a silently unmoved `current` is exactly the +# failure this step exists to prevent. +SWITCHED_TO="$(readlink "$CURRENT_LINK" 2>/dev/null || echo "")" +[ "$SWITCHED_TO" = "$STAGE" ] || die "current did not advance to $RELEASE_ID (points at '${SWITCHED_TO:-nothing}')." +ok "current -> $RELEASE_ID" + +# ------------------------------------------------------------- launchagent -- + +write_plist() { + mkdir -p "$(dirname "$PLIST")" + cat > "$PLIST" < + + + + Label + $LABEL + ProgramArguments + + /bin/bash + $BIN_DIR/run-server + + WorkingDirectory + $INSTALL_ROOT + RunAtLoad + + KeepAlive + + ThrottleInterval + 10 + ExitTimeOut + 15 + ProcessType + Background + StandardOutPath + $LOG_DIR/server.out.log + StandardErrorPath + $LOG_DIR/server.err.log + + +PLIST_EOF + chmod 0644 "$PLIST" + plutil -lint "$PLIST" >/dev/null || die "generated plist failed plutil -lint: $PLIST" +} + +rollback_release() { + warn "restoring the previous release" + if [ -z "$OLD_RELEASE" ]; then + warn "there is no previous release to restore (this was a first install)." + return 1 + fi + atomic_symlink "$OLD_RELEASE" "$CURRENT_LINK" "$OLD_RELEASE/runtime/node" + if [ "$TEST_MODE" != "1" ]; then + launchctl kickstart -k "gui/$UID/$LABEL" >/dev/null 2>&1 || true + fi + local j=0 + while [ $j -lt 60 ]; do + if curl -sf -o /dev/null "http://127.0.0.1:$LOOPBACK_PORT/api/hello"; then + warn "the previous release ($(basename "$OLD_RELEASE")) is healthy again." + return 0 + fi + sleep 0.5 + j=$((j + 1)) + done + warn "the previous release did NOT become healthy. Inspect: $LOG_DIR" + return 1 +} + +step "Installing the LaunchAgent" +write_plist +ok "wrote and linted $PLIST" + +if [ "$TEST_MODE" = "1" ]; then + warn "test mode: skipping launchctl bootout/bootstrap/kickstart" +else + BOOTOUT_OUT="$(launchctl bootout "gui/$UID/$LABEL" 2>&1)" && BOOTOUT_RC=0 || BOOTOUT_RC=$? + if [ "$BOOTOUT_RC" != "0" ]; then + case "$BOOTOUT_OUT" in + *"No such process"*|*"not find"*|*"not currently loaded"*) detail "no previously loaded agent (first install)" ;; + *) die "launchctl bootout failed unexpectedly (rc=$BOOTOUT_RC): $BOOTOUT_OUT" ;; + esac + else + detail "unloaded the previous agent" + fi + + launchctl bootstrap "gui/$UID" "$PLIST" || die "launchctl bootstrap failed for $PLIST" + launchctl kickstart -k "gui/$UID/$LABEL" || die "launchctl kickstart failed for $LABEL" + ok "LaunchAgent bootstrapped into gui/$UID" +fi + +# ------------------------------------------------------------ live health ---- + +step "Waiting for the installed service" + +if [ "$TEST_MODE" = "1" ]; then + warn "test mode: skipping the live health check (no LaunchAgent was loaded)" +else + LIVE_OK=0 + i=0 + while [ $i -lt 80 ]; do + if curl -sf -o /dev/null "http://127.0.0.1:$LOOPBACK_PORT/api/hello"; then LIVE_OK=1; break; fi + sleep 0.5 + i=$((i + 1)) + done + + if [ "$LIVE_OK" != "1" ]; then + warn "the new release never answered http://127.0.0.1:$LOOPBACK_PORT/api/hello" + [ -f "$LOG_DIR/server.err.log" ] && tail -30 "$LOG_DIR/server.err.log" >&2 + rollback_release || true + die "update FAILED. Rollback was attempted — this is not a success, whatever the previous release now reports." + fi + ok "http://127.0.0.1:$LOOPBACK_PORT/api/hello responds" + + if curl -sf -o /dev/null "http://127.0.0.1:$LOOPBACK_PORT/"; then + ok "Pocket app is served" + else + warn "the Pocket index did not load" + rollback_release || true + die "update FAILED (Pocket index). Rollback was attempted." + fi +fi + +# -------------------------------------------------------------- serve ------ + +step "Configuring Tailscale Serve" + +SERVE_BEFORE="$(ts serve status 2>&1 || true)" +if [ -n "$SERVE_BEFORE" ]; then + detail "existing Serve configuration:" + printf '%s\n' "$SERVE_BEFORE" | sed 's/^/ /' +fi + +NEEDS_SERVE=1 +if printf '%s' "$SERVE_BEFORE" | grep -q "127.0.0.1:$LOOPBACK_PORT"; then + ok "Serve already proxies to 127.0.0.1:$LOOPBACK_PORT" + NEEDS_SERVE=0 +elif printf '%s' "$SERVE_BEFORE" | grep -qE '^\|-- / +proxy'; then + EXISTING_TARGET="$(printf '%s' "$SERVE_BEFORE" | sed -n 's%^|-- / *proxy *%%p' | head -1)" + warn "the root HTTPS path is already mapped to something else: ${EXISTING_TARGET:-}" + warn "Dormouse needs / on this node to serve the Pocket app at the passkey origin." + confirm "Repoint / to 127.0.0.1:$LOOPBACK_PORT?" \ + || die "left the Serve config alone. Resolve the hostname/path conflict, then re-run." +fi + +if [ "$TEST_MODE" = "1" ]; then + warn "test mode: skipping the Serve mutation" +elif [ "$NEEDS_SERVE" = "1" ]; then + info "tailscale serve --bg $LOOPBACK_PORT" + detail "Tailscale may open a browser consent flow if HTTPS is not yet enabled." + ts serve --bg "$LOOPBACK_PORT" || die "\`tailscale serve --bg $LOOPBACK_PORT\` failed." + ok "Serve configured" +fi + +if [ "$TEST_MODE" != "1" ]; then + SERVE_AFTER="$(ts serve status 2>&1 || true)" + printf '%s' "$SERVE_AFTER" | grep -q "127.0.0.1:$LOOPBACK_PORT" \ + || { printf '%s\n' "$SERVE_AFTER" >&2; die "Serve does not report a proxy to 127.0.0.1:$LOOPBACK_PORT."; } + printf '%s' "$SERVE_AFTER" | grep -q "$TS_DNS" \ + || { printf '%s\n' "$SERVE_AFTER" >&2; die "Serve does not report the expected HTTPS origin $ORIGIN."; } + ok "Serve reports $ORIGIN -> 127.0.0.1:$LOOPBACK_PORT" +fi + +# ----------------------------------------------------------------- prune ---- + +step "Pruning old releases" + +KEEP_CURRENT="$(basename "$(readlink "$CURRENT_LINK")")" +KEEP_PREVIOUS="" +[ -L "$PREVIOUS_LINK" ] && KEEP_PREVIOUS="$(basename "$(readlink "$PREVIOUS_LINK")")" + +PRUNED=0 +for dir in "$RELEASES_DIR"/*; do + [ -d "$dir" ] || continue + name="$(basename "$dir")" + [ "$name" = "$KEEP_CURRENT" ] && continue + [ -n "$KEEP_PREVIOUS" ] && [ "$name" = "$KEEP_PREVIOUS" ] && continue + rm -rf "$dir" + detail "removed release $name" + PRUNED=$((PRUNED + 1)) +done +if [ "$PRUNED" = "0" ]; then + ok "nothing to prune (retaining current${KEEP_PREVIOUS:+ and previous})" +else + ok "pruned $PRUNED old release(s); config and state untouched" +fi + +# ---------------------------------------------------------------- summary --- + +step "Installed" + +printf ' origin %s\n' "$ORIGIN" +printf ' release %s\n' "$RELEASE_ID" +printf ' commit %s (dirty=%s)\n' "$GIT_SHA" "$GIT_DIRTY" +printf ' install root %s\n' "$INSTALL_ROOT" +printf ' config %s\n' "$ENV_FILE" +printf ' state %s\n' "$STATE_DIR" +printf ' logs %s\n' "$LOG_DIR" +printf '\n' +printf ' manage: "%s" \n' "$BIN_DIR/manage" +printf '\n' + +if [ "$FIRST_INSTALL" = "1" ]; then + printf ' First install. Retrieve the generated setup password when you are ready\n' + printf ' to create the passkey and enroll a Host:\n\n' + printf ' "%s" show-password\n\n' "$BIN_DIR/manage" +fi + +exit 0 diff --git a/docs/specs/server.md b/docs/specs/server.md index 5cc375a8..dd34cc02 100644 --- a/docs/specs/server.md +++ b/docs/specs/server.md @@ -637,6 +637,81 @@ Everything past this loop (browser surfaces, in-flight replay, thumbnails, the tethering display, WebRTC) is staged in remote-api.md `## Future` as additive follow-ups. +## Installing it (macOS, behind Tailscale) + +The selfhost deployment that exists today is a per-login macOS LaunchAgent on +the user's own Mac, reachable only from their tailnet. `tailscale serve` +terminates HTTPS on the node's own MagicDNS name and proxies to the server on +loopback. There is no cloud relay; that is staged in +[SELF_HOST.md](../../SELF_HOST.md) `## Future`. + +Source of truth: `deploy/local/install-macos.sh`. It is the whole mechanism — +one idempotent script, no hand-edited plists, no scheduled updater. Running it +again updates the installed release from the current checkout; it never pulls, +fetches, or switches branches. The operator runbook is +[SELF_HOST.md](../../SELF_HOST.md). + +The install root is `~/Library/Application Support/Dormouse Server`, holding +`bin/` (the stable `run-server` wrapper and the `manage` helper), `config/`, +`state/`, and `releases/` with `current`/`previous` symlinks. Each release +is self-contained: the production server tree, `lib/dist-pocket`, and a **copy +of the exact Node binary the build ran under**, so the service depends on +neither the source checkout, nor Homebrew/nvm, nor pnpm's store, nor the user's +interactive `PATH` — launchd reads none of those. + +Invariants the installer exists to hold: + +* **One replica, and an update is a short intentional restart.** Challenges, + sessions and relay bindings are in memory (Guardrails above), so Hosts and + Pocket clients reconnect across a release switch. There is no zero-downtime + swap to attempt. +* **State outlives code.** `config/` and `state/` sit outside `releases/`, are + mode `0700`, and are never touched by an update, a prune, or an uninstall. + `config/server.env` is mode `0600`, generated once with a locally generated + setup password, and preserved byte-for-byte thereafter. Purging state is a + separate, explicitly confirmed operation. +* **Loopback only.** The install pins `DORMOUSE_BIND_HOST=127.0.0.1` and + refuses to proceed without it — see the Configuration note above on why the + listen interface is a security boundary when the TLS proxy is local. + Port 3100, deliberately not 3000, so the installed service can coexist with + `pnpm dev:server` / `pnpm dev:pocket-server` on the same laptop. +* **`DORMOUSE_ORIGIN` is durable WebAuthn identity.** It is derived from the + node's MagicDNS name. If an existing installation records a different origin + the installer stops rather than rewriting it, because the rewrite silently + invalidates the registered passkey and every enrolled Host. +* **A failed update is a failure.** The candidate release is health-checked on + an ephemeral port against a throwaway state dir *before* `current` moves; if + the live service then fails to answer, `current` is restored to `previous` + and the installer exits nonzero. Rollback succeeding is not success. + +Two mechanical traps the script encodes, both of which fail silently otherwise: + +* **`pnpm deploy --prod --legacy` poisons the workspace.** It rewrites the root + `node_modules/.pnpm-workspace-state-v1.json` to `production: true` / + `dev: false`. Every later pnpm command in that checkout then decides the + workspace is stale and tries to run `pnpm install --production`, which would + strip the developer's devDependencies. The installer snapshots that file and + restores it from an `EXIT` trap, so even a failed install leaves the checkout + as it found it. +* **`mv -f tmp link` follows a symlink to a directory.** Used to swap + `current`, it deposits the temp link *inside* the old release and leaves + `current` pointing where it was — the update becomes a silent no-op, and the + prune then deletes the release nothing points at. The switch uses `rename(2)` + on the link path instead, and asserts afterwards that `current` advanced. + +`bin/manage` carries the operator surface: `status`, `verify` (runs every +acceptance check and exits nonzero on any failure), `logs`, `restart`, +`show-password`, `serve` (re-apply the Serve mapping after a dev session +repointed it), `rollback`, `uninstall`, and the separately-confirmed `purge`. + +The Host that connects to such a server needs a build whose baked relay +allowlist admits the origin — see "Where a Host may reach a relay server" +above; a `*.ts.net` origin requires `DORMOUSE_REMOTE_CONNECT_SRC` at build time. + +Availability follows from what a LaunchAgent is: a per-login agent, so the +relay is down while the Mac sleeps, is shut off, or has no logged-in user. +That is usually fine, since there is then no local Host to control either. + ## Future **Scope: saas-multitenant** — the server-side hurdles between today's From 1cc3e08931bbed7b5eafb4a5caa94607f7531ba8 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 20 Aug 2026 15:50:39 -0700 Subject: [PATCH 2/3] Address the review, and turn SELF_HOST.md into a runbook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The installer findings, all of which fail quietly: - `manage uninstall` skipped its confirmation entirely with no controlling terminal and fell straight through to `launchctl bootout` + `rm -rf`. Any non-interactive call uninstalled silently. No terminal is now a refusal, which is the stance the installer's own `confirm()` already takes. - Uninstall's `tailscale serve reset` fallback clears the node's entire Serve config, not the one mapping this installer owns — so on a node where `serve --bg off` failed, uninstalling Dormouse would take an unrelated app's Serve path down with it. It now says what it could not do and leaves the config alone. - `manage status` parsed `launchctl print` with `\|` alternation, a GNU sed extension that BSD sed treats as a literal — on macOS, the only platform this script runs on, the LaunchAgent section printed nothing at all. Replaced with awk anchored to the single-tab top-level fields, so the nested endpoint dictionaries no longer contribute stray `state =` lines. - `manage rollback` swapped `current` before `previous`, and its runtime fallback can be `$ROOT/current/runtime/node` — reached only when the previous release's runtime was rejected. Moving `current` first repointed that path at the rejected binary, so the second swap died under `set -e` with `current` moved and `previous` stale, orphaning the release just rolled back from. `previous` now goes first, while `current` still resolves. - `rollback_release` in the installer used `$OLD_RELEASE/runtime/node`, which is never checked, on the one path where the health check has already failed. It now uses `$STAGE/runtime/node`, verified executable and version/arch-matched earlier in the same run. - The usage header advertised an overridden `HOME` — the thing the code deliberately refuses because pnpm's store lives there — and never mentioned `DORMOUSE_INSTALL_ROOT`, which is the knob that exists. `--help` also ran two lines past the header, printing `set -euo pipefail` at the user. - Dropped the unread `RELEASES_TO_KEEP`: the prune keeps releases by matching the `current`/`previous` link names, not by count. SELF_HOST.md's first three sections still read as a build plan for a script that now ships — "author the local installer", "Create and review", a 17-item contract, "test before installing". Following it literally meant rewriting an existing file. The contract now lives in docs/specs/server.md → "Installing it", so those sections are replaced by six checkpoints that run the installer: preflight (naming the one thing the script does not check — that port 3100 is free), install, verify, first-run setup, updating/rollback/uninstall, and backup. First-run setup previously said "enroll a Host" with no mechanism; it now gives the `window.dormouseRemoteHost.enroll(...)` call, the pairing sequence, and the iOS Home-Screen-before-signing-in rule for push. Two comments in lib cited SELF_HOST.md as documenting the console hook, which it never did. They point at docs/specs/server.md now. Verified: `bash -n` on the installer and both generated scripts; `--help` output; the awk against real `launchctl print` output (and the old sed producing nothing); `manage uninstall` refusing at EOF and still accepting `y` under a pty, with config and state surviving both; and the rollback ordering in a throwaway root with an unusable previous runtime, which lands `current`/`previous` correctly where the old order orphaned a release. `pnpm lint:specs` OK. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016MVfFyGRbQyNKJAFEF33wW --- SELF_HOST.md | 553 ++++++++++-------------- deploy/local/install-macos.sh | 39 +- lib/src/host/remote/service-protocol.ts | 4 +- lib/src/remote/host/activation.ts | 5 +- 4 files changed, 266 insertions(+), 335 deletions(-) diff --git a/SELF_HOST.md b/SELF_HOST.md index 5f17e79f..c275a0e1 100644 --- a/SELF_HOST.md +++ b/SELF_HOST.md @@ -8,31 +8,48 @@ only from their tailnet at `https://..ts.net`. That is the whole self-host story today. An always-on cloud relay is designed but not built; it lives under `## Future`. +The installer already exists: `deploy/local/install-macos.sh`, one idempotent +command that ships in this repository. This runbook is about running it and +finishing the parts it cannot do on its own — the passkey, the Host build, the +backup. Nobody following it should have to write or edit code. + ## Instructions to the assistant Your job is to guide the user through this runbook one checkpoint at a time, -performing the repository and command-line work you safely can and pausing only -for browser-console actions, secrets, or explicit approval of external or -destructive changes. Do not dump the entire runbook back at the user. +performing the command-line work you safely can and pausing only for browser +consent flows, secrets, or explicit approval of external or destructive +changes. Do not dump the entire runbook back at the user. + +The installer is shipped, reviewed code. Run it — do not reimplement it, and do +not paper over it with hand-run `launchctl` or `tailscale serve` commands. If it +does the wrong thing, that is a bug in `deploy/local/install-macos.sh`: say so +plainly and offer to fix it as an ordinary reviewed code change, which is a +different task from this one. Its contract lives in `docs/specs/server.md` +under "Installing it (macOS, behind Tailscale)"; a change to one is a change to +both. Before acting: -1. Read `AGENTS.md`, `SECURITY.md`, `docs/specs/server.md`, - `docs/specs/remote-security-model.md`, and the CSP section of - `docs/specs/standalone.md` completely. -2. Inspect the worktree and preserve unrelated user changes. Determine whether - any files from this runbook already exist; resume and verify rather than - overwriting a partial setup. -3. Recheck the linked official documentation. This runbook was updated on - 2026-08-17; dashboards and CLI syntax can change. -4. Explain the current checkpoint, carry it out, verify it, and only then move +1. Read `docs/specs/server.md` — "Configuration", "Where a Host may reach a + relay server (self-host builds)", and "Installing it (macOS, behind + Tailscale)" — plus `docs/specs/remote-security-model.md` for the trust model + you are about to set up. +2. Run `./deploy/local/install-macos.sh --help` and skim the script. Its errors + are written to be read by whoever is standing here; quote them rather than + paraphrasing. +3. Check whether `~/Library/Application Support/Dormouse Server` already exists. + If it does, this is an update or a repair, not a first install: read + `bin/manage status` before changing anything. +4. Recheck the linked official documentation. This runbook was updated on + 2026-08-20; dashboards and CLI syntax change. +5. Explain the current checkpoint, carry it out, verify it, and only then move to the next checkpoint. -5. Never ask the user to paste the setup password or any other bearer - credential into chat. Generate the setup password on the laptop and leave it - in the installer-owned mode-`0600` config file. -6. Do not commit, push, merge, or delete installed state without first showing +6. Never ask the user to paste the setup password or any other bearer credential + into chat. The installer generates it on the laptop and leaves it in a + mode-`0600` file; `manage show-password` prints it in their terminal. +7. Do not commit, push, merge, or delete installed state without first showing the exact change and obtaining the user's approval. -7. If the user needs a relay that stays up while this laptop is asleep, stop and +8. If the user needs a relay that stays up while this laptop is asleep, stop and read `## Future` with them rather than improvising cloud infrastructure. Keep a small worksheet in the conversation and fill it in as values become @@ -41,12 +58,13 @@ known: | Value | Default / example | | --- | --- | | Laptop OS | must be macOS | -| Laptop Tailscale DNS name | derive from `tailscale status --json` | +| Laptop Tailscale DNS name | the installer derives it from `tailscale status --json` | | External origin | `https://.` | | Install root | `~/Library/Application Support/Dormouse Server` | | State directory | `~/Library/Application Support/Dormouse Server/state` | | LaunchAgent | `~/Library/LaunchAgents/sh.dormouse.server.plist` | | Loopback port | `3100` | +| Installed release | printed by the installer and by `manage status` | ## Prerequisites @@ -54,9 +72,9 @@ known: certificates enabled, Tailscale running on this Mac, and Tailscale on the phone that will run Pocket. A tailnet-only origin is not reachable merely because the laptop is on the tailnet. -- **macOS.** The installer below is macOS-only. On another OS, stop and design - the native service manager with the user rather than translating LaunchAgent - commands blindly. +- **macOS.** The installer is macOS-only and refuses to run anywhere else. On + another OS, stop and design the native service manager with the user rather + than translating LaunchAgent commands blindly. - **A Host build that can reach a `*.ts.net` origin.** The shipped standalone and VS Code Hosts bake in the SaaS-only relay allowlist, so a self-host relay needs a local build of whichever Host the user runs: @@ -71,9 +89,7 @@ known: Node Host bundles. The relay socket no longer lives in either webview, so changing a webview CSP does not widen this allowlist. -## Architecture - -### What gets installed +## What the installer does ```text user runs ./deploy/local/install-macos.sh @@ -92,130 +108,9 @@ tailscale serve --bg terminates private HTTPS | v https://..ts.net - -~/Library/Application Support/Dormouse Server/state - account.json - hosts.json - push-subscriptions.json - vapid.json -``` - -The LaunchAgent starts after the user logs in and restarts the process if it -crashes. Tailscale's background Serve configuration survives Tailscale and -machine restarts. The service is unavailable while the laptop sleeps, is shut -down, or has no logged-in user; that is normally fine because there is then no -local Dormouse Host to control. - -### Invariants - -- Run exactly one server replica. Challenges, sessions, WebSocket bindings, and - relay state are in memory. Multiple uncoordinated replicas are incorrect. -- An update is a short intentional restart. Existing Host and Pocket WebSockets - disconnect and reconnect; do not attempt a zero-downtime swap for this - protocol. -- Persist the entire state directory outside the installed release, including - `account.json`, `hosts.json`, `push-subscriptions.json`, and the generated - `vapid.json`. Code replacement must never replace state. -- Bind the server only to loopback. Do not make plain HTTP port 3100 reachable - from the LAN or the tailnet. Tailscale terminates HTTPS. -- Port 3100 is deliberately not 3000: `pnpm dev:server` and - `pnpm dev:pocket-server` both run the server on 3000, and the installed - service shares this laptop with that dev loop. -- Treat `DORMOUSE_ORIGIN` as durable WebAuthn identity. It is the laptop's - Tailscale DNS name; renaming or re-registering that node can require passkey - and Host re-enrollment. -- The installed release must contain both `server/dist` and `lib/dist-pocket`. - Building the `server` package alone is insufficient. -- The setup password remains only in a mode-`0600` local configuration file. - -## Definition of done - -- `https://..ts.net/api/hello` succeeds from a tailnet device - and is unreachable when that device leaves the tailnet. -- The Pocket app is served at the same HTTPS origin. -- Port 3100 is bound only to `127.0.0.1`. -- Every persistent state file survives replacement of the running release. -- One installer invocation builds and installs the exact current checkout. -- The LaunchAgent is loaded, starts at login, and restarts the server after an - intentional process kill. -- `tailscale serve --bg` is configured for the laptop's HTTPS name. -- Rerunning the installer updates the release and preserves state; a failed - update restores the prior release. -- `manage verify` exits zero and reports every check above that it can observe - locally. -- The repository specs describe the installed behavior. - -## Phase 0: preflight - -Inspect and report: - -- `git status --short`, current branch, and origin. -- The exact Node version in root `package.json` under - `devEngines.runtime.version`, and the pnpm version in `packageManager`. - `SECURITY.md` keys a mechanical `FAIL IF` to the `devEngines` field, so read - that field specifically rather than `engines`. -- The host OS and architecture. If this is not macOS, stop; see Prerequisites. -- Whether `tailscale` is installed, signed in, and on `PATH`; on macOS also - check the known application-bundle CLI paths. When invoking the bundled - macOS app executable from a script, set `TAILSCALE_BE_CLI=1` so it cannot - launch the GUI instead of acting as the CLI. -- Whether HTTPS and MagicDNS are enabled for the tailnet. -- The laptop's stable Tailscale DNS name. -- That port 3100 is available on loopback. -- That the user wants the currently checked-out worktree installed. Report the - Git SHA and whether it is dirty; do not silently switch or pull branches. - -Confirm that the user's phone runs Tailscale. - -## Install on this Mac - -This runbook is intentionally independent of GitHub and cloud hosting. Its only -remote dependency is the user's existing Tailscale account. The current checkout is -the release source; rerunning the installer is the update mechanism. - -### 1: author the local installer - -Create and review: - -```text -deploy/local/install-macos.sh -``` - -The installer may generate stable helper files inside its install root, but do -not require the user to maintain hand-edited plists or shell wrappers. The -normal command is exactly: - -```sh -./deploy/local/install-macos.sh -``` - -Running that command a second time updates the installed release from the -current checkout. It must not run `git pull`, switch branches, fetch a release, -or install a scheduled updater. - -The server already supports an explicit loopback bind: `DORMOUSE_BIND_HOST` is -read by `server/src/config.ts` and passed through the `@hono/node-server` listen -option, with the unset default still binding every interface. The local -configuration must set: - -```dotenv -DORMOUSE_BIND_HOST=127.0.0.1 ``` -Do not reintroduce a generic `HOST` variable for this, and do not change the -unset default — `server/test/bind-host.test.mjs` asserts both halves. - -Update `docs/specs/server.md` above the fold with the installation behavior, -using `Source of truth:` pointers. Add `deploy/local/install-macos.sh` to that -spec's exhaustive Files/Code Map if it has one. Update `SECURITY.md` only if the -installer changes an invariant it audits; this path adds no GitHub workflow or -deployment secret. - -### 2: installer contract - -The script must be idempotent, strict Bash and safe with spaces in paths. It -must refuse non-macOS hosts with a clear message. It should require no `sudo` -and install only into the current user's home directory: +It installs only under the current user's home, needs no `sudo`, and lays out: ```text ~/Library/Application Support/Dormouse Server/ @@ -242,186 +137,208 @@ and install only into the current user's home directory: ~/Library/Logs/Dormouse Server/ ``` -On each invocation it must: - -1. Confirm `tailscale` is installed, signed in, and reports a stable DNS name. - Detect both a CLI on `PATH` and supported macOS application-bundle CLI - locations. Export `TAILSCALE_BE_CLI=1` for every invocation of the bundled - app executable. Do not install or reauthenticate Tailscale without the user. -2. Derive the external origin from `tailscale status --json`, remove any - trailing dot, and show it to the user. If an existing installation's origin - differs, stop and explain the WebAuthn migration consequence rather than - silently rewriting it. -3. Report the current Git SHA, branch, architecture, and dirty/clean status. - Ask for confirmation before installing a dirty worktree, but allow it: the - whole point is to install exactly what is currently checked out. -4. Read the exact Node version from root `package.json` under - `devEngines.runtime.version` and the pnpm version from `packageManager`; use - Corepack and the repository versions rather than global floating versions. -5. Install with `pnpm install --frozen-lockfile`, build `lib/dist-pocket`, - `server-lib-common`, and `server`, then create a production-only server tree. - With the current workspace, use the verified `pnpm deploy --prod --legacy` - flow unless injected workspace packages are intentionally adopted. -6. Copy the exact `process.execPath` Node executable used for the build into the - release. The LaunchAgent must not depend on Homebrew, nvm, Volta, pnpm's - cache, the source checkout, or the user's interactive shell `PATH` after - installation. Verify the copied runtime's version and macOS architecture. -7. Copy `lib/dist-pocket` into the layout expected by `server/src/config.ts` - (or point `DORMOUSE_POCKET_DIR` at it). -8. Write a `RELEASE` metadata file containing at least Git SHA, dirty status, - build timestamp, Node version, and source checkout path. Do not claim a dirty - build is reproducibly identified by its SHA alone. -9. On first install, generate a high-entropy hexadecimal setup password on the - Mac and create mode-`0600` `config/server.env` containing: - - ```dotenv - DORMOUSE_SETUP_PASSWORD= - DORMOUSE_ORIGIN=https://. - DORMOUSE_STATE_DIR="/state" - DORMOUSE_BIND_HOST=127.0.0.1 - PORT=3100 - NODE_ENV=production - ``` +It deliberately will **not**: run `git pull`, fetch, or switch branches; install +a scheduled updater; ask for `sudo`; install or re-authenticate Tailscale; +rewrite an origin that no longer matches the node's DNS name; or touch `config/` +and `state/`, which survive every update, prune, and uninstall. - Preserve this file byte-for-byte on updates. Do not print the password - during routine install/update. Provide an explicit `manage show-password` - operation that warns before displaying it locally for setup or enrollment. - Keep the `config` and `state` directories mode `0700`; they contain the setup - password and Host bearer credentials. -10. Install a stable mode-`0700` `bin/run-server` wrapper outside the release. - It must safely load only the installer-owned env file and `exec` the copied - Node runtime with `current/server/dist/index.js`. It must not invoke a - shell-dependent package manager at service startup. -11. Install `~/Library/LaunchAgents/sh.dormouse.server.plist` with absolute - paths and `RunAtLoad` plus `KeepAlive`. Use `ProgramArguments`, a valid - `WorkingDirectory`, bounded restart throttling, and stdout/stderr paths - under `~/Library/Logs/Dormouse Server`. Do not embed the setup password in - the plist. Validate it with `plutil -lint`. -12. Stage the new release without touching `current`, run a disposable - loopback health check against the candidate, and only then switch the - symlink atomically. -13. Use modern `launchctl bootout`, `bootstrap`, and `kickstart` commands in the - current `gui/$UID` domain. Treat “not currently loaded” during a first - install as benign; treat other launchd errors as failures. -14. Wait for `http://127.0.0.1:3100/api/hello` and the Pocket index. If the new - release fails, restore `current` to `previous`, restart it, verify it is - healthy, and exit nonzero. Never report an update successful merely because - rollback worked. -15. Retain the current and previous releases and remove older releases only - after success. Never remove `state` or `config` during cleanup. -16. Inspect the node's existing Serve configuration, then configure the current - equivalent of `tailscale serve --bg 3100`. This is a node-scoped Serve - endpoint, not a Tailscale Service. Do not reset or overwrite unrelated Serve - paths; if another app already owns the root HTTPS mapping, stop and resolve - the hostname/path conflict with the user. Allow Tailscale's HTTPS consent - flow to open if the tailnet has not enabled certificates. -17. Verify Serve reports the same HTTPS origin written to `server.env`. - -The installed `bin/manage` helper should support at least: +The invariants it exists to hold — one replica, state outlives code, loopback +only, `DORMOUSE_ORIGIN` as durable WebAuthn identity, and a failed update being +a failure rather than a rollback dressed as success — are documented in +`docs/specs/server.md`. Two of them shape what the user should expect day to +day: -```text -status show LaunchAgent, process, health, Serve origin, and release -verify run the Definition of done checks and exit nonzero on any failure -logs tail the local server logs -restart kickstart the LaunchAgent and wait for health -show-password warn, then display the setup password locally -rollback switch to the retained previous release, preserving state -uninstall remove LaunchAgent and installed code only after confirmation -``` - -Uninstall must default to preserving `config` and `state`, explicitly report -their locations, and turn off only the Serve mapping owned by this installer. -Provide a separate explicit purge operation for irreversible state deletion; -require the user to type a confirmation phrase. Never make purge part of a -normal reinstall or uninstall. - -### 3: test before installing +- An update is a short intentional restart. Existing Host and Pocket WebSockets + disconnect and reconnect; there is no zero-downtime swap to attempt. +- A LaunchAgent is a per-login agent, so the service is unavailable while the + laptop sleeps, is shut down, or has no logged-in user. That is normally fine, + because there is then no local Dormouse Host to control either. -Before the user runs the installer against their real state: +## Definition of done -1. Run `bash -n` and a shell linter if one is already available. -2. Run `pnpm lint:specs` and the server tests. -3. Exercise installation with a temporary `HOME` or an installer test mode so - path quoting, plist generation, release switching, and cleanup can be tested - without loading a real LaunchAgent. Do not fake the final live validation. -4. Confirm the release starts without the repository or package-manager paths - on `PATH`. -5. Confirm plain HTTP is reachable at `127.0.0.1:3100` and not at the laptop's - LAN or Tailscale IP on port 3100. +`manage verify` checks all of these locally and exits nonzero on any failure: + +- The LaunchAgent is loaded in `gui/$UID`, its plist lints, declares `RunAtLoad` + and `KeepAlive`, and carries no credential. +- Loopback `/api/hello` responds and the Pocket app is served. +- Port 3100 is bound only to `127.0.0.1`, and the plaintext port is unreachable + on the laptop's Tailscale IP. +- `tailscale serve` proxies to `127.0.0.1:3100` at the same origin recorded in + `config/server.env`. +- `config/` and `state/` are mode `0700`, `config/server.env` is mode `0600`. +- `current` resolves to a release with `RELEASE` metadata, a `previous` release + is retained for rollback, and neither the plist nor `bin/run-server` refers to + the source checkout. + +These cannot be proven from the laptop, and are the checkpoints below: + +- The HTTPS origin answers from a second tailnet device, and stops answering + when that device leaves the tailnet. +- launchd restarts the server after a real kill. +- State survives a reinstall from a newer checkout, and rollback returns the + previous release. +- Pocket passkey setup and Host enrollment complete against this origin. +- The install root is backed up somewhere off this laptop. + +## Checkpoint 1: preflight + +The installer performs its own preflight and stops with a specific error rather +than proceeding, so do not re-run these by hand: macOS and non-root, the +Tailscale CLI (on `PATH` or in the app bundle, invoked with +`TAILSCALE_BE_CLI=1`), backend state `Running`, the node's MagicDNS name and +derived origin, tailnet HTTPS certificates, an origin that disagrees with an +existing installation, the Git SHA and dirty status (it asks before installing a +dirty worktree), and the Node and pnpm versions pinned in root `package.json`. + +Establish with the user what the script cannot: + +- **This checkout is the one they want installed.** Show `git status --short`, + the branch, and the SHA. Do not pull or switch branches on their behalf; the + installer intentionally installs exactly what is checked out. +- **Their phone runs Tailscale** and is signed in to the same tailnet. +- **Port 3100 is free.** The installer does not check this before installing. + `pnpm dev:server` and `pnpm dev:pocket-server` use 3000, not 3100, but a stale + process of any kind on 3100 would let the post-install health check pass + against the wrong server: -Show the exact repository diff and test results. Ask before committing; installing the -current checkout does not require a commit. + ```sh + lsof -nP -iTCP:3100 -sTCP:LISTEN + ``` -### 4: install and validate +## Checkpoint 2: install -With the user's approval, run: +With the user's approval: ```sh ./deploy/local/install-macos.sh ``` -The script may require the user to approve Tailscale HTTPS in a browser. It -must otherwise finish without a checklist of manual service-manager commands. +It prints each step. Read the output with the user rather than summarizing it — +the confirmations it asks for (a dirty worktree, a mismatched pnpm, repointing +an already-claimed Serve root path) are decisions, and it refuses to assume an +answer when there is no terminal. Tailscale may open a browser consent flow the +first time Serve requests a certificate; that one is the user's to click. -Verify: +On a first install it finishes by pointing at `manage show-password`. Do not run +that yet. + +## Checkpoint 3: verify ```sh "$HOME/Library/Application Support/Dormouse Server/bin/manage" verify ``` -That command must perform, at minimum, the equivalent of: - -```sh -launchctl print "gui/$UID/sh.dormouse.server" -curl --fail http://127.0.0.1:3100/api/hello -tailscale serve status -lsof -nP -iTCP:3100 -sTCP:LISTEN -``` +Expect every check to pass and the command to exit 0. `manage status` gives the +same picture without the pass/fail framing. Then, from another tailnet-connected device: -1. Request the HTTPS `/api/hello` endpoint. +1. Request `https://..ts.net/api/hello`. 2. Open the Pocket application at the same origin. -3. Temporarily leave Tailscale on that test device and verify it becomes +3. Temporarily leave Tailscale on that device and confirm the origin becomes unreachable. -Kill the server process once and verify LaunchAgent restarts it. Restart the -laptop only if the user approves the interruption; otherwise explain that -`RunAtLoad` plus the loaded LaunchAgent has been verified but the reboot test -was skipped. After a real login/reboot, verify both the process and background -Serve mapping return without rerunning the installer. +Kill the server process once and confirm launchd restarts it within a second or +two: -Complete Pocket passkey setup and Host enrollment using a standalone or VS Code -build whose `DORMOUSE_REMOTE_CONNECT_SRC` includes -`https://*.ts.net wss://*.ts.net`. After `account.json`, `hosts.json`, and -`vapid.json` exist (and `push-subscriptions.json` too if push was enabled): +```sh +pkill -f 'Dormouse Server/current/server/dist/index.js' +"$HOME/Library/Application Support/Dormouse Server/bin/manage" status +``` -1. Record ownership and checksums of every present state file without printing - contents. -2. Rerun the same installer from the same or a newer checkout. -3. Confirm the release changed as expected and state/checksums survived. -4. Exercise the retained-release rollback and return to the desired release. +Restart the laptop only if the user approves the interruption; otherwise say +plainly that `RunAtLoad` plus the loaded LaunchAgent has been verified but the +reboot test was skipped. After a real login or reboot, confirm both the process +and the background Serve mapping return without rerunning the installer. + +## Checkpoint 4: first-run setup + +The server is running but has no account, no passkey, and no enrolled Host. The +same sequence is documented against the dev loop in `docs/specs/server.md` +→ "Running it"; here it runs against the tailnet origin instead of +`localhost:3000`, and the password comes from the installer rather than the +command line. + +1. **The setup password.** Have the user run `manage show-password` in their own + terminal when they are ready. It warns before printing. Do not ask for the + value, and do not print it into the conversation. + +2. **The passkey.** On the phone, open `https://..ts.net` in + Safari → First-time setup (password + label) creates the passkey and signs + them in. The passkey is bound to this exact origin. If they want push + notifications, add Pocket to the Home Screen *before* signing in and do all + of this inside the installed app — iOS delivers Web Push only there, and the + install is a separate storage partition that would otherwise need its own + pairing (`docs/specs/pocket-app.md` → Installable web app). + +3. **The Host.** Launch the standalone or VS Code build made with + `DORMOUSE_REMOTE_CONNECT_SRC` (see Prerequisites) and enroll once from that + webview's devtools console: + + ```js + await window.dormouseRemoteHost.enroll('https://..ts.net', '', 'My Laptop') + ``` + + Enrollment persists in the Host service's own store — a mode-`0600` file + under the app-data dir in standalone, `SecretStorage` in VS Code — so later + launches connect on their own. `status()`, `reconnect()` and + `clearEnrollment()` live on the same object and are promises. + + A build without the `*.ts.net` allowlist refuses this outright, before the + password leaves the machine. That is the expected symptom of a stock build, + not a server problem. + +4. **A real session.** On the phone: Hosts → **Pair** → approve the modal that + appears on the laptop → **Connect** (one biometric prompt) → pick a pane and + type. Only now have HTTPS proxying, the WebSocket upgrade, and the security + flow been exercised together. -### 5: operational expectations and backup +5. **State.** Confirm `account.json`, `hosts.json`, and `vapid.json` now exist + in `state/` (plus `push-subscriptions.json` if push was enabled). Record + ownership and checksums without printing contents — checkpoint 5 checks them + against a reinstall. -Make these limitations explicit: +## Checkpoint 5: updating, rollback, uninstall -- The relay is unavailable while the Mac sleeps, is shut down, Tailscale is - disconnected, or the user is logged out. A LaunchAgent is a per-login agent, - not a pre-login system daemon. -- The installer does not follow `main`. To update: choose the checkout, inspect - it, and rerun `./deploy/local/install-macos.sh`. -- The HTTPS origin is tied to the laptop's Tailscale node name. Do not rename or - delete/re-enroll the node casually after registering passkeys. +Updating is choosing a checkout and rerunning the same command: + +```sh +git -C log --oneline -1 # decide deliberately what to install +./deploy/local/install-macos.sh +``` + +Prove it once, while the user is watching: + +1. Rerun the installer from the same or a newer checkout. +2. Confirm the release changed as expected and that the `state/` checksums from + checkpoint 4 and `config/server.env` are unchanged. +3. Run `manage rollback`, confirm the previous release comes back healthy, then + return to the desired release. + +`manage uninstall` removes the LaunchAgent and installed code and keeps `config` +and `state`, reporting where they are. `manage purge` is the separate, +irreversible operation that deletes them; it requires typing a confirmation +phrase and is never part of a reinstall. + +## Checkpoint 6: limits and backup + +Make these explicit: + +- The relay is down while the Mac sleeps, is shut down, Tailscale is + disconnected, or the user is logged out. +- The installer does not follow `main`. Updates happen only when the user + reruns it. +- The HTTPS origin is tied to the laptop's Tailscale node name. Renaming or + re-enrolling that node means redoing the passkey and Host enrollment, and the + installer will stop rather than rewrite the origin for you. - Tailscale network policy still controls which tailnet members can reach the laptop. Review existing grants if the tailnet contains other users. Confirm that the install root, especially `config` and `state`, is covered by Time Machine or another encrypted backup outside the laptop. A second directory -on the same disk is not a backup. Perform a small restore rehearsal without -overwriting live state. - -Give the handoff and stop. +on the same disk is not a backup. These files include Host bearer credentials +and a VAPID private key. Perform a small restore rehearsal without overwriting +live state. ## Final handoff @@ -443,7 +360,8 @@ Do not print the setup password or any credential in the handoff. ## Official references -- Dormouse runtime and state contract: `docs/specs/server.md` +- Dormouse runtime, state contract, and what the installer guarantees: + `docs/specs/server.md` - Dormouse trust model: `docs/specs/remote-security-model.md` - Host installations: `docs/specs/standalone.md`, `docs/specs/vscode.md` - [Install Tailscale on macOS](https://tailscale.com/docs/install/mac) @@ -453,30 +371,33 @@ Do not print the setup password or any credential in the handoff. ## Troubleshooting boundaries -- **Local install works only while the source checkout exists:** the LaunchAgent - was pointed into the repository instead of the self-contained install root. - Fix the installer; do not paper over it with a permanent checkout path. -- **Local LaunchAgent loops or will not load:** run `plutil -lint`, inspect - `launchctl print gui/$UID/sh.dormouse.server`, and read the configured stdout - and stderr files. Check absolute paths and permissions; launchd does not run - the user's interactive shell startup files. -- **Local HTTPS URL returns 502:** first check the loopback health endpoint, - then `tailscale serve status`. The LaunchAgent and Serve configuration have - separate lifecycles. -- **Port 3100 is visible on LAN or the Tailscale IP:** stop and fix - `DORMOUSE_BIND_HOST=127.0.0.1` before continuing. Tailscale access control is - not a reason to expose the plaintext backend. -- **Local origin changed:** do not overwrite the stored origin and continue. - Determine whether the Tailscale node was renamed/re-enrolled and plan passkey - and Host re-enrollment explicitly. -- **Pocket loads but passkey setup fails:** compare the browser URL byte-for-byte - with normalized `DORMOUSE_ORIGIN`; confirm HTTPS and the chosen node/Service - hostname. -- **Host cannot connect while Pocket can:** that Host build likely lacks the - `*.ts.net` `DORMOUSE_REMOTE_CONNECT_SRC` setting. +- **The service works only while the source checkout exists:** the release is + supposed to be self-contained, so this is a bug in the installer rather than + something to work around with a permanent checkout path. `manage verify` + checks for it directly. +- **The LaunchAgent loops or will not load:** run `plutil -lint` on the plist, + inspect `launchctl print gui/$UID/sh.dormouse.server`, and read + `~/Library/Logs/Dormouse Server`. launchd does not run the user's interactive + shell startup files, so a `PATH` that works in Terminal proves nothing here. +- **The HTTPS URL returns 502:** check the loopback health endpoint first, then + `tailscale serve status`. The LaunchAgent and the Serve configuration have + separate lifecycles; `manage serve` re-applies the mapping if a dev session + repointed it. +- **Port 3100 is visible on the LAN or the Tailscale IP:** stop. Confirm + `DORMOUSE_BIND_HOST=127.0.0.1` in `config/server.env`. Tailscale access + control is not a reason to expose the plaintext backend. +- **The installer stops on an origin mismatch:** it is refusing to invalidate + the registered passkey and every enrolled Host. Determine whether the + Tailscale node was renamed or re-enrolled, then either restore the old node + name or plan the re-enrollment explicitly. +- **Pocket loads but passkey setup fails:** compare the browser URL + byte-for-byte with the `DORMOUSE_ORIGIN` in `config/server.env`; confirm HTTPS + and the node hostname. +- **A Host cannot connect while Pocket can:** that Host build almost certainly + lacks the `*.ts.net` `DORMOUSE_REMOTE_CONNECT_SRC` setting. - **State disappears:** verify the absolute Application Support state path and - the installed config. Do not initialize a new account until old state has been - located or restored. + the installed config. Do not initialize a new account until the old state has + been located or restored. ## Future diff --git a/deploy/local/install-macos.sh b/deploy/local/install-macos.sh index 6bbdb68a..24042064 100755 --- a/deploy/local/install-macos.sh +++ b/deploy/local/install-macos.sh @@ -16,8 +16,8 @@ # Environment: # DORMOUSE_INSTALL_TEST=1 Build, stage, health-check and switch releases, # but do not touch launchd or the Serve config. -# Honors an overridden HOME, so a throwaway install -# root can be exercised end to end. +# DORMOUSE_INSTALL_ROOT A throwaway install root (requires the above), so +# path quoting and release switching can be tested. set -euo pipefail @@ -28,7 +28,6 @@ INSTALL_ROOT="$HOME/Library/Application Support/Dormouse Server" LOG_DIR="$HOME/Library/Logs/Dormouse Server" PLIST="$HOME/Library/LaunchAgents/$LABEL.plist" LOOPBACK_PORT=3100 -RELEASES_TO_KEEP=2 ASSUME_YES=0 [ "${DORMOUSE_INSTALL_ASSUME_YES:-0}" = "1" ] && ASSUME_YES=1 @@ -54,7 +53,7 @@ fi for arg in "$@"; do case "$arg" in --yes|-y) ASSUME_YES=1 ;; - --help|-h) sed -n '2,25p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + --help|-h) sed -n '2,20p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; *) echo "unknown argument: $arg" >&2; exit 64 ;; esac done @@ -623,9 +622,10 @@ cmd_status() { fi printf '\nLaunchAgent\n' if launchctl print "gui/$UID/$LABEL" >/dev/null 2>&1; then + # Only the top-level fields: launchctl indents them with a single tab, and + # the nested endpoint dictionaries carry their own `state =` lines. launchctl print "gui/$UID/$LABEL" 2>/dev/null \ - | sed -n 's/^[[:space:]]*\(state\|pid\|last exit code\) = / &/p' \ - | sed 's/^ [[:space:]]*/ /' + | awk -F ' = ' '$1 ~ /^\t(state|pid|last exit code)$/ { printf " %s = %s\n", substr($1, 2), $2 }' else printf ' %snot loaded%s\n' "$C_RED" "$C_OFF" fi @@ -822,8 +822,10 @@ cmd_rollback() { if [ -x "$candidate" ]; then node_bin="$candidate"; break; fi done [ -n "$node_bin" ] || { printf 'no usable runtime found to swap the symlinks\n' >&2; return 1; } - atomic_symlink "$prev" "$ROOT/current" "$node_bin" + # `previous` first: node_bin can be "$ROOT/current/runtime/node", and moving + # `current` to $prev would repoint it at the runtime that was just rejected. if [ -n "$cur" ]; then atomic_symlink "$cur" "$ROOT/previous" "$node_bin"; fi + atomic_symlink "$prev" "$ROOT/current" "$node_bin" if [ "$(readlink "$ROOT/current")" != "$prev" ]; then printf 'current did not advance to %s\n' "$prev" >&2 return 1 @@ -843,18 +845,23 @@ cmd_uninstall() { printf ' config : %s\n' "$ROOT/config" printf ' state : %s\n' "$STATE_DIR" printf '\nUse "manage purge" separately to delete those irreversibly.\n\n' - if [ -t 0 ]; then - printf 'Uninstall? [y/N] ' - local reply="" - read -r reply || true - case "$reply" in y|Y|yes|YES) ;; *) printf 'aborted\n'; return 1 ;; esac + if [ ! -t 0 ]; then + printf 'refusing to uninstall with no terminal to confirm at\n' >&2 + return 1 fi + printf 'Uninstall? [y/N] ' + local reply="" + read -r reply || true + case "$reply" in y|Y|yes|YES) ;; *) printf 'aborted\n'; return 1 ;; esac launchctl bootout "gui/$UID/$LABEL" 2>/dev/null || true rm -f "$PLIST" # Turn off only the mapping this installer owns. if ts serve status 2>/dev/null | grep -q "127.0.0.1:$PORT"; then - ts serve --bg off 2>/dev/null || ts serve reset 2>/dev/null || true - printf 'turned off the Serve mapping to 127.0.0.1:%s\n' "$PORT" + if ts serve --bg off 2>/dev/null; then + printf 'turned off the Serve mapping to 127.0.0.1:%s\n' "$PORT" + else + printf 'could not turn off the Serve mapping; check "tailscale serve status" and remove it by hand\n' >&2 + fi else printf 'left the Serve config alone (it does not point at 127.0.0.1:%s)\n' "$PORT" fi @@ -1027,7 +1034,9 @@ rollback_release() { warn "there is no previous release to restore (this was a first install)." return 1 fi - atomic_symlink "$OLD_RELEASE" "$CURRENT_LINK" "$OLD_RELEASE/runtime/node" + # $STAGE/runtime/node was verified executable and version/arch-matched earlier + # in this run; $OLD_RELEASE/runtime/node has not been checked at all. + atomic_symlink "$OLD_RELEASE" "$CURRENT_LINK" "$STAGE/runtime/node" if [ "$TEST_MODE" != "1" ]; then launchctl kickstart -k "gui/$UID/$LABEL" >/dev/null 2>&1 || true fi diff --git a/lib/src/host/remote/service-protocol.ts b/lib/src/host/remote/service-protocol.ts index fe356d33..910f2fb8 100644 --- a/lib/src/host/remote/service-protocol.ts +++ b/lib/src/host/remote/service-protocol.ts @@ -151,8 +151,8 @@ export interface EnrollResult { } /** - * What `window.dormouseRemoteHost.status()` prints. SELF_HOST.md documents these - * field names, so they are part of the user-facing surface. + * What `window.dormouseRemoteHost.status()` prints. `docs/specs/server.md` + * documents the console hook, so these field names are user-facing surface. */ export interface RemoteHostConsoleStatus { enrolled: boolean; diff --git a/lib/src/remote/host/activation.ts b/lib/src/remote/host/activation.ts index 3b50ee0d..1b55507e 100644 --- a/lib/src/remote/host/activation.ts +++ b/lib/src/remote/host/activation.ts @@ -112,8 +112,9 @@ function installBridgeMode(link: RemoteHostLink): void { const target = globalThis as unknown as { dormouseRemoteHost?: unknown }; if (target.dormouseRemoteHost) return; - // Same method names and result shapes as the legacy hook (SELF_HOST.md), one - // round trip further away — so `status()` and `reconnect()` are promises here. + // Same method names and result shapes as the legacy hook (docs/specs/server.md + // → "Running it"), one round trip further away — so `status()` and + // `reconnect()` are promises here. target.dormouseRemoteHost = { enroll: (serverUrl: string, password: string, label: string) => link.command('enroll', { serverUrl, password, label }), From 627b987a6b16d84899b4ca39b26727dbf1a7d488 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 20 Aug 2026 16:06:56 -0700 Subject: [PATCH 3/3] Refuse to print the setup password with no terminal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `cmd_show_password` had the same skip-on-no-TTY shape the last push removed from `cmd_uninstall`, and what it skipped was the gate on a bearer credential: `manage show-password Claude-Session: https://claude.ai/code/session_016MVfFyGRbQyNKJAFEF33wW --- AGENTS.md | 2 +- SELF_HOST.md | 7 ++++--- deploy/local/install-macos.sh | 12 +++++++----- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6cb9504c..cead5c14 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,7 +52,7 @@ Each spec's own `Files` / `Code Map` section is the exhaustive file→spec mappi - **`docs/specs/webgl-text.md`** — The SDF text-rendering stack for the 3D/WebXR terminal effort: the diffplug/xterm.js fork pipeline (branch strategy, sdf-version lockstep with `@xterm/xterm` pins, GitHub-release tarball distribution), the SDF glyph architecture in the forked webgl addon (color-free atlas with one texture entry per shape, shader tint/smoothstep contract, raster fallbacks for emoji/custom glyphs/decorated cells, the MSDF-compatible texel reservation), and the canopy Storybook lab with its upstream-vs-fork regression harness. Touch points: `canopy/`, the fork's `addons/addon-webgl` (separate repo), any bump of the fork tarball URL or `@xterm/*` pins in `canopy/package.json`. - **`docs/specs/remote-security-model.md`** — The trust model for remote control: passkeys prove fresh user presence (user credentials — they sync), non-extractable per-browser device keys prove long-lived Client identity, the Host's local ACL authorizes the *pair* via a local-approval pairing ceremony, and the Host — never the Server — makes the final access decision. Read this first for anything remote; the other three remote specs build on it. Touch points: `server-lib-common/src/security/`, `server/src/handshake.ts`, the security modules in `lib/src/remote/host/` and `lib/src/remote/client/`. - **`docs/specs/remote-api.md`** — The protocol a Client speaks after `authorizeConnection`: the shipped terminal-only **protocol-v1** (snapshot directory, attach-is-the-resize, last-attach-wins size authority) and the staged remainder (browser surfaces, in-flight replay, semantic scrollback, tethering display, grants, VR Window, WebRTC). Touch points: `server-lib-common/src/remote/wire.ts` (the fixed wire contract), `lib/src/remote/host/remote-api.ts` + `host-surface-provider.ts`, `lib/src/host/remote/` (the Node-side service both hosts install), `lib/src/remote/client/`. -- **`docs/specs/server.md`** — The selfhost coordinating server: env config, local JSON-file state, "WebAuthn without a WebAuthn library", the HTTP API, the relay frame flow (one host challenge feeds both signatures → one biometric prompt per connect), the baked relay-origin allowlist for self-host builds (`DORMOUSE_REMOTE_CONNECT_SRC`), Host/Pocket side responsibilities, the testing harness, and instructions for running it end to end. Touch points: `server/src/`, `lib/src/remote/host/enrollment.ts`, `scripts/csp-defaults.mjs`, the `dev:pocket-server` flow. +- **`docs/specs/server.md`** — The selfhost coordinating server: env config, local JSON-file state, "WebAuthn without a WebAuthn library", the HTTP API, the relay frame flow (one host challenge feeds both signatures → one biometric prompt per connect), the baked relay-origin allowlist for self-host builds (`DORMOUSE_REMOTE_CONNECT_SRC`), Host/Pocket side responsibilities, the testing harness, instructions for running it end to end, and the macOS LaunchAgent install behind Tailscale. Touch points: `server/src/`, `lib/src/remote/host/enrollment.ts`, `scripts/csp-defaults.mjs`, the `dev:pocket-server` flow, `deploy/local/install-macos.sh` (whose operator runbook is `SELF_HOST.md`). - **`docs/specs/pocket-app.md`** — Pocket app architecture: the remote session is a `PlatformAdapter` (`RemotePtyAdapter`), so Pocket is auth screens + the mobile-terminal-ui composition; the `lib/src/remote/` module layout and the same-origin deployment rule (WebAuthn origin binding + Chrome PNA). Touch points: `lib/src/remote/client/` + `pocket-app/`, `lib/vite.pocket.config.ts`, the Pocket static serving in `server/src/app.ts`. - **`docs/specs/deploy.md`** — Release process: the artifact matrix, release checklist, two-stage pipeline (CI builds unsigned + attests; a local script verifies, signs macOS/Windows, and creates the GitHub Release), Tauri updater manifest, changelog flow, and secrets. Touch points: `.github/workflows/release.yml`, `scripts/sign-and-deploy.sh`, `scripts/bump-version.sh`, the updater config in `tauri.conf.json`. diff --git a/SELF_HOST.md b/SELF_HOST.md index c275a0e1..85addfca 100644 --- a/SELF_HOST.md +++ b/SELF_HOST.md @@ -166,9 +166,10 @@ day: - `tailscale serve` proxies to `127.0.0.1:3100` at the same origin recorded in `config/server.env`. - `config/` and `state/` are mode `0700`, `config/server.env` is mode `0600`. -- `current` resolves to a release with `RELEASE` metadata, a `previous` release - is retained for rollback, and neither the plist nor `bin/run-server` refers to - the source checkout. +- `current` resolves to a release with `RELEASE` metadata, and neither the plist + nor `bin/run-server` refers to the source checkout. A retained `previous` + release is checked too, but a first install has none, so `verify` warns there + rather than failing. These cannot be proven from the laptop, and are the checkpoints below: diff --git a/deploy/local/install-macos.sh b/deploy/local/install-macos.sh index 24042064..b9f0293f 100755 --- a/deploy/local/install-macos.sh +++ b/deploy/local/install-macos.sh @@ -794,12 +794,14 @@ cmd_show_password() { printf '\n%sWARNING%s the setup password gates account creation and Host enrollment.\n' "$C_YEL" "$C_OFF" printf 'It is about to be printed to this terminal. Make sure nobody is looking\n' printf 'over your shoulder and that this session is not being recorded or shared.\n\n' - if [ -t 0 ]; then - printf 'Print it? [y/N] ' - local reply="" - read -r reply || true - case "$reply" in y|Y|yes|YES) ;; *) printf 'aborted\n'; return 1 ;; esac + if [ ! -t 0 ]; then + printf 'refusing to print the setup password with no terminal to confirm at\n' >&2 + return 1 fi + printf 'Print it? [y/N] ' + local reply="" + read -r reply || true + case "$reply" in y|Y|yes|YES) ;; *) printf 'aborted\n'; return 1 ;; esac printf '\n %s\n\n' "$(env_value DORMOUSE_SETUP_PASSWORD)" }