Skip to content

feat: add phone-first read-only fleet bridge - #84

Merged
Amplify-Logic merged 10 commits into
mainfrom
fm/fm-bridge-view-slice1-b1
Aug 19, 2026
Merged

feat: add phone-first read-only fleet bridge#84
Amplify-Logic merged 10 commits into
mainfrom
fm/fm-bridge-view-slice1-b1

Conversation

@Amplify-Logic

@Amplify-Logic Amplify-Logic commented Aug 19, 2026

Copy link
Copy Markdown
Owner

Intent

Ship slice 1 of the captain's phone-first read-only fleet page (Starship bridge view), exactly as specified in the accepted scout report. Do not reopen the closed forks: passcode not accounts, Tailscale Serve never Funnel never 0.0.0.0, read-only with no tap-to-act.

Required deliverables:

  1. Named Bearings --passive-view mode that is allowed while away mode is on; ordinary Bearings chat use must still refuse during away mode. Add a captain-facing title field to in-flight rows.
  2. A small Python 3.12 stdlib HTTP server on literal 127.0.0.1 only, published with Tailscale Serve HTTPS at https://larss-macbook-pro-2.taile26864.ts.net/. Never Funnel, never bind 0.0.0.0. launchd KeepAlive plist is a documented local setup action. Passcode login with a dedicated high-entropy passcode (not the glasses relay token), salted scrypt hash in a 0700 bridge/ directory, HttpOnly Secure SameSite=Strict host-only session cookie, ~7-day TTL, logout and revoke, Host and login Origin checks, Cache-Control no-store, nonced CSP, Referrer-Policy no-referrer, allowlisted GitHub PR links only.
  3. Phone-first page with four buckets (Needs you / Under way / Just finished / Waiting in the wings) from the passive Bearings observation; caps with honest N more; mailbox LISTENER via local listen or launchd never GET /v1/announcements; client fetch on interval plus pageshow/visibility; full-screen stale overlay when refreshes stop; server snapshot cache ~30s, one refresh in flight, timeout and size cap. The server must never take the session lock, drain wakes, or write backlog/state.
  4. Tests must cover Funnel on refuses to serve; Funnel off with Serve HTTPS claimed still starts; unauthorized/LAN access rejected; cookie never sent to port 8765; snapshot subprocess cannot write fleet state; away-mode passive refresh works while ordinary Bearings still refuses; auth (bad passcode, expired/revoked session); headers present.

Accepted later constraints that remain in force:

  • Serve was enabled with HTTPS certificates only; Funnel was explicitly not enabled and must stay off.
  • Keep the Funnel-off test.
  • A tailnet-only Serve HTTPS proxy is Funnel off and is the supported publish path; do not treat a claimed Serve config as Funnel merely because funnel status prints https://.
  • Bookmark remains https://larss-macbook-pro-2.taile26864.ts.net/.
  • The Stock macOS Bash snapshot must expect 45 Bearings tests, not 42, because three title and away-mode cases were added; 45 is the correct count.
  • No new frameworks. Python 3.12 stdlib only. Do not write under projects/.

What Changed

  • Add a phone-first, read-only Starship bridge view with four fleet-status buckets, bounded passive Bearings snapshots, automatic refresh, and stale-state handling.
  • Add a localhost-only Python HTTP service with passcode authentication, hardened sessions and response headers, Tailscale Serve safety checks, launchd setup support, and logout/session revocation.
  • Extend Bearings, home-port, CI, documentation, and regression coverage for passive away-mode access, captain-facing titles, authentication boundaries, Funnel refusal, and snapshot isolation.

Risk Assessment

✅ Low: The three prior issues are resolved with explicit Funnel markers, complete in-flight projection before bucket capping, and cross-process atomic session-store serialization; no additional material defects were found.

Testing

All dedicated bridge, 45-case Bearings, and home-port suites passed; end-to-end checks covered Funnel/Serve handling, loopback and unauthorized access, authentication and session expiry/revocation, secure cookie isolation, security headers, passive away-mode observation, read-only snapshot confinement, mailbox behavior, and the rendered mobile login and four-bucket fleet experience.

  • Evidence: Mobile passcode login (local file: /var/folders/1g/hctp3vpn27b1zrlsn4nsfg680000gn/T/no-mistakes-evidence/01M0CPDNKE0R95ZRH81HP76GED/bridge-login-mobile.png)
  • Evidence: Authenticated phone-first fleet view (local file: /var/folders/1g/hctp3vpn27b1zrlsn4nsfg680000gn/T/no-mistakes-evidence/01M0CPDNKE0R95ZRH81HP76GED/bridge-authenticated-mobile.png)
Evidence: Interactive login surface
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="referrer" content="no-referrer">
<title>STARSHIP</title>
<style nonce="evidence">
: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; }
</style>
</head>
<body>
<main>
<h1>Starship</h1>
<p>Log in</p>

<form method="post" action="/login" autocomplete="current-password">
<label for="passcode">Passcode</label>
<input id="passcode" name="passcode" type="password" required>
<button type="submit">Continue</button>
</form>
<p class="note">Only on your private network.</p>
</main>
</body>
</html>
Evidence: Interactive authenticated fleet surface
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="referrer" content="no-referrer">
<title>STARSHIP</title>
<style nonce="evidence">
: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; }
</style>
<script nonce="evidence">
const STALE_MS = 90 * 1000;
const REFRESH_MS = 30 * 1000;
let lastSuccess = Date.now();
function esc(value) {
  return String(value).replace(/[&<>"']/g, function(ch) {
    return ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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
      ? '<a href="' + safeUrl + '" rel="noreferrer">' + title + '</a>'
      : title;
    return '<li><span class="dot ' + dotClass(item.dot) + '"></span>' + label + '</li>';
  });
  let extra = '';
  if (bucket.more > 0) extra += '<p class="more">' + bucket.more + ' more waiting</p>';
  if (bucket.incomplete) extra += '<p class="incomplete">This list may be incomplete.</p>';
  if (!items.length) {
    extra = '<p class="' + (bucket.incomplete ? 'incomplete' : 'empty') + '">' +
      (bucket.incomplete ? 'This list may be incomplete.' : emptyText) + '</p>' +
      (bucket.more > 0 ? '<p class="more">' + bucket.more + ' more waiting</p>' : '');
  }
  root.innerHTML = (lines.length ? '<ul>' + lines.join('') + '</ul>' : '') + 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(); });
});
</script>
</head>
<body>
<div id="stale">Cannot reach the desk.</div>
<main>
<header>
  <h1>Starship</h1>
  <form class="logout" method="post" action="/logout"><button type="submit">Log out</button></form>
</header>
<p class="meta"><span id="desk">Desk reachable</span> · <span id="mailbox">Mailbox…</span></p>
<p class="meta" id="observed">Observed just now</p>
<p class="warn">Summary only. Do not approve from this page.</p>
<h2>Needs you</h2>
<div id="needs"><p class="empty">Loading…</p></div>
<h2>Under way</h2>
<div id="underway"><p class="empty">Loading…</p></div>
<h2>Just finished</h2>
<div id="finished"><p class="empty">Loading…</p></div>
<h2>Waiting in the wings</h2>
<div id="waiting"><p class="empty">Loading…</p></div>
</main>
<script nonce="evidence">document.addEventListener("DOMContentLoaded",function(){apply({"mailbox_listener": true, "needs_you": {"items": [{"title": "Choose the always-on cloud host and budget", "dot": "Needs you"}], "more": 0, "incomplete": false}, "under_way": {"items": [{"title": "VoiceLoop tap trigger", "dot": "Working"}, {"title": "Phone-first fleet bridge", "dot": "Reviewing", "url": "https://github.com/Amplify-Logic/firstmate/pull/84"}], "more": 0, "incomplete": false}, "just_finished": {"items": [{"title": "Spoken updates on the glasses", "dot": "Ready"}], "more": 0, "incomplete": false}, "waiting": {"items": [{"title": "Add Qwen to the fleet?", "dot": "Waiting"}, {"title": "Always-on cloud follow-up", "dot": "Waiting"}], "more": 3, "incomplete": false}});});</script></body>
</html>

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

🔧 **Review** - 3 issues found → auto-fixed ✅
  • 🚨 bin/fm-bridge-view.py:443 - The required constraint says “do not treat a claimed Serve config as Funnel merely because funnel status prints https://,” but this fallback returns Funnel-on for any successful status containing https://. If JSON status is unavailable or unsupported, a valid tailnet-only Serve configuration without the literal words “tailnet only” is incorrectly refused. Classify Funnel only from explicit Funnel evidence while continuing to fail closed on genuinely unverifiable output.
  • 🚨 bin/fm-bridge-view.py:340 - The required “caps with honest N more” contract is not met when Bearings truncates in-flight rows. extra_in_flight is assigned entirely to Under way, although omitted rows may be blocked, parked, or paused and therefore belong in Needs you or Waiting. Fetch all in-flight rows for the bounded subprocess projection, or expose per-state omitted counts before displaying bucket totals.
  • 🚨 bin/fm-bridge-view.py:974 - Session revocation races with the running server because the CLI and server use separate process-local locks around non-atomic read/modify/write operations. An in-flight login can load sessions, revoke-sessions can write an empty store, and that login can then save its stale copy plus a valid new session, defeating the lost-phone revoke guarantee. Use cross-process serialization and atomic replacement for session updates.

🔧 Fix: Fix bridge view review findings
✅ Re-checked - no issues remain.

✅ **Test** - passed

✅ No issues found.

  • bash tests/fm-bridge-view.test.sh
  • bash tests/fm-bearings-snapshot.test.sh
  • bash tests/fm-home-port.test.sh
  • Rendered login_html() and glance_html() from bin/fm-bridge-view.py with a representative four-bucket observation, then inspected both at a 390×844 phone viewport using chrome-devtools-axi.
  • Captured 390×844 PNG evidence with headless Chrome and visually inspected both screenshots.
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

Amplify-Logic and others added 10 commits August 19, 2026 10:11
Give Bearings a named passive-view mode and in-flight titles, then serve a
loopback Tailscale Serve glance so the phone can observe the fleet without
approving work or mixing logins with the glasses mailbox.

Co-authored-by: Cursor <cursoragent@cursor.com>
Point the new capability at the branch commit that added it so the
pre-merge provenance line resolves to this change rather than an
unrelated parent.

Co-authored-by: Cursor <cursoragent@cursor.com>
A claimed Serve HTTPS proxy reports https:// in funnel status, which
blocked the loopback server. Keep refusing when Funnel is actually on.

Co-authored-by: Cursor <cursoragent@cursor.com>
KeepAlive would otherwise miss Homebrew python3 and never bind loopback.

Co-authored-by: Cursor <cursoragent@cursor.com>
Three new title and away-mode cases landed; the macOS snapshot count was still 42.

Co-authored-by: Cursor <cursoragent@cursor.com>
@Amplify-Logic Amplify-Logic changed the title feat(bin): add phone-first read-only fleet bridge feat: add phone-first read-only fleet bridge Aug 19, 2026
@Amplify-Logic
Amplify-Logic merged commit d9c9f75 into main Aug 19, 2026
14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant