diff --git a/.agents/skills/bearings/SKILL.md b/.agents/skills/bearings/SKILL.md index 2365b9262..3ad3d57dd 100644 --- a/.agents/skills/bearings/SKILL.md +++ b/.agents/skills/bearings/SKILL.md @@ -43,7 +43,7 @@ It never tears down a task, merges a PR, dispatches new work, or mutates any tas This is the required artifact; it lives in gitignored `data/`. If today's file already exists, delete it first, then create a new file from scratch. - The chat response is the concise four-section digest defined by the contract below: materially shorter than the report file, complete as a current snapshot, internally consistent with the file, and linked to that file for the full picture. - - For a richer review surface, optionally offer a Lavish board with `lavish-axi` when the report has enough structure to deserve one, but the markdown file is the required artifact and the four-section chat digest is the required minimum. + - For a richer review surface, optionally offer a Lavish board with `bin/fm-lavish.sh` when the report has enough structure to deserve one, but the markdown file is the required artifact and the four-section chat digest is the required minimum. ## Chat-response contract diff --git a/.agents/skills/bootstrap-diagnostics/SKILL.md b/.agents/skills/bootstrap-diagnostics/SKILL.md index 31cc96767..7ddd5e18c 100644 --- a/.agents/skills/bootstrap-diagnostics/SKILL.md +++ b/.agents/skills/bootstrap-diagnostics/SKILL.md @@ -2,7 +2,7 @@ name: bootstrap-diagnostics description: >- Agent-only handling playbook for session-start bootstrap diagnostics. - Use whenever the session-start digest's bootstrap section prints an actionable diagnostic line - MISSING, MISSING_MANUAL, BACKEND_INVALID, ROLE_INVALID, ROLE_OVERLAY_MISSING, NEEDS_GH_AUTH, TANGLE, SELF_DRIFT, CREW_DISPATCH invalid, CURRENCY_BASE, BACKLOG_STALE, BACKLOG_UNREADABLE, FLEET_SYNC, PR_CHECK_MIGRATION, SECONDMATE_SYNC, SECONDMATE_LIVENESS, NUDGE_SECONDMATES, AXI_SUITE_UPDATED, AXI_SUITE_REVIEW, AXI_SUITE_STUCK, FIRSTMATE_UPDATE_AVAILABLE, FIRSTMATE_UPDATE_STUCK, FORK_SYNC, FORK_SYNC_STUCK, WATCHER_UNIT, FREQUENCY_MONITOR_UNIT, or FMX - or when a standalone bin/fm-bootstrap.sh run prints one of those lines. + Use whenever the session-start digest's bootstrap section prints an actionable diagnostic line - MISSING, MISSING_MANUAL, BACKEND_INVALID, ROLE_INVALID, ROLE_OVERLAY_MISSING, NEEDS_GH_AUTH, TANGLE, SELF_DRIFT, CREW_DISPATCH invalid, CURRENCY_BASE, LAVISH_ACCESS, BACKLOG_STALE, BACKLOG_UNREADABLE, FLEET_SYNC, PR_CHECK_MIGRATION, SECONDMATE_SYNC, SECONDMATE_LIVENESS, NUDGE_SECONDMATES, AXI_SUITE_UPDATED, AXI_SUITE_REVIEW, AXI_SUITE_STUCK, FIRSTMATE_UPDATE_AVAILABLE, FIRSTMATE_UPDATE_STUCK, FORK_SYNC, FORK_SYNC_STUCK, WATCHER_UNIT, FREQUENCY_MONITOR_UNIT, or FMX - or when a standalone bin/fm-bootstrap.sh run prints one of those lines. A silent bootstrap section, or a BOOTSTRAP_INFO fact, means no skill load. user-invocable: false metadata: @@ -71,6 +71,11 @@ When any diagnostic needs captain attention, report the plain consequence and re - `CURRENCY_BASE: config/ is unusable - ; ` - the home configured a comparison base for one of the two upstream checks, but the file cannot be used, so that check refuses rather than silently comparing against the wrong source. Fix the named file to one non-empty git URL or absolute path, or remove it to fall back to the canonical upstream template; `docs/configuration.md` "Upstream firstmate and curated-fork checks" owns which check reads which file and why they are separate. Until it is fixed, treat that check's currency signal as absent rather than as evidence the deployment is current. +- `LAVISH_ACCESS: open review board link(s) still point at this machine only ...` - this vessel has a tailnet, so those boards could be reachable, but the links already handed over open nothing on the captain's own devices. + Nothing is broken and nothing is at risk; the boards work locally, and the failure is silent precisely because they look correct here. + Reopen each affected board with `bin/fm-lavish.sh `, which moves it onto this vessel's tailnet address and prints the new link, then give the captain that new link if he was already sent the old one. + Do not hand-set the `LAVISH_AXI_*` variables to silence this: a server that is already running keeps emitting the hostname it was started with, so only reopening through the wrapper actually changes the link. + The check is detect-only, stays silent on a host with no tailnet, and repeats each session start until the affected sessions are reopened or ended. - `BACKLOG_STALE: task has ; fix: ` - a current durable backlog record has a mechanically stale dependency edge. Inspect the named record and apply the exact printed fix, and add the intended existing blocker afterward only when the dangling id was a typo. The fix is a `tasks-axi unblock` command only when that command would actually run; when the clause instead names a hand edit to `data/backlog.md` - because `config/backlog-backend` is `manual`, or because the line says no tasks-axi fix is available since tasks-axi cannot resolve that record - delete the quoted `blocked-by:` token from the named record by hand and do not substitute a `tasks-axi` command for it. diff --git a/.claude/settings.json b/.claude/settings.json index 4ad0e1aca..3cba84240 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -23,6 +23,10 @@ "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/bin/fm-cd-pretool-check.sh --claude" }, + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR\"/bin/fm-lavish-pretool-check.sh --claude" + }, { "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/bin/fm-continuity-pretool-check.sh" diff --git a/.codex/hooks.json b/.codex/hooks.json index e51d59dd8..ff6d2f4b7 100644 --- a/.codex/hooks.json +++ b/.codex/hooks.json @@ -25,6 +25,11 @@ "command": "bash -lc 'payload=$(cat 2>/dev/null || true); [ -n \"$payload\" ] || exit 0; command -v jq >/dev/null 2>&1 || exit 0; root=$(pwd -P) || exit 0; [ -x \"$root/bin/fm-cd-pretool-check.sh\" ] || exit 0; [ -f \"$root/AGENTS.md\" ] || exit 0; [ -f \"$root/.codex/hooks.json\" ] || exit 0; jq -e \"any(.hooks.PreToolUse[]?.hooks[]?.command?; type == \\\"string\\\" and contains(\\\"fm-cd-pretool-check.sh\\\"))\" \"$root/.codex/hooks.json\" >/dev/null 2>&1 || exit 0; printf \"%s\" \"$payload\" | \"$root/bin/fm-cd-pretool-check.sh\"'", "timeout": 10 }, + { + "type": "command", + "command": "bash -lc 'payload=$(cat 2>/dev/null || true); [ -n \"$payload\" ] || exit 0; command -v jq >/dev/null 2>&1 || exit 0; root=$(pwd -P) || exit 0; [ -x \"$root/bin/fm-lavish-pretool-check.sh\" ] || exit 0; [ -f \"$root/AGENTS.md\" ] || exit 0; [ -f \"$root/.codex/hooks.json\" ] || exit 0; jq -e \"any(.hooks.PreToolUse[]?.hooks[]?.command?; type == \\\"string\\\" and contains(\\\"fm-lavish-pretool-check.sh\\\"))\" \"$root/.codex/hooks.json\" >/dev/null 2>&1 || exit 0; printf \"%s\" \"$payload\" | \"$root/bin/fm-lavish-pretool-check.sh\"'", + "timeout": 10 + }, { "type": "command", "command": "bash -lc 'command -v graphify >/dev/null 2>&1 || exit 0; graphify hook-check'", diff --git a/.grok/hooks/fm-primary-lavish-check.json b/.grok/hooks/fm-primary-lavish-check.json new file mode 100644 index 000000000..bc658ce38 --- /dev/null +++ b/.grok/hooks/fm-primary-lavish-check.json @@ -0,0 +1,16 @@ +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "bash -lc '[ -n \"${GROK_WORKSPACE_ROOT:-}\" ] || exit 0; exec \"${GROK_WORKSPACE_ROOT:-}/bin/fm-lavish-pretool-check.sh\"'", + "timeout": 10 + } + ] + } + ] + } +} diff --git a/.opencode/plugins/fm-primary-lavish-check.js b/.opencode/plugins/fm-primary-lavish-check.js new file mode 100644 index 000000000..fd8601aba --- /dev/null +++ b/.opencode/plugins/fm-primary-lavish-check.js @@ -0,0 +1,65 @@ +import { realpathSync } from "node:fs"; +import { resolve } from "node:path"; +import { spawn } from "node:child_process"; + +// PreToolUse seatbelt for OpenCode: deny a bare `lavish-axi` before the agent's +// bash tool runs it and hands the captain a http://127.0.0.1 link that opens +// nothing on his own devices (see bin/fm-lavish-pretool-check.sh and +// docs/lavish-access.md). This mirrors fm-primary-cd-check.js, calling the +// lavish-guard owner instead of the cd-guard one. tool.execute.before can block +// by throwing (verified 2026-07-09 against OpenCode 1.17.15 for the watcher-arm +// plugin; the same mechanism carries this guard). Unlike the cd-guard, the +// owner script is deliberately NOT primary-checkout-only: it fires wherever +// bin/fm-lavish.sh exists, because boards get opened from crew worktrees too. + +function runProcess(command, args) { + return new Promise((resolvePromise) => { + const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"] }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk) => { + stdout += chunk.toString(); + }); + child.stderr.on("data", (chunk) => { + stderr += chunk.toString(); + }); + child.on("error", () => resolvePromise({ code: 0, stdout: "", stderr: "" })); + child.on("close", (code) => resolvePromise({ code: code ?? 0, stdout, stderr })); + }); +} + +async function resolveRoot(anchor) { + if (!anchor) return ""; + const result = await runProcess("git", ["-C", anchor, "rev-parse", "--show-toplevel"]); + const root = result.stdout.trim(); + if (result.code === 0 && root) return root; + try { + return realpathSync(anchor); + } catch { + return resolve(anchor); + } +} + +export const FmPrimaryLavishCheck = async ({ directory, worktree }) => { + const root = worktree ? (() => { + try { + return realpathSync(worktree); + } catch { + return resolve(worktree); + } + })() : await resolveRoot(directory); + + return { + "tool.execute.before": async (input, output) => { + if (!root || input?.tool !== "bash") return; + const command = output?.args?.command; + if (!command || typeof command !== "string") return; + + const result = await runProcess(`${root}/bin/fm-lavish-pretool-check.sh`, ["--command", command]); + if (result.code !== 2) return; + + const reason = result.stderr.trim() || "denied by the lavish-guard PreToolUse seatbelt"; + throw new Error(reason); + }, + }; +}; diff --git a/.pi/extensions/fm-primary-turnend-guard.ts b/.pi/extensions/fm-primary-turnend-guard.ts index 1224e100d..3105ff809 100644 --- a/.pi/extensions/fm-primary-turnend-guard.ts +++ b/.pi/extensions/fm-primary-turnend-guard.ts @@ -78,12 +78,19 @@ function runGuard(): Promise<{ code: number; stderr: string }> { } // PreToolUse seatbelts (bin/fm-arm-pretool-check.sh, docs/arm-pretool-check.md; -// bin/fm-cd-pretool-check.sh, docs/cd-guard.md). Both piggyback on this same -// extension file rather than separate ones so no extra Pi -e flag is needed at -// launch - the primary already loads this file for the turn-end guard, and +// bin/fm-cd-pretool-check.sh, docs/cd-guard.md; bin/fm-lavish-pretool-check.sh, +// docs/lavish-access.md). All three piggyback on this same extension file +// rather than separate ones so no extra Pi -e flag is needed at launch - the +// primary already loads this file for the turn-end guard, and // pi.on("tool_call", ...) can block (verified 2026-07-09 against pi 0.80.5: // returning {block: true} prevents the bash command from running). Each owner -// script owns its own decision and is inert outside the real primary checkout. +// script owns its own decision and its own scope: the first two are inert +// outside the real primary checkout, while the lavish guard's own scope is any +// checkout carrying bin/fm-lavish.sh. What THIS file delivers is narrower than +// that scope: fm-spawn launches a Pi crewmate or scout with a generated +// per-task extension carrying only a turn_end handler, so this file is loaded +// by the primary session and by a Pi secondmate home, not by a Pi crew +// worktree (docs/lavish-access.md records that limit). function runChecker(script: string, command: string): Promise<{ code: number; stderr: string }> { return new Promise((resolveResult) => { const child = spawn(`${root}/bin/${script}`, ["--command", command], { @@ -106,6 +113,10 @@ function runCdCheck(command: string): Promise<{ code: number; stderr: string }> return runChecker("fm-cd-pretool-check.sh", command); } +function runLavishCheck(command: string): Promise<{ code: number; stderr: string }> { + return runChecker("fm-lavish-pretool-check.sh", command); +} + export default function (pi: ExtensionAPI) { pi.on?.("session_start", (event) => { const reason = String((event as { reason?: unknown }).reason ?? ""); @@ -126,6 +137,10 @@ export default function (pi: ExtensionAPI) { if (cdResult.code === 2) { return { block: true, reason: cdResult.stderr.trim() || "denied by the cd-guard PreToolUse seatbelt" }; } + const lavishResult = await runLavishCheck(command); + if (lavishResult.code === 2) { + return { block: true, reason: lavishResult.stderr.trim() || "denied by the lavish-guard PreToolUse seatbelt" }; + } const result = await runPretoolCheck(command); if (result.code !== 2) return {}; return { block: true, reason: result.stderr.trim() || "denied by the watcher-arm PreToolUse seatbelt" }; diff --git a/AGENTS.md b/AGENTS.md index bdf433337..dc5742eac 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -121,6 +121,8 @@ state/ volatile runtime signals; gitignored .pr-check-migration-scan-v1 private marker proving the non-executing scan disabled every unsafe legacy check; .pr-check-migration-v1 separately records completed private repairs x-watch.check.sh generated X-mode relay poll shim; present only when opted in (section 14) pending-replies/ parent-owned secondmate pending-reply records (correlation id, delivery vs reply, recovery, escalation); fm-pending-reply-lib.sh + service-port. published record of the address and port one vessel-local service took; a record, NEVER a lock, so nothing may read it to decide a port is free (bin/fm-service-port.sh; docs/lavish-access.md) + lavish/ this home's private review-board server state, session store, and claim token; owned by bin/fm-lavish.sh x-inbox/ generated X-mode pending mention payloads; fmx-respond drains it (section 14) x-context/ generated X-mode durable per-request reply context and one-wake offer markers, keyed by request_id; survives inbox cleanup and expires within seven days (section 14; bin/fm-x-lib.sh) x-outbox/ generated X-mode dry-run reply and dismiss previews; inspect it when FMX_DRY_RUN is set (section 14) @@ -171,7 +173,7 @@ A lock-refused session must not spawn, steer, merge, drain the wake queue, repai Bootstrap detects first, asks for consent, and installs only after the captain approves in the current session. Do not dispatch until the required tools are present and GitHub authentication is good. -Use `gh-axi` for GitHub, `chrome-devtools-axi` for browser work, and `lavish-axi` for structured decisions or reports; consult current help rather than memorizing flags. +Use `gh-axi` for GitHub, `chrome-devtools-axi` for browser work, and `bin/fm-lavish.sh` - never bare `lavish-axi` - for structured decisions or reports; consult current help rather than memorizing flags. A silent bootstrap section needs no action; for any printed actionable diagnostic line, load `bootstrap-diagnostics` and follow its owner procedure. `BOOTSTRAP_INFO:` lines are completed no-action facts and do not require loading a skill. `secondmate-provisioning` owns startup secondmate sync, liveness, and inherited local-material convergence. @@ -442,7 +444,7 @@ Reach the captain immediately for: Do not surface automatic fixes, retries, routine progress, or internal supervision mechanics. Batch non-urgent updates into the next natural reply. -Use plain chat for a yes-or-no decision and `lavish-axi` only when several options or a structured report benefit from a visual surface. +Use plain chat for a yes-or-no decision and `bin/fm-lavish.sh` only when several options or a structured report benefit from a visual surface; it is the only sanctioned way to open a review board, because bare `lavish-axi` hands the captain a link that opens nowhere but this machine (docs/lavish-access.md). Whenever a PR is mentioned, include its full `https://...` URL before any shorthand reference. Generate a PDF deliverable only through `bin/fm-pdf-finish.sh`, which refuses to publish a file a real reader cannot read, because a browser-printed document looks correct on screen and fails at the recipient (docs/pdf-output.md). Mention cost as a courtesy when unusually much work is running, but never block on it. @@ -496,7 +498,7 @@ Bootstrap separately detects when the primary checkout's own default branch has These skills are not captain-invocable; load them only at their precise triggers. -- `bootstrap-diagnostics` - load whenever the session-start digest's bootstrap section prints an actionable diagnostic line (`MISSING:`, `MISSING_MANUAL:`, `BACKEND_INVALID:`, `ROLE_INVALID:`, `ROLE_OVERLAY_MISSING:`, `NEEDS_GH_AUTH`, `TANGLE:`, `SELF_DRIFT:`, `CREW_DISPATCH: invalid`, `CURRENCY_BASE:`, `BACKLOG_STALE:`, `BACKLOG_UNREADABLE:`, `FLEET_SYNC:`, `PR_CHECK_MIGRATION:`, `SECONDMATE_SYNC:`, `SECONDMATE_LIVENESS:`, `NUDGE_SECONDMATES:`, `AXI_SUITE_UPDATED:`, `AXI_SUITE_REVIEW:`, `AXI_SUITE_STUCK:`, `FIRSTMATE_UPDATE_AVAILABLE:`, `FIRSTMATE_UPDATE_STUCK:`, `FORK_SYNC:`, `FORK_SYNC_STUCK:`, or `FMX:`); silence and `BOOTSTRAP_INFO:` need no load. +- `bootstrap-diagnostics` - load whenever the session-start digest's bootstrap section prints an actionable diagnostic line (`MISSING:`, `MISSING_MANUAL:`, `BACKEND_INVALID:`, `ROLE_INVALID:`, `ROLE_OVERLAY_MISSING:`, `NEEDS_GH_AUTH`, `TANGLE:`, `SELF_DRIFT:`, `CREW_DISPATCH: invalid`, `CURRENCY_BASE:`, `LAVISH_ACCESS:`, `BACKLOG_STALE:`, `BACKLOG_UNREADABLE:`, `FLEET_SYNC:`, `PR_CHECK_MIGRATION:`, `SECONDMATE_SYNC:`, `SECONDMATE_LIVENESS:`, `NUDGE_SECONDMATES:`, `AXI_SUITE_UPDATED:`, `AXI_SUITE_REVIEW:`, `AXI_SUITE_STUCK:`, `FIRSTMATE_UPDATE_AVAILABLE:`, `FIRSTMATE_UPDATE_STUCK:`, `FORK_SYNC:`, `FORK_SYNC_STUCK:`, or `FMX:`); silence and `BOOTSTRAP_INFO:` need no load. - `diagnostic-reasoning` - load before scoping a reported bug and before acting on a diagnostic report. - `harness-adapters` - load before spawning or recovering a crewmate or secondmate, handling a trust dialog, sending a harness-specific skill invocation, interrupting or exiting an agent, resuming an exited agent, or verifying a new harness adapter. - `firstmate-orca` - load before switching to Orca, spawning or supervising Orca-backed work, smoke-testing Orca backend behavior, debugging Orca task state, or reconciling Orca-backed task metadata. diff --git a/bin/fm-bootstrap.sh b/bin/fm-bootstrap.sh index 6060a851b..5ad1e2264 100755 --- a/bin/fm-bootstrap.sh +++ b/bin/fm-bootstrap.sh @@ -12,6 +12,7 @@ # "ROLE_OVERLAY_MISSING: (expected: roles/.md)", # "CREW_DISPATCH: invalid config/crew-dispatch.json - ", # "CURRENCY_BASE: config/ is unusable - ; ", +# "LAVISH_ACCESS: open review board link(s) still point at this machine only ...", # "BACKLOG_STALE: task has ; fix: ", # "BACKLOG_UNREADABLE: task in is parsed by but not ; fix: ", # "FLEET_SYNC: : skipped|recovered|STUCK: ", @@ -744,6 +745,59 @@ currency_base_validate() { done } +# Detect-only: this vessel is still handing the captain review-board links that +# open nothing on his own devices. The whole point of bin/fm-lavish.sh is that +# this failure is silent - a loopback board looks correct on the machine that +# made it - so without a startup check it regresses unnoticed, which it already +# did once while the fix sat queued. +# +# Deliberately ordered cheap-first: the session stores are plain file reads, and +# the address resolver only runs once a loopback link has actually been found. +# A host with no tailnet is honestly limited rather than regressed, so it is +# silent. +lavish_access_check() { + local loopback found=0 reach shared + command -v jq >/dev/null 2>&1 || return 0 + # This home's own store, written by bin/fm-lavish.sh. Everything in it belongs + # to this home, so no attribution filter is needed. + if [ -r "$STATE/lavish/state.json" ]; then + loopback=$(jq -r ' + [ (.sessions // {}) | to_entries[] + | select(.value.status != "ended") + | .value.url // "" + | select(test("^https?://(127\\.0\\.0\\.1|localhost|\\[::1\\])[:/]")) + ] | length' "$STATE/lavish/state.json" 2>/dev/null) || loopback= + case "${loopback:-}" in + ''|*[!0-9]*) ;; + *) found=$((found + loopback)) ;; + esac + fi + # lavish-axi's default store, which is where a bypass lands. It is shared by + # every home of one UNIX account, so a session counts only when its board file + # lives under THIS home - otherwise a parent and its secondmate would both + # report the same boards, and an unrelated home's boards would be blamed here. + shared="$HOME/.lavish-axi/state.json" + if [ -r "$shared" ] && [ "$shared" != "$STATE/lavish/state.json" ]; then + loopback=$(FM_HOME_PREFIX="$FM_HOME/" jq -r ' + ($ENV.FM_HOME_PREFIX) as $home + | [ (.sessions // {}) | to_entries[] + | select(.value.status != "ended") + | select((.value.file // "") | startswith($home)) + | .value.url // "" + | select(test("^https?://(127\\.0\\.0\\.1|localhost|\\[::1\\])[:/]")) + ] | length' "$shared" 2>/dev/null) || loopback= + case "${loopback:-}" in + ''|*[!0-9]*) ;; + *) found=$((found + loopback)) ;; + esac + fi + [ "$found" -gt 0 ] || return 0 + reach=$("$SCRIPT_DIR/fm-service-port.sh" lavish --check 2>/dev/null \ + | sed -n 's/^reachability=\(.*\)$/\1/p' | head -1) + [ "$reach" = tailnet ] || return 0 + echo "LAVISH_ACCESS: $found open review board link(s) still point at this machine only and will not open on the captain's devices; reopen them with bin/fm-lavish.sh" +} + crew_dispatch_validate() { local file err file="$CONFIG/crew-dispatch.json" @@ -936,6 +990,7 @@ if [ "${FM_BOOTSTRAP_VERBOSE_FACTS:-0}" = 1 ] && [ -n "$crew" ] && [ "$crew" != fi crew_dispatch_validate currency_base_validate +lavish_access_check if fm_tasks_axi_compatible && command -v jq >/dev/null 2>&1; then "$SCRIPT_DIR/fm-backlog-lint.sh" || true fi diff --git a/bin/fm-brief.sh b/bin/fm-brief.sh index 5f68739c9..b6fd0f5a1 100755 --- a/bin/fm-brief.sh +++ b/bin/fm-brief.sh @@ -243,8 +243,10 @@ The report is the only thing that survives, so anything worth keeping must be in # Rules 1. Never push to any remote and never open a PR. -2. Stay inside this worktree; the only files you may write outside it are the report and the status file below. +2. Stay inside this worktree; the only files you may write outside it are the report, the status file below, and firstmate's own state under $STATE/, which the tools in rule 3 write for you. 3. Use gh-axi for GitHub operations and chrome-devtools-axi for browser operations. + To open a Lavish review board, run \`$FM_ROOT/bin/fm-lavish.sh\` instead of bare lavish-axi: + bare lavish-axi emits a link that opens nothing outside this machine. 4. Report status by appending one line: \`echo "{state}: {one short line}" >> $STATUS_FILE\` States: working, needs-decision, blocked, $PAUSED_VERB, done, failed. @@ -350,8 +352,10 @@ If the top-level path is the primary checkout or not the worktree you were launc # Rules $RULE1 -2. Stay inside this worktree; modify nothing outside it. +2. Stay inside this worktree; the only files you may write outside it are the status file below and firstmate's own state under $STATE/, which the tools in rule 3 write for you. 3. Use gh-axi for GitHub operations and chrome-devtools-axi for browser operations. + To open a Lavish review board, run \`$FM_ROOT/bin/fm-lavish.sh\` instead of bare lavish-axi: + bare lavish-axi emits a link that opens nothing outside this machine. 4. Report status by appending one line: \`echo "{state}: {one short line}" >> $STATUS_FILE\` States: working, needs-decision, blocked, $PAUSED_VERB, done, failed. diff --git a/bin/fm-lavish-command-policy.mjs b/bin/fm-lavish-command-policy.mjs new file mode 100755 index 000000000..a561639b0 --- /dev/null +++ b/bin/fm-lavish-command-policy.mjs @@ -0,0 +1,178 @@ +#!/usr/bin/env node +// Semantic policy for the lavish-guard: does a shell command run bare +// `lavish-axi` instead of firstmate's entry point? +// +// A bare `lavish-axi` binds loopback and hands the captain a +// http://127.0.0.1:/... link that opens nothing on his own devices, and +// it fails silently - the board looks correct on the machine that made it. That +// mistake has already been made here after the problem was written down, which +// is why this is a guard and not a note: instructions do not reach an agent's +// non-interactive shell, and habit beats memory. +// +// The environmental scoping - only where bin/fm-lavish.sh actually exists - +// lives in the bin/fm-lavish-pretool-check.sh transport, not here. +// +// The shell tokenizer and command-position analysis are imported from +// bin/fm-arm-command-policy.mjs, the sole owner of firstmate's shell +// classification, so this guard never duplicates shell lexing. This policy +// never evaluates, expands, sources, or runs any byte of the submitted command; +// it inspects lexical command positions only. +// +// Usage: +// fm-lavish-command-policy.mjs --command '' [--wrapper ] +// +// Prints "allow", or "deny\t\t". --wrapper supplies the absolute +// path this checkout's wrapper actually lives at, which only the transport +// knows; without it the reason names the repo-relative path. + +import { Lexer, splitProgram, commandPosition } from "./fm-arm-command-policy.mjs"; +import { realpathSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +const GUARDED = "lavish-axi"; + +// Subcommands that neither start a server nor emit a link, so denying them +// would prevent nothing. `setup hooks` in particular is part of the install +// command bin/fm-bootstrap.sh and bin/fm-axi-suite.sh print for the captain to +// run. Each of these is a subcommand lavish-axi itself recognises, which is what +// makes the list safe: lavish-axi only rewrites an argv into `open` when the +// first word is NOT one of its own subcommands. +// +// Version and help flags are deliberately absent even though they serve +// nothing on their own: a flag-led argv carrying an html path is rewritten into +// `open` by lavish-axi, so `lavish-axi --version board.html` opens a board. +// Telling those apart would mean keeping a second copy of lavish-axi's argv +// normalisation here, and a wrong ALLOW is silent while a wrong DENY is loud +// and answered by running the wrapper. +// +// `stop` is absent for a different reason: shutting a server down is ownership +// sensitive and belongs to the wrapper, which proves the port is this home's +// first. +const NON_SERVING = new Set(["setup", "playbook", "design", "export"]); + +function reasonFor(wrapper) { + return ( + `bare \`${GUARDED}\` is blocked in a firstmate checkout because it binds loopback and writes a http://127.0.0.1 link that opens nothing on the captain's own devices, and it fails silently. Run \`${wrapper}\` with the same arguments instead: it resolves this vessel's tailnet address and a port no co-hosted vessel is using, and it says so plainly when there is no tailnet rather than emitting a link that will not open.` + ); +} + +function basename(value) { + const cleaned = value.endsWith("/") ? value.slice(0, -1) : value; + const slash = cleaned.lastIndexOf("/"); + return slash === -1 ? cleaned : cleaned.slice(slash + 1); +} + +// `command -v lavish-axi` and `type lavish-axi` ask whether the tool exists; +// they never start a server or emit a link. bin/fm-bootstrap.sh does exactly +// this, so denying it would break tool detection to prevent nothing. +function hasCommandQueryPrefix(position) { + let commandPrefix = false; + for (const word of position.words.slice(position.prefixAssignments, position.index)) { + if (basename(word.value) === "command") { + commandPrefix = true; + continue; + } + if (commandPrefix && /^-[^-]*[vV]/.test(word.value)) return true; + } + return false; +} + +// A first argument that lavish-axi recognises as one of its own subcommands is +// dispatched to that subcommand, whatever follows it. Anything else - a board +// file, a flag, nothing at all - can end up opening a board, so only a word +// from the list above makes an invocation non-serving. +function isNonServing(words, index) { + const first = words[index + 1]; + return Boolean(first) && NON_SERVING.has(first.value); +} + +function decision(command, wrapper = "bin/fm-lavish.sh") { + const lexed = new Lexer(command).tokenize(); + // Fail open on syntax this classifier cannot tokenize. The threat model is an + // agent reaching for the familiar tool, which always tokenizes, so zero false + // blocks matters more than catching deliberate obfuscation. + if (lexed.error) return { decision: "allow" }; + + // Every top-level command list runs its command, so unlike the cd-guard there + // is no subshell or background exemption to make: a backgrounded or piped + // `lavish-axi` emits exactly the same unreachable link. + const { nodes } = splitProgram(lexed.tokens); + for (let index = 0; index < nodes.length; index += 1) { + const position = commandPosition(nodes[index]); + if (hasCommandQueryPrefix(position)) continue; + let executed = position.command; + let wordIndex = position.index; + while (executed && (executed.value === "builtin" || executed.value === "command")) { + wordIndex += 1; + executed = position.words[wordIndex]; + } + if (!executed) continue; + if (basename(executed.value) !== GUARDED) continue; + if (isNonServing(position.words, wordIndex)) continue; + return { decision: "deny", code: "bare-lavish-axi", reason: reasonFor(wrapper) }; + } + return { decision: "allow" }; +} + +function parseArguments(argv) { + const result = { command: "", commandSet: false, wrapper: "bin/fm-lavish.sh" }; + for (let i = 0; i < argv.length; i += 1) { + const name = argv[i]; + if (name === "--command") { + if (i + 1 >= argv.length) throw new Error("--command requires a value"); + result.command = argv[i + 1]; + result.commandSet = true; + i += 1; + continue; + } + if (name.startsWith("--command=")) { + result.command = name.slice("--command=".length); + result.commandSet = true; + continue; + } + if (name === "--wrapper") { + if (i + 1 >= argv.length) throw new Error("--wrapper requires a value"); + result.wrapper = argv[i + 1]; + i += 1; + continue; + } + if (name.startsWith("--wrapper=")) { + result.wrapper = name.slice("--wrapper=".length); + continue; + } + throw new Error(`unknown argument: ${name}`); + } + return result; +} + +function invokedDirectly() { + const entry = process.argv[1]; + if (!entry) return false; + const self = fileURLToPath(import.meta.url); + try { + return realpathSync(entry) === realpathSync(self); + } catch { + return entry === self; + } +} + +if (invokedDirectly()) { + try { + const args = parseArguments(process.argv.slice(2)); + if (!args.commandSet || !args.command) { + process.stdout.write("allow\n"); + } else { + const result = decision(args.command, args.wrapper); + if (result.decision === "allow") { + process.stdout.write("allow\n"); + } else { + process.stdout.write(`deny\t${result.code}\t${result.reason}\n`); + } + } + } catch (error) { + process.stderr.write(`${error.message}\n`); + process.exitCode = 1; + } +} + +export { decision }; diff --git a/bin/fm-lavish-pretool-check.sh b/bin/fm-lavish-pretool-check.sh new file mode 100755 index 000000000..36cbff065 --- /dev/null +++ b/bin/fm-lavish-pretool-check.sh @@ -0,0 +1,170 @@ +#!/usr/bin/env bash +# Stable PreToolUse transport for the lavish-guard command policy. +# +# A bare `lavish-axi` binds loopback and hands the captain a +# http://127.0.0.1:/... link that opens nothing on his PC or phone, and it +# fails silently because the board looks correct on the machine that made it. +# This seatbelt denies that command before it runs and names the entry point +# that gets it right (bin/fm-lavish.sh). It exists because a written instruction +# already failed here: ~/.bashrc returns early for non-interactive shells and an +# agent tool shell loads its own snapshot, so nothing an operator exports ever +# reaches the invocation. +# +# bin/fm-lavish-command-policy.mjs is the sole owner of the block/allow +# decision; it reuses the shell classifier owned by +# bin/fm-arm-command-policy.mjs. This wrapper only scopes the guard, acquires +# the harness payload, invokes that policy, and renders the established harness +# responses. It never executes, sources, evaluates, or expands the command. +# See docs/lavish-access.md for the complete contract and validation record. +# +# SCOPE, and this deliberately differs from bin/fm-cd-pretool-check.sh: the +# guard fires wherever the wrapper exists, including a linked task worktree, not +# only in the plain primary checkout. Boards get opened from crew worktrees too, +# and a guard that is inert exactly where the mistake happens is not a guard. +# +# Usage: +# | bin/fm-lavish-pretool-check.sh +# bin/fm-lavish-pretool-check.sh --command '' +# +# Stdin mode extracts .toolInput.command for Grok or .tool_input.command for +# Claude and Codex. CLI mode is used by OpenCode and Pi after their adapters +# extract the exact command string. +# +# Exit/output contract (identical shape to bin/fm-cd-pretool-check.sh): +# ALLOW - exit 0 and no output. +# DENY - exit 2, a Claude-shaped deny object on stderr, and a Grok-shaped +# deny object on stdout unless --claude was supplied. +# INERT - no firstmate checkout with bin/fm-lavish.sh here: exit 0 with no +# output, exactly like ALLOW. +# FAIL OPEN - malformed or empty stdin, missing jq for stdin transport, +# missing Node or policy owner, or an invalid policy response. +# +# Claude requires stdout to remain empty on deny. +# Codex blocks on exit 2 and displays stderr. +# Grok consumes the stdout decision object. +# OpenCode and Pi consume exit 2 plus stderr. +set -u + +CMD="" +CMD_SET=0 +CLAUDE_MODE=0 + +usage() { + cat <<'EOF' +Usage: fm-lavish-pretool-check.sh [--command ] [--claude] + +With no --command, reads a PreToolUse-style JSON payload on stdin (Grok +toolInput.command, or Claude/Codex tool_input.command). +Fires wherever this firstmate checkout carries bin/fm-lavish.sh, including task +worktrees; it is a silent no-op anywhere else. +Exits 0 to allow and 2 to deny a bare lavish-axi invocation. +The deny reason is written to stderr, with a Grok decision object on stdout +unless --claude is supplied. +Malformed transport and an unavailable classifier runtime fail open. +EOF +} + +while [ "$#" -gt 0 ]; do + case "$1" in + --command) + [ "$#" -gt 1 ] || { echo "error: --command requires a value" >&2; exit 2; } + CMD=$2 + CMD_SET=1 + shift 2 + ;; + --command=*) + CMD=${1#--command=} + CMD_SET=1 + shift + ;; + --claude) + CLAUDE_MODE=1 + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "error: unknown argument: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +if [ "$CMD_SET" -eq 0 ]; then + PAYLOAD=$(cat 2>/dev/null || true) + [ -n "$PAYLOAD" ] || exit 0 + command -v jq >/dev/null 2>&1 || exit 0 + CMD=$(printf '%s' "$PAYLOAD" | jq -r '(.toolInput.command // .tool_input.command // empty)' 2>/dev/null) || exit 0 +fi + +[ -n "$CMD" ] || exit 0 + +# Strict-superset prefilter (transport only; owns zero classification +# semantics). Strip syntax bytes the classifier joins within a shell word before +# looking for the guarded program name, so an ordinary quoted or escaped +# fragment cannot hide a deniable invocation from the policy owner. A +# quoting-decoder marker - a $ immediately followed by a single quote (ANSI-C +# $'...') or a double quote (bash locale $"...") - delegates too, because the +# classifier decodes those and can reconstruct the name from bytes this +# substring test cannot see. This marker set is COUPLED to the classifier's +# decoder set in bin/fm-arm-command-policy.mjs: adding any new quote or +# expansion form the classifier decodes REQUIRES extending it here in the same +# change, or the prefilter stops being a strict superset. Deliberate deeper +# obfuscation is out of scope by the same agent-mistake threat model the policy +# uses. +PREFILTER=$CMD +PREFILTER=${PREFILTER//\\/} +PREFILTER=${PREFILTER//\"/} +PREFILTER=${PREFILTER//\'/} +PREFILTER=${PREFILTER//$'\n'/} +PREFILTER=${PREFILTER//$'\r'/} +case "$CMD" in + *"\$'"*|*'$"'*) ;; + *) + case "$PREFILTER" in + *lavish-axi*) ;; + *) exit 0 ;; + esac + ;; +esac + +SCRIPT_DIR=$(CDPATH='' cd -- "$(dirname -- "${BASH_SOURCE[0]}")" 2>/dev/null && pwd -P) || exit 0 +FM_ROOT=${FM_ROOT_OVERRIDE:-$(CDPATH='' cd -- "$SCRIPT_DIR/.." 2>/dev/null && pwd -P)} || exit 0 + +# Scope to any firstmate checkout that actually carries the wrapper. There is +# deliberately no git-dir/git-common-dir test here: a crewmate task worktree is +# a linked worktree, and that is one of the places boards get opened. Any +# failure to confirm the checkout is inert (exit 0), never a block, so a broken +# environment never denies a shell command. +[ -f "$FM_ROOT/AGENTS.md" ] || exit 0 +WRAPPER="$FM_ROOT/bin/fm-lavish.sh" +[ -x "$WRAPPER" ] || exit 0 + +POLICY="$FM_ROOT/bin/fm-lavish-command-policy.mjs" +command -v node >/dev/null 2>&1 || exit 0 +[ -f "$POLICY" ] || exit 0 + +POLICY_OUTPUT=$(node "$POLICY" --command "$CMD" --wrapper "$WRAPPER" 2>/dev/null) || exit 0 +[ -n "$POLICY_OUTPUT" ] || exit 0 + +TAB=$(printf '\t') +DECISION=${POLICY_OUTPUT%%"$TAB"*} +[ "$DECISION" = "deny" ] || exit 0 +REST=${POLICY_OUTPUT#*"$TAB"} +[ "$REST" != "$POLICY_OUTPUT" ] || exit 0 +CODE=${REST%%"$TAB"*} +REASON=${REST#*"$TAB"} +[ -n "$CODE" ] && [ -n "$REASON" ] && [ "$REASON" != "$REST" ] || exit 0 + +json_escape() { + printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' | tr '\n' ' ' +} + +DETAIL="[$CODE] $REASON" +ESCAPED=$(json_escape "$DETAIL") +printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny"},"systemMessage":"%s"}\n' "$ESCAPED" >&2 +[ "$CLAUDE_MODE" -eq 1 ] || printf '{"decision":"deny","reason":"%s"}\n' "$ESCAPED" +exit 2 diff --git a/bin/fm-lavish.sh b/bin/fm-lavish.sh new file mode 100755 index 000000000..4d8d9aa79 --- /dev/null +++ b/bin/fm-lavish.sh @@ -0,0 +1,455 @@ +#!/usr/bin/env bash +# The one entry point every agent uses to open a Lavish review board, instead of +# bare `lavish-axi`. +# +# A bare `lavish-axi` binds loopback and writes http://127.0.0.1:/... into +# the link it hands the captain. That link opens nothing on his PC or phone, and +# it fails silently: the board looks fine on the machine that made it. This +# wrapper resolves this host's own tailnet address at runtime, takes a port no +# other vessel on this machine is using, restricts the Host allowlist to this +# vessel's own names, and emits a link that has been checked before it is +# handed over. +# +# Documentation could not fix this. A profile export never reaches an agent's +# non-interactive shell, and it had already failed here once. So the mechanism +# is this script plus the PreToolUse guard that denies bare `lavish-axi` +# (bin/fm-lavish-pretool-check.sh), not a note somebody has to remember. +# +# Usage: +# fm-lavish.sh open or resume a board +# fm-lavish.sh poll wait for reviewer feedback +# fm-lavish.sh end end the session +# fm-lavish.sh stop stop THIS vessel's board server +# fm-lavish.sh +# fm-lavish.sh --fm-help this help +# +# Every argument except the wrapper's own --fm-* flags is passed through +# untouched, so `poll`, `end`, `export`, and `stop` all resolve the same server +# as the invocation that opened the board. A wrapper that fronted only the open +# call would leave every follow-up pointed at the compiled-in default port. +# +# A flag-led invocation carrying no .html or .htm argument - `--version`, +# `--help` - opens nothing, so it claims no port, writes no record, and gets no +# reachability or link line. `--version .html` IS an open, because that +# is how lavish-axi itself reads it, and takes the full open path. +# +# Whether a board actually opened is decided by what the run PRINTED, never by +# what its arguments looked like: an argv that dispatches `open` still opens +# nothing when it carries --help. So every line this wrapper says about a LINK +# waits for a session URL in the output. Whether this host has a tailnet is a +# different kind of fact - already known, and unchanged by anything the run +# does - so that one is stated up front instead. What has to be decided before +# the run - the port claim and the records that follow it - is likewise decided +# from the arguments, because a port must exist before the command can run at +# all. +# +# `stop --port ` is held to exactly the same ownership proof as a bare +# `stop`: the claim token has to answer on first. lavish-axi's own stop path +# shuts down any server that answers /health with a lavish-axi body, and every +# vessel on this machine binds this same address, so an explicitly named port +# that was never proved would be a neighbour's board. An unproven port that is +# serving is refused by name; one that answers nothing at all is reported as +# nothing to stop. +# +# What it sets, and why each one is needed: +# LAVISH_AXI_HOST the tailnet address, so the server is reachable +# off this machine. Never a wildcard: an +# all-interfaces bind is broader than the captain +# approved. +# LAVISH_AXI_PORT a port proved free by bin/fm-service-port.sh, so +# vessels sharing this machine do not collide. +# LAVISH_AXI_LINK_HOST the hostname written into the link. The tailnet +# DNS name only after it has been confirmed to +# resolve to the bound address; otherwise the +# address itself. +# LAVISH_AXI_ALLOWED_HOSTS this vessel's tailnet DNS name, its short node +# name, its address, and this home's claim token. +# Never "*". +# LAVISH_AXI_STATE_DIR FM_HOME/state/lavish, so a secondmate home does +# not share one server and one session store with +# its parent, which shares its UNIX account. +# +# The claim token is a per-home random name in the Host allowlist. It is how +# this wrapper answers "is the server on my port MY server", which no health +# body can answer: every lavish-axi reports the same {ok, app, version}, so a +# neighbouring UNIX account's server on the same port is silently adopted +# otherwise. It adds no exposure - anyone who can already reach the port can +# already reach it under the address - it only makes one specific Host header +# identify one specific home's server. +# +# Honest degradation, in every branch: +# - No usable tailnet: this says so in one plain sentence and falls back to +# loopback. The board still opens locally and is never presented as +# reachable. +# - The link host does not answer after the board opens: this says so and +# names the address form that does work. +# - No free port in the window: bin/fm-service-port.sh refuses. There is no +# silent loopback downgrade, because that would reproduce the original bug +# somewhere new. +# +# `share` publishes the board to a third-party host. Review boards carry fleet +# names, security findings, and captain decisions, so it is refused unless +# --fm-allow-share (or FM_LAVISH_ALLOW_SHARE=1) makes the intent explicit. The +# refusal names the override; it does not remove the capability. +# +# Not solved here, and stated rather than implied: a board is an +# unauthenticated server that anything on the tailnet can read. That is the +# fleet's existing trust boundary, and this script does not widen it - but it +# does not close it either. +set -u + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" +FM_HOME="${FM_HOME:-$FM_ROOT}" +STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" +PROBE="$SCRIPT_DIR/fm-service-port-probe.mjs" +LAV_STATE="$STATE/lavish" +OWNER="$LAV_STATE/fm-owner" +TOKEN_FILE="$LAV_STATE/claim-token" + +# shellcheck source=bin/fm-axi-path-lib.sh +. "$SCRIPT_DIR/fm-axi-path-lib.sh" + +note() { printf 'fm-lavish: %s\n' "$1" >&2; } +die() { note "$1"; exit "${2:-1}"; } + +usage() { + cat <<'EOF' +Usage: fm-lavish.sh open or resume a review board + fm-lavish.sh poll wait for reviewer feedback + fm-lavish.sh end end the session + fm-lavish.sh stop stop this vessel's board server + fm-lavish.sh anything else, passed through + fm-lavish.sh --fm-help this help + +Opens Lavish review boards on this vessel's own tailnet address and a port no +other vessel on this machine is using, so the link works on the captain's own +devices instead of only on this machine. --fm-allow-share is required before +`share` will publish a board to third-party hosting. Read this script's header +for the full contract. +EOF +} + +# --- wrapper flags ---------------------------------------------------------- + +ALLOW_SHARE=${FM_LAVISH_ALLOW_SHARE:-0} +ARGS=() +for arg in "$@"; do + case "$arg" in + --fm-allow-share) ALLOW_SHARE=1 ;; + --fm-help) usage; exit 0 ;; + *) ARGS+=("$arg") ;; + esac +done + +if [ "${#ARGS[@]}" -eq 0 ]; then + usage >&2 + exit 2 +fi + +# Mirror lavish-axi's own dispatch: a first argument that is not a known +# subcommand is a file, which means the default open command. +SUBCOMMAND=open +EXPLICIT_PORT=0 +EXPLICIT_PORT_VALUE="" +first=1 +want_port=0 +for arg in "${ARGS[@]}"; do + if [ "$first" -eq 1 ]; then + first=0 + case "$arg" in + poll|end|stop|server|playbook|design|setup|export|share|open) SUBCOMMAND=$arg ;; + esac + fi + if [ "$want_port" -eq 1 ]; then + EXPLICIT_PORT_VALUE=$arg + want_port=0 + continue + fi + case "$arg" in + --port) EXPLICIT_PORT=1; want_port=1 ;; + --port=*) EXPLICIT_PORT=1; EXPLICIT_PORT_VALUE=${arg#--port=} ;; + esac +done + +# lavish-axi rewrites a flag-led argv into `open` only when some argument is an +# html path, and otherwise passes it through untouched. This wrapper mirrors +# that one fact because it has to actually RUN the command: treating +# `--version` as an open would claim a port, rewrite this home's records, and +# then report on a board that was never opened. bin/fm-lavish-command-policy.mjs +# deliberately mirrors nothing of the sort, because it fails safe by denying +# rather than running anything. +if [ "$SUBCOMMAND" = open ]; then + case "${ARGS[0]}" in + -*) + OPENS_BOARD=0 + for arg in "${ARGS[@]}"; do + case "$(printf '%s' "$arg" | tr '[:upper:]' '[:lower:]')" in + *.html|*.htm) OPENS_BOARD=1; break ;; + esac + done + [ "$OPENS_BOARD" -eq 1 ] || SUBCOMMAND=passthrough + ;; + esac +fi + +if [ "$SUBCOMMAND" = share ] && [ "$ALLOW_SHARE" != 1 ]; then + die "refusing to publish this board to third-party hosting: review boards carry fleet names, security findings, and captain decisions, and the transport is meant to stay inside the tailnet. Pass --fm-allow-share (or set FM_LAVISH_ALLOW_SHARE=1) if this particular board is genuinely not private." 3 +fi + +# Commands that can start a server need a port that was actually proved free. +# The rest must never trigger an allocation, because claiming a port they will +# not use is how a window fills up with nothing. +case "$SUBCOMMAND" in + open|poll|end|server) NEEDS_PORT=1 ;; + *) NEEDS_PORT=0 ;; +esac + +# The vessel's own AXI prefix wins over whatever PATH resolves, so a home always +# drives its own installed lavish-axi. Resolved after the wrapper's own flags so +# --fm-help and the share refusal still work on a vessel that has no lavish-axi. +LAVISH=$(fm_axi_bin_dir 2>/dev/null)/lavish-axi +[ -x "$LAVISH" ] || LAVISH=$(command -v lavish-axi 2>/dev/null) \ + || die "lavish-axi is not installed for this vessel" 6 + +# --- identity --------------------------------------------------------------- + +if ! IDENTITY=$("$SCRIPT_DIR/fm-service-port.sh" lavish --check 2>&1); then + printf '%s\n' "$IDENTITY" >&2 + die "could not resolve this vessel's address for review boards" 4 +fi + +ADDR=""; DNSNAME=""; MACHINE=""; SEAT=""; WINDOW=""; REACHABILITY=""; REASON="" +while IFS='=' read -r key value; do + case "$key" in + addr) ADDR=$value ;; + dnsname) DNSNAME=$value ;; + machine) MACHINE=$value ;; + seat) SEAT=$value ;; + window) WINDOW=$value ;; + reachability) REACHABILITY=$value ;; + reason) REASON=$value ;; + esac +done </dev/null || die "cannot create the board state directory $LAV_STATE" 4 +if [ -s "$TOKEN_FILE" ]; then + CLAIM_TOKEN=$(tr -dc 'a-z0-9-' < "$TOKEN_FILE" 2>/dev/null | head -c 64) +fi +if [ -z "${CLAIM_TOKEN:-}" ]; then + raw=$(od -An -N8 -tx1 /dev/urandom 2>/dev/null | tr -dc 'a-f0-9') + [ -n "$raw" ] || raw=$(printf '%s%s' "$$" "$(date +%s)" | cksum | awk '{print $1}') + CLAIM_TOKEN="fm-claim-$raw" + ( umask 077; printf '%s\n' "$CLAIM_TOKEN" > "$TOKEN_FILE" ) 2>/dev/null \ + || die "cannot write this home's board claim token" 4 +fi + +ALLOWED="$LINK_HOST $ADDR $CLAIM_TOKEN" +[ "$REACHABILITY" != tailnet ] || [ -z "$MACHINE" ] || ALLOWED="$MACHINE $ALLOWED" + +# --- is the server on our recorded port actually ours ------------------------ +# +# A bind probe cannot answer this: a port we are ourselves listening on always +# reports as taken. The claim token can, and it is the only check here that +# distinguishes our server from a same-version one belonging to another UNIX +# account on this machine. + +port_is_ours() { + command -v node >/dev/null 2>&1 || return 1 + [ -f "$PROBE" ] || return 1 + node "$PROBE" http "http://$ADDR:$1/health" "$CLAIM_TOKEN" >/dev/null 2>&1 +} + +# Deliberately without the token: it separates "a board is serving here and it +# is not ours" from "nothing is serving here at all", which are different facts +# and deserve different answers. +port_answers() { + command -v node >/dev/null 2>&1 || return 1 + [ -f "$PROBE" ] || return 1 + node "$PROBE" http "http://$ADDR:$1/health" >/dev/null 2>&1 +} + +owned_port() { + local record="$STATE/service-port.lavish" recorded="" + [ -r "$record" ] || return 1 + recorded=$(sed -n 's/^port=\([0-9][0-9]*\)$/\1/p' "$record" | head -1) + [ -n "$recorded" ] || return 1 + port_is_ours "$recorded" || return 1 + printf '%s\n' "$recorded" +} + +owner_field() { + [ -r "$OWNER" ] || return 1 + sed -n "s/^$1=\(.*\)$/\1/p" "$OWNER" | head -1 +} + +# A port outside the currently resolved window is as stale as a changed link +# host: an operator who narrows the window to move this service off a conflict +# would otherwise keep the old port forever, because it is still legitimately +# ours. +in_window() { + local port=$1 start=${WINDOW%%-*} end=${WINDOW##*-} + case "$start$end" in + ''|*[!0-9]*) return 0 ;; + esac + [ "$port" -ge "$start" ] && [ "$port" -le "$end" ] +} + +stop_server() { + # Safe only because the claim token has already proved this process is this + # home's; the same call against an unproven port could shut down a neighbour. + LAVISH_AXI_HOST=$ADDR LAVISH_AXI_PORT=$1 \ + "$LAVISH" stop --port "$1" >/dev/null 2>&1 || true +} + +MINE="" +STALE_SAME_PORT="" +RELOCATE_FROM="" +PROVEN=$(owned_port) || PROVEN="" +if [ -n "$PROVEN" ]; then + # The running server emits the link host it was born with, so a changed link + # host is a stale server rather than a reusable one. + if [ "$(owner_field link_host || true)" = "$LINK_HOST" ] \ + && [ "$(owner_field allowed || true)" = "$ALLOWED" ] \ + && in_window "$PROVEN"; then + MINE=$PROVEN + elif in_window "$PROVEN"; then + # Same port wanted, new configuration: stopping first is what frees the seat + # to be reclaimed immediately. + STALE_SAME_PORT=$PROVEN + else + # A different port is wanted, so the working board must survive until a + # replacement is actually secured. Tearing it down first would turn a failed + # move into a lost board. + RELOCATE_FROM=$PROVEN + fi +fi + +# --- port ------------------------------------------------------------------- + +if [ "$NEEDS_PORT" -eq 1 ] && [ -n "$STALE_SAME_PORT" ]; then + note "the running board server was started with a different address configuration; restarting it so links are correct" + stop_server "$STALE_SAME_PORT" +fi + +PORT="" +if [ "$NEEDS_PORT" -eq 1 ]; then + ALLOCATE=("$SCRIPT_DIR/fm-service-port.sh" lavish) + [ -z "$MINE" ] || ALLOCATE+=(--mine "$MINE") + if ! RECORD=$("${ALLOCATE[@]}" 2>&1); then + printf '%s\n' "$RECORD" >&2 + [ -z "$RELOCATE_FROM" ] \ + || note "the board already serving on port $RELOCATE_FROM was left running, so nothing was lost" + die "no port is available for a review board on this vessel, so no board was opened" 5 + fi + PORT=$(printf '%s\n' "$RECORD" | sed -n 's/^port=\([0-9][0-9]*\)$/\1/p' | head -1) + [ -n "$PORT" ] || die "the port allocator returned no port" 5 + if [ -n "$RELOCATE_FROM" ]; then + note "moving this vessel's boards from port $RELOCATE_FROM to $PORT; links already handed over on the old port stop working" + stop_server "$RELOCATE_FROM" + fi +else + PORT=${MINE:-${PROVEN:-$SEAT}} +fi + +# Without the probe there is no proof either way, and "nothing to stop" would be +# a concrete claim this vessel cannot make. +if [ "$SUBCOMMAND" = stop ] && { ! command -v node >/dev/null 2>&1 || [ ! -f "$PROBE" ]; }; then + die "cannot check whether a board server on $ADDR belongs to this vessel (node or $PROBE is unavailable), so nothing was stopped" 7 +fi + +if [ "$SUBCOMMAND" = stop ] && [ "$EXPLICIT_PORT" -eq 1 ]; then + # An explicitly named port earns no shortcut. It gets the same claim-token + # proof the recorded port gets, because lavish-axi's stop reaches any + # lavish-shaped server on the address it is handed. + case "$EXPLICIT_PORT_VALUE" in + ''|*[!0-9]*) + die "--port needs a port number, so this vessel can prove the server on it is its own before stopping it" 2 + ;; + esac + if ! port_is_ours "$EXPLICIT_PORT_VALUE"; then + if port_answers "$EXPLICIT_PORT_VALUE"; then + die "the board server on $ADDR port $EXPLICIT_PORT_VALUE is not one this vessel can prove is its own, so it was not stopped; a co-hosted vessel serves on this same address, and only the home that opened a board may stop it" 7 + fi + note "no review-board server owned by this vessel is running on $ADDR port $EXPLICIT_PORT_VALUE; nothing to stop" + exit 0 + fi + PORT=$EXPLICIT_PORT_VALUE +elif [ "$SUBCOMMAND" = stop ] && [ -z "$PROVEN" ]; then + note "no review-board server owned by this vessel is running on $ADDR; nothing to stop" + exit 0 +fi + +# --- environment ------------------------------------------------------------ + +export LAVISH_AXI_HOST="$ADDR" +export LAVISH_AXI_PORT="$PORT" +export LAVISH_AXI_LINK_HOST="$LINK_HOST" +export LAVISH_AXI_ALLOWED_HOSTS="$ALLOWED" +export LAVISH_AXI_STATE_DIR="$LAV_STATE" + +if [ "$NEEDS_PORT" -eq 1 ]; then + ( umask 077; printf 'port=%s\naddr=%s\nlink_host=%s\nallowed=%s\n' \ + "$PORT" "$ADDR" "$LINK_HOST" "$ALLOWED" > "$OWNER" ) 2>/dev/null || true +fi + +# This describes the HOST, not the outcome, and it is already known: no run can +# make a loopback-only vessel reachable or a tailnet one unreachable. So it goes +# out on every invocation that could open a board, before that board is opened. +# Saying it to somebody who only asked for help is noise; withholding it on a +# genuinely loopback-only host is the silent failure this whole mechanism exists +# to end. +if [ "$NEEDS_PORT" -eq 1 ]; then + if [ "$REACHABILITY" != tailnet ]; then + note "no tailnet on this host (${REASON:-reason unavailable}) - this board opens only on this machine." + elif [ -n "$REASON" ]; then + note "$REASON" + fi +fi + +# --- run -------------------------------------------------------------------- + +if [ "$SUBCOMMAND" != open ]; then + exec "$LAVISH" "${ARGS[@]}" +fi + +# stdout is captured and replayed so this wrapper can read what the run +# actually produced. An argument shape cannot answer "did a board open": a +# lavish-axi argv that dispatches `open` still opens nothing when it carries +# --help, and a wrapper that predicts instead of observing ends up describing a +# board that does not exist. +OUTPUT=$("$LAVISH" "${ARGS[@]}") +STATUS=$? +[ -z "$OUTPUT" ] || printf '%s\n' "$OUTPUT" + +# A session link in the output is the evidence that a board is now serving and +# that a link has been handed over. Without it there is no link to make any +# claim about, and silence is the honest answer. Only claims ABOUT A LINK hang +# off this; the host's own reachability is stated above regardless, so a change +# in lavish-axi's output shape can never silence that. The shape itself is +# pinned against the installed tool by tests/fm-lavish-access.test.sh. +case "$OUTPUT" in + *://*/session/*) OPENED=1 ;; + *) OPENED=0 ;; +esac + +# Verify the name that is now sitting in the captain's link, not the address we +# bound. A bind that succeeded proves nothing about whether the emitted URL +# resolves and answers, and the emitted URL is the only thing he can act on. +# This is still a same-host probe: it proves the name and the allowlist, not +# that his device has tailnet reach. +if [ "$OPENED" -eq 1 ] && [ "$STATUS" -eq 0 ] && command -v node >/dev/null 2>&1 && [ -f "$PROBE" ]; then + if ! node "$PROBE" http "http://$LINK_HOST:$PORT/health" >/dev/null 2>&1; then + note "the board is serving, but the link's hostname ($LINK_HOST) does not answer here - hand over http://$ADDR:$PORT/... instead and check this vessel's tailnet name." + fi +fi + +exit "$STATUS" diff --git a/bin/fm-service-port-probe.mjs b/bin/fm-service-port-probe.mjs new file mode 100755 index 000000000..d497d8bd6 --- /dev/null +++ b/bin/fm-service-port-probe.mjs @@ -0,0 +1,243 @@ +#!/usr/bin/env node +// Network oracle for bin/fm-service-port.sh: the two questions a shell cannot +// answer portably and must not answer by guessing. +// +// `bind` exists because a successful bind is the ONLY proof that a port is +// free. A registry file cannot stop another UNIX account on the same machine +// from taking a port, and a connect probe cannot see a holder that binds a +// different address on the same port. This probe performs the same +// `net.Server.listen()` a Node service will perform, so its answer is the +// answer the real service gets. It never holds a port: each candidate is bound, +// then immediately closed, so the caller still races anyone else who wants it. +// That residual race is real and is handled by the caller retrying, not by +// pretending the probe reserved anything. +// +// `resolve` exists because the hostname written into a link is only useful if +// it resolves to the address the service actually bound. Checking that before +// the URL is emitted is what keeps a link from failing silently on another +// device. +// +// `http` exists because a readiness probe must target the address the service +// actually bound. Probing loopback while a service binds only a tailnet address +// reports a healthy server as failed to start, which has already cost this +// fleet real time. The optional Host header also lets a caller ask a specific +// server "were you started with my configuration", which no shared health body +// can answer. +// +// Usage: +// fm-service-port-probe.mjs bind [ ...] +// Prints the first port that binds on , exit 0. +// Exit 3 - every candidate is in use (EADDRINUSE). +// Exit 4 - cannot be bound at all on this host +// (EADDRNOTAVAIL, EAFNOSUPPORT, EINVAL); the reason is on stderr. +// This is NOT a collision and callers must not report it as one. +// Only these address-scoped errnos abort the walk, because no +// port on this address could ever have worked. +// Exit 5 - no candidate was bindable and at least one failure was neither +// a collision nor address-scoped (EACCES on a privileged port, +// for instance). One summary line on stderr counts how many +// candidates were held and how many were refused, and names the +// distinct errnos, because "held" and "refused" are different +// facts and neither may be asserted for the other. This is a +// port-scoped verdict, so callers must not report it as an +// unusable address either. +// fm-service-port-probe.mjs resolve +// Exit 0 - resolves over IPv4 to . +// Exit 1 - it does not resolve, or resolves elsewhere; reason on stderr. +// fm-service-port-probe.mjs http [] +// Prints the HTTP status code. Exit 0 on 2xx, exit 1 otherwise. +// Bounded by FM_SERVICE_PORT_PROBE_TIMEOUT milliseconds (default 4000). +// +// Exit 2 is reserved for a usage error in every mode. + +import net from "node:net"; +import dns from "node:dns"; +import http from "node:http"; + +// Errors that mean "this address is unusable here", never "someone else holds +// this port". Reporting one of these as a collision would send the caller +// walking a whole port window that could never have worked - and, the other way +// round, reporting a port-scoped errno as one of these would abort a walk that +// the very next candidate would have satisfied. EACCES is deliberately NOT +// here: it is what a privileged port answers, which says nothing about the +// address. +const ADDRESS_UNUSABLE = new Set(["EADDRNOTAVAIL", "EAFNOSUPPORT", "EINVAL"]); + +function usage() { + process.stderr.write( + "Usage: fm-service-port-probe.mjs bind ...\n" + + " fm-service-port-probe.mjs resolve \n" + + " fm-service-port-probe.mjs http []\n", + ); +} + +function probeTimeoutMs() { + const raw = Number(process.env.FM_SERVICE_PORT_PROBE_TIMEOUT); + return Number.isInteger(raw) && raw > 0 ? raw : 4000; +} + +// Bind, listen, and release one candidate. Resolves "free", "taken", or an +// unusable-address verdict carrying the concrete errno. +function tryBind(addr, port) { + return new Promise((resolve) => { + const server = net.createServer(); + let settled = false; + const finish = (verdict) => { + if (settled) return; + settled = true; + resolve(verdict); + }; + server.once("error", (error) => { + const code = error && error.code ? error.code : "EUNKNOWN"; + if (code === "EADDRINUSE") { + finish({ state: "taken" }); + return; + } + if (ADDRESS_UNUSABLE.has(code)) { + finish({ state: "unusable", code }); + return; + } + finish({ state: "refused", code }); + }); + server.once("listening", () => { + // Close before reporting free, so the caller's own listen() is the next + // thing to touch the port rather than queueing behind this probe. + server.close(() => finish({ state: "free" })); + }); + // exclusive:true keeps a clustered parent from handing this probe a shared + // handle instead of a real bind, which would make the answer meaningless. + server.listen({ host: addr, port, exclusive: true }); + }); +} + +async function bindMode(argv) { + const addr = argv[0]; + const ports = argv.slice(1); + if (!addr || ports.length === 0) { + usage(); + return 2; + } + let taken = 0; + const refused = []; + for (const raw of ports) { + const port = Number(raw); + if (!Number.isInteger(port) || port < 1 || port > 65535) { + process.stderr.write(`fm-service-port-probe: not a port: ${raw}\n`); + return 2; + } + const verdict = await tryBind(addr, port); + if (verdict.state === "free") { + process.stdout.write(`${port}\n`); + return 0; + } + if (verdict.state === "unusable") { + process.stderr.write( + `fm-service-port-probe: cannot bind ${addr} on this host (${verdict.code})\n`, + ); + return 4; + } + if (verdict.state === "taken") taken += 1; + // Port-scoped, so the walk continues: the next candidate may well bind. + if (verdict.state === "refused") refused.push(verdict.code); + } + if (refused.length === 0) return 3; + const codes = [...new Set(refused)].join(", "); + process.stderr.write( + `fm-service-port-probe: nothing bound on ${addr}: ${taken} candidate(s) held (EADDRINUSE), ` + + `${refused.length} refused (${codes})\n`, + ); + return 5; +} + +async function resolveMode(argv) { + const [hostname, expected] = argv; + if (!hostname || !expected) { + usage(); + return 2; + } + let records; + try { + records = await dns.promises.lookup(hostname, { family: 4, all: true }); + } catch (error) { + const code = error && error.code ? error.code : "EUNKNOWN"; + process.stderr.write(`fm-service-port-probe: ${hostname} does not resolve (${code})\n`); + return 1; + } + const addresses = records.map((record) => record.address); + if (addresses.includes(expected)) return 0; + process.stderr.write( + `fm-service-port-probe: ${hostname} resolves to ${addresses.join(", ") || "nothing"}, not ${expected}\n`, + ); + return 1; +} + +function httpMode(argv) { + const [url, hostHeader] = argv; + if (!url) { + usage(); + return Promise.resolve(2); + } + let target; + try { + target = new URL(url); + } catch { + process.stderr.write(`fm-service-port-probe: not a URL: ${url}\n`); + return Promise.resolve(2); + } + if (target.protocol !== "http:") { + process.stderr.write(`fm-service-port-probe: only http:// is probed: ${url}\n`); + return Promise.resolve(2); + } + return new Promise((resolve) => { + const headers = {}; + // An explicit Host header lets the caller address one specific server's + // configured allowlist rather than whatever name happens to be in the URL. + if (hostHeader) headers.Host = hostHeader; + const request = http.request( + { + host: target.hostname, + port: target.port || 80, + path: `${target.pathname}${target.search}`, + method: "GET", + headers, + timeout: probeTimeoutMs(), + }, + (response) => { + const status = response.statusCode || 0; + response.resume(); + process.stdout.write(`${status}\n`); + resolve(status >= 200 && status < 300 ? 0 : 1); + }, + ); + request.once("timeout", () => { + request.destroy(new Error("timed out")); + }); + request.once("error", (error) => { + process.stdout.write("-\n"); + process.stderr.write( + `fm-service-port-probe: ${url} did not answer (${error && error.message ? error.message : error})\n`, + ); + resolve(1); + }); + request.end(); + }); +} + +async function main() { + const [mode, ...rest] = process.argv.slice(2); + if (mode === "bind") return bindMode(rest); + if (mode === "resolve") return resolveMode(rest); + if (mode === "http") return httpMode(rest); + usage(); + return 2; +} + +main().then( + (code) => { + process.exitCode = code; + }, + (error) => { + process.stderr.write(`fm-service-port-probe: ${error && error.message ? error.message : error}\n`); + process.exitCode = 2; + }, +); diff --git a/bin/fm-service-port.sh b/bin/fm-service-port.sh new file mode 100755 index 000000000..9b5194f9e --- /dev/null +++ b/bin/fm-service-port.sh @@ -0,0 +1,360 @@ +#!/usr/bin/env bash +# Resolve one vessel-local service's reachable address and a port it can +# actually bind, on a machine that may carry several vessels as separate UNIX +# accounts. +# +# This is a GENERAL allocator. Lavish review boards are its first consumer +# (bin/fm-lavish.sh), not its subject: any vessel-local service has the same +# problem, so nothing here knows what the service does. +# +# Two rules this script exists to enforce, both learned the hard way: +# +# 1. A successful bind is the ONLY proof a port is free. No registry file can +# stop another UNIX account's process from taking a port, so this script +# never consults a record to decide availability - it probes +# (bin/fm-service-port-probe.mjs) and treats EADDRINUSE as authoritative. +# The record it writes afterwards is a published fact, not a reservation. +# 2. Nothing is emitted that implies reach the vessel has not established. +# With no usable tailnet this prints reachability=loopback with a concrete +# reason instead of a tailnet address the consumer would put into a link +# that opens nothing off this machine. +# +# Usage: +# fm-service-port.sh [--mine ]... [--check] +# +# lowercase slug naming the service, e.g. lavish +# --mine a port the CALLER has already proved it owns and is still using. +# The first such port is returned unprobed, because probing a port +# you are yourself listening on would report a false collision. +# The ownership proof belongs to the caller; this script does not +# and cannot verify it. +# --check resolve identity and reachability only. No bind is attempted and +# no record is written, so no port is claimed: the output carries +# seat= (the deterministic preference) and no port= at all. +# +# Output: key=value lines on stdout, exit 0. +# +# service=lavish +# vessel=coditan +# machine=crew-hlr +# dnsname=crew-hlr.tail7b8448.ts.net +# addr=100.121.172.63 +# seat=4413 +# window=4400-4499 +# port=4413 +# reachability=tailnet +# reason= +# +# machine tailnet node name, the only fleet-unique machine identity +# available; unknown- when there is no tailnet, so a +# machine that cannot be identified is never silently treated as +# a distinct one. +# dnsname the hostname a link may use. Set ONLY when it resolves over IPv4 +# to addr, so a consumer never writes a name into a URL without +# that name having been checked. Empty means "use addr". +# seat the deterministic preferred port for this service in this home. +# port the port actually bound (absent under --check). +# reason empty when fully nominal; otherwise one plain sentence naming +# the concrete thing that is missing or degraded. +# +# Exit codes: +# 0 resolved +# 1 no port in the window could be bound: every candidate is held by another +# process, or some were refused by this host (a privileged port, say). Both +# are port-scoped, and the probe's own line counts how many candidates fell +# into each case and names the errnos, so neither is asserted for the other. +# 2 usage error +# 3 the resolved address cannot be bound on this host, or the probe runtime +# is unavailable - distinct from 1, because no port was ever contended +# +# The port window defaults to 4400-4499: above lavish-axi's compiled-in 4387 +# default so a stray bare invocation never lands on an allocated seat, and far +# below the ephemeral range. Override with FM_SERVICE_PORT_RANGE=-. +# +# The record is written to FM_HOME/state/service-port.. Read it to see +# who holds what; never read it to decide whether a port is free. +set -u + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" +FM_HOME="${FM_HOME:-$FM_ROOT}" +CONFIG="${FM_CONFIG_OVERRIDE:-$FM_HOME/config}" +STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" +PROBE="$SCRIPT_DIR/fm-service-port-probe.mjs" + +die() { + printf 'SERVICE_PORT: %s\n' "$1" >&2 + exit "${2:-2}" +} + +usage() { + cat <<'EOF' +Usage: fm-service-port.sh [--mine ]... [--check] + +Prints key=value lines describing this vessel's reachable address and a port it +actually bound for . --check resolves identity and reachability only +and claims no port. See the script header for the full contract. +EOF +} + +SERVICE="" +CHECK_ONLY=0 +MINE="" + +while [ "$#" -gt 0 ]; do + case "$1" in + --check) + CHECK_ONLY=1 + shift + ;; + --mine) + [ "$#" -gt 1 ] || die "--mine requires a port" + MINE="$MINE $2" + shift 2 + ;; + --mine=*) + MINE="$MINE ${1#--mine=}" + shift + ;; + -h|--help) + usage + exit 0 + ;; + -*) + usage >&2 + die "unknown argument: $1" + ;; + *) + [ -z "$SERVICE" ] || { usage >&2; die "unexpected argument: $1"; } + SERVICE=$1 + shift + ;; + esac +done + +[ -n "$SERVICE" ] || { usage >&2; die "a service name is required"; } +case "$SERVICE" in + [a-z0-9]|[a-z0-9][a-z0-9-]*) ;; + *) die "service name must be a lowercase slug: $SERVICE" ;; +esac + +# --- identity --------------------------------------------------------------- + +# The vessel id names the fleet member. It is NOT sufficient on its own to +# separate a secondmate from its parent: they share both the id and the UNIX +# account, so the realpath of FM_HOME is what actually separates their seats. +resolve_vessel() { + local raw="" + if [ -n "${FM_BRIDGE_VESSEL:-}" ]; then + raw=${FM_BRIDGE_VESSEL%% *} + elif [ -r "$CONFIG/bridge-vessel" ]; then + raw=$(tr -s '[:space:]' ' ' < "$CONFIG/bridge-vessel" 2>/dev/null || true) + raw=${raw## } + raw=${raw%% *} + fi + [ -n "$raw" ] || raw=$(basename -- "$FM_HOME") + raw=$(printf '%s' "$raw" | tr '[:upper:]' '[:lower:]' | tr -c 'a-z0-9-' '-') + raw=${raw%%-} + [ -n "$raw" ] || raw=vessel + printf '%s\n' "$raw" +} + +VESSEL=$(resolve_vessel) +HOME_REAL=$(cd "$FM_HOME" 2>/dev/null && pwd -P) || HOME_REAL=$FM_HOME + +# --- address ---------------------------------------------------------------- +# +# Everything below is read from the running tailscale at runtime. Nothing +# vessel-specific is compiled in, so this resolves correctly on every vessel +# including secondmate homes. + +ADDR="" +DNSNAME="" +MACHINE="" +REACHABILITY=loopback +REASON="" + +tailscale_json() { + command -v jq >/dev/null 2>&1 || return 1 + tailscale status --json 2>/dev/null +} + +resolve_tailnet() { + local json state addr host suffix dnsname + command -v tailscale >/dev/null 2>&1 || { + REASON="tailscale is not installed on this host" + return 1 + } + json=$(tailscale_json) || { + REASON="tailscale status could not be read as JSON (jq missing or tailscale not responding)" + return 1 + } + [ -n "$json" ] || { + REASON="tailscale status returned nothing" + return 1 + } + state=$(printf '%s' "$json" | jq -r '.BackendState // empty' 2>/dev/null) + [ "$state" = "Running" ] || { + REASON="tailscale is installed but not running (backend state: ${state:-unknown})" + return 1 + } + addr=$(printf '%s' "$json" | jq -r '[.Self.TailscaleIPs // [] | .[] | select(test("^[0-9]+\\.")) ] | first // empty' 2>/dev/null) + case "$addr" in + [0-9]*.[0-9]*.[0-9]*.[0-9]*) ;; + *) + REASON="tailscale reports no IPv4 address for this node" + return 1 + ;; + esac + host=$(printf '%s' "$json" | jq -r '.Self.HostName // empty' 2>/dev/null) + suffix=$(printf '%s' "$json" | jq -r '.MagicDNSSuffix // empty' 2>/dev/null) + dnsname=$(printf '%s' "$json" | jq -r '.Self.DNSName // empty' 2>/dev/null) + dnsname=${dnsname%.} + [ -n "$dnsname" ] || { [ -z "$host" ] || [ -z "$suffix" ] || dnsname="$host.$suffix"; } + ADDR=$addr + MACHINE=$host + REACHABILITY=tailnet + # A name is only worth writing into a link once it has been checked, so an + # unresolvable or misdirected name degrades to the bare address with a reason + # rather than becoming a URL that fails on the captain's device. A name that + # could not be checked at all is a different fact from one that was checked + # and pointed elsewhere, and saying the second when the first happened would + # hand the reader a concrete diagnosis that is simply untrue. + if [ -n "$dnsname" ]; then + if ! command -v node >/dev/null 2>&1 || [ ! -f "$PROBE" ]; then + REASON="the tailnet name $dnsname could not be checked here (node or $PROBE is unavailable), so links use the address instead" + elif node "$PROBE" resolve "$dnsname" "$addr" >/dev/null 2>&1; then + DNSNAME=$dnsname + else + REASON="the tailnet name $dnsname does not resolve to $addr here, so links use the address instead" + fi + fi + return 0 +} + +if ! resolve_tailnet; then + ADDR=127.0.0.1 + DNSNAME="" + REACHABILITY=loopback + MACHINE="" +fi +[ -n "$MACHINE" ] || MACHINE="unknown-$VESSEL" + +# --- window and deterministic seat ------------------------------------------ + +WINDOW_START=4400 +WINDOW_END=4499 +if [ -n "${FM_SERVICE_PORT_RANGE:-}" ]; then + case "$FM_SERVICE_PORT_RANGE" in + [0-9]*-[0-9]*) + WINDOW_START=${FM_SERVICE_PORT_RANGE%%-*} + WINDOW_END=${FM_SERVICE_PORT_RANGE##*-} + ;; + *) die "FM_SERVICE_PORT_RANGE must be -: $FM_SERVICE_PORT_RANGE" ;; + esac +fi +if [ "$WINDOW_START" -lt 1 ] || [ "$WINDOW_END" -gt 65535 ] || [ "$WINDOW_START" -gt "$WINDOW_END" ]; then + die "port window $WINDOW_START-$WINDOW_END is not a usable range" +fi +WINDOW_SIZE=$((WINDOW_END - WINDOW_START + 1)) + +# FM_HOME's realpath is in the key, not just the vessel id, because a secondmate +# home shares both the id and the UNIX account with its parent - exactly the +# case that must not collide. +SEAT_KEY=$(printf '%s\n%s\n%s\n' "$SERVICE" "$VESSEL" "$HOME_REAL") +SEAT_SUM=$(printf '%s' "$SEAT_KEY" | cksum | awk '{print $1}') +[ -n "$SEAT_SUM" ] || die "could not derive a deterministic seat (cksum unavailable)" 3 +SEAT=$((WINDOW_START + SEAT_SUM % WINDOW_SIZE)) + +emit() { + printf 'service=%s\n' "$SERVICE" + printf 'vessel=%s\n' "$VESSEL" + printf 'machine=%s\n' "$MACHINE" + printf 'dnsname=%s\n' "$DNSNAME" + printf 'addr=%s\n' "$ADDR" + printf 'seat=%s\n' "$SEAT" + printf 'window=%s-%s\n' "$WINDOW_START" "$WINDOW_END" + [ -z "${1:-}" ] || printf 'port=%s\n' "$1" + printf 'reachability=%s\n' "$REACHABILITY" + printf 'reason=%s\n' "$REASON" +} + +if [ "$CHECK_ONLY" -eq 1 ]; then + emit + exit 0 +fi + +# --- a port the caller already owns ----------------------------------------- + +for candidate in $MINE; do + case "$candidate" in + ''|*[!0-9]*) die "--mine must be a port number: $candidate" ;; + esac + if [ "$candidate" -ge 1 ] && [ "$candidate" -le 65535 ]; then + PORT=$candidate + break + fi + die "--mine must be a port number: $candidate" +done + +# --- bind ------------------------------------------------------------------ + +if [ -z "${PORT:-}" ]; then + command -v node >/dev/null 2>&1 || die "node is required to prove a port is free" 3 + [ -f "$PROBE" ] || die "port probe is missing: $PROBE" 3 + + CANDIDATES="" + offset=0 + while [ "$offset" -lt "$WINDOW_SIZE" ]; do + next=$((WINDOW_START + (SEAT - WINDOW_START + offset) % WINDOW_SIZE)) + CANDIDATES="$CANDIDATES $next" + offset=$((offset + 1)) + done + + # The probe's stderr is deliberately not discarded: it is silent on success, + # and on failure it is the only thing that names the concrete errnos. + # shellcheck disable=SC2086 + PORT=$(node "$PROBE" bind "$ADDR" $CANDIDATES) + probe_status=$? + case "$probe_status" in + 0) ;; + 3) + die "no free port in $WINDOW_START-$WINDOW_END on $ADDR for $SERVICE; every candidate is held by another process, so $SERVICE cannot start until one is released or FM_SERVICE_PORT_RANGE names a different window" 1 + ;; + 4) + die "$ADDR cannot be bound on this host, so $SERVICE has no address to serve on; re-check the network interface before treating this as a port collision" 3 + ;; + 5) + die "no bindable port in $WINDOW_START-$WINDOW_END on $ADDR for $SERVICE; the port probe's line above counts how many candidates were held and how many this host refused, and names the errnos, so read it before deciding whether the window or the account's permissions is what has to change" 1 + ;; + *) + die "the port probe failed for $SERVICE on $ADDR (exit $probe_status)" 3 + ;; + esac + case "$PORT" in + ''|*[!0-9]*) die "the port probe returned no usable port for $SERVICE on $ADDR" 3 ;; + esac +fi + +# --- published record ------------------------------------------------------- +# +# Written after the fact, never consulted to decide availability. + +RECORD="$STATE/service-port.$SERVICE" +if mkdir -p "$STATE" 2>/dev/null; then + { + printf '# PUBLISHED RECORD, NOT A LOCK.\n' + printf '# Nothing may treat this port as reserved and no allocator may read this\n' + printf '# file to decide whether a port is free. The only proof that a port is\n' + printf '# available is a successful bind. This exists so a human or an agent can\n' + printf '# see who holds what, and can spot a collision after the fact.\n' + printf '# Written by bin/fm-service-port.sh.\n' + emit "$PORT" + printf 'home=%s\n' "$HOME_REAL" + printf 'recorded_at=%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" + } > "$RECORD.tmp.$$" 2>/dev/null && mv -f "$RECORD.tmp.$$" "$RECORD" 2>/dev/null + rm -f "$RECORD.tmp.$$" 2>/dev/null || true +fi + +emit "$PORT" +exit 0 diff --git a/bin/fm-test-run.sh b/bin/fm-test-run.sh index 69254ab36..c153cd523 100755 --- a/bin/fm-test-run.sh +++ b/bin/fm-test-run.sh @@ -150,6 +150,7 @@ family_for_basename() { printf '%s\n' secondmate ;; fm-bootstrap.test.sh|fm-fleet-sync.test.sh|fm-gate-refuse.test.sh|fm-gotmp.test.sh|\ + fm-lavish-access.test.sh|\ fm-session-start.test.sh|fm-sessionstart-nudge.test.sh|fm-tangle-guard.test.sh|\ fm-update.test.sh) printf '%s\n' session-bootstrap diff --git a/docs/arm-pretool-check.md b/docs/arm-pretool-check.md index 756b125c0..039112d56 100644 --- a/docs/arm-pretool-check.md +++ b/docs/arm-pretool-check.md @@ -4,7 +4,7 @@ This document is the authoritative human-readable contract for the supervision a `bin/fm-arm-command-policy.mjs` is the single semantic owner. `bin/fm-arm-pretool-check.sh` is only the stable harness transport and output renderer. The tracked harness adapters forward command text without classifying it. -`bin/fm-arm-command-policy.mjs` is also the sole owner of firstmate's shell classification: it exports the tokenizer and command-position analysis, which the sibling cd-guard seatbelt (`bin/fm-cd-pretool-check.sh`, `docs/cd-guard.md`) reuses instead of duplicating shell lexing. +`bin/fm-arm-command-policy.mjs` is also the sole owner of firstmate's shell classification: it exports the tokenizer and command-position analysis, which the sibling cd-guard seatbelt (`bin/fm-cd-pretool-check.sh`, `docs/cd-guard.md`) and the lavish-guard seatbelt (`bin/fm-lavish-pretool-check.sh`, `docs/lavish-access.md`) reuse instead of duplicating shell lexing. ## Purpose and boundary diff --git a/docs/configuration.md b/docs/configuration.md index 2d51575b4..6f1decac3 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -23,6 +23,10 @@ The patterns stay narrow so the tracked `.claude/settings.json` and the tracked The producing PR and X helpers own the fields they append, `bin/fm-classify-lib.sh` owns status-event vocabulary, and `bin/fm-crew-state.sh` owns current-state reconciliation. Wake, watcher, direct Telegram receiver, away-mode, and X-specific state mechanics remain with their named scripts and reference sections rather than being duplicated into one exhaustive state tree here. +Vessel-local service reachability uses `state/service-port.` for the address and port one service took, and `state/lavish/` for this home's private review-board server state, session store, and claim token. +`bin/fm-service-port.sh` owns the record's fields and the allocation contract, and `docs/lavish-access.md` owns the reasoning; the one rule that binds every reader is that the record is a published fact written after the fact and never a reservation, so nothing may read it to decide whether a port is free. +Several vessels can share one machine as separate UNIX accounts, so a successful bind is the only proof a port is available. + Watcher coordination uses `state/.watch.lock` for the daemon pid, executable, home, manager, source, and X-mode identities, `state/.last-watcher-beat` for daemon freshness, `state/.wake-queue` for durable delivery, `state/.wake-queue.lock` for atomic append and drain, and `state/.wake-stub.lock` for the current session's delivery-stub pid plus executable, home, and session-lock identities. The tmux fallback also records `state/.watch-keeper.pid`, while systemd convergence writes the private mode-`0600` `state/.watch-service.env` environment file. @@ -168,7 +172,7 @@ Those overrides currently set `sandbox_mode = "workspace-write"`, `approval_poli The tracked `.codex/hooks.json` has `SessionStart`, `PreToolUse`, and `Stop` project hooks. Its `SessionStart` hook is the Codex integration for `bin/fm-sessionstart-nudge.sh`; see [`docs/sessionstart-nudge.md`](sessionstart-nudge.md) for the full native session-start nudge contract. Its `Stop` hook is the Codex integration for `bin/fm-turnend-guard.sh`; see [`docs/turnend-guard.md`](turnend-guard.md) for the full primary turn-end supervision contract. -Its `PreToolUse` hooks run the supervision-arm and cd-guard seatbelts plus a fail-open Graphify check. +Its `PreToolUse` hooks run the supervision-arm, cd-guard, and lavish-guard seatbelts plus a fail-open Graphify check. The Graphify hook exits successfully if `graphify` is not on `PATH`; otherwise it runs `graphify hook-check` with a ten-second timeout. That hook is intentionally portable and bounded so Codex tool use is not blocked by a missing Graphify install or a slow hook. @@ -582,6 +586,9 @@ FM_ZELLIJ_SESSION=firstmate # zellij-only: named session for normal backend ops FM_BACKEND_CMUX_COMPOSER_LINES=20 # cmux-only: tail lines scanned to locate the composer row for submit verification FM_BACKEND_CMUX_IDLE_RE='^Type a message\.\.\.$' # cmux-only: empty-composer placeholder regex after border/prompt stripping CMUX_SOCKET_PASSWORD= # cmux-only: socket password fallback when config/cmux-socket-password is absent (docs/cmux-backend.md) +FM_SERVICE_PORT_RANGE=4400-4499 # port window bin/fm-service-port.sh probes; above lavish-axi's 4387 default and below the ephemeral range +FM_SERVICE_PORT_PROBE_TIMEOUT=4000 # milliseconds allowed per readiness probe in bin/fm-service-port-probe.mjs http +FM_LAVISH_ALLOW_SHARE=0 # 1 permits `fm-lavish.sh share`, which publishes a review board to third-party hosting (docs/lavish-access.md) FM_SESSION_START_STATUS_TAIL=5 # state/*.status lines printed per task in the session-start digest FM_BOOTSTRAP_DETECT_ONLY=0 # internal/read-only session-start mode: skip bootstrap's mutating sweeps and print advisory TANGLE wording FM_GUARD_READ_ONLY=0 # internal/read-only guard mode: keep alarms but suppress drain, supervision repair, and checkout repair commands diff --git a/docs/fleet-service-port-registry.md b/docs/fleet-service-port-registry.md new file mode 100644 index 000000000..73c0ed821 --- /dev/null +++ b/docs/fleet-service-port-registry.md @@ -0,0 +1,124 @@ +# Cross-vessel service-port registry - proposal + +**Status: proposal, not binding.** +This changes a schema other vessels depend on, so it is written to be put to them through Bridge before it means anything. +Nothing in firstmate reads or writes this registry today, and nothing should be made to depend on it landing. + +The vessel-local half of this work - address resolution, port allocation, the entry point, honest degradation - is implemented and needs nobody's agreement, because it is entirely local to one vessel. +See `docs/lavish-access.md`. +This document covers only the part that is shared. + +## The problem + +Several vessels run on one physical machine as separate UNIX accounts. +They cannot see each other's private state and cannot coordinate through it, so today they avoid each other's ports by luck. + +Measured on `crew-hlr`, 2026-07-30: three UNIX accounts exist on this machine, and in the Lavish port band there were three listeners of which only one belonged to this vessel. +`lavish-axi`'s compiled-in default port 4387 was held by a different account, by something that is not an HTTP server, and another account held 4392 with an all-interfaces bind. +A vessel that assumes a fixed port on this machine is already wrong. + +## Position 1: the registry is a published record, never a lock + +Correctness does not depend on this registry and must not be allowed to. + +No file can stop another UNIX account's process from taking a port. +A registry entry claiming a port is free would be one more instrument reporting success while being wrong, which is the failure class this whole piece of work exists to remove. +The only proof a port is available is a successful bind, and `bin/fm-service-port.sh` already treats it that way. + +The registry exists so a human or an agent can see who holds what and can spot a collision after the fact. +The schema says so in its own comment, so no later reader mistakes it for an allocator. + +## Position 2: the current host field cannot detect a co-host collision + +`doctrine/roster.yaml` in `coditan-bridge` (schema `bridge-roster.v1`) records a `host:` per vessel: + +| vessel | host | +| --- | --- | +| captain | `windows-wsl-local` | +| tugboat | `local-wsl` | +| sc1 | `local-wsl` | +| sc2 | `local-wsl` | +| hlr | `crew-hlr` | +| ak | `crew-allesknut` | +| coditan | `crew-hlr` | + +Two problems, and the second is the one that settles it: + +1. `local-wsl` is claimed by three vessels, so it is a label rather than an identity. + A registry keyed on it cannot distinguish "three vessels on one machine" from "three vessels on three machines, all badly named" - which is exactly the distinction a collision report needs. +2. There is a UNIX account named `tugboat` on `crew-hlr`, while the roster places tugboat on `local-wsl`. + That does not by itself prove the tugboat vessel runs here, and it is not asserted as proof; it does show the field cannot be trusted to answer the co-host question either way. + +So the machine key has to be self-reported by each vessel from something the vessel can observe, not inherited from the current field or guessed centrally. + +## Position 3: the machine key is the tailnet node name + +Proposal: add `machine:`, keyed on the tailnet node name (`tailscale status --json`, `Self.HostName`). + +It is already unique across the fleet, it is already how vessels reach each other, and two rows (`hlr`, `coditan`) already carry exactly that value in `host:`. +No new naming authority has to be invented, and no vessel has to be told what its machine is called. + +A vessel with no tailnet writes `machine: unknown-` and is excluded from co-host reasoning. +Honest degradation applies to the registry too: a machine that cannot be identified must never be silently treated as a distinct one. + +## Position 4: this outlives Lavish + +Any vessel-local service has this problem, so the allocator is a general mechanism that Lavish is merely the first consumer of. +The registry follows: `services:` is a list, keyed by service name, not a `lavish_port:` field. + +## Proposed block + +Inside the per-vessel manifest that `fleet-repo-vessel-manifests` already plans: + +```yaml +# fleet/vessels/coditan.yaml (excerpt) +# +# PUBLISHED RECORD, NOT A LOCK. +# Nothing may treat these ports as reserved, and no allocator may consult this +# file to decide whether a port is free. A vessel's only proof that a port is +# available is its own successful bind. This block exists so a human or an +# agent can see who holds what, and can spot a collision after the fact. +machine: crew-hlr # tailnet node name; the machine key +account: coditan # UNIX account on that machine +services: + - name: lavish + port: 4413 + bind: tailnet # tailnet | loopback + observed: 2026-07-30 # when this vessel last bound it, not a claim about now +``` + +Each vessel reads those values straight out of its own `FM_HOME/state/service-port.`, which `bin/fm-service-port.sh` already writes in exactly this shape. + +## Migration, without central guessing + +1. Add `machine:` as a new **optional** field. + Nothing breaks; `host:` stays as a free-text human label through the transition. +2. Each vessel self-reports its own `Self.HostName` on its next push. + No vessel writes another vessel's key, and no central pass rewrites the wrong values - they are wrong precisely because they were not self-reported. +3. A vessel with no tailnet writes `machine: unknown-`. +4. Once every row carries `machine:`, `host:` is either dropped or explicitly demoted to a human comment. + +## What each vessel must do + +1. Run `bin/fm-service-port.sh ` once and read its record. +2. Add `machine:`, `account:`, and the `services:` block to its own manifest in its next fleet-repo PR. +3. Nothing else. + +No coordination, no ordering, no cutover window. +A vessel that never adopts this loses the published record and keeps working, because correctness never depended on it. + +## The collision check is advisory + +A `fmf-`style check can report duplicate `(machine, port)` pairs as an advisory collision report. +It must never gate. +A gate would make CI depend on a record that is, by construction, allowed to be stale - and would hand a false green to whichever vessel happened to update last. + +## Where it lands, and when + +Inside `fleet-repo-vessel-manifests`, not beside it. +That item already plans `fleet/vessels/.yaml` per vessel carrying host, account, scope, projects, harness, toolset, role, and capabilities, and already plans to move the roster into the fleet repo as the identity source with `coditan-bridge` consuming rather than owning it. +A ports block is one more declarative section of the same manifest; a parallel registry beside it would be exactly the divergence that item exists to end. + +Sequencing, stated rather than buried: `fleet-repo-vessel-manifests` is `blocked-by: fleet-repo-migration`. +The vessel-local half ships independently and immediately. +This half queues behind that migration, and must not be used as an argument for accelerating it. diff --git a/docs/lavish-access.md b/docs/lavish-access.md new file mode 100644 index 000000000..b3343ac51 --- /dev/null +++ b/docs/lavish-access.md @@ -0,0 +1,333 @@ +# Reachable review boards + +Lavish review boards are how firstmate hands the captain a decision surface. +A bare `lavish-axi` binds loopback and writes `http://127.0.0.1:/...` into the link it hands over, which opens nothing on his PC or phone. +The failure is silent: the board renders correctly on the machine that made it, so nothing signals that the link is dead everywhere else. + +`bin/fm-lavish.sh` is the entry point that fixes it, `bin/fm-service-port.sh` is the general port allocator underneath it, and `bin/fm-lavish-pretool-check.sh` is the guard that stops an agent reaching for the old command out of habit. +Each script's header owns its exact flags and mechanics; this document owns the contract between them, the reasoning, and the validation record. + +## Read this first: one acceptance criterion is not proven + +The second acceptance criterion - an HTTP 200 for a board fetched from a *different* device on the tailnet - is **not proven**. +It could not be proven from the machine this was built on: the other Linux nodes reject `tailscale ssh` with no known host key, and the captain's PC and phone cannot be driven by an agent. + +What is proven is everything on this side of the wire: the server binds only the tailnet address and loopback is not served, the emitted hostname resolves to that address, a request carrying the tailnet name is accepted by the Host allowlist and returns 200, and the tailnet peer path to the captain's PC answers. +So the mechanism is demonstrated up to the last hop, and the last hop is the part nobody here can perform. + +One fetch of a wrapper-emitted link, from the captain's own PC or phone, closes it. +The phone is the case worth doing specifically, for the reason recorded under "Open gap: the off-device request" at the end of this document, which also carries the full attempt log. + +## Why this could not be a documented environment variable + +`~/.bashrc` returns immediately for non-interactive shells, and an agent tool shell loads its own snapshot instead, so an operator's profile export never reaches an agent's invocation. +That route had already been tried here and had already failed once: the problem was written onto a review board, and the link to that same board went out on loopback. +The fix therefore has to be a firstmate-owned mechanism that is hard to bypass by habit, not a note somebody has to remember. + +## What the entry point sets + +| Variable | Value | Why | +| --- | --- | --- | +| `LAVISH_AXI_HOST` | this vessel's tailnet IPv4 address | reachable off this machine, and never a wildcard, because an all-interfaces bind is broader than was approved | +| `LAVISH_AXI_PORT` | a port proved free by `bin/fm-service-port.sh` | vessels sharing one machine do not collide | +| `LAVISH_AXI_LINK_HOST` | the tailnet DNS name, but only once it has been confirmed to resolve to the bound address; otherwise the address | the hostname the captain actually receives is checked before he receives it | +| `LAVISH_AXI_ALLOWED_HOSTS` | this vessel's DNS name, short node name, address, and this home's claim token; never `*` | a closed Host allowlist rather than an open one | +| `LAVISH_AXI_STATE_DIR` | `FM_HOME/state/lavish` | a secondmate home shares its UNIX account with its parent, so without this they share one server and one session store | + +Every value is resolved at runtime from `tailscale status --json`. +Nothing vessel-specific is compiled in, which is what makes this work on every vessel including secondmate homes rather than only on the one it was built on. + +## The three failures this design is built around + +Each was measured on `crew-hlr` on 2026-07-30, not inferred. + +### A fixed default port is contended across UNIX accounts + +This host carries three accounts (`coditan`, `crew`, `tugboat`). +`lavish-axi`'s compiled-in default is 4387, and on this host 4387 was already held by a different account by something that is not even an HTTP server. +No vessel may assume a fixed port, so `bin/fm-service-port.sh` derives a deterministic preferred seat and probes forward from it. + +### A same-version neighbour is silently adopted, not refused + +`ensureServer()` decides whether to reuse an existing server purely from the `/health` body, and `shouldRestartServer` returns false when the version matches. +Every `lavish-axi` of the same version returns a byte-identical body, so a vessel configured onto a port another UNIX account already serves does not collide loudly - it adopts that account's server process and emits a URL pointing at it. + +This is why `bin/fm-lavish.sh` writes a per-home **claim token** into the Host allowlist. +A request carrying `Host: ` is accepted only by a server that was started with that token, so one probe answers "is the server on my port MY server", which no shared health body can answer. +The token adds no exposure: anything that can already reach the port can already reach it under the address, and the token only makes one specific Host header identify one specific home's server. + +### Setting the environment at call time does not fix an already-running server + +The session URL is built inside the server process from the environment it was born with, and the server is spawned detached by whichever invocation first started it. +So exporting a corrected `LAVISH_AXI_LINK_HOST` on a later invocation changes nothing while a healthy server is still running on that port. +`bin/fm-lavish.sh` records the configuration it launched a server with, compares it on the next run, and restarts its own server on a mismatch - which is safe only because the claim token has already proved the process belongs to this home. + +Stopping reaches into a running process the same way, so it carries the same proof, and an explicitly named port is no exception. +`lavish-axi stop` shuts down whatever answers `/health` with a lavish-axi body on the address it is handed, without authentication and without regard for the owning UNIX account, and every co-hosted vessel binds that same address. +So `bin/fm-lavish.sh stop --port ` runs the claim-token probe against `` before it reaches `lavish-axi` at all: a port that answers but is not this home's is refused by name and left running, and a port that answers nothing is reported as nothing to stop. + +A port outside the currently resolved window counts as the same kind of staleness: an operator who narrows `FM_SERVICE_PORT_RANGE` to move this service off a conflict would otherwise keep the old port forever, because it is still legitimately ours. +The two cases are handled differently on purpose. +When the same port is still wanted, the old server is stopped first so the seat can be reclaimed immediately. +When a different port is wanted, the working board keeps serving until a replacement port has actually been secured, so a failed move cannot turn into a lost board - and a successful move says plainly that links already handed over on the old port stop working. + +## The port allocator + +`bin/fm-service-port.sh` is general: Lavish is its first consumer, not its subject, because any vessel-local service has this problem. + +Two rules it exists to enforce: + +- **A successful bind is the only proof a port is free.** + No registry file can stop another UNIX account's process from taking a port, and a stale entry claiming otherwise would be one more instrument reporting success while being wrong. + The allocator probes with `bin/fm-service-port-probe.mjs`, which performs the same `net.Server.listen()` a Node service will perform, and treats `EADDRINUSE` as authoritative. + A specific-address bind also fails against a holder that bound the wildcard, so probing `:` detects `0.0.0.0` squatters from other accounts too. +- **The record is a published fact, not a reservation.** + `FM_HOME/state/service-port.` is written after the fact and says so in its own first lines. + Nothing reads it to decide availability. + +The deterministic seat is `4400 + cksum("\n\n") % 100`, probing forward with wraparound over the whole window. +`FM_HOME`'s realpath is in the key rather than the vessel id alone, because a secondmate home shares both the id and the UNIX account with its parent - exactly the case that must not collide. +The window sits above 4387 so a stray bare invocation never lands on an allocated seat, and far below this host's ephemeral range of 32768-60999. + +A bound port that is released before the consumer's own `listen()` is a genuine race, and it is handled by the consumer retrying rather than by pretending the probe reserved anything. + +## Honest degradation + +The rule is that no URL is emitted implying reach the vessel has not established. + +- **No usable tailnet** - `reachability=loopback` with a concrete reason, and the wrapper prints `no tailnet on this host () - this board opens only on this machine.` alongside Lavish's own output. + The board still opens locally and is never presented as reachable. +- **The tailnet name does not resolve to the bound address** - the link falls back to the address and the reason says so, rather than writing an unchecked name into the captain's link. +- **The tailnet name could not be checked at all**, because `node` or the probe is unavailable - the link falls back to the address and the reason says *that*, since reporting a resolution failure that was never attempted is a concrete diagnosis that happens to be untrue. +- **The link host does not answer after the board opens** - the wrapper names the address form that does work. +- **Every candidate port is taken** - `bin/fm-service-port.sh` exits non-zero with one plain sentence and no board is opened. + There is deliberately no silent loopback downgrade here, because that would reproduce the original bug somewhere new. +- **The address cannot be bound at all** - a distinct exit code and message, because that is a network-interface problem and reporting it as a port collision would send the reader hunting the wrong thing. + Only address-scoped errnos (`EADDRNOTAVAIL`, `EAFNOSUPPORT`, `EINVAL`) count as this, and they are the only ones that end the walk early, since no port on that address could have worked. + A port-scoped refusal such as `EACCES` on a privileged port is neither a collision nor an unusable address: the walk continues past it, and only if nothing in the window binds does the allocator report that some candidates were held and others refused. + +A readiness probe must target the address that was bound. +Probing loopback while a service binds only a tailnet address reports a healthy server as failed to start; that happened during this investigation and cost real time. +`bin/fm-service-port-probe.mjs http` exists so callers cannot get this wrong by accident. + +### The wrapper observes rather than predicts + +Sentences about a **link** are held back until the wrapper has seen a link. +It decides that from a session URL in what the run printed, not from the shape of the arguments it was given. + +The reason is that argument shapes cannot answer the question. +`lavish-axi` dispatches `open` for `--help .html` and for `open --help`, and then its CLI layer returns the command's help text without ever calling the handler, so an invocation that dispatches `open` can still open nothing. +Predicting that from argv means keeping a copy of somebody else's argument handling and getting it wrong on the next shape, which is what happened twice here before this rule replaced it. +Observation has no such failure mode: no session URL means no link exists, and silence is the only honest thing to say about a link that does not exist. + +Sentences about the **host** are a different kind of fact and must not be held back with them. +Whether this vessel has a usable tailnet is settled before anything runs, and no run changes it, so `no tailnet on this host ... this board opens only on this machine.` goes out on every invocation that could open a board. +Telling somebody their boards are local-only when they only asked for help is harmless noise; staying quiet about it on a genuinely loopback-only host is the exact silent failure this mechanism exists to end, so the two are deliberately not gated together. + +That leaves one dependency on somebody else's output shape, and it is pinned rather than trusted. +`tests/fm-lavish-access.test.sh` opens a board with the *installed* `lavish-axi` and asserts its output still carries the session URL the wrapper looks for, so a future release that changes that shape fails a test here instead of quietly disabling the link check. +The test skips visibly when `lavish-axi` is not installed, because a check that passes without checking anything is the same failure in a smaller package. + +Two things still have to be decided before the run, and are therefore still decided from the arguments. +The port claim comes first because a port has to exist before the command can run at all, so `bin/fm-lavish.sh open --help .html` does take a port from the window and does rewrite `state/service-port.lavish` and `state/lavish/fm-owner`, and can restart or relocate this vessel's own server on the way. +That is the wrapper's ordinary open bookkeeping against its own home rather than a false statement to the captain, and closing it would mean running the command before knowing what port to run it on. +The other is a command that replaces this process with `lavish-axi` (`poll`, `end`, `server`): nothing after an `exec` can observe anything, which is one more reason the host's own reachability is stated before the run rather than after it. + +## Third-party publishing is refused by default + +`lavish-axi share` publishes a board to third-party hosting. +Review boards carry vessel names, security findings, and captain decisions, so `bin/fm-lavish.sh share` refuses unless `--fm-allow-share` or `FM_LAVISH_ALLOW_SHARE=1` makes the intent explicit. +This does not remove the capability, it requires intent, and the refusal names the override rather than being a dead end. + +Lavish's own browser chrome also offers a publish action in its overflow menu. +That path is the reviewer's own choice in their own browser and is outside this wrapper's reach; the refusal covers the command line, which is where an agent would reach for it. + +## What this does not do + +A board remains an unauthenticated server that anything on the tailnet can read. +That is the fleet's existing trust boundary, not a new exposure, and this design does not widen it - the bind is one specific address, the Host allowlist is closed, and nothing is published outside the tailnet. +It does not close it either, and no part of this should be read as adding authentication. + +## The guard + +`bin/fm-lavish-pretool-check.sh` denies a bare `lavish-axi` in command position and names the wrapper in the reason. +`bin/fm-lavish-command-policy.mjs` owns the decision and imports the shell classifier from `bin/fm-arm-command-policy.mjs`, so it never duplicates shell lexing and never evaluates a byte of the submitted command. + +Its scope deliberately differs from the cd-guard (`docs/cd-guard.md`), which is inert outside a plain primary checkout. +This guard fires wherever `bin/fm-lavish.sh` exists, **including a linked task worktree**, because boards get opened from crew worktrees too and a guard that is inert exactly where the mistake happens is not a guard. + +Its structural limit, stated rather than implied: the hook is registered by firstmate's own harness configuration, so it reaches firstmate checkouts and not a worktree of some other project. +`bin/fm-brief.sh` covers that gap by naming the wrapper in every generated brief's rules. + +`command -v lavish-axi` and `type lavish-axi` are allowed: they ask whether the tool exists, never start a server, and `bin/fm-bootstrap.sh` does exactly this, so denying them would break tool detection to prevent nothing. +The same reasoning allows the subcommands that neither start a server nor emit a link - `setup`, `playbook`, `design`, and `export` - because `bin/fm-bootstrap.sh` and `bin/fm-axi-suite.sh` print `... && PATH=:$PATH lavish-axi setup hooks` as the install command the captain is told to run, and a guard that denies its own repo's instructions prevents nothing while costing trust in every other denial. +Those four are safe to allow for one specific reason: `lavish-axi` rewrites an argv into `open` only when the first word is not one of its own subcommands, so an HTML path after `export` cannot turn it into a board. +Version and help flags are NOT allowed, even though they serve nothing on their own, because that rewrite makes `lavish-axi --version board.html` an `open`. +Separating those would mean keeping a second copy of `lavish-axi`'s argv normalisation in this guard, which would drift; a wrong allow here is silent, while a wrong deny is loud and answered by running the wrapper. +Because that deny sends `lavish-axi --version` through `bin/fm-lavish.sh`, the wrapper does mirror the one fact the guard refuses to: a flag-led argv opens a board only when some argument is an html path. +The asymmetry is the point rather than an inconsistency - the guard fails safe by denying and never runs anything, while the wrapper has to run the command, and treating `--version` as an open would claim a port, rewrite this home's records, and then report on a board that was never opened. +`stop` is deliberately not on that list either: shutting a server down is the ownership-sensitive action described above, and the wrapper is the path that proves the port first. + +## The startup regression check + +`bin/fm-bootstrap.sh`'s `lavish_access_check` prints `LAVISH_ACCESS:` when this vessel has a tailnet but open board links still point at loopback. +It reads the wrapper's own session store and the default `~/.lavish-axi/state.json`, which is where a bare invocation writes. +It is detect-only, ordered so the cheap file reads run before the address resolver, and silent on a host with no tailnet. +`bootstrap-diagnostics` owns the response. + +This check is the reason the fix cannot regress unnoticed, which matters because it already did regress once while the fix sat queued. + +## Compatibility review + +- **Harnesses** (claude, codex, opencode, pi, grok) - the wrapper and the allocator are ordinary scripts and are harness-neutral. + The guard is not, and is registered on all five surfaces the way the cd-guard is: `.claude/settings.json` for Claude, `.codex/hooks.json` for Codex with the same self-registration check the other Codex hooks use, `.grok/hooks/fm-primary-lavish-check.json` for Grok, `.opencode/plugins/fm-primary-lavish-check.js` for OpenCode, and a `runChecker` call in `.pi/extensions/fm-primary-turnend-guard.ts`'s `tool_call` handler for Pi. + The last two consume the `--command` CLI form plus exit 2 and stderr, which the transport already emits. + Leaving OpenCode and Pi unregistered would have left the guard inert on two of the five harnesses this fleet spawns agents on, which is the same "inert exactly where the mistake happens" failure the scope decision above exists to avoid. + What each registration actually reaches differs, and the difference is worth stating rather than rounding up. + OpenCode reaches everything: a crewmate runs inside its worktree and OpenCode discovers `.opencode/plugins/` from the project root, so the plugin loads there as well as in the primary. + Pi reaches the primary session and a Pi secondmate home, which are the sessions launched with `-e .pi/extensions/fm-primary-turnend-guard.ts`, and it does not reach a Pi crewmate or scout: `bin/fm-spawn.sh` launches those with a generated per-task extension that carries a `turn_end` handler and no `tool_call` hook at all. + That is the same structural limit the cd-guard already has on Pi crews and is not something this change introduces; closing it means changing what `bin/fm-spawn.sh` generates for every Pi crew launch, which is its own task rather than a rider on this one. + A Pi crewmate is covered by the brief rule instead, which is the same fallback `bin/fm-brief.sh` provides for worktrees of other projects. +- **Runtime backends** (tmux, herdr, zellij, orca, cmux) - not applicable, confirmed by inspecting `bin/fm-backend.sh`'s surface rather than assumed. + No backend touches service ports or the AXI prefix, and the allocator runs inside an already-spawned worker's shell. +- **Secondmate homes** - covered by construction: the seat key includes the realpath of `FM_HOME`, and `LAVISH_AXI_STATE_DIR` is per home, so a secondmate and its parent never share a seat or a session store despite sharing a UNIX account. +- **Hosts without tailscale** - covered by the loopback degradation path, the only branch where behaviour matches today's, apart from the honest warning. +- **Hosts without `jq`** - the allocator degrades to `reachability=loopback` with a reason, and the startup check stays silent. + +## Validation record + +Host `crew-hlr`, tailnet `tail7b8448.ts.net`, address `100.121.172.63`, UNIX account `coditan`, `lavish-axi` 0.1.43, 2026-07-30. + +### Two vessels on one machine, no clash + +Two scratch homes, no coordination between them, each opening a board through the wrapper: + +``` +$ FM_HOME=/vesselA bin/fm-lavish.sh /vesselA/.lavish/board.html --no-open + url: "http://crew-hlr.tail7b8448.ts.net:4411/session/4cedced659385d92" +$ FM_HOME=/vesselB bin/fm-lavish.sh /vesselB/.lavish/board.html --no-open + url: "http://crew-hlr.tail7b8448.ts.net:4484/session/7a4994b5c3bdd3fe" + +$ ss -ltnH | awk '$4 ~ /:(44[0-9][0-9])$/ {print $4}' +100.121.172.63:4484 +100.121.172.63:4411 + +$ curl -s -o /dev/null -w '%{http_code}' http://crew-hlr.tail7b8448.ts.net:4411/session/4cedced659385d92 +200 +$ curl -s -o /dev/null -w '%{http_code}' http://crew-hlr.tail7b8448.ts.net:4484/session/7a4994b5c3bdd3fe +200 +``` + +Both bind the tailnet address only, on different ports, and both serve concurrently. + +### The bind probe is authoritative across UNIX accounts + +``` +$ node bin/fm-service-port-probe.mjs bind 100.121.172.63 4388 4387 4392 4405 +4405 +$ node bin/fm-service-port-probe.mjs bind 100.121.172.63 4392 ; echo $? +3 +$ node bin/fm-service-port-probe.mjs bind 10.9.9.9 4400 ; echo $? +fm-service-port-probe: cannot bind 10.9.9.9 on this host (EADDRNOTAVAIL) +4 +``` + +4388 was this account's own server, 4387 and 4392 were held by other accounts, and 4392 was a wildcard bind - all three were correctly refused, and the unusable address was reported as exit 4 rather than as a collision. + +### The claim token distinguishes our server from a neighbour's + +``` +$ curl -s -o /dev/null -w '%{http_code}' -H 'Host: crew-hlr' http://100.121.172.63:4388/health +200 +$ curl -s -o /dev/null -w '%{http_code}' -H 'Host: crew-hlr' http://100.121.172.63:4392/health +403 +$ curl -s -o /dev/null -w '%{http_code}' -H 'Host: fm-claim-testnonce123' http://100.121.172.63:4388/health +403 +$ curl -s -o /dev/null -w '%{http_code}' -H 'Host: 127.0.0.1' http://100.121.172.63:4392/health +200 +``` + +4392 is another account's server, and it answers loopback identically to ours - which is exactly why loopback cannot be the discriminator and a per-home token can. + +### A neighbour's server is refused, and the working board survives the refusal + +Vessel B pointed at the port vessel A's server already holds, using the real wrapper end to end: + +``` +$ FM_HOME=/vesselB FM_SERVICE_PORT_RANGE=4411-4411 bin/fm-lavish.sh --no-open +SERVICE_PORT: no free port in 4411-4411 on 100.121.172.63 for lavish; every candidate is held by another process, ... +fm-lavish: the board already serving on port 4484 was left running, so nothing was lost +fm-lavish: no port is available for a review board on this vessel, so no board was opened +exit=5 + +$ ss -ltnH | awk '$4 ~ /:(44[0-9][0-9])$/ {print $4}' +100.121.172.63:4484 +100.121.172.63:4411 +$ curl -s -o /dev/null -w '%{http_code}' http://crew-hlr.tail7b8448.ts.net:4484/session/... +200 +``` + +No adoption, no board, and vessel B's existing board still serving. +The first attempt at this design did stop B's own server before discovering the new port was unavailable, which is why the preserve-before-move rule above exists. + +### Reuse, restart, and relocation + +``` +$ FM_HOME=/vesselB bin/fm-lavish.sh --no-open # stale link host recorded +fm-lavish: the running board server was started with a different address configuration; restarting it so links are correct + url: "http://crew-hlr.tail7b8448.ts.net:4484/session/..." # same port reclaimed + +$ FM_HOME=/vesselB FM_SERVICE_PORT_RANGE=4470-4479 bin/fm-lavish.sh --no-open +fm-lavish: moving this vessel's boards from port 4484 to 4474; links already handed over on the old port stop working + url: "http://crew-hlr.tail7b8448.ts.net:4474/session/..." + +$ FM_HOME=/vesselB bin/fm-lavish.sh stop # -> status: stopped, port: 4474 +$ FM_HOME=/vesselB bin/fm-lavish.sh stop # -> nothing to stop +``` + +A second open of an unchanged board reuses the running server and leaves exactly one listener. + +### The loopback readiness trap, reproduced + +``` +$ node bin/fm-service-port-probe.mjs http http://127.0.0.1:4411/health ; echo $? +fm-service-port-probe: http://127.0.0.1:4411/health did not answer (connect ECONNREFUSED 127.0.0.1:4411) +1 +$ node bin/fm-service-port-probe.mjs http http://100.121.172.63:4411/health ; echo $? +200 +0 +``` + +The server on 4411 is healthy and serving throughout; loopback simply is not where it lives. + +### Guard behaviour + +``` +$ bin/fm-lavish-pretool-check.sh --command 'lavish-axi board.html' ; echo $? +{"hookSpecificOutput":{...,"permissionDecision":"deny"},"systemMessage":"[bare-lavish-axi] ..."} +{"decision":"deny","reason":"[bare-lavish-axi] ..."} +2 +$ bin/fm-lavish-pretool-check.sh --claude --command 'lavish-axi board.html' >/dev/null ; echo $? +2 +$ bin/fm-lavish-pretool-check.sh --command 'bin/fm-lavish.sh board.html' ; echo $? +0 +$ FM_ROOT_OVERRIDE=/tmp bin/fm-lavish-pretool-check.sh --command 'lavish-axi x.html' ; echo $? +0 +``` + +Claude's `--claude` deny leaves stdout empty, Grok's stdin shape returns the stdout decision object, and a checkout without the wrapper is inert. +`tests/fm-lavish-access.test.sh` owns the full acceptance matrix. + +### Open gap: the off-device request + +Acceptance asked for an HTTP 200 fetched from a different device on the tailnet. +That was not completed from this task's worktree: the two other Linux nodes (`aurora`, `crew-allesknut`) refuse SSH with `Permission denied (publickey,password)` and do not advertise a Tailscale SSH host key, and the captain's own `timbook` and `s26-ultra-von-tim` cannot be driven from here. + +What was proved instead is everything on this side of the wire: the server binds only the tailnet address (loopback is refused), the emitted hostname resolves to that address, a request carrying the tailnet name is accepted by the Host allowlist and returns 200, and `tailscale ping timbook` answers directly. +The remaining check is one request from the captain's PC or phone against a link the wrapper emits. +The phone is the one worth doing specifically: the MagicDNS name carries both an A and an AAAA record while Lavish binds one address, so a v6-preferring client reaches a closed port first and has to recover through Happy Eyeballs. + +## Related + +- `docs/fleet-service-port-registry.md` - the cross-vessel published-record proposal, which is a proposal and not binding. +- `docs/cd-guard.md`, `docs/arm-pretool-check.md`, `docs/subagent-guard.md` - the sibling PreToolUse guards this one follows. diff --git a/docs/scripts.md b/docs/scripts.md index e67d04c37..1d9f67a0f 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -45,6 +45,11 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize | `fm-cd-pretool-check.sh` | Stable PreToolUse transport for the cd-guard command policy (docs/cd-guard.md) | | `fm-cd-command-policy.mjs` | Semantic owner of the cd-guard PreToolUse policy (docs/cd-guard.md) | | `fm-subagent-pretool-check.sh` | Primary-home delegation-shape PreToolUse guard (docs/subagent-guard.md) | +| `fm-lavish-pretool-check.sh` | Stable PreToolUse transport for the lavish-guard command policy (docs/lavish-access.md) | +| `fm-lavish-command-policy.mjs` | Semantic owner of the bare-`lavish-axi` PreToolUse policy (docs/lavish-access.md) | +| `fm-lavish.sh` | Open review boards on this vessel's own tailnet address and port (docs/lavish-access.md) | +| `fm-service-port.sh` | Resolve one vessel-local service's reachable address and a port it actually bound | +| `fm-service-port-probe.mjs` | Bind, DNS, and readiness oracle for the service-port allocator | | `fm-supervision-instructions.sh` | Render the session-start primary-harness supervision block or the one-line repair instruction | | `fm-home-seed.sh` | Transactionally provision a secondmate home and maintain `data/secondmates.md` | | `fm-spawn.sh` | Spawn crewmates, scouts, `id=repo` batches, and secondmates on the resolved harness and runtime backend | diff --git a/docs/turnend-guard.md b/docs/turnend-guard.md index e0a6d37cc..eba830071 100644 --- a/docs/turnend-guard.md +++ b/docs/turnend-guard.md @@ -4,7 +4,7 @@ This is the authoritative contract for the "no turn ends blind" primary guard re The turn-end supervision predicate lives in `bin/fm-turnend-guard.sh`. Its primary-checkout scope lives in `bin/fm-primary-scope-lib.sh`, shared with the native session-start nudge documented in `docs/sessionstart-nudge.md`. Harness-specific tracked hook files only adapt each verified harness's real turn-end mechanism to that shared predicate. -Related but separate PreToolUse guards deny a bad tool or command shape before it runs rather than detecting a blind turn end afterward: the supervision-arm seatbelt (`bin/fm-arm-pretool-check.sh`, `docs/arm-pretool-check.md`), the cd-guard (`bin/fm-cd-pretool-check.sh`, `docs/cd-guard.md`), and the primary delegation-shape guard (`bin/fm-subagent-pretool-check.sh`, `docs/subagent-guard.md`). +Related but separate PreToolUse guards deny a bad tool or command shape before it runs rather than detecting a blind turn end afterward: the supervision-arm seatbelt (`bin/fm-arm-pretool-check.sh`, `docs/arm-pretool-check.md`), the cd-guard (`bin/fm-cd-pretool-check.sh`, `docs/cd-guard.md`), the primary delegation-shape guard (`bin/fm-subagent-pretool-check.sh`, `docs/subagent-guard.md`), and the lavish-guard (`bin/fm-lavish-pretool-check.sh`, `docs/lavish-access.md`), which unlike the others is not primary-checkout-scoped. Each guard's own document defines its scope; do not infer this guard's scoping, loop safety, or fail-open tradeoffs for its PreToolUse siblings. ## Gap Closed diff --git a/tests/fm-bootstrap.test.sh b/tests/fm-bootstrap.test.sh index 4c3cb716d..afb81fdac 100755 --- a/tests/fm-bootstrap.test.sh +++ b/tests/fm-bootstrap.test.sh @@ -866,6 +866,71 @@ SH pass "captain-approved AXI installs and hooks use the vessel prefix" } +# A vessel that has a tailnet but is still emitting loopback board links is the +# regression this check exists to catch: it is silent by construction, because a +# loopback board renders correctly on the machine that made it. +test_lavish_access_detection() { + local case_dir fakebin home out + case_dir="$TMP_ROOT/lavish-access" + home="$case_dir/home" + mkdir -p "$home/config" "$home/state/lavish" + printf '%s\n' manual > "$home/config/backlog-backend" + fakebin=$(make_fake_toolchain "$case_dir") + # A fake tailscale so the verdict never depends on whether the machine + # running this suite happens to be on a tailnet. + cat > "$fakebin/tailscale" <<'SH' +#!/usr/bin/env bash +[ "${1:-}" = status ] && [ "${2:-}" = --json ] || exit 1 +case "${FM_FAKE_TAILNET:-on}" in + on) printf '{"BackendState":"Running","MagicDNSSuffix":"","Self":{"HostName":"localhost","DNSName":"localhost.","TailscaleIPs":["127.0.0.1"]}}\n' ;; + *) printf '{"BackendState":"Stopped","Self":{}}\n' ;; +esac +SH + chmod +x "$fakebin/tailscale" + + run_case() { + PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FM_ROOT_OVERRIDE="$ROOT" \ + FM_FAKE_TREEHOUSE_LEASE_HELP=1 HOME="$case_dir/fakehome" \ + FM_FAKE_TAILNET="${1:-on}" "$ROOT/bin/fm-bootstrap.sh" + } + mkdir -p "$case_dir/fakehome" + + out=$(run_case on) + assert_not_contains "$out" "LAVISH_ACCESS:" "no boards at all must stay silent" + + printf '{"sessions":{"a":{"url":"http://crew.example:4411/session/a","status":"open"}}}\n' \ + > "$home/state/lavish/state.json" + out=$(run_case on) + assert_not_contains "$out" "LAVISH_ACCESS:" "a reachable board link must stay silent" + + printf '{"sessions":{"a":{"url":"http://127.0.0.1:4387/session/a","status":"open"},"b":{"url":"http://127.0.0.1:4387/session/b","status":"ended"}}}\n' \ + > "$home/state/lavish/state.json" + out=$(run_case on) + assert_contains "$out" "LAVISH_ACCESS: 1 open review board link(s)" \ + "an open loopback board link must be reported, and an ended one must not be counted" + + out=$(run_case off) + assert_not_contains "$out" "LAVISH_ACCESS:" \ + "a host with no tailnet is honestly limited, not regressed, so it stays silent" + + # lavish-axi's default store is shared by every home of one UNIX account, so a + # board belonging to another home must never be blamed on this one. + rm -f "$home/state/lavish/state.json" + mkdir -p "$case_dir/fakehome/.lavish-axi" + printf '{"sessions":{"a":{"file":"/somewhere/else/.lavish/x.html","url":"http://127.0.0.1:4387/session/a","status":"open"}}}\n' \ + > "$case_dir/fakehome/.lavish-axi/state.json" + out=$(run_case on) + assert_not_contains "$out" "LAVISH_ACCESS:" \ + "another home's board in the shared store must not be reported here" + + printf '{"sessions":{"a":{"file":"%s/.lavish/x.html","url":"http://127.0.0.1:4387/session/a","status":"open"}}}\n' \ + "$home" > "$case_dir/fakehome/.lavish-axi/state.json" + out=$(run_case on) + assert_contains "$out" "LAVISH_ACCESS: 1 open review board link(s)" \ + "a bypass that wrote to the shared store is still caught for this home" + pass "bootstrap reports open loopback board links only where a tailnet makes them fixable" +} + test_bootstrap_reporting test_no_mistakes_min_version test_git_is_required_with_supported_install_instruction @@ -889,3 +954,4 @@ test_crew_dispatch_active_rules_are_verbose_bootstrap_info test_crew_dispatch_validation test_currency_base_validation test_approved_axi_install_uses_vessel_prefix +test_lavish_access_detection diff --git a/tests/fm-lavish-access.test.sh b/tests/fm-lavish-access.test.sh new file mode 100755 index 000000000..1b2304529 --- /dev/null +++ b/tests/fm-lavish-access.test.sh @@ -0,0 +1,810 @@ +#!/usr/bin/env bash +# shellcheck disable=SC1091,SC2016 +# Behavior tests for reachable review boards (docs/lavish-access.md). +# +# Three cooperating pieces are covered here: +# - bin/fm-service-port.sh, the general vessel-local port allocator, whose one +# rule is that a successful bind is the only proof a port is free; +# - bin/fm-lavish.sh, the entry point that turns that into a link the captain +# can actually open, and that degrades honestly when it cannot; +# - bin/fm-lavish-pretool-check.sh, the guard that denies bare `lavish-axi`. +# +# Everything is hermetic: a fake `tailscale` reports 127.0.0.1 / localhost, so +# the allocator's tailnet path is exercised without touching a real tailnet, and +# every bind happens on loopback. A fake `lavish-axi` records the environment it +# was handed. No harness is spawned; live per-harness evidence lives in +# docs/lavish-access.md. +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +fm_test_tmproot TMP_ROOT fm-lavish-access + +command -v node >/dev/null 2>&1 || fail "node is required for the service-port probe" +command -v jq >/dev/null 2>&1 || fail "jq is required for the tailnet identity read" + +# --- fixtures --------------------------------------------------------------- + +# A fake tailscale whose answers are driven by FM_TEST_TS_MODE, so every +# degradation branch is reachable without a real tailnet: +# running - a node on 127.0.0.1 whose DNS name (localhost) really resolves +# badname - running, but the DNS name does not resolve to the address +# stopped - installed but not running +# noipv4 - running with no IPv4 address +make_fake_tailscale() { + local bin=$1 + cat > "$bin/tailscale" <<'SH' +#!/usr/bin/env bash +[ "${1:-}" = status ] || exit 1 +[ "${2:-}" = --json ] || { echo "127.0.0.1 fake"; exit 0; } +case "${FM_TEST_TS_MODE:-running}" in + running) + printf '{"BackendState":"Running","MagicDNSSuffix":"","Self":{"HostName":"localhost","DNSName":"localhost.","TailscaleIPs":["127.0.0.1","fd7a::1"]}}\n' + ;; + badname) + printf '{"BackendState":"Running","MagicDNSSuffix":"invalid","Self":{"HostName":"nowhere","DNSName":"nowhere.invalid.","TailscaleIPs":["127.0.0.1"]}}\n' + ;; + stopped) + printf '{"BackendState":"Stopped","MagicDNSSuffix":"","Self":{}}\n' + ;; + noipv4) + printf '{"BackendState":"Running","MagicDNSSuffix":"","Self":{"HostName":"v6only","DNSName":"v6only.","TailscaleIPs":["fd7a::1"]}}\n' + ;; + *) exit 1 ;; +esac +SH + chmod +x "$bin/tailscale" +} + +# A fake lavish-axi installed where fm_axi_bin_dir looks first, so the wrapper +# resolves the vessel's own copy rather than anything on PATH. It records the +# environment it was handed and every argument it received. +make_fake_lavish() { + local home=$1 bindir="$1/.local/axi/bin" + mkdir -p "$bindir" + cat > "$bindir/lavish-axi" <<'SH' +#!/usr/bin/env bash +# lavish-axi's CLI layer answers --help with the command's help text and never +# reaches the handler, so an argv that dispatches `open` can still open nothing. +for arg in "$@"; do + [ "$arg" = --help ] || continue + printf 'Usage: lavish-axi [--no-open] [--no-gate] [--reopen]\n' + exit 0 +done +{ + printf 'ARGS=%s\n' "$*" + printf 'HOST=%s\n' "${LAVISH_AXI_HOST:-}" + printf 'PORT=%s\n' "${LAVISH_AXI_PORT:-}" + printf 'LINK=%s\n' "${LAVISH_AXI_LINK_HOST:-}" + printf 'ALLOWED=%s\n' "${LAVISH_AXI_ALLOWED_HOSTS:-}" + printf 'STATEDIR=%s\n' "${LAVISH_AXI_STATE_DIR:-}" +} >> "$FM_TEST_LAVISH_LOG" +printf 'url: "http://%s:%s/session/deadbeef"\n' "${LAVISH_AXI_LINK_HOST:-}" "${LAVISH_AXI_PORT:-}" +SH + chmod +x "$bindir/lavish-axi" +} + +make_home() { + local dir=$1 + mkdir -p "$dir/state" "$dir/config" "$dir/.lavish" + printf 'board\n' > "$dir/.lavish/board.html" + make_fake_lavish "$dir" + printf '%s\n' "$dir" +} + +# Occupy ports for real, because a test that mocks the collision would not be +# testing the one thing the allocator promises. +PORT_HOLDER_PID= +hold_ports() { + local addr=$1 + shift + local ready="$TMP_ROOT/holder.ready" waited=0 + rm -f "$ready" + cat > "$TMP_ROOT/holder.mjs" <<'JS' +import net from "node:net"; +const [ready, addr, ...ports] = process.argv.slice(2); +import { writeFileSync } from "node:fs"; +let live = 0; +for (const port of ports) { + const server = net.createServer(); + server.listen({ host: addr, port: Number(port) }, () => { + if (++live === ports.length) writeFileSync(ready, "ready\n"); + }); +} +process.stdin.resume(); +JS + node "$TMP_ROOT/holder.mjs" "$ready" "$addr" "$@" /dev/null 2>&1 & + PORT_HOLDER_PID=$! + while [ ! -s "$ready" ]; do + waited=$((waited + 1)) + [ "$waited" -lt 100 ] || fail "port holder did not come up" + sleep 0.05 + done +} + +release_ports() { + [ -n "$PORT_HOLDER_PID" ] || return 0 + kill "$PORT_HOLDER_PID" 2>/dev/null || true + wait "$PORT_HOLDER_PID" 2>/dev/null || true + PORT_HOLDER_PID= +} + +trap 'release_ports; fm_test_cleanup' EXIT + +field() { + printf '%s\n' "$2" | sed -n "s/^$1=\(.*\)$/\1/p" | head -1 +} + +FAKEBIN=$(fm_fakebin "$TMP_ROOT") +make_fake_tailscale "$FAKEBIN" +PATH="$FAKEBIN:$PATH" +export PATH +export FM_TEST_TS_MODE=running + +# --- the probe is the oracle ------------------------------------------------- + +PROBE="$ROOT/bin/fm-service-port-probe.mjs" + +hold_ports 127.0.0.1 4731 +out=$(node "$PROBE" bind 127.0.0.1 4731 4732) +expect_code 0 "$?" "probe should find the free candidate" +[ "$out" = 4732 ] || fail "probe should skip the held port and return 4732, got '$out'" +node "$PROBE" bind 127.0.0.1 4731 >/dev/null 2>&1 +expect_code 3 "$?" "a fully held candidate list is exit 3" +pass "the bind probe treats a real listener as authoritative and reports exhaustion distinctly" + +node "$PROBE" bind 203.0.113.7 4731 >/dev/null 2>&1 +expect_code 4 "$?" "an unbindable address is exit 4, never a collision" +pass "an unusable address is reported apart from a port collision" + +# A refused port (EACCES on a privileged port) is port-scoped: it says nothing +# about the address, so it must neither end the walk nor be dressed up as an +# unusable address, which would send the reader hunting the network interface. +node "$PROBE" bind 127.0.0.1 80 >/dev/null 2>&1 +low_port_status=$? +if [ "$low_port_status" -eq 5 ]; then + out=$(node "$PROBE" bind 127.0.0.1 80 4733 2>/dev/null) + expect_code 0 "$?" "a refused candidate must not end the walk" + [ "$out" = 4733 ] || fail "the walk should continue past a refused port and return 4733, got '$out'" + # The summary must count what happened rather than assert contention that was + # never observed: nothing was held here, and saying otherwise would be the + # same false-concrete-diagnosis class the reason lines exist to avoid. + summary=$(node "$PROBE" bind 127.0.0.1 80 81 2>&1 >/dev/null) + assert_contains "$summary" "0 candidate(s) held" "an all-refused window must not claim any candidate was held" + assert_contains "$summary" "2 refused" "the summary counts the refusals it actually saw" + assert_contains "$summary" "EACCES" "the summary names the concrete errno" + mixed=$(node "$PROBE" bind 127.0.0.1 80 4731 2>&1 >/dev/null) + expect_code 5 "$?" "a window of one refused and one held candidate is neither exhaustion nor an unusable address" + assert_contains "$mixed" "1 candidate(s) held" "a mixed window counts the held candidate" + pass "a refused port keeps the walk going and is counted apart from a held one" +else + pass "this host lets an unprivileged account bind port 80, so the refused-port branch is not exercisable here" +fi + +node "$PROBE" resolve localhost 127.0.0.1 >/dev/null 2>&1 +expect_code 0 "$?" "localhost resolves to 127.0.0.1" +node "$PROBE" resolve nowhere.invalid 127.0.0.1 >/dev/null 2>&1 +expect_code 1 "$?" "an unresolvable name must not be treated as usable" +pass "the resolve mode refuses a name that does not point at the bound address" +release_ports + +# --- allocator: identity and determinism ------------------------------------- + +HOME_A=$(make_home "$TMP_ROOT/vessel-a") +HOME_B=$(make_home "$TMP_ROOT/vessel-b") + +rec1=$(FM_HOME="$HOME_A" FM_SERVICE_PORT_RANGE=4740-4759 "$ROOT/bin/fm-service-port.sh" lavish) +expect_code 0 "$?" "allocation should succeed" +rec2=$(FM_HOME="$HOME_A" FM_SERVICE_PORT_RANGE=4740-4759 "$ROOT/bin/fm-service-port.sh" lavish) +[ "$(field seat "$rec1")" = "$(field seat "$rec2")" ] \ + || fail "the seat must be deterministic for one home and service" +[ "$(field port "$rec1")" = "$(field seat "$rec1")" ] \ + || fail "an unheld seat must be the port taken" +[ "$(field addr "$rec1")" = 127.0.0.1 ] || fail "the fake tailnet address should be used" +[ "$(field reachability "$rec1")" = tailnet ] || fail "a running tailnet is reachability=tailnet" +[ -z "$(field reason "$rec1")" ] || fail "a nominal resolution must carry no reason" +pass "one home gets the same deterministic seat every run" + +recB=$(FM_HOME="$HOME_B" FM_SERVICE_PORT_RANGE=4740-4759 "$ROOT/bin/fm-service-port.sh" lavish) +[ "$(field seat "$rec1")" != "$(field seat "$recB")" ] \ + || fail "two homes on one machine must not share a seat" +pass "two co-hosted homes are separated without talking to each other" + +# The realpath of FM_HOME, not the vessel id, is what separates a secondmate +# home from its parent: they share the id and the UNIX account. +mkdir -p "$HOME_A/state" "$HOME_B/state" +printf 'shared\n' > "$HOME_A/config/bridge-vessel" +printf 'shared\n' > "$HOME_B/config/bridge-vessel" +sameA=$(FM_HOME="$HOME_A" FM_SERVICE_PORT_RANGE=4740-4759 "$ROOT/bin/fm-service-port.sh" lavish --check) +sameB=$(FM_HOME="$HOME_B" FM_SERVICE_PORT_RANGE=4740-4759 "$ROOT/bin/fm-service-port.sh" lavish --check) +[ "$(field vessel "$sameA")" = shared ] && [ "$(field vessel "$sameB")" = shared ] \ + || fail "the configured vessel id should be used" +[ "$(field seat "$sameA")" != "$(field seat "$sameB")" ] \ + || fail "two homes sharing one vessel id must still get different seats" +rm -f "$HOME_A/config/bridge-vessel" "$HOME_B/config/bridge-vessel" +pass "a shared vessel id does not collapse two homes onto one seat" + +# --- allocator: --check claims nothing --------------------------------------- + +rm -f "$HOME_B/state/service-port.other" +chk=$(FM_HOME="$HOME_B" FM_SERVICE_PORT_RANGE=4740-4759 "$ROOT/bin/fm-service-port.sh" other --check) +assert_not_contains "$chk" "port=" "--check must not report a port it never bound" +assert_contains "$chk" "seat=" "--check still reports the deterministic seat" +assert_absent "$HOME_B/state/service-port.other" "--check must not write a record" +pass "--check resolves identity without claiming a port or writing a record" + +# --- allocator: the record is a published fact, not a lock -------------------- + +record="$HOME_A/state/service-port.lavish" +assert_present "$record" "an allocation writes its published record" +assert_grep "PUBLISHED RECORD, NOT A LOCK." "$record" \ + "the record must say in its own bytes that it confers no reservation" +assert_grep "port=$(field port "$rec1")" "$record" "the record carries the port actually taken" +pass "the record names itself a published fact so nobody mistakes it for an allocator" + +# --- allocator: the deterministic seat is taken ------------------------------ + +seat=$(field seat "$rec1") +hold_ports 127.0.0.1 "$seat" +advanced=$(FM_HOME="$HOME_A" FM_SERVICE_PORT_RANGE=4740-4759 "$ROOT/bin/fm-service-port.sh" lavish) +expect_code 0 "$?" "a held seat must not be a failure" +[ "$(field port "$advanced")" != "$seat" ] || fail "the allocator must not hand back a held seat" +[ "$(field seat "$advanced")" = "$seat" ] || fail "the preferred seat is still reported as the preference" +assert_grep "port=$(field port "$advanced")" "$record" "the record follows the port actually taken" +release_ports +pass "a held seat advances, and the record reports the port actually taken" + +# --- allocator: the whole window is taken ------------------------------------ + +hold_ports 127.0.0.1 4760 4761 +err=$(FM_HOME="$HOME_A" FM_SERVICE_PORT_RANGE=4760-4761 "$ROOT/bin/fm-service-port.sh" lavish 2>&1 >/dev/null) +code=$? +expect_code 1 "$code" "an exhausted window must fail loudly" +assert_contains "$err" "SERVICE_PORT:" "the exhaustion diagnostic is prefixed" +assert_contains "$err" "no free port in 4760-4761" "the diagnostic names the window it exhausted" +assert_not_contains "$err" "127.0.0.1 loopback" "there is no silent loopback downgrade on exhaustion" +release_ports +pass "an exhausted window refuses loudly instead of downgrading silently" + +# --- allocator: --mine is returned unprobed ---------------------------------- + +hold_ports 127.0.0.1 4762 +mine=$(FM_HOME="$HOME_A" FM_SERVICE_PORT_RANGE=4762-4762 "$ROOT/bin/fm-service-port.sh" lavish --mine 4762) +expect_code 0 "$?" "a caller-owned port must be accepted" +[ "$(field port "$mine")" = 4762 ] || fail "--mine must be returned as the port" +release_ports +pass "a port the caller already owns is returned without a self-defeating bind probe" + +# --- allocator: honest degradation ------------------------------------------- + +for mode in stopped noipv4; do + deg=$(FM_TEST_TS_MODE=$mode FM_HOME="$HOME_B" FM_SERVICE_PORT_RANGE=4740-4759 \ + "$ROOT/bin/fm-service-port.sh" lavish --check) + [ "$(field reachability "$deg")" = loopback ] || fail "$mode must degrade to loopback" + [ "$(field addr "$deg")" = 127.0.0.1 ] || fail "$mode must fall back to the loopback address" + [ -n "$(field reason "$deg")" ] || fail "$mode must carry a concrete reason" + case "$(field machine "$deg")" in + unknown-*) : ;; + *) fail "$mode must not claim a machine identity it could not establish" ;; + esac +done +pass "a vessel with no usable tailnet says so concretely instead of implying reach" + +badname=$(FM_TEST_TS_MODE=badname FM_HOME="$HOME_B" FM_SERVICE_PORT_RANGE=4740-4759 \ + "$ROOT/bin/fm-service-port.sh" lavish --check) +[ "$(field reachability "$badname")" = tailnet ] || fail "a resolvable address is still tailnet reach" +[ -z "$(field dnsname "$badname")" ] || fail "an unresolvable name must not be offered for links" +assert_contains "$badname" "does not resolve to" "the reason names the concrete DNS problem" +pass "a tailnet name that does not resolve to the bound address is dropped, not written into a link" + +# --- allocator: usage errors ------------------------------------------------- + +FM_HOME="$HOME_A" "$ROOT/bin/fm-service-port.sh" 'Bad Name' >/dev/null 2>&1 +expect_code 2 "$?" "a non-slug service name is a usage error" +FM_HOME="$HOME_A" FM_SERVICE_PORT_RANGE=nonsense "$ROOT/bin/fm-service-port.sh" lavish >/dev/null 2>&1 +expect_code 2 "$?" "a malformed window is a usage error" +pass "malformed input is refused rather than guessed at" + +# --- entry point: the environment it hands to lavish-axi --------------------- + +export FM_TEST_LAVISH_LOG="$TMP_ROOT/lavish-a.log" +: > "$FM_TEST_LAVISH_LOG" +out=$(FM_HOME="$HOME_A" FM_SERVICE_PORT_RANGE=4740-4759 \ + "$ROOT/bin/fm-lavish.sh" "$HOME_A/.lavish/board.html" 2>&1) +expect_code 0 "$?" "opening a board should succeed" +log=$(cat "$FM_TEST_LAVISH_LOG") +assert_contains "$log" "HOST=127.0.0.1" "the bind host is this vessel's address, never a wildcard" +assert_contains "$log" "LINK=localhost" "the link host is the checked tailnet name" +assert_contains "$log" "STATEDIR=$HOME_A/state/lavish" "each home gets its own server state directory" +assert_contains "$log" "ALLOWED=" "a closed Host allowlist is always set" +assert_not_contains "$log" "ALLOWED=*" "the allowlist must never be a wildcard" +port=$(printf '%s\n' "$log" | sed -n 's/^PORT=//p' | head -1) +case "$port" in + 47[45][0-9]) : ;; + *) fail "the port should come from the configured window, got '$port'" ;; +esac +token=$(cat "$HOME_A/state/lavish/claim-token") +assert_contains "$log" "$token" "this home's claim token must be in the allowlist" +pass "the entry point hands lavish-axi a reachable address, a free port, and a closed allowlist" + +# The claim token is what identifies this home's own server, so it must be +# stable across runs rather than regenerated into a new identity each time. +: > "$FM_TEST_LAVISH_LOG" +FM_HOME="$HOME_A" FM_SERVICE_PORT_RANGE=4740-4759 \ + "$ROOT/bin/fm-lavish.sh" "$HOME_A/.lavish/board.html" >/dev/null 2>&1 +[ "$(cat "$HOME_A/state/lavish/claim-token")" = "$token" ] \ + || fail "the claim token must be stable for one home" +pass "the claim token is one durable identity per home" + +# --- entry point: passthrough ------------------------------------------------ + +: > "$FM_TEST_LAVISH_LOG" +FM_HOME="$HOME_A" FM_SERVICE_PORT_RANGE=4740-4759 \ + "$ROOT/bin/fm-lavish.sh" poll "$HOME_A/.lavish/board.html" >/dev/null 2>&1 +log=$(cat "$FM_TEST_LAVISH_LOG") +assert_contains "$log" "ARGS=poll $HOME_A/.lavish/board.html" "arguments pass through untouched" +assert_contains "$log" "HOST=127.0.0.1" "a follow-up command resolves the same server, not the default port" +pass "poll and other follow-ups resolve the same server the open call did" + +# --- entry point: a flag-led invocation that opens nothing -------------------- +# +# The guard denies bare `lavish-axi --version` and names this wrapper as the way +# to run it, so the wrapper has to treat it as what lavish-axi treats it as: a +# command that opens no board at all. + +HOME_F=$(make_home "$TMP_ROOT/vessel-f") +export FM_TEST_LAVISH_LOG="$TMP_ROOT/lavish-f.log" +: > "$FM_TEST_LAVISH_LOG" +out=$(FM_TEST_TS_MODE=stopped FM_HOME="$HOME_F" FM_SERVICE_PORT_RANGE=4740-4759 \ + "$ROOT/bin/fm-lavish.sh" --version 2>&1) +expect_code 0 "$?" "a flag-led invocation that opens nothing must still run" +assert_contains "$(cat "$FM_TEST_LAVISH_LOG")" "ARGS=--version" "its arguments reach lavish-axi intact" +assert_absent "$HOME_F/state/service-port.lavish" "a command that opens no board must claim no port" +assert_absent "$HOME_F/state/lavish/fm-owner" "a command that opens no board must not rewrite the owner record" +assert_not_contains "$out" "does not answer here" "nothing may report on a link for a board that was never opened" +assert_not_contains "$out" "opens only on this machine" "nothing may describe the reach of a board that was never opened" +pass "a flag-led invocation carrying no board file claims nothing and reports on nothing" + +: > "$FM_TEST_LAVISH_LOG" +FM_HOME="$HOME_F" FM_SERVICE_PORT_RANGE=4740-4759 \ + "$ROOT/bin/fm-lavish.sh" --version "$HOME_F/.lavish/board.html" >/dev/null 2>&1 +log=$(cat "$FM_TEST_LAVISH_LOG") +assert_contains "$log" "HOST=127.0.0.1" "a flag in front of a board file is still an open" +assert_contains "$log" "ARGS=--version $HOME_F/.lavish/board.html" "the open still passes its arguments through untouched" +assert_present "$HOME_F/state/service-port.lavish" "an open claims a port however its arguments are ordered" +assert_present "$HOME_F/state/lavish/fm-owner" "an open records what it launched a server with" +pass "a flag-led invocation that does carry a board file takes the full open path" + +# --- entry point: what it says about a board is what it observed -------------- +# +# An argv can dispatch `open` and still open nothing, because lavish-axi answers +# --help with help text and never reaches the handler. The wrapper therefore +# decides from the session URL in the output, not from the argument shape. + +for helped in "--help $HOME_F/.lavish/board.html" "open --help $HOME_F/.lavish/board.html"; do + : > "$FM_TEST_LAVISH_LOG" + # shellcheck disable=SC2086 + out=$(FM_TEST_TS_MODE=stopped FM_HOME="$HOME_F" FM_SERVICE_PORT_RANGE=4740-4759 \ + "$ROOT/bin/fm-lavish.sh" $helped 2>&1) + expect_code 0 "$?" "asking for help must succeed: $helped" + assert_contains "$out" "Usage: lavish-axi" "the help text still reaches the caller: $helped" + assert_not_contains "$out" "does not answer here" \ + "nothing may report a link for a board that was never opened: $helped" + # The host's own reachability is not a claim about a link, and withholding it + # is the silent failure this mechanism exists to end. + assert_contains "$out" "opens only on this machine" \ + "a loopback-only host is still named on an invocation that could open a board: $helped" +done +pass "an argv that dispatches open but opens nothing makes no claim about a link" + +: > "$FM_TEST_LAVISH_LOG" +out=$(FM_TEST_TS_MODE=stopped FM_HOME="$HOME_F" FM_SERVICE_PORT_RANGE=4740-4759 \ + "$ROOT/bin/fm-lavish.sh" "$HOME_F/.lavish/board.html" 2>&1) +assert_contains "$out" "/session/" "a genuine open emits the session link" +assert_contains "$out" "does not answer here" \ + "a genuine open still verifies the hostname now sitting in the captain's link" +assert_contains "$out" "opens only on this machine" \ + "a genuine open still says plainly that this board is local only" +pass "a genuine open still gets every line the captain needs about its link" + +# --- the observed-open shape is pinned to the real tool ---------------------- +# +# The wrapper decides that a board opened from a session URL in lavish-axi's own +# output, so a change to that output would disable the link check silently. The +# fake above cannot catch that, because this suite writes the very shape the +# wrapper reads, so the expectation is taken from the installed tool instead. + +assert_grep '*://*/session/*' "$ROOT/bin/fm-lavish.sh" \ + "the wrapper must still recognise an open by the session URL shape pinned below" +REAL_LAVISH=$(command -v lavish-axi 2>/dev/null || true) +if [ -z "$REAL_LAVISH" ]; then + echo "skip: lavish-axi not installed, so the observed-open shape was not pinned against the real tool" +else + HOME_R=$(make_home "$TMP_ROOT/vessel-real") + real_port=$(node "$PROBE" bind 127.0.0.1 4796 4797 4798) + [ -n "$real_port" ] || fail "no free port to open a real board on" + real_out=$(LAVISH_AXI_HOST=127.0.0.1 LAVISH_AXI_PORT="$real_port" \ + LAVISH_AXI_LINK_HOST=127.0.0.1 LAVISH_AXI_ALLOWED_HOSTS="127.0.0.1 localhost" \ + LAVISH_AXI_STATE_DIR="$HOME_R/state/lavish" \ + "$REAL_LAVISH" "$HOME_R/.lavish/board.html" --no-open 2>&1) || true + LAVISH_AXI_HOST=127.0.0.1 LAVISH_AXI_PORT="$real_port" \ + LAVISH_AXI_STATE_DIR="$HOME_R/state/lavish" \ + "$REAL_LAVISH" stop --port "$real_port" >/dev/null 2>&1 || true + case "$real_out" in + *://*/session/*) : ;; + *) fail "the installed lavish-axi no longer prints a session URL in the shape the wrapper reads: $real_out" ;; + esac + pass "the shape the wrapper reads as an open is the shape the installed lavish-axi prints" +fi + +# --- entry point: honest degradation to the captain -------------------------- + +export FM_TEST_LAVISH_LOG="$TMP_ROOT/lavish-b.log" +: > "$FM_TEST_LAVISH_LOG" +err=$(FM_TEST_TS_MODE=stopped FM_HOME="$HOME_B" FM_SERVICE_PORT_RANGE=4740-4759 \ + "$ROOT/bin/fm-lavish.sh" "$HOME_B/.lavish/board.html" 2>&1 >/dev/null) +assert_contains "$err" "no tailnet on this host" "a loopback-only board must say so" +assert_contains "$err" "opens only on this machine" "the consequence is stated in plain words" +assert_contains "$(cat "$FM_TEST_LAVISH_LOG")" "HOST=127.0.0.1" "the board still opens locally" +pass "a vessel with no tailnet still opens the board and never implies it is reachable" + +# --- entry point: third-party publishing needs explicit intent --------------- + +: > "$FM_TEST_LAVISH_LOG" +err=$(FM_HOME="$HOME_B" "$ROOT/bin/fm-lavish.sh" share "$HOME_B/.lavish/board.html" 2>&1 >/dev/null) +expect_code 3 "$?" "share is refused by default" +assert_contains "$err" "--fm-allow-share" "the refusal names the override rather than being a dead end" +[ ! -s "$FM_TEST_LAVISH_LOG" ] || fail "a refused share must not reach lavish-axi at all" +FM_HOME="$HOME_B" FM_SERVICE_PORT_RANGE=4740-4759 \ + "$ROOT/bin/fm-lavish.sh" share "$HOME_B/.lavish/board.html" --fm-allow-share >/dev/null 2>&1 +assert_contains "$(cat "$FM_TEST_LAVISH_LOG")" "ARGS=share" "the override lets the command through" +assert_not_contains "$(cat "$FM_TEST_LAVISH_LOG")" "--fm-allow-share" \ + "the wrapper's own flag must not leak into lavish-axi's arguments" +pass "publishing outside the tailnet requires stated intent, and stays available with it" + +# --- entry point: stop only touches a server we own -------------------------- + +: > "$FM_TEST_LAVISH_LOG" +err=$(FM_HOME="$HOME_B" FM_SERVICE_PORT_RANGE=4740-4759 "$ROOT/bin/fm-lavish.sh" stop 2>&1 >/dev/null) +expect_code 0 "$?" "nothing to stop is not a failure" +assert_contains "$err" "nothing to stop" "the wrapper says plainly that no owned server is running" +[ ! -s "$FM_TEST_LAVISH_LOG" ] || fail "stop must not shut down a server this vessel does not own" +pass "stop refuses to reach a server this vessel has not proved is its own" + +# --- guard: the decision matrix ---------------------------------------------- + +GUARD="$ROOT/bin/fm-lavish-pretool-check.sh" + +"$GUARD" --command 'lavish-axi board.html' >/dev/null 2>&1 +expect_code 2 "$?" "bare lavish-axi must be denied" +"$GUARD" --command 'bin/fm-lavish.sh board.html' >/dev/null 2>&1 +expect_code 0 "$?" "the entry point must not block itself" +"$GUARD" --command 'command -v lavish-axi' >/dev/null 2>&1 +expect_code 0 "$?" "tool detection must keep working" +"$GUARD" --command 'npm install -g lavish-axi' >/dev/null 2>&1 +expect_code 0 "$?" "installing the package is not opening a board" +"$GUARD" --command 'echo hi && lavish-axi poll x.html' >/dev/null 2>&1 +expect_code 2 "$?" "a later command in the list is still denied" +"$GUARD" --command '/opt/axi/bin/lavish-axi x.html' >/dev/null 2>&1 +expect_code 2 "$?" "a path-qualified invocation is the same mistake" +"$GUARD" --command 'lavish-axi stop' >/dev/null 2>&1 +expect_code 2 "$?" "stopping a server is ownership sensitive and belongs to the wrapper" +pass "the guard denies the invocation that emits an unreachable link and nothing else" + +# Subcommands that neither start a server nor emit a link prevent nothing by +# being denied, and this repo prints some of them for the captain to run. Each +# one is a subcommand lavish-axi recognises, so an html argument after it cannot +# promote the invocation to `open`. +for allowed in \ + 'lavish-axi setup hooks' \ + 'lavish-axi playbook table' \ + 'lavish-axi design' \ + 'lavish-axi export board.html --out /tmp/board.html'; do + "$GUARD" --command "$allowed" >/dev/null 2>&1 + expect_code 0 "$?" "a non-serving invocation must not be denied: $allowed" +done +pass "non-serving subcommands stay available, because denying them prevents nothing" + +# lavish-axi rewrites a flag-led argv carrying an html path into `open`, so a +# version or help flag is not proof that no board is opened. +for flagged in \ + 'lavish-axi --version board.html' \ + 'lavish-axi -v /tmp/b.html' \ + 'lavish-axi --help board.html' \ + 'lavish-axi -h b.htm'; do + "$GUARD" --command "$flagged" >/dev/null 2>&1 + expect_code 2 "$?" "a flag in front of a board file still opens a board: $flagged" +done +pass "a version or help flag never buys an invocation past the guard" + +# The exact shapes bin/fm-bootstrap.sh install_cmd and bin/fm-axi-suite.sh +# install_hint print inside MISSING: and AXI_SUITE_REVIEW: diagnostics. They are +# handed to the captain and to agents as commands to run, so a guard that denies +# them would break the repo's own instructions. +"$GUARD" --command 'npm install -g --prefix /home/coditan/.local/axi lavish-axi && PATH=/home/coditan/.local/axi/bin:$PATH lavish-axi setup hooks' >/dev/null 2>&1 +expect_code 0 "$?" "the install command bin/fm-bootstrap.sh prints must be allowed" +"$GUARD" --command 'npm install -g --prefix /home/coditan/.local/axi lavish-axi@0.1.43 && PATH=/home/coditan/.local/axi/bin:$PATH lavish-axi setup hooks' >/dev/null 2>&1 +expect_code 0 "$?" "the install hint bin/fm-axi-suite.sh prints must be allowed" +pass "the install commands this repo itself emits are not denied by its own guard" + +deny_out=$("$GUARD" --command 'lavish-axi board.html' 2>/dev/null || true) +assert_contains "$deny_out" '"decision":"deny"' "grok consumes the stdout decision object" +deny_err=$("$GUARD" --command 'lavish-axi board.html' 2>&1 >/dev/null || true) +assert_contains "$deny_err" 'fm-lavish.sh' "the reason names the entry point to use instead" +claude_out=$("$GUARD" --claude --command 'lavish-axi board.html' 2>/dev/null || true) +[ -z "$claude_out" ] || fail "Claude requires stdout to stay empty on deny" +grok_stdin=$(printf '{"toolInput":{"command":"lavish-axi x.html"}}' | "$GUARD" 2>/dev/null || true) +assert_contains "$grok_stdin" '"decision":"deny"' "the grok stdin shape is accepted" +codex_stdin=$(printf '{"tool_input":{"command":"lavish-axi x.html"}}' | "$GUARD" 2>&1 >/dev/null || true) +assert_contains "$codex_stdin" 'permissionDecision' "the claude/codex stdin shape is accepted" +pass "every tracked harness entry and output shape is honoured" + +printf 'not json' | "$GUARD" >/dev/null 2>&1 +expect_code 0 "$?" "malformed transport fails open" +printf '' | "$GUARD" >/dev/null 2>&1 +expect_code 0 "$?" "empty transport fails open" +pass "a broken transport never denies a shell command" + +# --- guard: scope is the wrapper's presence, not the primary checkout -------- + +INERT="$TMP_ROOT/no-wrapper" +mkdir -p "$INERT/bin" +: > "$INERT/AGENTS.md" +cp "$ROOT/bin/fm-lavish-pretool-check.sh" "$INERT/bin/" +cp "$ROOT/bin/fm-lavish-command-policy.mjs" "$INERT/bin/" +cp "$ROOT/bin/fm-arm-command-policy.mjs" "$INERT/bin/" +chmod +x "$INERT/bin/fm-lavish-pretool-check.sh" +"$INERT/bin/fm-lavish-pretool-check.sh" --command 'lavish-axi x.html' >/dev/null 2>&1 +expect_code 0 "$?" "a checkout without the wrapper is inert" + +# A linked worktree is where crewmates open boards, and is exactly where the +# cd-guard is deliberately inert. This guard must NOT be. +fm_git_identity +BASE="$TMP_ROOT/base-repo" +git init -q "$BASE" +git -C "$BASE" commit -q --allow-empty -m init +WT="$TMP_ROOT/task-worktree" +fm_git_worktree "$BASE" "$WT" fm/lavish-guard-scope +: > "$WT/AGENTS.md" +mkdir -p "$WT/bin" +cp "$ROOT/bin/fm-lavish-pretool-check.sh" "$ROOT/bin/fm-lavish-command-policy.mjs" \ + "$ROOT/bin/fm-arm-command-policy.mjs" "$ROOT/bin/fm-lavish.sh" "$WT/bin/" +chmod +x "$WT/bin/fm-lavish-pretool-check.sh" "$WT/bin/fm-lavish.sh" +"$WT/bin/fm-lavish-pretool-check.sh" --command 'lavish-axi x.html' >/dev/null 2>&1 +expect_code 2 "$?" "the guard must fire in a task worktree, where boards also get opened" +pass "the guard is scoped to the wrapper's presence, so it covers crew worktrees too" + +# --- harness wiring ---------------------------------------------------------- + +jq -e 'any(.hooks.PreToolUse[]?.hooks[]?.command?; type == "string" and contains("fm-lavish-pretool-check.sh --claude"))' \ + "$ROOT/.claude/settings.json" >/dev/null \ + || fail ".claude/settings.json must register the lavish guard in Claude mode" +jq -e 'any(.hooks.PreToolUse[]?.hooks[]?.command?; type == "string" and contains("fm-lavish-pretool-check.sh"))' \ + "$ROOT/.codex/hooks.json" >/dev/null \ + || fail ".codex/hooks.json must register the lavish guard" +jq -e 'any(.hooks.PreToolUse[]?.hooks[]?.command?; type == "string" and contains("fm-lavish-pretool-check.sh"))' \ + "$ROOT/.grok/hooks/fm-primary-lavish-check.json" >/dev/null \ + || fail ".grok/hooks must register the lavish guard" + +# OpenCode and Pi are not JSON hook surfaces; they call the guard through their +# own plugin and extension, exactly as the cd-guard is wired. +OPENCODE_PLUGIN="$ROOT/.opencode/plugins/fm-primary-lavish-check.js" +assert_present "$OPENCODE_PLUGIN" ".opencode/plugins must carry a lavish guard plugin" +assert_grep 'tool.execute.before' "$OPENCODE_PLUGIN" \ + "the OpenCode plugin must run before the bash tool executes" +assert_grep 'fm-lavish-pretool-check.sh' "$OPENCODE_PLUGIN" \ + "the OpenCode plugin must invoke the lavish guard owner" +assert_grep 'throw new Error' "$OPENCODE_PLUGIN" \ + "the OpenCode plugin must block by throwing" + +PI_EXTENSION="$ROOT/.pi/extensions/fm-primary-turnend-guard.ts" +assert_grep 'fm-lavish-pretool-check.sh' "$PI_EXTENSION" \ + "the Pi extension must invoke the lavish guard owner" +assert_grep 'runLavishCheck(command)' "$PI_EXTENSION" \ + "the Pi extension must run the lavish check on a tool call" +assert_grep 'block: true' "$PI_EXTENSION" \ + "the Pi extension must block the command it denies" + +# Pi's registration reaches the sessions launched with this extension, which is +# not every session bin/fm-spawn.sh starts. The doc has to say so, because the +# whole point of this change is not overstating reach. +assert_grep 'not by a Pi crew' "$PI_EXTENSION" \ + "the Pi extension must state which sessions actually load it" +assert_grep 'does not reach a Pi crewmate or scout' "$ROOT/docs/lavish-access.md" \ + "the doc must name the Pi sessions this registration does not reach" +pass "the guard is registered on all five tracked harness surfaces, with Pi's crew limit stated rather than rounded up" + +# --- the instruction surface points at the entry point ----------------------- + +assert_grep 'bin/fm-lavish.sh' "$ROOT/AGENTS.md" "AGENTS.md must name the entry point" +assert_grep 'LAVISH_ACCESS:' "$ROOT/AGENTS.md" \ + "AGENTS.md section 13 must list the new bootstrap diagnostic" +assert_grep 'LAVISH_ACCESS' "$ROOT/.agents/skills/bootstrap-diagnostics/SKILL.md" \ + "bootstrap-diagnostics must own the LAVISH_ACCESS response" +assert_grep 'fm-lavish.sh' "$ROOT/bin/fm-brief.sh" \ + "generated briefs must name the entry point, since the guard cannot reach project worktrees" +pass "the instruction surface names one owner and one entry point" + +# --- server-backed cases: ownership, reuse, and refusal to adopt ------------- +# +# The cases above use a fake lavish-axi that records its environment. These need +# something that actually listens, because the property under test is exactly +# the one no static fixture can express: whether the server on our port is OUR +# server. The fake board server below mimics the only behaviour that matters +# here - lavish-axi's Host allowlist, which always admits loopback and otherwise +# admits only the names it was started with. + +cat > "$TMP_ROOT/board-server.mjs" <<'JS' +import http from "node:http"; +import { writeFileSync } from "node:fs"; +const [ready, addr, port, ...allowed] = process.argv.slice(2); +const names = new Set(["127.0.0.1", "::1", "localhost", ...allowed].map((v) => v.toLowerCase())); +http + .createServer((req, res) => { + const host = String(req.headers.host || "").split(":")[0].toLowerCase(); + if (!names.has(host)) { + res.writeHead(403).end('{"error":"forbidden host"}'); + return; + } + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ ok: true, app: "lavish-axi", version: "0.0.0-test" })); + }) + .listen({ host: addr, port: Number(port) }, () => writeFileSync(ready, "ready\n")); +JS + +# A lavish-axi that really starts and stops a server, so the wrapper's ownership +# probe has something truthful to answer against. +make_serving_lavish() { + local home=$1 bindir="$1/.local/axi/bin" + mkdir -p "$bindir" + cat > "$bindir/lavish-axi" </dev/null || true; rm -f "\$pidfile"; fi + printf 'stopped %s\n' "\$target" + exit 0 +fi +if [ ! -s "\$pidfile" ]; then + ready="\$FM_TEST_SERVERS/\${LAVISH_AXI_PORT}.ready" + rm -f "\$ready" + # Detach every stream: a background child that inherits stdout would keep the + # caller's command substitution open until it exits, which never happens. + node "$TMP_ROOT/board-server.mjs" "\$ready" "\$LAVISH_AXI_HOST" "\$LAVISH_AXI_PORT" \$LAVISH_AXI_ALLOWED_HOSTS \\ + "\$FM_TEST_SERVERS/\${LAVISH_AXI_PORT}.log" 2>&1 & + echo \$! > "\$pidfile" + for _ in \$(seq 1 100); do [ -s "\$ready" ] && break; sleep 0.05; done +fi +printf 'url: "http://%s:%s/session/test"\n' "\$LAVISH_AXI_LINK_HOST" "\$LAVISH_AXI_PORT" +SH + chmod +x "$bindir/lavish-axi" +} + +export FM_TEST_SERVERS="$TMP_ROOT/servers" +mkdir -p "$FM_TEST_SERVERS" +kill_test_servers() { + local p + for p in "$FM_TEST_SERVERS"/*.pid; do + [ -e "$p" ] || continue + kill "$(cat "$p")" 2>/dev/null || true + rm -f "$p" + done +} +trap 'kill_test_servers; release_ports; fm_test_cleanup' EXIT + +HOME_C=$(make_home "$TMP_ROOT/vessel-c") +make_serving_lavish "$HOME_C" + +first=$(FM_HOME="$HOME_C" FM_SERVICE_PORT_RANGE=4770-4779 \ + "$ROOT/bin/fm-lavish.sh" "$HOME_C/.lavish/board.html" 2>/dev/null) +port_c=$(printf '%s\n' "$first" | sed -n 's|.*:\([0-9][0-9]*\)/session.*|\1|p') +[ -n "$port_c" ] || fail "the first open should emit a port" +second=$(FM_HOME="$HOME_C" FM_SERVICE_PORT_RANGE=4770-4779 \ + "$ROOT/bin/fm-lavish.sh" "$HOME_C/.lavish/board.html" 2>/dev/null) +[ "$first" = "$second" ] || fail "a second open must reuse the running server, got '$second' after '$first'" +[ "$(find "$FM_TEST_SERVERS" -name '*.pid' | wc -l)" = 1 ] \ + || fail "reusing must not leave a second server behind" +pass "a vessel reuses its own running board server instead of starting another" + +# A neighbouring UNIX account's lavish-axi answers /health identically, so the +# only thing that separates it from ours is the claim token in its allowlist. +hold_ready="$TMP_ROOT/foreign.ready" +rm -f "$hold_ready" +node "$TMP_ROOT/board-server.mjs" "$hold_ready" 127.0.0.1 4781 localhost \ + "$TMP_ROOT/foreign.log" 2>&1 & +FOREIGN_PID=$! +for _ in $(seq 1 100); do [ -s "$hold_ready" ] && break; sleep 0.05; done +[ -s "$hold_ready" ] || fail "the foreign server did not come up" + +HOME_D=$(make_home "$TMP_ROOT/vessel-d") +make_serving_lavish "$HOME_D" +err=$(FM_HOME="$HOME_D" FM_SERVICE_PORT_RANGE=4781-4781 \ + "$ROOT/bin/fm-lavish.sh" "$HOME_D/.lavish/board.html" 2>&1 >/dev/null) +code=$? +expect_code 5 "$code" "a port held by another account's server must not produce a board" +assert_contains "$err" "no free port" "the refusal names the concrete port problem" +assert_contains "$err" "no board was opened" "the refusal is explicit that nothing was opened" +kill "$FOREIGN_PID" 2>/dev/null || true +wait "$FOREIGN_PID" 2>/dev/null || true +pass "a same-version server belonging to another account is refused, never silently adopted" + +# A running server keeps emitting the link host it was born with, so a changed +# configuration has to restart it rather than be exported at it. +owner="$HOME_C/state/lavish/fm-owner" +sed -i.bak 's/^link_host=.*/link_host=stale.invalid/' "$owner" && rm -f "$owner.bak" +out=$(FM_HOME="$HOME_C" FM_SERVICE_PORT_RANGE=4770-4779 \ + "$ROOT/bin/fm-lavish.sh" "$HOME_C/.lavish/board.html" 2>&1) +assert_contains "$out" "different address configuration" "a stale server must be reported and restarted" +assert_contains "$out" ":$port_c/session" "the restart reclaims the same port" +pass "a server started with a stale configuration is restarted rather than reused" + +# Moving to a new window must never destroy a working board before a +# replacement exists. +hold_ports 127.0.0.1 4791 +err=$(FM_HOME="$HOME_C" FM_SERVICE_PORT_RANGE=4791-4791 \ + "$ROOT/bin/fm-lavish.sh" "$HOME_C/.lavish/board.html" 2>&1 >/dev/null) +expect_code 5 "$?" "a full window must refuse" +assert_contains "$err" "was left running, so nothing was lost" \ + "a failed move must say the existing board survived" +release_ports +still=$(node "$ROOT/bin/fm-service-port-probe.mjs" http "http://127.0.0.1:$port_c/health" 2>/dev/null) +[ "$still" = 200 ] || fail "the existing board must still be serving after a failed move" +pass "a failed relocation leaves the working board untouched" + +moved=$(FM_HOME="$HOME_C" FM_SERVICE_PORT_RANGE=4792-4793 \ + "$ROOT/bin/fm-lavish.sh" "$HOME_C/.lavish/board.html" 2>&1) +assert_contains "$moved" "moving this vessel's boards from port $port_c" \ + "a real move must name both ports" +assert_contains "$moved" "links already handed over on the old port stop working" \ + "the cost of moving must be stated, not hidden" +pass "a successful relocation reports the consequence for links already handed over" + +out=$(FM_HOME="$HOME_C" FM_SERVICE_PORT_RANGE=4792-4793 "$ROOT/bin/fm-lavish.sh" stop 2>&1) +assert_contains "$out" "stopped" "stop reaches this vessel's own server" +out=$(FM_HOME="$HOME_C" FM_SERVICE_PORT_RANGE=4792-4793 "$ROOT/bin/fm-lavish.sh" stop 2>&1) +assert_contains "$out" "nothing to stop" "a second stop has nothing left to reach" +pass "stop reaches this vessel's own server and then reports honestly that none is left" + +# --- entry point: an explicit --port earns no shortcut ----------------------- +# +# lavish-axi's own stop shuts down whatever answers /health on the address it is +# handed, and every co-hosted vessel binds that same address, so a named port +# has to clear the same claim-token proof the recorded one does. + +reopened=$(FM_HOME="$HOME_C" FM_SERVICE_PORT_RANGE=4792-4793 \ + "$ROOT/bin/fm-lavish.sh" "$HOME_C/.lavish/board.html" 2>/dev/null) +own_port=$(printf '%s\n' "$reopened" | sed -n 's|.*:\([0-9][0-9]*\)/session.*|\1|p') +[ -n "$own_port" ] || fail "reopening the board should emit a port" +out=$(FM_HOME="$HOME_C" FM_SERVICE_PORT_RANGE=4792-4793 \ + "$ROOT/bin/fm-lavish.sh" stop --port "$own_port" 2>&1) +expect_code 0 "$?" "an explicit --port on a proven-own server is a normal stop" +assert_contains "$out" "stopped" "a proven-own port is still stopped when named explicitly" + +neighbour_ready="$TMP_ROOT/neighbour.ready" +rm -f "$neighbour_ready" +node "$TMP_ROOT/board-server.mjs" "$neighbour_ready" 127.0.0.1 4794 localhost \ + "$TMP_ROOT/neighbour.log" 2>&1 & +NEIGHBOUR_PID=$! +for _ in $(seq 1 100); do [ -s "$neighbour_ready" ] && break; sleep 0.05; done +[ -s "$neighbour_ready" ] || fail "the neighbouring server did not come up" + +err=$(FM_HOME="$HOME_C" FM_SERVICE_PORT_RANGE=4792-4793 \ + "$ROOT/bin/fm-lavish.sh" stop --port 4794 2>&1 >/dev/null) +expect_code 7 "$?" "an unproven --port must be refused, not obeyed" +assert_contains "$err" "4794" "the refusal names the port it declined to touch" +assert_contains "$err" "not one this vessel can prove is its own" \ + "the refusal says plainly why the port was left alone" +alive=$(node "$ROOT/bin/fm-service-port-probe.mjs" http http://127.0.0.1:4794/health 2>/dev/null) +[ "$alive" = 200 ] || fail "a neighbour's board must still be serving after a refused stop" +kill "$NEIGHBOUR_PID" 2>/dev/null || true +wait "$NEIGHBOUR_PID" 2>/dev/null || true + +out=$(FM_HOME="$HOME_C" FM_SERVICE_PORT_RANGE=4792-4793 \ + "$ROOT/bin/fm-lavish.sh" stop --port 4795 2>&1) +expect_code 0 "$?" "a named port with nothing on it is not a failure" +assert_contains "$out" "nothing to stop" "an empty port is reported as nothing to stop" +assert_contains "$out" "4795" "the message names the port that was checked" +pass "stop --port is held to the same ownership proof as a bare stop" diff --git a/tests/fm-turnend-guard.test.sh b/tests/fm-turnend-guard.test.sh index 57fc31bae..4e863994a 100755 --- a/tests/fm-turnend-guard.test.sh +++ b/tests/fm-turnend-guard.test.sh @@ -171,6 +171,7 @@ export async function loadPiTurnendExtension(plugin) { .replace(/function runChecker\(script: string, command: string\): Promise<\{ code: number; stderr: string \}>/, "function runChecker(script, command)") .replace(/function runPretoolCheck\(command: string\): Promise<\{ code: number; stderr: string \}>/, "function runPretoolCheck(command)") .replace(/function runCdCheck\(command: string\): Promise<\{ code: number; stderr: string \}>/, "function runCdCheck(command)") + .replace(/function runLavishCheck\(command: string\): Promise<\{ code: number; stderr: string \}>/, "function runLavishCheck(command)") .replace(/\(event as \{ reason\?: unknown \}\)/g, "event") .replace(/\(event.input as \{ command\?: unknown \}\)/g, "event.input"); return import(`data:text/javascript;base64,${Buffer.from(source).toString("base64")}`);