diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5dfb85c29ca..f4563608262 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -410,8 +410,8 @@ jobs: bearings_output=$(/bin/bash tests/fm-bearings-snapshot.test.sh) printf '%s\n' "$bearings_output" bearings_count=$(printf '%s\n' "$bearings_output" | grep -c '^ok - ') - [ "$bearings_count" -eq 42 ] || { - echo "::error::expected 42 Bearings tests, got $bearings_count" + [ "$bearings_count" -eq 45 ] || { + echo "::error::expected 45 Bearings tests, got $bearings_count" exit 1 } diff --git a/.gitignore b/.gitignore index ed8409d29af..829f8f09b4b 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,8 @@ __pycache__/ # Captain-private local operating choices; never published. Anchored to the # repo root so a config-named directory elsewhere in the tree stays visible. /config/ +# Phone-bridge passcode hash, sessions, and logs. Mode 0700 at runtime; never publish. +/bridge/ # Generated per-task hook artifacts (also written into each task worktree's # .git/info/exclude by fm-spawn). Tracked here so a primary checkout cannot # accidentally stage them into a PR. diff --git a/bin/fm-bearings-snapshot.sh b/bin/fm-bearings-snapshot.sh index 4c59f81a315..43307a1037d 100755 --- a/bin/fm-bearings-snapshot.sh +++ b/bin/fm-bearings-snapshot.sh @@ -61,9 +61,15 @@ # --all-recorded-prs include every locally recorded PR # --all-unhealthy include every unhealthy endpoint # --all-pr-repos query every discovered repository under --include-prs +# --passive-view named read-only observation for the phone bridge page; +# skips the away-mode return guard so a refresh still works +# while the captain is away. Ordinary Bearings chat still +# calls the guard. Refuses --include-prs (local-only). # -h,--help usage # # Output contract: `fm-bearings.v1`. Read-only; no locks, no mutation, no reports. +# in_flight rows include a captain-facing `title` (backlog title, else a +# non-id fallback). `doing` remains the internal current-state detail. set -u SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -103,7 +109,8 @@ validate_bound FM_BEARINGS_PR_LIMIT "$FM_BEARINGS_PR_LIMIT" usage() { cat <<'EOF' -usage: fm-bearings-snapshot.sh [--json] [--include-prs] [--fields ] +usage: fm-bearings-snapshot.sh [--json] [--include-prs] [--passive-view] + [--fields ] [--all-in-flight] [--all-decisions] [--all-secondmates] [--all-landed] [--all-reports] [--all-queued] @@ -112,8 +119,10 @@ usage: fm-bearings-snapshot.sh [--json] [--include-prs] [--fields ] Compact bearings projection over fm-fleet-snapshot.sh. TOON by default. Default is LOCAL-ONLY (no network); --include-prs is the only path that fetches. +--passive-view is the named away-mode exception for the phone bridge page; +ordinary invocations still refuse while away mode is on. -Default fields: schema, home, generated, prs, in_flight{id,kind,state,doing}, +Default fields: schema, home, generated, prs, in_flight{id,kind,state,title,doing}, secondmates{id,state,doing,provenance,freshness,age_seconds,contradiction,reason}, decisions_open{id,key,verb,summary,owner}, landed{id,what,artifact,owner}, gates{id,title,blocked_by,reason,owner}, reports{id,path}, recorded_prs{id,url}, @@ -147,11 +156,13 @@ ALL_LANDED=0 ALL_RECORDED_PRS=0 ALL_UNHEALTHY=0 ALL_PR_REPOS=0 +PASSIVE_VIEW=0 FIELDS="" while [ $# -gt 0 ]; do case "$1" in --json) FORMAT=json ;; --include-prs) INCLUDE_PRS=1 ;; + --passive-view) PASSIVE_VIEW=1 ;; --all-reports) ALL_REPORTS=1 ;; --all-queued) ALL_QUEUED=1 ;; --all-in-flight) ALL_IN_FLIGHT=1 ;; @@ -171,10 +182,19 @@ done command -v jq >/dev/null 2>&1 || { echo "fm-bearings-snapshot: jq not found" >&2; exit 1; } +if [ "$PASSIVE_VIEW" = 1 ] && [ "$INCLUDE_PRS" = 1 ]; then + echo "fm-bearings-snapshot: --passive-view is local-only and refuses --include-prs" >&2 + exit 2 +fi + # The deterministic return-catch-up owner must clear before this or any other # ordinary captain request proceeds. Bearings does not reproduce that policy; -# it only consults the shared read-only gate. -"$SCRIPT_DIR/fm-afk-return.sh" guard || exit $? +# it only consults the shared read-only gate. --passive-view is the named +# exception for the phone bridge observation: that page is most useful while +# the captain is away, and it must not copy or skip the guard in bridge code. +if [ "$PASSIVE_VIEW" != 1 ]; then + "$SCRIPT_DIR/fm-afk-return.sh" guard || exit $? +fi NOW=${FM_BEARINGS_NOW:-$(date -u +%Y-%m-%dT%H:%M:%SZ)} if [ "$ALL_LANDED" = 1 ] || [ "$ALL_SECONDMATES" = 1 ]; then @@ -397,12 +417,14 @@ MODEL=$(printf '%s' "$SNAP" | jq \ | select(.backlog.current_role != "held" or .current_state.state == "working") | {id, kind, state: .current_state.state, + title: ((.backlog.title // "Untitled work") | trunc(90)), doing: ((.current_state.detail // "") as $d | (if $d != "" then $d else (.hints.last_event_text // "") end) | trunc(90)) } ] + [ $secondmate_views[] | select(.bearings_state == "active_child_work") | {id,kind:"secondmate",state:.bearings_state, + title:"Second-mate work", doing:([.active_children[] | .id + ": " + (.doing // .state)] | join("; ") | trunc(90))} ]) as $in_flight_all | ([ .backlog.records[] | select(.structured and .captain_actionable == true) diff --git a/bin/fm-bridge-view.py b/bin/fm-bridge-view.py new file mode 100755 index 00000000000..1a7d7366bb1 --- /dev/null +++ b/bin/fm-bridge-view.py @@ -0,0 +1,1117 @@ +#!/usr/bin/env python3 +"""Starship bridge view: read-only phone page on IPv4 loopback. + +The bind address is the literal LOOPBACK constant. There is no flag or +environment variable that widens it. Tailscale Serve publishes HTTPS in +front of this process; Funnel stays off. + +This process never takes the session lock, never drains wakes, and never +writes backlog or fleet state. Session and passcode files live under the +home's 0700 bridge/ directory. Logs also go there, not into state/. +""" + +from __future__ import annotations + +import argparse +import base64 +import fcntl +import hashlib +import hmac +import html +import json +import os +import re +import secrets +import selectors +import signal +import socket +import subprocess +import sys +import threading +import time +import unicodedata +from contextlib import contextmanager +from http.cookies import SimpleCookie +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple +from urllib.parse import parse_qs, urlparse + +LOOPBACK = "127.0.0.1" +DEFAULT_PORT = 8766 +SESSION_TTL_SECONDS = 7 * 24 * 60 * 60 +CACHE_TTL_SECONDS = 30 +SNAPSHOT_TIMEOUT_SECONDS = 20 +SNAPSHOT_MAX_BYTES = 1_000_000 +COOKIE_NAME = "fm_bridge_sid" +COOKIE_PATH = "/" +SCRYPT_N = 2**14 +SCRYPT_R = 8 +SCRYPT_P = 1 +SCRYPT_DKLEN = 32 +BUCKET_CAP_NEEDS = 5 +BUCKET_CAP_UNDERWAY = 8 +BUCKET_CAP_LANDED = 6 +BUCKET_CAP_WAITING = 5 +STALE_CLIENT_SECONDS = 90 +REFRESH_CLIENT_SECONDS = 30 +MAILBOX_PORT = 8765 +MAILBOX_LAUNCHD = "com.firstmate.glasses-voice-mailbox" +GITHUB_PR_RE = re.compile(r"^https://github\.com/[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+/pull/[0-9]+$") +IPV4_RE = re.compile(r"^\d{1,3}(?:\.\d{1,3}){3}$") +HOST_LABEL_RE = re.compile(r"^[A-Za-z0-9.-]+(?::\d+)?$") +CHILD_PATH = "/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin:/opt/homebrew/bin" + +TEST_MODE = os.environ.get("FM_BRIDGE_VIEW_TEST") == "1" + + +def fail(message: str, code: int = 1) -> None: + print(f"fm-bridge-view: {message}", file=sys.stderr) + raise SystemExit(code) + + +def is_ip_host(host: str) -> bool: + name = host.split("%", 1)[0] + if name.startswith("[") and name.endswith("]"): + name = name[1:-1] + if IPV4_RE.match(name): + return True + try: + socket.inet_pton(socket.AF_INET6, name) + return True + except OSError: + return False + + +def split_hostport(value: str) -> Tuple[str, Optional[str]]: + raw = value.strip().lower() + if raw.startswith("["): + end = raw.find("]") + if end == -1: + return raw, None + host = raw[1:end] + rest = raw[end + 1 :] + port = rest[1:] if rest.startswith(":") else None + return host, port + if raw.count(":") == 1: + host, port = raw.split(":", 1) + return host, port + return raw, None + + +def load_host_config(home: Path) -> str: + env_host = os.environ.get("FM_BRIDGE_VIEW_HOST", "").strip() + if env_host: + return env_host + path = home / "config" / "bridge-view" + if not path.is_file(): + return "" + for line in path.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if line.startswith("host="): + return line.split("=", 1)[1].strip() + return "" + + +def bridge_dir(home: Path) -> Path: + return home / "bridge" + + +def ensure_bridge_dir(home: Path) -> Path: + path = bridge_dir(home) + path.mkdir(mode=0o700, parents=True, exist_ok=True) + os.chmod(path, 0o700) + return path + + +def write_private(path: Path, data: bytes) -> None: + path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + fd = os.open(str(path), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + try: + os.write(fd, data) + finally: + os.close(fd) + os.chmod(path, 0o600) + + +def scrypt_hash(password: str, salt: bytes) -> bytes: + normalized = unicodedata.normalize("NFC", password).encode("utf-8") + return hashlib.scrypt( + normalized, salt=salt, n=SCRYPT_N, r=SCRYPT_R, p=SCRYPT_P, dklen=SCRYPT_DKLEN + ) + + +def format_passcode_record(salt: bytes, digest: bytes) -> str: + return "scrypt$n={n}$r={r}$p={p}${salt}${digest}\n".format( + n=SCRYPT_N, + r=SCRYPT_R, + p=SCRYPT_P, + salt=base64.b64encode(salt).decode("ascii"), + digest=base64.b64encode(digest).decode("ascii"), + ) + + +def parse_passcode_record(text: str) -> Tuple[bytes, bytes]: + parts = text.strip().split("$") + if len(parts) != 6 or parts[0] != "scrypt": + fail("passcode hash file is malformed") + try: + salt = base64.b64decode(parts[4]) + digest = base64.b64decode(parts[5]) + except Exception: + fail("passcode hash file is malformed") + return salt, digest + + +def hash_passcode(password: str) -> str: + salt = secrets.token_bytes(16) + digest = scrypt_hash(password, salt) + return format_passcode_record(salt, digest) + + +def verify_passcode(password: str, record: str) -> bool: + salt, expected = parse_passcode_record(record) + actual = scrypt_hash(password, salt) + return hmac.compare_digest(actual, expected) + + +def generate_passcode() -> str: + return secrets.token_urlsafe(24) + + +class SessionStore: + def __init__(self, path: Path) -> None: + self.path = path + self.lock_path = path.with_suffix(path.suffix + ".lock") + self.lock = threading.Lock() + + @contextmanager + def _locked(self): + with self.lock: + fd = os.open(str(self.lock_path), os.O_RDWR | os.O_CREAT, 0o600) + try: + fcntl.flock(fd, fcntl.LOCK_EX) + yield + finally: + fcntl.flock(fd, fcntl.LOCK_UN) + os.close(fd) + + def _load(self) -> Dict[str, Any]: + if not self.path.is_file(): + return {"sessions": {}} + try: + data = json.loads(self.path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return {"sessions": {}} + sessions = data.get("sessions") + if not isinstance(sessions, dict): + return {"sessions": {}} + return {"sessions": sessions} + + def _save(self, data: Dict[str, Any]) -> None: + payload = json.dumps(data, indent=2, sort_keys=True).encode("utf-8") + temporary = self.path.with_name(f".{self.path.name}.{os.getpid()}.{secrets.token_hex(8)}") + try: + write_private(temporary, payload) + os.replace(temporary, self.path) + finally: + try: + temporary.unlink() + except FileNotFoundError: + pass + + def create(self) -> str: + token = secrets.token_urlsafe(32) + now = int(time.time()) + with self._locked(): + data = self._load() + data["sessions"][token] = { + "created": now, + "expires": now + SESSION_TTL_SECONDS, + } + self._save(data) + return token + + def valid(self, token: str) -> bool: + if not token: + return False + now = int(time.time()) + with self._locked(): + data = self._load() + row = data["sessions"].get(token) + if not isinstance(row, dict): + return False + expires = int(row.get("expires") or 0) + if expires <= now: + data["sessions"].pop(token, None) + self._save(data) + return False + return True + + def revoke(self, token: str) -> None: + with self._locked(): + data = self._load() + if token in data["sessions"]: + data["sessions"].pop(token, None) + self._save(data) + + def revoke_all(self) -> None: + with self._locked(): + self._save({"sessions": {}}) + + +def omitted_total(omitted: List[Dict[str, Any]], prefix: str, shown: int) -> int: + for row in omitted: + surface = str(row.get("surface") or "") + if surface.startswith(prefix + " showing "): + match = re.search(r" of (\d+)", surface) + if match: + return int(match.group(1)) + return shown + + +def map_dot(state: str) -> str: + return { + "parked": "Needs you", + "working": "Under way", + "paused": "Waiting", + "done": "Ready", + "failed": "Failed", + "blocked": "Stuck", + "unknown": "Stuck", + }.get(state, "Under way") + + +def allowlisted_pr(url: str) -> Optional[str]: + if GITHUB_PR_RE.match(url): + return url + return None + + +def project_observation(model: Dict[str, Any]) -> Dict[str, Any]: + omitted = model.get("omitted") or [] + if not isinstance(omitted, list): + omitted = [] + decisions = model.get("decisions_open") or [] + in_flight = model.get("in_flight") or [] + landed = model.get("landed") or [] + gates = model.get("gates") or [] + if not isinstance(decisions, list): + decisions = [] + if not isinstance(in_flight, list): + in_flight = [] + if not isinstance(landed, list): + landed = [] + if not isinstance(gates, list): + gates = [] + + stuck_states = {"blocked", "failed", "parked"} + needs_items: List[Dict[str, str]] = [] + for row in decisions: + summary = str(row.get("summary") or "").strip() + if summary: + needs_items.append({"title": summary, "dot": "Needs you"}) + stuck_items: List[Dict[str, str]] = [] + underway_items: List[Dict[str, str]] = [] + waiting_live: List[Dict[str, str]] = [] + unknown_live = False + for row in in_flight: + state = str(row.get("state") or "") + title = str(row.get("title") or "").strip() or "Untitled work" + item = {"title": title, "dot": map_dot(state)} + if state in stuck_states: + stuck_items.append(item) + elif state == "paused": + waiting_live.append(item) + elif state == "unknown": + unknown_live = True + underway_items.append(item) + else: + underway_items.append(item) + needs_items.extend(stuck_items) + + landed_items: List[Dict[str, str]] = [] + for row in landed: + title = str(row.get("what") or "").strip() or "Finished work" + artifact = str(row.get("artifact") or "") + pr = allowlisted_pr(artifact) + item = {"title": title, "dot": "Ready"} + if pr: + item["url"] = pr + landed_items.append(item) + + waiting_items: List[Dict[str, str]] = [] + waiting_items.extend(waiting_live) + for row in gates: + title = str(row.get("title") or "").strip() or "Queued work" + waiting_items.append({"title": title, "dot": "Waiting"}) + + decisions_total = omitted_total(omitted, "decisions_open", len(decisions)) + in_flight_total = omitted_total(omitted, "in_flight", len(in_flight)) + landed_total = omitted_total(omitted, "landed", len(landed)) + gates_total = omitted_total(omitted, "gates", len(gates)) + extra_decisions = max(0, decisions_total - len(decisions)) + extra_in_flight = max(0, in_flight_total - len(in_flight)) + extra_landed = max(0, landed_total - len(landed)) + extra_gates = max(0, gates_total - len(gates)) + + def cap(items: List[Dict[str, str]], limit: int, extra: int) -> Tuple[List[Dict[str, str]], int]: + more = extra + max(0, len(items) - limit) + return items[:limit], more + + needs, needs_more = cap(needs_items, BUCKET_CAP_NEEDS, extra_decisions) + underway, underway_more = cap(underway_items, BUCKET_CAP_UNDERWAY, extra_in_flight) + finished, finished_more = cap(landed_items, BUCKET_CAP_LANDED, extra_landed) + waiting, waiting_more = cap(waiting_items, BUCKET_CAP_WAITING, extra_gates) + + incomplete_reasons: List[str] = [] + for row in omitted: + surface = str(row.get("surface") or "") + if "unreadable" in surface or "unavailable" in surface or "truncated" in surface: + incomplete_reasons.append(surface) + if "main in-flight" in surface or "unstructured current" in surface: + incomplete_reasons.append(surface) + if unknown_live: + incomplete_reasons.append("a live worker state is unknown") + + return { + "generated": model.get("generated"), + "needs_you": {"items": needs, "more": needs_more, "incomplete": bool(incomplete_reasons)}, + "under_way": {"items": underway, "more": underway_more, "incomplete": unknown_live or bool(incomplete_reasons)}, + "just_finished": {"items": finished, "more": finished_more, "incomplete": bool(extra_landed)}, + "waiting": {"items": waiting, "more": waiting_more, "incomplete": bool(incomplete_reasons)}, + "incomplete": bool(incomplete_reasons), + } + + +def mailbox_listener_available() -> bool: + port = MAILBOX_PORT + if TEST_MODE: + override = os.environ.get("FM_BRIDGE_VIEW_MAILBOX_PORT", "").strip() + if override.isdigit(): + port = int(override) + launchctl = os.environ.get("FM_BRIDGE_VIEW_LAUNCHCTL", "launchctl") + uid = os.getuid() + try: + proc = subprocess.run( + [launchctl, "print", f"gui/{uid}/{MAILBOX_LAUNCHD}"], + capture_output=True, + text=True, + timeout=2, + check=False, + env={"PATH": CHILD_PATH}, + ) + if proc.returncode == 0 and "state = running" in proc.stdout: + return True + except (OSError, subprocess.TimeoutExpired): + pass + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(0.2) + try: + result = sock.connect_ex((LOOPBACK, port)) + return result == 0 + except OSError: + return False + finally: + sock.close() + + +def _tailscale_output(args: List[str]) -> Tuple[int, str]: + try: + proc = subprocess.run( + ["tailscale", *args], + capture_output=True, + text=True, + timeout=5, + check=False, + env={"PATH": os.environ.get("PATH", CHILD_PATH)}, + ) + except FileNotFoundError: + return 127, "tailscale not found" + except subprocess.TimeoutExpired: + return 124, "timed out" + return proc.returncode, (proc.stdout or "") + (proc.stderr or "") + + +def _allow_funnel_from_json(text: str) -> Optional[bool]: + raw = text.strip() + if not raw.startswith("{"): + return None + try: + parsed = json.loads(raw) + except json.JSONDecodeError: + return None + if not isinstance(parsed, dict): + return None + allow = parsed.get("AllowFunnel") + if isinstance(allow, dict) and any(bool(value) for value in allow.values()): + return True + return False + + +def funnel_is_on() -> Tuple[bool, str]: + json_rc, json_text = _tailscale_output(["funnel", "status", "--json"]) + if json_rc == 0: + json_allow = _allow_funnel_from_json(json_text) + if json_allow is not None: + return json_allow, json_text.strip() or "No serve config" + rc, text = _tailscale_output(["funnel", "status"]) + lowered = text.lower() + if rc == 0 and ("no serve config" in lowered or "funnel is not enabled" in lowered): + return False, text.strip() or "No serve config" + if rc == 0 and "tailnet only" in lowered: + return False, text.strip() + if rc == 0 and re.search(r"\bfunnel\s+(?:is\s+)?(?:enabled|on|active)\b|\(funnel\)", lowered): + return True, text.strip() + if rc == 0 and "https://" in lowered: + return False, text.strip() + detail = text.strip() or json_text.strip() or "status unavailable" + return True, f"could not verify Funnel is off: {detail}" + + +def _kill_process_group(proc: subprocess.Popen[bytes]) -> None: + try: + os.killpg(proc.pid, signal.SIGKILL) + except ProcessLookupError: + pass + + +def _bounded_process_output(proc: subprocess.Popen[bytes]) -> Tuple[bytes, bytes]: + selector = selectors.DefaultSelector() + streams = {proc.stdout: bytearray(), proc.stderr: bytearray()} + terminated = False + for stream in streams: + if stream is not None: + selector.register(stream, selectors.EVENT_READ) + deadline = time.monotonic() + SNAPSHOT_TIMEOUT_SECONDS + try: + while selector.get_map(): + remaining = deadline - time.monotonic() + if remaining <= 0: + _kill_process_group(proc) + terminated = True + raise RuntimeError("bearings snapshot timed out") + events = selector.select(remaining) + if not events: + _kill_process_group(proc) + terminated = True + raise RuntimeError("bearings snapshot timed out") + for key, _ in events: + chunk = os.read(key.fileobj.fileno(), 65536) + if not chunk: + selector.unregister(key.fileobj) + continue + streams[key.fileobj].extend(chunk) + if sum(len(output) for output in streams.values()) > SNAPSHOT_MAX_BYTES: + _kill_process_group(proc) + terminated = True + raise RuntimeError("bearings snapshot exceeded size cap") + try: + proc.wait(timeout=max(0.1, deadline - time.monotonic())) + except subprocess.TimeoutExpired as exc: + _kill_process_group(proc) + terminated = True + raise RuntimeError("bearings snapshot timed out") from exc + finally: + selector.close() + if not terminated: + _kill_process_group(proc) + proc.wait() + return bytes(streams.get(proc.stdout, b"")), bytes(streams.get(proc.stderr, b"")) + + +def run_snapshot(home: Path, root: Path) -> Dict[str, Any]: + script = root / "bin" / "fm-bearings-snapshot.sh" + if not script.is_file(): + raise RuntimeError(f"bearings snapshot missing: {script}") + scratch = Path(os.environ.get("TMPDIR") or "/tmp") / f"fm-bridge-view-{os.getpid()}" + scratch.mkdir(mode=0o700, exist_ok=True) + env = { + "PATH": CHILD_PATH, + "HOME": os.environ.get("HOME", "/var/empty"), + "TMPDIR": str(scratch), + "LANG": "C", + "LC_ALL": "C", + "FM_HOME": str(home), + "FM_ROOT_OVERRIDE": str(root), + } + proc = subprocess.Popen( + [str(script), "--json", "--passive-view", "--all-in-flight"], + cwd=str(scratch), + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + start_new_session=True, + ) + stdout, stderr = _bounded_process_output(proc) + if proc.returncode != 0: + err = (stderr or stdout).decode("utf-8", "replace").strip() + raise RuntimeError(err or f"bearings snapshot exited {proc.returncode}") + try: + model = json.loads(stdout.decode("utf-8")) + except json.JSONDecodeError as exc: + raise RuntimeError(f"bearings snapshot was not JSON: {exc}") from exc + if not isinstance(model, dict) or model.get("schema") != "fm-bearings.v1": + raise RuntimeError("bearings snapshot schema mismatch") + return model + + +class SnapshotCache: + def __init__(self, home: Path, root: Path) -> None: + self.home = home + self.root = root + self.lock = threading.Lock() + self.refresh = threading.Lock() + self.payload: Optional[Dict[str, Any]] = None + self.fetched_at = 0.0 + self.error: Optional[str] = None + + def get(self) -> Dict[str, Any]: + now = time.monotonic() + with self.lock: + if self.payload is not None and now - self.fetched_at < CACHE_TTL_SECONDS: + return dict(self.payload) + with self.refresh: + now = time.monotonic() + with self.lock: + if self.payload is not None and now - self.fetched_at < CACHE_TTL_SECONDS: + return dict(self.payload) + started = time.time() + model = run_snapshot(self.home, self.root) + observation = project_observation(model) + observation["mailbox_listener"] = mailbox_listener_available() + observation["read_started"] = model.get("generated") + observation["server_unix"] = int(started) + with self.lock: + self.payload = observation + self.fetched_at = time.monotonic() + self.error = None + return dict(observation) + + +def csp(nonce: str) -> str: + return ( + "default-src 'none'; " + f"style-src 'nonce-{nonce}'; " + f"script-src 'nonce-{nonce}'; " + "img-src 'self'; " + "connect-src 'self'; " + "form-action 'self'; " + "base-uri 'none'; " + "frame-ancestors 'none'" + ) + + +def security_headers(nonce: str) -> List[Tuple[str, str]]: + return [ + ("Cache-Control", "no-store"), + ("Content-Security-Policy", csp(nonce)), + ("Referrer-Policy", "no-referrer"), + ("X-Content-Type-Options", "nosniff"), + ("X-Frame-Options", "DENY"), + ] + + +PAGE_CSS = """ +:root { color-scheme: dark; } +* { box-sizing: border-box; } +html, body { margin: 0; padding: 0; } +body { + font-family: ui-sans-serif, system-ui, -apple-system, sans-serif; + background: #101418; + color: #f2f4f3; + line-height: 1.45; + padding: max(1rem, env(safe-area-inset-top)) 1.1rem 2rem; +} +main { max-width: 40rem; margin: 0 auto; } +h1 { font-size: 0.8rem; letter-spacing: 0.18em; text-transform: uppercase; margin: 0 0 0.4rem; color: #9aa7a0; } +h2 { font-size: 0.78rem; letter-spacing: 0.12em; text-transform: uppercase; margin: 1.4rem 0 0.5rem; color: #c5d0c8; } +.meta { color: #c5d0c8; font-size: 0.95rem; } +.warn { color: #e6c07b; font-size: 0.92rem; margin: 0.4rem 0 0; } +ul { list-style: none; padding: 0; margin: 0; } +li { padding: 0.55rem 0; border-bottom: 1px solid #2a3330; font-size: 1.05rem; } +.dot { display: inline-block; width: 0.65rem; height: 0.65rem; border-radius: 50%; margin-right: 0.55rem; background: #6ea8fe; } +.dot.needs { background: #c084fc; } +.dot.under { background: #6ea8fe; } +.dot.wait { background: #fbbf24; } +.dot.ready { background: #34d399; } +.dot.failed { background: #f87171; } +.dot.stuck { background: #fb923c; } +.more, .empty, .incomplete { color: #9aa7a0; font-size: 0.92rem; margin: 0.4rem 0 0; } +a { color: #9cdcfe; } +input, button { + font: inherit; width: 100%; min-height: 2.75rem; border-radius: 0.5rem; + border: 1px solid #3b4742; padding: 0.6rem 0.8rem; +} +input { background: #1b2220; color: inherit; margin: 0.8rem 0; } +button { background: #d7e0d8; color: #101418; font-weight: 600; } +.note { color: #9aa7a0; font-size: 0.9rem; } +#stale { + display: none; position: fixed; inset: 0; background: #101418; + color: #f2f4f3; align-items: center; justify-content: center; + text-align: center; padding: 2rem; font-size: 1.4rem; z-index: 9; +} +#stale.on { display: flex; } +header { display: flex; justify-content: space-between; align-items: baseline; gap: 1rem; } +form.logout { margin: 0; width: auto; } +form.logout button { width: auto; min-height: 2rem; padding: 0.3rem 0.7rem; background: transparent; color: #c5d0c8; border-color: #3b4742; } +""" + +PAGE_JS = """ +const STALE_MS = %d * 1000; +const REFRESH_MS = %d * 1000; +let lastSuccess = Date.now(); +function esc(value) { + return String(value).replace(/[&<>"']/g, function(ch) { + return ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[ch]); + }); +} +function setStale(on) { + const el = document.getElementById('stale'); + if (!el) return; + el.classList.toggle('on', on); +} +function dotClass(name) { + if (name === 'Needs you') return 'needs'; + if (name === 'Under way') return 'under'; + if (name === 'Waiting') return 'wait'; + if (name === 'Ready') return 'ready'; + if (name === 'Failed') return 'failed'; + if (name === 'Stuck') return 'stuck'; + return 'under'; +} +function renderBucket(id, bucket, emptyText) { + const root = document.getElementById(id); + if (!root || !bucket) return; + const items = bucket.items || []; + const lines = items.map(function(item) { + const title = esc(item.title || ''); + const url = item.url || ''; + const safeUrl = /^https:\\/\\/github\\.com\\/[A-Za-z0-9_.-]+\\/[A-Za-z0-9_.-]+\\/pull\\/[0-9]+$/.test(url) ? url : ''; + const label = safeUrl + ? '' + title + '' + : title; + return '
  • ' + label + '
  • '; + }); + let extra = ''; + if (bucket.more > 0) extra += '

    ' + bucket.more + ' more waiting

    '; + if (bucket.incomplete) extra += '

    This list may be incomplete.

    '; + if (!items.length) { + extra = '

    ' + + (bucket.incomplete ? 'This list may be incomplete.' : emptyText) + '

    ' + + (bucket.more > 0 ? '

    ' + bucket.more + ' more waiting

    ' : ''); + } + root.innerHTML = (lines.length ? '
      ' + lines.join('') + '
    ' : '') + extra; +} +function apply(data) { + lastSuccess = Date.now(); + setStale(false); + const age = document.getElementById('observed'); + if (age) age.textContent = 'Observed just now'; + const desk = document.getElementById('desk'); + if (desk) desk.textContent = 'Desk reachable'; + const mail = document.getElementById('mailbox'); + if (mail) mail.textContent = data.mailbox_listener ? 'Mailbox on' : 'Mailbox listener off'; + renderBucket('needs', data.needs_you, 'Nothing needs you right now.'); + renderBucket('underway', data.under_way, 'Nothing is under way.'); + renderBucket('finished', data.just_finished, 'No recent completions.'); + renderBucket('waiting', data.waiting, 'Nothing is waiting.'); +} +function tickObserved() { + const age = document.getElementById('observed'); + if (!age) return; + const seconds = Math.max(0, Math.round((Date.now() - lastSuccess) / 1000)); + age.textContent = 'Observed ' + seconds + ' seconds ago'; + if (Date.now() - lastSuccess > STALE_MS) setStale(true); +} +async function refresh() { + try { + const ctl = new AbortController(); + const timer = setTimeout(function() { ctl.abort(); }, 10000); + const res = await fetch('/api/observation', { credentials: 'same-origin', cache: 'no-store', signal: ctl.signal }); + clearTimeout(timer); + if (!res.ok) throw new Error('status ' + res.status); + apply(await res.json()); + } catch (err) { + tickObserved(); + } +} +document.addEventListener('DOMContentLoaded', function() { + refresh(); + setInterval(refresh, REFRESH_MS); + setInterval(tickObserved, 1000); + document.addEventListener('visibilitychange', function() { if (!document.hidden) refresh(); }); + window.addEventListener('pageshow', function() { refresh(); }); +}); +""" % (STALE_CLIENT_SECONDS, REFRESH_CLIENT_SECONDS) + + +def login_html(nonce: str, error: str = "") -> str: + err = f'

    {html.escape(error)}

    ' if error else "" + return f""" + + + + + +STARSHIP + + + +
    +

    Starship

    +

    Log in

    +{err} +
    + + + +
    +

    Only on your private network.

    +
    + + +""" + + +def glance_html(nonce: str) -> str: + js = PAGE_JS + return f""" + + + + + +STARSHIP + + + + +
    Cannot reach the desk.
    +
    +
    +

    Starship

    +
    +
    +

    Desk reachable · Mailbox…

    +

    Observed just now

    +

    Summary only. Do not approve from this page.

    +

    Needs you

    +

    Loading…

    +

    Under way

    +

    Loading…

    +

    Just finished

    +

    Loading…

    +

    Waiting in the wings

    +

    Loading…

    +
    + + +""" + + +class BridgeState: + def __init__(self, home: Path, root: Path, expected_host: str) -> None: + self.home = home + self.root = root + self.expected_host = expected_host.lower() + self.sessions = SessionStore(bridge_dir(home) / "sessions.json") + self.cache = SnapshotCache(home, root) + self.passcode_path = bridge_dir(home) / "passcode.hash" + self.login_lock = threading.Lock() + + def expected_hosts(self) -> set[str]: + host = self.expected_host + return {host, f"{host}:443"} + + def expected_origins(self) -> set[str]: + host = self.expected_host + return {f"https://{host}", f"https://{host}:443"} + + +STATE: Optional[BridgeState] = None + + +class BridgeHandler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def log_message(self, fmt: str, *args: Any) -> None: + log_path = bridge_dir(STATE.home) / "bridge.log" if STATE else None + line = "%s - %s\n" % (self.log_date_time_string(), fmt % args) + if log_path is not None: + try: + with open(log_path, "a", encoding="utf-8") as handle: + handle.write(line) + except OSError: + pass + + def _host_ok(self) -> bool: + header = (self.headers.get("Host") or "").strip() + if not header or not HOST_LABEL_RE.match(header): + return False + host, _port = split_hostport(header) + if is_ip_host(host): + return False + return header.lower() in STATE.expected_hosts() or host in STATE.expected_hosts() + + def _origin_ok(self) -> bool: + origin = (self.headers.get("Origin") or "").strip().rstrip("/") + return origin.lower() in STATE.expected_origins() + + def _send(self, code: int, body: bytes, content_type: str, extra: Optional[List[Tuple[str, str]]] = None) -> None: + nonce = secrets.token_urlsafe(16) + self.send_response(code) + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(body))) + for key, value in security_headers(nonce): + if key == "Content-Security-Policy" and content_type.startswith("application/json"): + self.send_header(key, "default-src 'none'; frame-ancestors 'none'") + elif key == "Content-Security-Policy": + self.send_header(key, csp(nonce)) + else: + self.send_header(key, value) + for key, value in extra or []: + self.send_header(key, value) + self.end_headers() + self.wfile.write(body) + + def _html(self, code: int, renderer, extra: Optional[List[Tuple[str, str]]] = None, **kwargs: Any) -> None: + nonce = secrets.token_urlsafe(16) + body = renderer(nonce, **kwargs).encode("utf-8") + self.send_response(code) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + for key, value in security_headers(nonce): + self.send_header(key, value) + for key, value in extra or []: + self.send_header(key, value) + self.end_headers() + self.wfile.write(body) + + def _session(self) -> str: + raw = self.headers.get("Cookie") or "" + cookie = SimpleCookie() + try: + cookie.load(raw) + except Exception: + return "" + morsel = cookie.get(COOKIE_NAME) + return morsel.value if morsel else "" + + def _authed(self) -> bool: + return STATE.sessions.valid(self._session()) + + def _set_cookie(self, token: str) -> str: + return ( + f"{COOKIE_NAME}={token}; HttpOnly; Secure; SameSite=Strict; " + f"Path={COOKIE_PATH}; Max-Age={SESSION_TTL_SECONDS}" + ) + + def _clear_cookie(self) -> str: + return f"{COOKIE_NAME}=; HttpOnly; Secure; SameSite=Strict; Path={COOKIE_PATH}; Max-Age=0" + + def do_GET(self) -> None: # noqa: N802 + if STATE is None or not self._host_ok(): + self._send(403, b"forbidden\n", "text/plain; charset=utf-8") + return + parsed = urlparse(self.path) + if parsed.path == "/": + if self._authed(): + self._html(200, glance_html) + else: + self._html(200, login_html) + return + if parsed.path == "/api/observation": + if not self._authed(): + self._send(401, b'{"error":"unauthorized"}\n', "application/json") + return + try: + payload = STATE.cache.get() + except Exception as exc: + self._send(503, json.dumps({"error": "desk unreachable", "detail": str(exc)}).encode("utf-8"), "application/json") + return + body = json.dumps(payload).encode("utf-8") + self._send(200, body, "application/json") + return + self._send(404, b"not found\n", "text/plain; charset=utf-8") + + def do_POST(self) -> None: # noqa: N802 + if STATE is None or not self._host_ok(): + self._send(403, b"forbidden\n", "text/plain; charset=utf-8") + return + parsed = urlparse(self.path) + if parsed.path not in {"/login", "/logout"}: + self._send(404, b"not found\n", "text/plain; charset=utf-8") + return + if not self._origin_ok(): + self._send(403, b"forbidden\n", "text/plain; charset=utf-8") + return + length = int(self.headers.get("Content-Length") or 0) + if length > 4096: + self._send(413, b"too large\n", "text/plain; charset=utf-8") + return + raw = self.rfile.read(length) if length else b"" + if parsed.path == "/logout": + STATE.sessions.revoke(self._session()) + self._html(303, login_html, extra=[("Set-Cookie", self._clear_cookie()), ("Location", "/")]) + return + fields = parse_qs(raw.decode("utf-8", "replace")) + password = (fields.get("passcode") or [""])[0] + record_path = STATE.passcode_path + if not record_path.is_file(): + self._html(500, login_html, error="Passcode is not initialized.") + return + record = record_path.read_text(encoding="utf-8") + with STATE.login_lock: + ok = verify_passcode(password, record) + if not TEST_MODE: + time.sleep(0.2 if ok else 0.8) + if not ok: + self._html(401, login_html, error="That passcode was not accepted.") + return + STATE.sessions.revoke(self._session()) + token = STATE.sessions.create() + self.send_response(303) + nonce = secrets.token_urlsafe(16) + self.send_header("Location", "/") + self.send_header("Set-Cookie", self._set_cookie(token)) + self.send_header("Content-Length", "0") + for key, value in security_headers(nonce): + self.send_header(key, value) + self.end_headers() + + +def command_init_passcode(home: Path) -> None: + ensure_bridge_dir(home) + hash_path = bridge_dir(home) / "passcode.hash" + if hash_path.is_file(): + fail("passcode hash already exists; revoke or replace it by hand") + password = generate_passcode() + write_private(hash_path, hash_passcode(password).encode("utf-8")) + plaintext = home / "data" / "bridge-view-passcode.txt" + plaintext.parent.mkdir(parents=True, exist_ok=True) + write_private(plaintext, (password + "\n").encode("utf-8")) + print(plaintext) + + +def command_revoke(home: Path) -> None: + ensure_bridge_dir(home) + SessionStore(bridge_dir(home) / "sessions.json").revoke_all() + print("revoked") + + +def command_render_plist(home: Path, root: Path) -> None: + script = root / "bin" / "fm-bridge-view.sh" + log = bridge_dir(home) / "bridge.log" + print( + f""" + + + + Label + com.firstmate.bridge-view + ProgramArguments + + /bin/bash + {script} + serve + + EnvironmentVariables + + FM_HOME + {home} + PATH + /opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin + + WorkingDirectory + {root} + RunAtLoad + + KeepAlive + + ThrottleInterval + 10 + StandardOutPath + {log} + StandardErrorPath + {log} + + +""" + ) + + +def command_mailbox() -> None: + print("on" if mailbox_listener_available() else "off") + + +def command_funnel() -> None: + on, detail = funnel_is_on() + if on: + fail(f"Tailscale Funnel is on; refuse to publish the bridge: {detail}") + print(detail or "No serve config") + + +def command_serve(home: Path, root: Path, port: int, host: str) -> None: + if not host: + fail("expected Serve hostname is missing; set FM_BRIDGE_VIEW_HOST or config/bridge-view host=") + if is_ip_host(host): + fail("expected host must be a MagicDNS name, not an IP") + ensure_bridge_dir(home) + on, detail = funnel_is_on() + if on: + fail(f"Tailscale Funnel is on; refuse to publish the bridge: {detail}") + if not (bridge_dir(home) / "passcode.hash").is_file() and not TEST_MODE: + fail("passcode hash is missing; run fm-bridge-view.sh init-passcode") + global STATE + STATE = BridgeState(home, root, host) + try: + ThreadingHTTPServer.allow_reuse_address = True + server = ThreadingHTTPServer((LOOPBACK, port), BridgeHandler) + server.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + except OSError as exc: + fail(f"could not bind {LOOPBACK}:{port}: {exc}") + bound = server.server_address[1] + print(f"listening on {LOOPBACK}:{bound}", flush=True) + try: + server.serve_forever() + except KeyboardInterrupt: + print("stopping", flush=True) + finally: + server.server_close() + + +def main(argv: Optional[List[str]] = None) -> None: + parser = argparse.ArgumentParser(prog="fm-bridge-view.py") + sub = parser.add_subparsers(dest="command", required=True) + serve = sub.add_parser("serve") + serve.add_argument("--home", required=True) + serve.add_argument("--root", required=True) + serve.add_argument("--port", type=int, default=DEFAULT_PORT) + serve.add_argument("--host", default="") + initp = sub.add_parser("init-passcode") + initp.add_argument("--home", required=True) + revoke = sub.add_parser("revoke-sessions") + revoke.add_argument("--home", required=True) + plist = sub.add_parser("render-plist") + plist.add_argument("--home", required=True) + plist.add_argument("--root", required=True) + sub.add_parser("mailbox-listener") + sub.add_parser("check-funnel") + args = parser.parse_args(argv) + if args.command == "serve": + command_serve(Path(args.home), Path(args.root), args.port, args.host or load_host_config(Path(args.home))) + elif args.command == "init-passcode": + command_init_passcode(Path(args.home)) + elif args.command == "revoke-sessions": + command_revoke(Path(args.home)) + elif args.command == "render-plist": + command_render_plist(Path(args.home), Path(args.root)) + elif args.command == "mailbox-listener": + command_mailbox() + elif args.command == "check-funnel": + command_funnel() + + +if __name__ == "__main__": + main() diff --git a/bin/fm-bridge-view.sh b/bin/fm-bridge-view.sh new file mode 100755 index 00000000000..e097fc62b68 --- /dev/null +++ b/bin/fm-bridge-view.sh @@ -0,0 +1,113 @@ +#!/usr/bin/env bash +# fm-bridge-view.sh - captain's phone-first read-only fleet page. +# +# Serves one small Python 3 standard-library HTTP server on IPv4 loopback and +# exposes it on the tailnet with Tailscale Serve HTTPS. It never binds +# 0.0.0.0, never enables Funnel, and never writes backlog or fleet state. +# The observation is `fm-bearings-snapshot.sh --json --passive-view`. +# +# The bind address is the literal 127.0.0.1 constant in bin/fm-bridge-view.py +# and there is no flag or environment variable that widens it. +# +# Usage: +# fm-bridge-view.sh serve [--port ] [--host ] +# fm-bridge-view.sh init-passcode +# fm-bridge-view.sh revoke-sessions +# fm-bridge-view.sh render-plist +# fm-bridge-view.sh mailbox-listener +# fm-bridge-view.sh check-funnel +# +# Environment: +# FM_HOME private Firstmate home; defaults to this repository root +# FM_BRIDGE_VIEW_PORT dedicated IPv4 loopback port; defaults to 8766 +# FM_BRIDGE_VIEW_HOST expected Serve MagicDNS hostname (Host/Origin checks) +# FM_BRIDGE_VIEW_TEST set to 1 only in the behavior suite +set -eu + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" +FM_HOME="${FM_HOME:-$FM_ROOT}" +ENGINE="$SCRIPT_DIR/fm-bridge-view.py" +PORT="${FM_BRIDGE_VIEW_PORT:-8766}" +HOST_NAME="${FM_BRIDGE_VIEW_HOST:-}" + +usage() { + awk ' + NR == 1 { next } + /^#/ { sub(/^# ?/, ""); print; next } + { exit } + ' "$0" +} + +fail() { + printf 'fm-bridge-view: %s\n' "$*" >&2 + exit 1 +} + +require_runtime() { + command -v python3 >/dev/null 2>&1 || fail "python3 is required" + [ -f "$ENGINE" ] || fail "server is missing: $ENGINE" +} + +check_port() { + case "$1" in + ''|*[!0-9]*) fail "port must be a non-negative integer: $1" ;; + esac + [ "$1" -le 65535 ] || fail "port must be below 65536: $1" +} + +engine() { + require_runtime + FM_HOME="$FM_HOME" FM_ROOT_OVERRIDE="$FM_ROOT" python3 "$ENGINE" "$@" +} + +command_serve() { + while [ "$#" -gt 0 ]; do + case "$1" in + --port) shift; PORT=${1:-} ;; + --host) shift; HOST_NAME=${1:-} ;; + *) usage >&2; exit 2 ;; + esac + shift + done + check_port "$PORT" + set -- serve --home "$FM_HOME" --root "$FM_ROOT" --port "$PORT" + [ -n "$HOST_NAME" ] && set -- "$@" --host "$HOST_NAME" + engine "$@" +} + +command_init_passcode() { + [ "$#" -eq 0 ] || { usage >&2; exit 2; } + engine init-passcode --home "$FM_HOME" +} + +command_revoke() { + [ "$#" -eq 0 ] || { usage >&2; exit 2; } + engine revoke-sessions --home "$FM_HOME" +} + +command_render_plist() { + [ "$#" -eq 0 ] || { usage >&2; exit 2; } + engine render-plist --home "$FM_HOME" --root "$FM_ROOT" +} + +command_mailbox() { + [ "$#" -eq 0 ] || { usage >&2; exit 2; } + engine mailbox-listener +} + +command_funnel() { + [ "$#" -eq 0 ] || { usage >&2; exit 2; } + engine check-funnel +} + +case "${1:-}" in + serve) shift; command_serve "$@" ;; + init-passcode) shift; command_init_passcode "$@" ;; + revoke-sessions) shift; command_revoke "$@" ;; + render-plist) shift; command_render_plist "$@" ;; + mailbox-listener) shift; command_mailbox "$@" ;; + check-funnel) shift; command_funnel "$@" ;; + -h|--help) usage ;; + *) usage >&2; exit 2 ;; +esac diff --git a/bin/fm-home-port.sh b/bin/fm-home-port.sh index a6cfe27976e..d72206450fa 100755 --- a/bin/fm-home-port.sh +++ b/bin/fm-home-port.sh @@ -7,8 +7,8 @@ # docs/porting.md - this header owns exact flags, paths, and fail-closed rules. # # Captain decision 2026-07-21: -# - secrets (.env, live API credentials, cmux socket password) NEVER port -# - state/ and projects/ NEVER port (machine-local) +# - secrets (.env, live API credentials, cmux socket password, bridge passcode) NEVER port +# - state/, projects/, and bridge/ NEVER port (machine-local) # - sync is explicit and captain-triggered, never silent two-way auto-sync # # Usage: @@ -51,8 +51,8 @@ Usage: fm-home-port.sh --help Port captain-private portable Firstmate material between machines through an -explicit, captain-triggered private-git sync. Secrets, state/, and projects/ -never port; see docs/porting.md. +explicit, captain-triggered private-git sync. Secrets, state/, projects/, and +bridge/ never port; see docs/porting.md. scan runs the credential scan (non-zero on SECRET_HIT). --warn-machine-local adds an advisory email /Users/ pass that never @@ -136,6 +136,7 @@ REFUSED_BASENAMES=( REFUSED_PREFIXES=( state/ projects/ + bridge/ ) resolve_home() { @@ -164,6 +165,7 @@ is_refused_relpath() { config/x-mode.env|./config/x-mode.env) return 0 ;; data/projects.md|./data/projects.md) return 0 ;; data/secondmates.md|./data/secondmates.md) return 0 ;; + data/bridge-view-passcode.txt|./data/bridge-view-passcode.txt) return 0 ;; esac return 1 } @@ -233,6 +235,14 @@ announce_standing_refusals() { refuse "data/secondmates.md - secondmate homes bind absolute paths; never port" refused=1 fi + if [ -d "$home/bridge" ]; then + refuse "bridge/ - phone-page passcode hash and sessions; never port" + refused=1 + fi + if [ -e "$home/data/bridge-view-passcode.txt" ]; then + refuse "data/bridge-view-passcode.txt - one-shot bridge passcode envelope; never port" + refused=1 + fi # Loud refusal is mandatory whenever refused material exists, so a future # operator cannot assume a "successful export" carried secrets or runtime. [ "$refused" -eq 1 ] || printf 'REFUSED: (none present) .env, state/, projects/, secret config - standing policy still applies\n' >&2 diff --git a/bin/fm-test-run.sh b/bin/fm-test-run.sh index 070928730fa..058ed7f15e4 100755 --- a/bin/fm-test-run.sh +++ b/bin/fm-test-run.sh @@ -123,7 +123,7 @@ family_for_basename() { fm-cd-pretool-check.test.sh|fm-composer-ghost.test.sh|fm-composer-lib.test.sh|\ fm-continuity-pretool-check.test.sh|fm-crew-state.test.sh|fm-decision-hold-lifecycle.test.sh|\ fm-decision-surface.test.sh|fm-dispatch-select.test.sh|fm-ensure-agents-md.test.sh|fm-grok-harness.test.sh|\ - fm-read.test.sh|fm-chart-room.test.sh|fm-overlay.test.sh|\ + fm-read.test.sh|fm-chart-room.test.sh|fm-overlay.test.sh|fm-bridge-view.test.sh|\ fm-herdr-lab.test.sh|fm-instruction-owners.test.sh|fm-lint.test.sh|fm-fork-surface.test.sh|\ fm-install-herdr.test.sh|fm-nm-test-contract.test.sh|fm-no-mistakes-ownership.test.sh|\ fm-pi-primary-types.test.sh|fm-operational-input.test.sh|\ @@ -391,6 +391,7 @@ tests/fm-daemon.test.sh tests/fm-pending-reply.test.sh tests/fm-pi-watch-extension.test.sh tests/fm-chart-room.test.sh +tests/fm-bridge-view.test.sh tests/fm-spawn-herdr-presentation.test.sh tests/fm-session-start.test.sh tests/fm-secondmate-liveness.test.sh @@ -869,7 +870,7 @@ families_for_changed_path() { bin/fm-lint.sh|bin/fm-install-shellcheck.sh|\ bin/fm-brief.sh|bin/fm-ensure-agents-md.sh|bin/fm-crew-state.sh|\ bin/fm-decision-hold.sh|bin/fm-decision-surface.*|bin/fm-read.*|bin/fm-supervision*|bin/fm-transition-lib.sh|\ - bin/fm-chart-room.*|bin/fm-overlay.sh|\ + bin/fm-chart-room.*|bin/fm-overlay.sh|bin/fm-bridge-view.*|\ bin/fm-tmux-lib.sh|bin/fm-marker-lib.sh|bin/fm-operational-input.sh|bin/fm-tasks-axi-lib.sh|\ bin/fm-primary-scope-lib.sh|bin/fm-project-mode.sh|bin/fm-promote.sh|\ bin/fm-ff-lib.sh|bin/fm-gotmp*|bin/*pretool*|\ diff --git a/docs/architecture.md b/docs/architecture.md index 45ca34370db..a3d217639fb 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -35,6 +35,7 @@ In that status-log fallback, a declared wait - a `paused:` external wait or a `c For herdr, that pane fallback trusts a native `busy` verdict outright, but corroborates native `idle` or unknown verdicts against the rendered busy signature before deciding the crew is not working. For whole-fleet read-only review, `bin/fm-fleet-snapshot.sh --json` emits schema `fm-fleet-snapshot.v1` from the backlog, task metadata, current crew state, endpoint probes, PR/report pointers, scout reports, bounded current summaries from registered secondmate homes, and secondmate return-channel guidance. `bin/fm-fleet-view.sh` renders that snapshot as Markdown for humans, while `bin/fm-bearings-snapshot.sh` provides the bounded bearings projection, so both views consume one structured contract instead of reparsing raw fleet files. +The phone bridge page is a read-only projection of that same bearings observation, served on loopback and published with Tailscale Serve; see [`bridge-view.md`](bridge-view.md). The script header owns the exact JSON schema. ### Registered secondmate current state diff --git a/docs/bridge-view.md b/docs/bridge-view.md new file mode 100644 index 00000000000..b2dd91df318 --- /dev/null +++ b/docs/bridge-view.md @@ -0,0 +1,85 @@ +# Starship bridge view + +The captain's phone-first read-only fleet page. +`bin/fm-bridge-view.sh` and `bin/fm-bridge-view.py` own commands, bind, auth, and snapshot mechanics; read the script header before first use. + +## What it is + +A glance of what needs the captain, what is under way, what just finished, and what is waiting. +It is an observation of durable fleet records, not a place to approve, answer, merge, or spawn. +Tapping a GitHub pull-request link is the only outbound jump, and those URLs are allowlisted. + +The page is served from the Mac on IPv4 loopback and published on the tailnet with **Tailscale Serve HTTPS**. +It never uses Tailscale Funnel and never binds `0.0.0.0`. +The server requires Python 3.12 and uses only the standard library. + +Bookmark: `https://larss-macbook-pro-2.taile26864.ts.net/` after Serve is pointed at the loopback port. + +## Auth + +There is one captain, so there is no account system. +The first visit shows Log in. +A dedicated high-entropy passcode, not the glasses relay token, sets an HttpOnly, Secure, SameSite=Strict, host-only session cookie with about a seven-day lifetime. +The salted scrypt hash lives at `bridge/passcode.hash` inside a mode-0700 `bridge/` directory. +Sessions live in `bridge/sessions.json`. +Logs go to `bridge/bridge.log`, not into `state/`. + +`config/bridge-view` may contain `host=` for Host and login Origin checks. +`FM_BRIDGE_VIEW_HOST` overrides that file. + +Initialize once: + +```sh +FM_HOME=/path/to/home bin/fm-bridge-view.sh init-passcode +``` + +That writes the hash and a one-shot plaintext envelope at `data/bridge-view-passcode.txt` (mode 0600) so firstmate can hand the passcode to the captain. +Delete the envelope after it has been delivered. +Never put the passcode in a URL. + +Lost phone: run `FM_HOME=/path/to/home bin/fm-bridge-view.sh revoke-sessions`, and if needed remove the phone from the tailnet. + +A VoiceLoop compromise can still read fleet files because both processes run as the same Mac user. +Separate passcodes only limit token leakage, not a full process break. + +## Serving + +Listen only on `127.0.0.1:8766` (override the port with `FM_BRIDGE_VIEW_PORT`). +Publish with Serve, never Funnel: + +```sh +tailscale serve --bg --https=443 http://127.0.0.1:8766 +``` + +`bin/fm-bridge-view.sh check-funnel` refuses if Funnel is on. +The server also refuses to start while Funnel is on. +A tailnet-only Serve HTTPS proxy is Funnel off and is the supported publish path. + +Keep the process alive across reboot with launchd, same KeepAlive pattern as `com.firstmate.glasses-voice-mailbox`. +Render a local plist and install it by hand; this is a machine-local setup action, not tracked captain state: + +```sh +FM_HOME=/path/to/home bin/fm-bridge-view.sh render-plist > ~/Library/LaunchAgents/com.firstmate.bridge-view.plist +launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.firstmate.bridge-view.plist +``` + +## Observation + +The page calls `bin/fm-bearings-snapshot.sh --json --passive-view`. +That named Bearings mode is allowed while away mode is on; ordinary `/bearings` chat still refuses until return catch-up finishes. +The server caches one observation for about 30 seconds, runs one refresh at a time, and caps subprocess time and output size. +It never takes the session lock, never drains wakes, and never writes backlog or state. + +The client refreshes every 30 seconds and also refreshes on `pageshow` and when a hidden tab becomes visible. +The four buckets show at most 5 Needs you, 8 Under way, 6 Just finished, and 5 Waiting in the wings rows, with an honest `N more` count for omitted rows. + +The mailbox indicator is a local listen or launchd check. +It must never call `GET /v1/announcements`, because that call marks announcements delivered. + +If refreshes stop for 90 seconds, the already-open tab overlays "Cannot reach the desk" from the client clock. +Last-good on the server cannot save a tab that never hears back. + +## Writes + +Slice 1 permits only read-only GETs and the login and logout POSTs needed for authentication. +There is no approve, answer, merge, or spawn control on this page. diff --git a/docs/configuration.md b/docs/configuration.md index 8df3792e057..e94d839788d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -13,6 +13,7 @@ The tracked code root contains the shared instruction, skill, documentation, wor `data/` holds durable private fleet records such as the project and secondmate registries, captain preferences, optional shared captain preferences, learnings, backlog, briefs, scout reports, the optional capability outcome log, the optional per-project goal charters under `data/goals/` (`docs/chart-room.md`), and the optional action-gateway state root under `data/action-gateway/` (`docs/action-gateway.md`). `state/` holds volatile runtime records such as task metadata, append-only status events, endpoint signals, watcher and wake-queue coordination, away-mode state, generated X-mode artifacts, parent-owned secondmate pending replies under `state/pending-replies/`, and per-task isolated browse profiles under `state/browse//` (`docs/worker-browsing.md`). `config/` holds local gitignored operating choices, and `projects/` holds the local project clones that Firstmate reads but changes only through the guarded exceptions in `AGENTS.md`. +`bridge/` is a mode-0700 directory for the phone-page passcode hash, sessions, and logs (`docs/bridge-view.md`); it is not `data/` or `config/` because those directories are world-readable today. Porting a home to a second machine - which material is portable, which is machine-local, and the explicit private-git sync - is owned by [`docs/porting.md`](porting.md) and `bin/fm-home-port.sh`. `bin/fm-spawn.sh` owns the base task-metadata fields it emits, while the runtime-backend section below owns backend-specific fields and selector interpretation. diff --git a/docs/porting.md b/docs/porting.md index 5f89686e6bd..caf3f6e58a5 100644 --- a/docs/porting.md +++ b/docs/porting.md @@ -23,7 +23,7 @@ Choose one before you treat a later pull as routine. | --- | --- | --- | | Tracked repo | `AGENTS.md`, `bin/`, skills, docs, workflows | `git clone` / `bin/fm-update.sh` | | Captain-private portable | The portable data files plus allowlisted non-secret `config/` files (see "Portable allowlist" below) | `bin/fm-home-port.sh` push/pull against a private transport | -| Machine-local, never port | Secrets, `state/`, `projects/`, fleet registries, the capability outcome log, and anything naming an absolute path or a running process on one machine | Recreate on the destination; do not copy | +| Machine-local, never port | Secrets, `state/`, `projects/`, `bridge/`, fleet registries, the capability outcome log, and anything naming an absolute path or a running process on one machine | Recreate on the destination; do not copy | Porting machine-local material causes real confusion: dead panes, wrong worktree bindings, and watcher locks that belong to another computer. @@ -53,7 +53,7 @@ Each machine holds its own. `bin/fm-home-port.sh` refuses to include them and reports that refusal loudly rather than silently skipping, so a future operator cannot assume they came across. It also scans exported material for accidentally embedded credentials before writing or pushing a bundle. -The standing loud-refusal set, when present in the source home, is `.env`, `state/`, `projects/`, `config/action-captain-secret`, `config/cmux-socket-password`, `config/x-mode.env`, `data/projects.md`, and `data/secondmates.md`. +The standing loud-refusal set, when present in the source home, is `.env`, `state/`, `projects/`, `bridge/`, `config/action-captain-secret`, `config/cmux-socket-password`, `config/x-mode.env`, `data/projects.md`, `data/secondmates.md`, and `data/bridge-view-passcode.txt`. Export and push print a `REFUSED:` line for each of those that exists (or `REFUSED: (none present)` when none of them do). ## What export omits without a transcript line diff --git a/docs/scripts.md b/docs/scripts.md index 8c42580add4..e40bb200ee4 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -27,6 +27,7 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize | `fm-decision-surface.sh` | Render, poll, and route the private loopback Lavish surface over captain decisions | | `fm-read.sh` | Render a Markdown path or task report as a private loopback Lavish reading page | | `fm-chart-room.sh` | Serve the private read-only chart room: fleet home, per-project goal maps, rendered fresh on every request (docs/chart-room.md) | +| `fm-bridge-view.sh` | Serve the captain's phone-first read-only fleet page on loopback behind Tailscale Serve (docs/bridge-view.md) | | `fm-overlay.sh` | Open a Markdown view as an in-terminal Herdr overlay pane, degrading to a printed pointer to the same content; installs nothing and is called nowhere by default (docs/chart-room.md) | | `fm-present.sh` | Present a captain-action artifact once per unchanged milestone through its existing local owner | | `fm-adhd.sh` | Bounded ADHD divergent-ideation wrapper; writes distilled CLI output and refuses when `adhd` is absent (docs/adhd.md) | diff --git a/fork-surface.conf b/fork-surface.conf index f8c8381caaa..36aa420ae5a 100644 --- a/fork-surface.conf +++ b/fork-surface.conf @@ -1125,3 +1125,33 @@ assert = files+test # Pre-merge branch commit; repair to the squash-merge commit once this PR lands. commits = cbca69e topology = independent + +[capability] +id = captain-bridge-view +title = Captain's phone-first read-only fleet page +layer = shared +scope = team +status = active +why = A Tailscale-Serve HTTPS glance of the Bearings observation lets the captain read the fleet from a phone without opening desk review pages or spending from the glasses mailbox. +owns = bin/fm-bridge-view.py +owns = bin/fm-bridge-view.sh +owns = docs/bridge-view.md +owns = tests/fm-bridge-view.test.sh +modifies = bin/fm-bearings-snapshot.sh +modifies = bin/fm-home-port.sh +modifies = bin/fm-test-run.sh +modifies = docs/architecture.md +modifies = docs/configuration.md +modifies = docs/porting.md +modifies = docs/scripts.md +modifies = tests/fm-bearings-snapshot.test.sh +modifies = tests/fm-home-port.test.sh +modifies = .gitignore +anchor = bin/fm-bridge-view.py :: ^LOOPBACK = "127\.0\.0\.1" +proves = tests/fm-bridge-view.test.sh +assert = files+test +secret = bridge/passcode.hash +secret = data/bridge-view-passcode.txt +# Pre-merge branch commit; repair to the squash-merge commit after this PR lands. +commits = 292193b +topology = independent diff --git a/tests/fm-bearings-snapshot.test.sh b/tests/fm-bearings-snapshot.test.sh index b37119dda3c..64455fe1f86 100755 --- a/tests/fm-bearings-snapshot.test.sh +++ b/tests/fm-bearings-snapshot.test.sh @@ -1923,6 +1923,44 @@ test_chat_contract_four_sections() { pass "the /bearings skill states the four-section chat contract in order, with empty-states and the At Anchor exclusion" } +test_in_flight_rows_carry_captain_facing_title() { + local home fakebin json + home=$(make_home titles); write_fixture "$home" + fakebin=$(make_fakebin "$home") + json=$(run "$home" "$fakebin" --json) + printf '%s' "$json" | jq -e ' + (.in_flight | any(.[]; .id == "ship-task" and .title == "Ship the thing")) + and (.in_flight | all(has("title") and has("doing"))) + ' >/dev/null || fail "in-flight rows must carry a captain-facing title: $json" + pass "in-flight rows include a captain-facing title beside internal doing" +} + +test_ordinary_bearings_refuses_while_away_mode_is_on() { + local home fakebin rc err + home=$(make_home away-refuse); write_fixture "$home" + fakebin=$(make_fakebin "$home") + date '+%s' > "$home/state/.afk" + err=$(run "$home" "$fakebin" --json 2>&1 >/dev/null) && rc=0 || rc=$? + expect_code 3 "$rc" "ordinary bearings must refuse while away mode is on" + assert_contains "$err" "away mode is still active" "ordinary bearings must name the away-mode guard" + pass "ordinary Bearings still refuses during away mode" +} + +test_passive_view_is_allowed_while_away_mode_is_on() { + local home fakebin json rc err + home=$(make_home away-passive); write_fixture "$home" + fakebin=$(make_fakebin "$home") + date '+%s' > "$home/state/.afk" + json=$(run "$home" "$fakebin" --json --passive-view); rc=$? + expect_code 0 "$rc" "passive-view must observe while away mode is on" + printf '%s' "$json" | jq -e '.schema == "fm-bearings.v1" and (.in_flight | length) > 0' >/dev/null \ + || fail "passive-view must still emit a bearings observation: $json" + err=$(run "$home" "$fakebin" --json --passive-view --include-prs 2>&1 >/dev/null) && rc=0 || rc=$? + expect_code 2 "$rc" "passive-view must refuse live PR fetching" + assert_contains "$err" "local-only" "passive-view must stay local-only" + pass "passive-view refreshes while away mode is on, without live PR fetching" +} + test_domain_alpha_stale_parent_event_does_not_become_current_work test_gnu_stat_uses_file_formats_without_bsd_fallback_pollution test_parent_activity_evidence_is_bounded_and_disclosed @@ -1954,6 +1992,9 @@ test_main_orphan_counterfactual_meta_clears_inventory_warning test_mixed_secondmate_roles_partial_state_and_captain_readiness test_main_captain_readiness_matches_secondmate_projection test_chat_contract_four_sections +test_in_flight_rows_carry_captain_facing_title +test_ordinary_bearings_refuses_while_away_mode_is_on +test_passive_view_is_allowed_while_away_mode_is_on test_completed_scout_report_not_pending test_open_decision_surfaces_end_to_end test_report_pointers_surface diff --git a/tests/fm-bridge-view.test.sh b/tests/fm-bridge-view.test.sh new file mode 100755 index 00000000000..bc264015ca6 --- /dev/null +++ b/tests/fm-bridge-view.test.sh @@ -0,0 +1,567 @@ +#!/usr/bin/env bash +# Behavior tests for the phone bridge view: loopback bind, Tailscale Funnel +# refusal, Host/Origin checks, session cookie isolation from port 8765, read-only +# snapshot subprocess, away-mode passive refresh, and auth headers. +set -u + +# shellcheck source=tests/lib.sh +# shellcheck disable=SC1091 +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +BRIDGE="$ROOT/bin/fm-bridge-view.sh" +TMP_ROOT=$(fm_test_tmproot fm-bridge-view) +HOST_NAME=bridge.test.example +ORIGIN="https://$HOST_NAME" + +command -v python3 >/dev/null 2>&1 || { echo "skip: python3 not found"; exit 0; } +command -v jq >/dev/null 2>&1 || { echo "skip: jq not found"; exit 0; } +command -v curl >/dev/null 2>&1 || { echo "skip: curl not found"; exit 0; } + +BRIDGE_PIDS=() +fm_bridge_cleanup() { + local pid + for pid in "${BRIDGE_PIDS[@]:-}"; do + kill "$pid" 2>/dev/null || true + wait "$pid" 2>/dev/null || true + done + fm_test_cleanup +} +trap fm_bridge_cleanup EXIT + +make_fakebin() { # [off|on|serve|legacy-serve] + local fb funnel=${2:-off} + fb=$(fm_fakebin "$1") + cat > "$fb/tailscale" <&2 + exit 1 + fi + printf '{}\\n' + exit 0 +fi +if [ "\$*" = "funnel status" ]; then + if [ "$funnel" = on ]; then + printf 'https://funnel.example.ts.net\\n|-- proxy http://127.0.0.1:8766 (Funnel)\\n' + exit 0 + fi + if [ "$funnel" = serve ]; then + printf 'https://bridge.test.example (tailnet only)\\n|-- / proxy http://127.0.0.1:8766\\n' + exit 0 + fi + if [ "$funnel" = legacy-serve ]; then + printf 'https://bridge.test.example\n|-- / proxy http://127.0.0.1:8766\n' + exit 0 + fi + printf 'No serve config\\n' + exit 0 +fi +if [ "\$*" = "serve status" ]; then + if [ "$funnel" = serve ]; then + printf 'https://bridge.test.example (tailnet only)\\n|-- / proxy http://127.0.0.1:8766\\n' + exit 0 + fi + printf 'No serve config\\n' + exit 0 +fi +exit 0 +SH + cat > "$fb/launchctl" <<'SH' +#!/usr/bin/env bash +exit 1 +SH + chmod +x "$fb/tailscale" "$fb/launchctl" + printf '%s\n' "$fb" +} + +make_broken_fakebin() { # + local fb + fb=$(fm_fakebin "$1") + cat > "$fb/tailscale" <<'SH' +#!/usr/bin/env bash +printf 'tailscaled unavailable\n' >&2 +exit 1 +SH + chmod +x "$fb/tailscale" + printf '%s\n' "$fb" +} + +make_home() { # + local home=$TMP_ROOT/$1 + mkdir -p "$home/state" "$home/data" "$home/config" "$home/projects" + cat > "$home/data/backlog.md" <<'EOF' +## In flight +- [ ] ship-task - VoiceLoop tap trigger (repo: firstmate) (kind: ship) (since 2026-08-19) + +## Queued +- [ ] cloud-hold - Always-on cloud, host+budget (repo: firstmate) (kind: ship) +- [ ] captain-q - Add Qwen to the fleet? (repo: firstmate) (kind: captain) (hold: captain choice pending) (hold-kind: captain) + +## Done +- [x] done-a - Spoken updates on the glasses https://github.com/kunchenguid/firstmate/pull/7 (repo: firstmate) (kind: ship) (merged 2026-08-18) +EOF + fm_write_meta "$home/state/ship-task.meta" \ + "window=firstmate:fm-ship-task" \ + "worktree=$home/projects/ship-wt" \ + "project=firstmate" \ + "harness=codex" \ + "kind=ship" \ + "mode=no-mistakes" + mkdir -p "$home/projects/ship-wt" + printf 'working: building the page\n' > "$home/state/ship-task.status" + printf '%s\n' "$home" +} + +fingerprint() { # + (cd "$1" && find data state -type f -print | LC_ALL=C sort | xargs cksum) +} + +wait_listening() { # + local log=$1 n=0 line + while [ "$n" -lt 50 ]; do + line=$(grep -E '^listening on 127.0.0.1:[0-9]+$' "$log" 2>/dev/null || true) + if [ -n "$line" ]; then + printf '%s\n' "${line##*:}" + return 0 + fi + sleep 0.1 + n=$((n + 1)) + done + fail "bridge server did not print a loopback listener: $(cat "$log" 2>/dev/null)" +} + +start_bridge() { # + local home=$1 fakebin=$2 log + log=$home/bridge-serve.log + : > "$log" + FM_BRIDGE_VIEW_TEST=1 FM_BRIDGE_VIEW_LAUNCHCTL="$fakebin/launchctl" \ + PATH="$fakebin:$PATH" FM_HOME="$home" FM_ROOT_OVERRIDE="$ROOT" \ + "$BRIDGE" serve --host "$HOST_NAME" --port 0 >"$log" 2>&1 & + BRIDGE_PIDS+=("$!") + wait_listening "$log" +} + +init_passcode() { # + local path + path=$(FM_HOME="$1" "$BRIDGE" init-passcode) + [ -f "$path" ] || fail "init-passcode did not write a plaintext envelope: $path" + printf '%s\n' "$(cat "$path")" +} + +curl_bridge() { # [curl args...] + local port=$1 path=$2 hdr=$3 body=$4 + shift 4 + curl -sS --max-time 30 \ + --header "Host: $HOST_NAME" \ + -D "$hdr" -o "$body" \ + "$@" \ + "http://127.0.0.1:${port}${path}" +} + +test_bind_is_loopback_constant() { + grep -q '^LOOPBACK = "127.0.0.1"$' "$ROOT/bin/fm-bridge-view.py" \ + || fail "bridge server lost the literal loopback bind constant" + grep -Eq '0\.0\.0\.0' "$ROOT/bin/fm-bridge-view.py" \ + && fail "bridge server mentions 0.0.0.0" + pass "bridge bind address is the literal loopback constant" +} + +test_funnel_on_refuses_to_serve() { + local home fakebin log rc=0 + home=$(make_home funnel-on) + fakebin=$(make_fakebin "$home" on) + init_passcode "$home" >/dev/null + log=$home/funnel.log + PATH="$fakebin:$PATH" FM_HOME="$home" FM_ROOT_OVERRIDE="$ROOT" FM_BRIDGE_VIEW_TEST=1 \ + "$BRIDGE" serve --host "$HOST_NAME" --port 0 >"$log" 2>&1 || rc=$? + [ "$rc" -ne 0 ] || fail "serve must refuse when Funnel is on: $(cat "$log")" + assert_contains "$(cat "$log")" "Funnel" "funnel refusal did not name Funnel" + pass "serve refuses to start while Tailscale Funnel is on" +} + +test_funnel_off_serve_starts() { + local home fakebin port hdr body rc=0 + home=$(make_home funnel-off-serve) + fakebin=$(make_fakebin "$home" serve) + init_passcode "$home" >/dev/null + PATH="$fakebin:$PATH" FM_HOME="$home" FM_ROOT_OVERRIDE="$ROOT" FM_BRIDGE_VIEW_TEST=1 \ + "$BRIDGE" check-funnel >/dev/null || fail "check-funnel must pass for tailnet-only Serve" + port=$(start_bridge "$home" "$fakebin") + hdr=$home/off.hdr; body=$home/off.body + curl_bridge "$port" / "$hdr" "$body" || rc=$? + expect_code 0 "$rc" "Funnel-off Serve should answer on loopback" + assert_contains "$(head -n 1 "$hdr")" "200" "Funnel-off Serve must serve the login page" + pass "serve starts when Funnel is off and Serve HTTPS is claimed" +} + +test_funnel_off_legacy_serve_output_starts() { + local home fakebin + home=$(make_home funnel-off-legacy-serve) + fakebin=$(make_fakebin "$home" legacy-serve) + PATH="$fakebin:$PATH" FM_HOME="$home" FM_ROOT_OVERRIDE="$ROOT" FM_BRIDGE_VIEW_TEST=1 \ + "$BRIDGE" check-funnel >/dev/null || fail "legacy Serve HTTPS output must not be classified as Funnel" + pass "Serve HTTPS without an explicit Funnel marker remains Funnel off" +} + +test_unverifiable_funnel_refuses_to_serve() { + local home fakebin log rc=0 + home=$(make_home funnel-unverifiable) + fakebin=$(make_broken_fakebin "$home") + init_passcode "$home" >/dev/null + log=$home/funnel-unverifiable.log + PATH="$fakebin:$PATH" FM_HOME="$home" FM_ROOT_OVERRIDE="$ROOT" FM_BRIDGE_VIEW_TEST=1 \ + "$BRIDGE" serve --host "$HOST_NAME" --port 0 >"$log" 2>&1 || rc=$? + [ "$rc" -ne 0 ] || fail "serve must refuse when Funnel state is unverifiable" + assert_contains "$(cat "$log")" "could not verify Funnel is off" \ + "unverifiable Funnel refusal was not explicit" + pass "serve fails closed when Funnel status is unavailable" +} + +test_lan_and_unauthorized_hosts_are_rejected() { + local home fakebin port hdr body rc=0 + home=$(make_home hosts) + fakebin=$(make_fakebin "$home") + init_passcode "$home" >/dev/null + port=$(start_bridge "$home" "$fakebin") + hdr=$home/lan.hdr; body=$home/lan.body + curl -sS --max-time 8 -D "$hdr" -o "$body" \ + --header "Host: 192.168.1.10" \ + "http://127.0.0.1:${port}/" || rc=$? + expect_code 0 "$rc" "LAN Host request should complete" + assert_contains "$(head -n 1 "$hdr")" "403" "LAN Host must be forbidden" + hdr=$home/ip.hdr; body=$home/ip.body + curl -sS --max-time 8 -D "$hdr" -o "$body" \ + "http://127.0.0.1:${port}/" || true + assert_contains "$(head -n 1 "$hdr")" "403" "default loopback Host must be forbidden" + pass "LAN and unauthorized Host headers are rejected" +} + +test_auth_cookie_headers_and_isolation() { + local home fakebin port pass hdr body cookie token sessions jar sink_pid sink_port n + home=$(make_home auth) + fakebin=$(make_fakebin "$home") + pass=$(init_passcode "$home") + port=$(start_bridge "$home" "$fakebin") + hdr=$home/bad.hdr; body=$home/bad.body + curl_bridge "$port" /login "$hdr" "$body" \ + --header "Origin: $ORIGIN" \ + --data "passcode=wrong-passcode" + assert_contains "$(head -n 1 "$hdr")" "401" "bad passcode must be rejected" + hdr=$home/origin.hdr; body=$home/origin.body + curl_bridge "$port" /login "$hdr" "$body" \ + --header "Origin: https://evil.example" \ + --data "passcode=$pass" + assert_contains "$(head -n 1 "$hdr")" "403" "wrong Origin must be rejected" + hdr=$home/ok.hdr; body=$home/ok.body + curl_bridge "$port" /login "$hdr" "$body" \ + --header "Origin: $ORIGIN" \ + --data "passcode=$pass" + assert_contains "$(head -n 1 "$hdr")" "303" "good passcode must redirect" + cookie=$(awk 'tolower($1)=="set-cookie:" {print substr($0, index($0,$2))}' "$hdr") + assert_contains "$cookie" "HttpOnly" "session cookie must be HttpOnly" + assert_contains "$cookie" "Secure" "session cookie must be Secure" + assert_contains "$cookie" "SameSite=Strict" "session cookie must be SameSite=Strict" + assert_contains "$cookie" "Path=/" "session cookie must stay on the bridge path" + case "$cookie" in + *[Dd]omain=*) fail "session cookie must be host-only, got: $cookie" ;; + esac + hdr=$home/obs.hdr; body=$home/obs.body + curl_bridge "$port" /api/observation "$hdr" "$body" \ + --header "Cookie: ${cookie%%;*}" + assert_contains "$(head -n 1 "$hdr")" "200" "authed observation must succeed" + assert_contains "$(cat "$hdr")" "Cache-Control: no-store" "observation must not be stored" + assert_contains "$(cat "$hdr")" "Referrer-Policy: no-referrer" "observation must set Referrer-Policy" + assert_contains "$(cat "$hdr")" "Content-Security-Policy:" "observation must set CSP" + printf '%s' "$(cat "$body")" | jq -e '.needs_you and .under_way and .just_finished and .waiting' >/dev/null \ + || fail "observation JSON missing buckets: $(cat "$body")" + assert_not_contains "$(cat "$body")" "ship-task" "observation must not leak task ids" + hdr=$home/page.hdr; body=$home/page.body + curl_bridge "$port" / "$hdr" "$body" --header "Cookie: ${cookie%%;*}" + assert_contains "$(cat "$body")" "Summary only. Do not approve from this page." \ + "glance page missing the summary-only warning" + assert_not_contains "$(cat "$body")" "http-equiv=\"refresh\"" "page must not use meta-refresh" + jar=$home/cookies.txt + curl -sS --max-time 8 --resolve "$HOST_NAME:$port:127.0.0.1" \ + --cookie-jar "$jar" --header "Origin: $ORIGIN" --data "passcode=$pass" \ + -o /dev/null "http://$HOST_NAME:$port/login" + python3 - "$home/port-8765.headers" <<'PY' & +import pathlib, socket, sys +out = pathlib.Path(sys.argv[1]) +sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) +sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) +sock.bind(("127.0.0.1", 0)) +(out.parent / "port-8765.port").write_text(str(sock.getsockname()[1])) +sock.listen(1) +conn, _ = sock.accept() +conn.settimeout(2) +data = conn.recv(8192) +out.write_bytes(data) +conn.sendall(b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n") +conn.close() +sock.close() +PY + sink_pid=$! + BRIDGE_PIDS+=("$sink_pid") + n=0 + while [ "$n" -lt 30 ] && [ ! -f "$home/port-8765.port" ]; do + sleep 0.1 + n=$((n + 1)) + done + sink_port=$(cat "$home/port-8765.port") + curl -sS --max-time 8 --noproxy "$HOST_NAME" \ + --connect-to "$HOST_NAME:8765:127.0.0.1:$sink_port" \ + --cookie "$jar" -o /dev/null "http://$HOST_NAME:8765/" + wait "$sink_pid" + assert_not_contains "$(cat "$home/port-8765.headers")" "Cookie:" \ + "Secure bridge cookie was sent to the HTTP service on port 8765" + token=${cookie%%;*} + token=${token#*=} + sessions=$home/bridge/sessions.json + python3 - "$sessions" "$token" <<'PY' +import json, pathlib, sys +path = pathlib.Path(sys.argv[1]) +data = json.loads(path.read_text()) +data["sessions"][sys.argv[2]]["expires"] = 0 +path.write_text(json.dumps(data)) +PY + hdr=$home/expired.hdr; body=$home/expired.body + curl_bridge "$port" /api/observation "$hdr" "$body" --header "Cookie: ${cookie%%;*}" + assert_contains "$(head -n 1 "$hdr")" "401" "expired session must be rejected" + hdr=$home/revoked.hdr; body=$home/revoked.body + curl_bridge "$port" /login "$hdr" "$body" \ + --header "Origin: $ORIGIN" --data "passcode=$pass" + cookie=$(awk 'tolower($1)=="set-cookie:" {print substr($0, index($0,$2)); exit}' "$hdr") + hdr=$home/revoked.hdr; body=$home/revoked.body + FM_HOME="$home" "$BRIDGE" revoke-sessions >/dev/null + curl_bridge "$port" /api/observation "$hdr" "$body" --header "Cookie: ${cookie%%;*}" + assert_contains "$(head -n 1 "$hdr")" "401" "revoked session must be rejected" + pass "passcode login, security headers, host-only cookie, and revoke all work" +} + +test_snapshot_subprocess_does_not_write_fleet_state() { + local home fakebin port before after hdr body spies + home=$(make_home readonly) + fakebin=$(make_fakebin "$home") + spies=$home/spies + mkdir -p "$spies" + for cmd in fm-lock.sh fm-wake-drain.sh fm-afk-launch.sh; do + cat > "$spies/$cmd" <<'SH' +#!/usr/bin/env bash +printf 'spy:%s\n' "$(basename "$0")" >> "$(dirname "$0")/../state/spy.log" +exit 0 +SH + chmod +x "$spies/$cmd" + done + init_passcode "$home" >/dev/null + before=$(fingerprint "$home") + port=$(start_bridge "$home" "$fakebin") + hdr=$home/login.hdr; body=$home/login.body + pass=$(cat "$home/data/bridge-view-passcode.txt") + curl_bridge "$port" /login "$hdr" "$body" \ + --header "Origin: $ORIGIN" --data "passcode=$pass" + cookie=$(awk 'tolower($1)=="set-cookie:" {print substr($0, index($0,$2)); exit}' "$hdr") + hdr=$home/obs.hdr; body=$home/obs.body + PATH="$spies:$PATH" curl_bridge "$port" /api/observation "$hdr" "$body" \ + --header "Cookie: ${cookie%%;*}" + assert_contains "$(head -n 1 "$hdr")" "200" "observation failed: $(cat "$hdr") $(cat "$body")" + after=$(fingerprint "$home") + [ "$before" = "$after" ] || fail "observation mutated fleet files" + assert_absent "$home/state/spy.log" "snapshot PATH spies were invoked" + assert_absent "$home/state/.watch.lock" "bridge took a session lock" + pass "snapshot subprocess does not write fleet state or take the session lock" +} + +test_snapshot_output_is_bounded_during_capture() { + local home fixture output child_pid rc=0 + home=$(make_home snapshot-cap) + fixture=$home/root + mkdir -p "$fixture/bin" + cat > "$fixture/bin/fm-bearings-snapshot.sh" <<'SH' +#!/usr/bin/env bash +(sleep 300) & +printf '%s\n' "$!" > "$FM_HOME/oversized-child.pid" +head -c 2000000 /dev/zero +SH + chmod +x "$fixture/bin/fm-bearings-snapshot.sh" + output=$(python3 - "$ROOT/bin/fm-bridge-view.py" "$home" "$fixture" 2>&1 <<'PY' +import importlib.util, pathlib, sys +spec = importlib.util.spec_from_file_location("fm_bridge_view", sys.argv[1]) +module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(module) +try: + module.run_snapshot(pathlib.Path(sys.argv[2]), pathlib.Path(sys.argv[3])) +except RuntimeError as exc: + if "exceeded size cap" in str(exc): + raise SystemExit(0) + raise +raise SystemExit("snapshot unexpectedly completed") +PY + ) || rc=$? + expect_code 0 "$rc" "oversized snapshot must be stopped during capture: $output" + child_pid=$(cat "$home/oversized-child.pid") + kill -0 "$child_pid" 2>/dev/null && fail "oversized snapshot left descendant $child_pid running" + pass "snapshot output is bounded during capture" +} + +test_snapshot_requests_all_in_flight_rows() { + local home fixture output + home=$(make_home snapshot-all-in-flight) + fixture=$home/root + mkdir -p "$fixture/bin" + cat > "$fixture/bin/fm-bearings-snapshot.sh" <<'SH' +#!/usr/bin/env bash +case " $* " in + *' --all-in-flight '*) printf '%s\n' '{"schema":"fm-bearings.v1"}' ;; + *) exit 9 ;; +esac +SH + chmod +x "$fixture/bin/fm-bearings-snapshot.sh" + output=$(python3 - "$ROOT/bin/fm-bridge-view.py" "$home" "$fixture" <<'PY' +import importlib.util, pathlib, sys +spec = importlib.util.spec_from_file_location("fm_bridge_view", sys.argv[1]) +module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(module) +module.run_snapshot(pathlib.Path(sys.argv[2]), pathlib.Path(sys.argv[3])) +PY + ) || fail "bridge snapshot did not request every in-flight row: $output" + pass "snapshot requests all in-flight rows before bucket classification" +} + +test_session_revoke_serializes_with_login_create() { + local output + output=$(python3 - "$ROOT/bin/fm-bridge-view.py" "$TMP_ROOT/session-race" <<'PY' +import importlib.util, multiprocessing, pathlib, sys, time +spec = importlib.util.spec_from_file_location("fm_bridge_view", sys.argv[1]) +module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(module) +path = pathlib.Path(sys.argv[2]) / "sessions.json" +path.parent.mkdir(parents=True) +context = multiprocessing.get_context("fork") +started = context.Event() +release = context.Event() + +class PausingStore(module.SessionStore): + def _load(self): + data = super()._load() + started.set() + release.wait(5) + return data + +creator = context.Process(target=lambda: PausingStore(path).create()) +revoker = context.Process(target=lambda: module.SessionStore(path).revoke_all()) +creator.start() +if not started.wait(5): + raise SystemExit("creator did not reach the serialized update") +revoker.start() +time.sleep(0.2) +if not revoker.is_alive(): + raise SystemExit("revoke did not wait for the in-flight session update") +release.set() +creator.join(5) +revoker.join(5) +if creator.exitcode or revoker.exitcode: + raise SystemExit(f"child failure: create={creator.exitcode} revoke={revoker.exitcode}") +if module.SessionStore(path)._load()["sessions"]: + raise SystemExit("revoke left a concurrently created session valid") +PY + ) || fail "session revoke/create serialization failed: $output" + pass "session revoke serializes across processes with login creation" +} + +test_away_mode_passive_refresh_works() { + local home fakebin port pass hdr body cookie rc=0 + home=$(make_home away) + fakebin=$(make_fakebin "$home") + date '+%s' > "$home/state/.afk" + PATH="$fakebin:$PATH" FM_HOME="$home" "$ROOT/bin/fm-bearings-snapshot.sh" --json >/dev/null 2>&1 || rc=$? + expect_code 3 "$rc" "ordinary bearings must still refuse while away" + pass=$(init_passcode "$home") + port=$(start_bridge "$home" "$fakebin") + hdr=$home/login.hdr; body=$home/login.body + curl_bridge "$port" /login "$hdr" "$body" \ + --header "Origin: $ORIGIN" --data "passcode=$pass" + cookie=$(awk 'tolower($1)=="set-cookie:" {print substr($0, index($0,$2)); exit}' "$hdr") + hdr=$home/obs.hdr; body=$home/obs.body + curl_bridge "$port" /api/observation "$hdr" "$body" --header "Cookie: ${cookie%%;*}" + assert_contains "$(head -n 1 "$hdr")" "200" "passive observation must work while away: $(cat "$body")" + printf '%s' "$(cat "$body")" | jq -e '.under_way.items | any(.title == "VoiceLoop tap trigger")' >/dev/null \ + || fail "away-mode observation lost live work titles: $(cat "$body")" + pass "away-mode passive refresh works while ordinary Bearings still refuses" +} + +test_mailbox_listener_never_consumes_announcements() { + local home + home=$(make_home mailbox) + python3 - "$home" <<'PY' & +import socket, pathlib, sys +home = pathlib.Path(sys.argv[1]) +sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) +sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) +sock.bind(("127.0.0.1", 0)) +port = sock.getsockname()[1] +(home / "mb.port").write_text(str(port)) +sock.settimeout(3) +sock.listen(1) +try: + conn, _ = sock.accept() + data = conn.recv(4096) + (home / "mb.got").write_bytes(data) + conn.close() +except Exception: + (home / "mb.got").write_bytes(b"") +sock.close() +PY + BRIDGE_PIDS+=("$!") + n=0 + while [ "$n" -lt 30 ] && [ ! -f "$home/mb.port" ]; do + sleep 0.1 + n=$((n + 1)) + done + FM_BRIDGE_VIEW_TEST=1 FM_BRIDGE_VIEW_MAILBOX_PORT="$(cat "$home/mb.port")" \ + FM_BRIDGE_VIEW_LAUNCHCTL=/usr/bin/false \ + "$BRIDGE" mailbox-listener >/dev/null + sleep 0.3 + if [ -f "$home/mb.got" ]; then + assert_not_contains "$(cat "$home/mb.got")" "GET /v1/announcements" \ + "mailbox listener check consumed announcements" + [ ! -s "$home/mb.got" ] || assert_not_contains "$(cat "$home/mb.got")" "HTTP/" \ + "mailbox listener check sent HTTP" + fi + pass "mailbox listener check never calls GET /v1/announcements" +} + +test_render_plist_keep_alive_pattern() { + local home out + home=$(make_home plist) + out=$(FM_HOME="$home" "$BRIDGE" render-plist) + assert_contains "$out" "com.firstmate.bridge-view" "plist missing launchd label" + assert_contains "$out" "KeepAlive" "plist missing KeepAlive" + assert_contains "$out" "RunAtLoad" "plist missing RunAtLoad" + assert_contains "$out" "fm-bridge-view.sh" "plist must launch the tracked wrapper" + assert_contains "$out" "/opt/homebrew/bin" "plist PATH must include Homebrew python3" + pass "launchd plist uses the KeepAlive pattern" +} + +test_bind_is_loopback_constant +test_funnel_on_refuses_to_serve +test_funnel_off_serve_starts +test_funnel_off_legacy_serve_output_starts +test_unverifiable_funnel_refuses_to_serve +test_lan_and_unauthorized_hosts_are_rejected +test_auth_cookie_headers_and_isolation +test_snapshot_subprocess_does_not_write_fleet_state +test_snapshot_output_is_bounded_during_capture +test_snapshot_requests_all_in_flight_rows +test_session_revoke_serializes_with_login_create +test_away_mode_passive_refresh_works +test_mailbox_listener_never_consumes_announcements +test_render_plist_keep_alive_pattern diff --git a/tests/fm-home-port.test.sh b/tests/fm-home-port.test.sh index 14fced41cbf..def880c3a0f 100755 --- a/tests/fm-home-port.test.sh +++ b/tests/fm-home-port.test.sh @@ -86,6 +86,9 @@ test_export_copies_portable_only() { printf 'clone\n' > "$home/projects/README" printf 'registry\n' > "$home/data/projects.md" printf 'synthetic-capability-proof\n' > "$home/config/action-captain-secret" + mkdir -p "$home/bridge" + printf 'scrypt-hash\n' > "$home/bridge/passcode.hash" + printf 'one-shot-passcode\n' > "$home/data/bridge-view-passcode.txt" printf 'interval_seconds = 604800\n' > "$home/config/upstream-watch" mkdir -p "$dest" @@ -95,6 +98,8 @@ test_export_copies_portable_only() { assert_contains "$out" 'REFUSED: state/' "export did not loudly refuse state/" assert_contains "$out" 'REFUSED: projects/' "export did not loudly refuse projects/" assert_contains "$out" 'REFUSED: config/action-captain-secret' "export did not loudly refuse action capability secret" + assert_contains "$out" 'REFUSED: bridge/' "export did not loudly refuse bridge secrets" + assert_contains "$out" 'REFUSED: data/bridge-view-passcode.txt' "export did not loudly refuse the one-shot bridge passcode" assert_contains "$out" 'PORTABLE: data/captain.md' "export missed captain.md" assert_contains "$out" 'EXPORT_OK:' "export missed EXPORT_OK" @@ -107,6 +112,8 @@ test_export_copies_portable_only() { assert_present "$dest/config/upstream-watch" "manifest-declared upstream-watch config not exported" assert_absent "$dest/.env" ".env must not be exported" assert_absent "$dest/config/action-captain-secret" "action capability secret must not be exported" + assert_absent "$dest/bridge" "bridge/ must not be exported" + assert_absent "$dest/data/bridge-view-passcode.txt" "bridge passcode envelope must not be exported" assert_absent "$dest/state" "state/ must not be exported" assert_absent "$dest/projects" "projects/ must not be exported" assert_absent "$dest/data/projects.md" "projects.md must not be exported"