Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions bin/backends/herdr.sh
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ fm_backend_herdr_workspace_label() {
fm_backend_herdr_cli() { # <session> <herdr-subcommand-and-args...>
local session=$1
shift
command -v herdr >/dev/null 2>&1 || return 1
HERDR_SESSION="$session" herdr "$@" --session "$session"
}

Expand Down Expand Up @@ -212,6 +213,9 @@ fm_backend_herdr_session() {
# call. Bounded poll for the server to report running.
fm_backend_herdr_server_ensure() { # <session>
local session=$1 running out i
# Validate before background launch so a missing CLI cannot enter the
# bounded readiness poll; tests/fm-backend-herdr.test.sh covers this guard.
fm_backend_herdr_tool_check || return 1
running=$(fm_backend_herdr_cli "$session" status --json 2>/dev/null | jq -r '.server.running // false' 2>/dev/null)
[ "$running" = "true" ] && return 0
( fm_backend_herdr_cli "$session" server >/dev/null 2>&1 & ) || return 1
Expand Down
159 changes: 137 additions & 22 deletions bin/fm-bridge-view.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
import re
import secrets
import selectors
import shutil
import signal
import socket
import subprocess
Expand Down Expand Up @@ -86,7 +87,89 @@
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"
ISO_DAY_RE = re.compile(r"^(\d{4}-\d{2}-\d{2})")
FILENAME_DAY_RE = re.compile(r"^(\d{4})(\d{2})(\d{2})T")
BASE_CHILD_PATH = "/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin:/opt/homebrew/bin"
CHILD_PATH_TOOLS = (
"jq",
"git",
"tmux",
"herdr",
"no-mistakes",
"tasks-axi",
"gh",
"node",
"timeout",
"gtimeout",
"perl",
)


def _append_unique_dir(dirs: List[str], seen: set[str], candidate: str) -> None:
if not candidate:
return
resolved = os.path.abspath(candidate)
if resolved in seen or not os.path.isdir(resolved):
return
seen.add(resolved)
dirs.append(resolved)


def _nvm_bin_from_home(home: str) -> str:
versions = os.path.join(home, ".nvm", "versions", "node")
named: List[str] = []
with_axi = ""
if os.path.isdir(versions):
try:
children = os.listdir(versions)
except OSError:
children = []
for name in sorted(children):
bin_dir = os.path.join(versions, name, "bin")
if not os.path.isdir(bin_dir):
continue
named.append(bin_dir)
if os.path.isfile(os.path.join(bin_dir, "tasks-axi")):
with_axi = bin_dir
if with_axi:
return with_axi
if named:
return named[-1]
env_bin = os.environ.get("NVM_BIN", "").strip()
if env_bin and os.path.isdir(env_bin):
return env_bin
return ""


def resolve_child_path(source_path: Optional[str] = None, home: Optional[str] = None) -> str:
"""Build a scrubbed PATH that still locates snapshot tools.

Keep the base system dirs, then add only the directories of tools the
snapshot actually needs, resolved from HOME and the parent PATH. Never
copy the rest of the parent environment.
"""
home_path = home if home is not None else os.environ.get("HOME", "")
user_dirs: List[str] = []
seen: set[str] = set()
if home_path:
_append_unique_dir(user_dirs, seen, os.path.join(home_path, ".local", "bin"))
_append_unique_dir(user_dirs, seen, _nvm_bin_from_home(home_path))
search = BASE_CHILD_PATH
if source_path is None:
source_path = os.environ.get("PATH", "")
if source_path:
search = source_path + ":" + BASE_CHILD_PATH
for tool in CHILD_PATH_TOOLS:
found = shutil.which(tool, path=search)
if found:
_append_unique_dir(user_dirs, seen, os.path.dirname(found))
base_dirs: List[str] = []
for part in BASE_CHILD_PATH.split(":"):
_append_unique_dir(base_dirs, seen, part)
return ":".join(user_dirs + base_dirs)


CHILD_PATH = resolve_child_path()

TEST_MODE = os.environ.get("FM_BRIDGE_VIEW_TEST") == "1"

Expand Down Expand Up @@ -301,6 +384,22 @@ def extract_upload(content_type: str, body: bytes) -> Optional[Tuple[bytes, str,
return None


def sidecar_received_day(path: Path, name: str) -> str:
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, UnicodeError, json.JSONDecodeError):
data = None
if isinstance(data, dict):
received = str(data.get("received_at") or "").strip()
match = ISO_DAY_RE.match(received)
if match:
return match.group(1)
match = FILENAME_DAY_RE.match(name)
if match:
return f"{match.group(1)}-{match.group(2)}-{match.group(3)}"
return ""


def photos_received_today(home: Path) -> int:
inbox = inbox_dir(home)
if not inbox.is_dir() or inbox.is_symlink():
Expand All @@ -317,14 +416,7 @@ def photos_received_today(home: Path) -> int:
path = inbox / name
if path.is_symlink() or not path.is_file():
continue
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, UnicodeError, json.JSONDecodeError):
continue
if not isinstance(data, dict):
continue
received = str(data.get("received_at") or "")
if received.startswith(today):
if sidecar_received_day(path, name) == today:
count += 1
return count

Expand Down Expand Up @@ -956,7 +1048,7 @@ def security_headers(nonce: str) -> List[Tuple[str, str]]:
PAGE_JS = """
const STALE_MS = %d * 1000;
const REFRESH_MS = %d * 1000;
let lastSuccess = Date.now();
let lastSuccess = 0;
function esc(value) {
return String(value).replace(/[&<>"']/g, function(ch) {
return ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[ch]);
Expand All @@ -967,6 +1059,19 @@ def security_headers(nonce: str) -> List[Tuple[str, str]]:
if (!el) return;
el.classList.toggle('on', on);
}
function setPhotoCount(n) {
const count = document.getElementById('photo-count');
if (count) count.textContent = 'Photos received today: ' + n;
}
function markBucketsUnreachable() {
['needs','underway','finished','waiting'].forEach(function(id) {
const root = document.getElementById(id);
if (!root) return;
if (root.textContent.indexOf('Loading') !== -1) {
root.innerHTML = '<p class="empty">Cannot reach the desk.</p>';
}
});
}
function dotClass(name) {
if (name === 'Needs you') return 'needs';
if (name === 'Under way') return 'under';
Expand Down Expand Up @@ -1012,8 +1117,7 @@ def security_headers(nonce: str) -> List[Tuple[str, str]]:
renderBucket('underway', data.under_way, 'Nothing is under way.');
renderBucket('finished', data.just_finished, 'No recent completions.');
renderBucket('waiting', data.waiting, 'Nothing is waiting.');
const count = document.getElementById('photo-count');
if (count) count.textContent = 'Photos received today: ' + (data.photos_today || 0);
setPhotoCount(data.photos_today != null ? data.photos_today : 0);
}
async function sendPhoto(ev) {
ev.preventDefault();
Expand Down Expand Up @@ -1045,10 +1149,7 @@ def security_headers(nonce: str) -> List[Tuple[str, str]]:
status.className = 'ok';
status.textContent = 'Photo received.';
input.value = '';
const count = document.getElementById('photo-count');
if (count && payload.received_today != null) {
count.textContent = 'Photos received today: ' + payload.received_today;
}
if (payload.received_today != null) setPhotoCount(payload.received_today);
} catch (err) {
status.className = 'warn';
status.textContent = 'Send failed.';
Expand All @@ -1057,6 +1158,7 @@ def security_headers(nonce: str) -> List[Tuple[str, str]]:
function tickObserved() {
const age = document.getElementById('observed');
if (!age) return;
if (!lastSuccess) 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);
Expand All @@ -1067,10 +1169,17 @@ def security_headers(nonce: str) -> List[Tuple[str, str]]:
const timer = setTimeout(function() { ctl.abort(); }, 10000);
const res = await fetch('/api/observation', { credentials: 'same-origin', cache: 'no-store', signal: ctl.signal });
clearTimeout(timer);
const payload = await res.json().catch(function() { return {}; });
if (payload && payload.photos_today != null) setPhotoCount(payload.photos_today);
if (!res.ok) throw new Error('status ' + res.status);
apply(await res.json());
apply(payload);
} catch (err) {
tickObserved();
if (!lastSuccess) {
setStale(true);
markBucketsUnreachable();
} else {
tickObserved();
}
}
}
document.addEventListener('DOMContentLoaded', function() {
Expand Down Expand Up @@ -1132,8 +1241,8 @@ def glance_html(nonce: str) -> str:
<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="meta"><span id="desk">Checking the desk</span> · <span id="mailbox">Mailbox…</span></p>
<p class="meta" id="observed">Observing…</p>
<p class="warn">Summary only. Do not approve from this page.</p>
<h2>Send a photo</h2>
<form id="photo-form" method="post" action="/upload" enctype="multipart/form-data">
Expand Down Expand Up @@ -1342,12 +1451,18 @@ def do_GET(self) -> None: # noqa: N802
if not self._authed():
self._send(401, b'{"error":"unauthorized"}\n', "application/json")
return
photos_today = photos_received_today(STATE.home)
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")
body = json.dumps({
"error": "desk unreachable",
"detail": str(exc),
"photos_today": photos_today,
}).encode("utf-8")
self._send(503, body, "application/json")
return
payload["photos_today"] = photos_received_today(STATE.home)
payload["photos_today"] = photos_today
body = json.dumps(payload).encode("utf-8")
self._send(200, body, "application/json")
return
Expand Down
11 changes: 4 additions & 7 deletions bin/fm-crew-state.sh
Original file line number Diff line number Diff line change
Expand Up @@ -142,17 +142,14 @@ LOG_VERB=$(status_line_verb "$LOG_LINE")
# pane_readable is consulted ONLY in the no-run fallback below. The run-step path
# stays authoritative regardless of pane liveness - judge by the run-step, not the
# shell - so a finished crew whose endpoint has closed still reports its run-step
# state (e.g. done) instead of being masked as unknown. Backend-aware
# (fm_backend_of_meta defaults absent backend= to tmux, the P1 contract): a
# herdr task is read through fm_backend_capture instead of a bare tmux probe.
# state (e.g. done) instead of being masked as unknown. Use the shared
# read-only existence check (fm_backend_target_exists): observation must not
# start a session-provider server as a side effect.
TASK_BACKEND=$(fm_backend_of_meta "$META")
BACKEND_TARGET=$(fm_backend_target_of_meta "$META")
EXPECTED_LABEL="fm-$ID"
pane_readable() { # <target>
case "$TASK_BACKEND" in
tmux) tmux display-message -p -t "$1" '#{pane_id}' >/dev/null 2>&1 ;;
*) fm_backend_capture "$TASK_BACKEND" "$1" 1 "$EXPECTED_LABEL" >/dev/null 2>&1 ;;
esac
fm_backend_target_exists "$TASK_BACKEND" "$1" "$EXPECTED_LABEL"
}
# crew_pane_is_busy: the busy-signature fallback, backend-aware the same way -
# fm_backend_busy_state's native semantic state (herdr's agent.get) when
Expand Down
11 changes: 7 additions & 4 deletions docs/bridge-view.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,18 +75,21 @@ launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.firstmate.bridge-vie
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.
The snapshot child keeps a scrubbed environment (no parent secrets) but resolves tool directories at server start from HOME and the parent PATH, including `~/.local/bin` and a discovered nvm node bin, so CLIs such as herdr and tasks-axi are found without a version-specific hardcoded path.
A missing session-provider CLI must fail immediately rather than polling; the snapshot either returns glance data or a 503 with a clear error within seconds.
It never takes the session lock, never drains wakes, and never writes backlog or state.
Photo drops are the exception write path documented below; observation GETs still only read.
A 503 still includes today's photo count from inbox sidecars so the counter does not depend on glance data loading.

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.
If the first observation request fails, the buckets replace "Loading…" with "Cannot reach the desk" immediately and the full-page overlay appears.
If refreshes stop for 90 seconds after a successful load, 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.

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

Login and logout POSTs remain the authentication writes.
Expand All @@ -103,7 +106,7 @@ Names never overwrite; the directory is quarantined storage only.
The server does not execute, decode, or otherwise parse image contents beyond the magic-byte sniff.
Nothing else in the bridge process writes outside `bridge/` and this inbox.

The glance page exposes a phone-first file input (`accept` images, no `capture` attribute so iOS Safari offers Photo Library as well as Take Photo) and submit control, a success or failure message, and the count of photos received today (UTC date of `received_at`).
The glance page exposes a phone-first file input (`accept` images, no `capture` attribute so iOS Safari offers Photo Library as well as Take Photo) and submit control, a success or failure message, and the count of photos received today (UTC date of `received_at`, falling back to the timestamped sidecar filename when that field is absent or unreadable).
Existing `form-action 'self'` and `connect-src 'self'` CSP directives cover the form and `fetch`; scripts and styles stay nonce-based.

There is still no approve, answer, merge, or spawn control on this page.
15 changes: 15 additions & 0 deletions tests/fm-backend-herdr.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,20 @@ test_version_check_refuses_missing_herdr() {
pass "fm_backend_herdr_version_check: refuses loudly when herdr is not installed"
}

test_server_ensure_fails_fast_when_herdr_missing() {
local dir out status started elapsed
dir="$TMP_ROOT/server-ensure-missing"; mkdir -p "$dir/empty-fakebin"
started=$(python3 -c 'import time; print(time.monotonic())')
out=$( PATH="$dir/empty-fakebin:/usr/bin:/bin" \
bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_server_ensure missinglab' "$ROOT" 2>&1 )
status=$?
elapsed=$(python3 -c "import time; print(time.monotonic() - $started)")
[ "$status" -ne 0 ] || fail "server_ensure should refuse when herdr is not installed: $out"
python3 -c "import sys; sys.exit(0 if float(sys.argv[1]) < 2 else 1)" "$elapsed" \
|| fail "server_ensure hung ${elapsed}s when herdr was missing: $out"
pass "fm_backend_herdr_server_ensure: fails immediately when herdr is not installed"
}

# --- workspace_label: per-firstmate-HOME resolution (P3, herdr-sm-spaces-k4) -

test_workspace_label_primary_home_no_marker() {
Expand Down Expand Up @@ -2287,6 +2301,7 @@ test_list_live_hidden_legacy_and_other_home_boundary() {
test_version_check_accepts_current_protocol
test_version_check_refuses_old_protocol
test_version_check_refuses_missing_herdr
test_server_ensure_fails_fast_when_herdr_missing
test_workspace_label_primary_home_no_marker
test_workspace_label_secondmate_home_uses_marker_id
test_workspace_label_secondmate_marker_trims_whitespace
Expand Down
Loading
Loading