diff --git a/.agents/skills/lavish-decisions/SKILL.md b/.agents/skills/lavish-decisions/SKILL.md index edabfcbcce5..be19c342817 100644 --- a/.agents/skills/lavish-decisions/SKILL.md +++ b/.agents/skills/lavish-decisions/SKILL.md @@ -57,15 +57,15 @@ Do not pad an item to satisfy a wrapper it does not have. ``` For a self-contained browser board, run `bin/fm-lavish-board.sh --home ` as documented in `tools/lavish/README.md`. - Only a successful exit authorizes reporting the board open because the helper checks its answering machinery before it arms pickup or opens Chrome. - Then tell the captain the named decision board is open. + Only a successful exit authorizes reporting the board open because the helper checks its download and manual-backup machinery before handing the file to the operating system's default browser. + Then tell the captain the named decision board is open and ask them to tell firstmate after saving the answer. Do not surface or invent a session URL. The surfaced command is for the captain's shell, not firstmate's environment. It must retain the emitted `--home` argument and resolved absolute home path even when firstmate has `FM_HOME` exported. Never shorten the command or reconstruct it from a placeholder. Always carrying the explicit home is slightly noisier than asking the captain to export `FM_HOME`, but it makes every decision independently runnable and avoids a hidden setup dependency. -The browser wrapper must receive that same explicit resolved home so its dedicated profile and recovery check stay bound to the decision's fleet home. +The browser wrapper must receive that same explicit resolved home so the downloaded payload's `home_marker` stays bound to the decision's fleet home. Do not edit `request.md` or `manifest.toon` after surfacing the decision. Their digest and ordered question or item set are the immutable contract. @@ -73,9 +73,11 @@ Their digest and ordered question or item set are the immutable contract. ## Consume Firstmate's ordinary wake drain and session start invoke Lavish intake. +When the captain says a browser board is answered, run one bounded `lavish-axi intake --home ` immediately rather than waiting for an automated submission prompt; the board intentionally has none. +The downloaded payload is the landing record until intake validates it into `answer.toon`. The answer file is authoritative; the wake record is only a pointer. Visible prompt delivery is redundant; `tools/lavish/README.md` owns its home-bound routing and manifest-destination contract. -If that proof is unavailable, accept the fail-closed refusal and rely on the durable wake path rather than targeting ambient terminal state or asking the captain to submit again. +If that proof is unavailable, accept the fail-closed refusal and rely on the durable answer path rather than targeting ambient terminal state or asking the captain to answer again. When a destination appears: @@ -89,9 +91,9 @@ When a destination appears: Never start a server, create or share a session URL, poll, long-poll, register a filesystem watcher, schedule a timer sweep, or launch a resident server or listener process for Lavish decision capture. Do not use upstream `serve`, `poll`, browser, layout-audit, or session-lifecycle commands. -The sole browser exception is `bin/fm-lavish-board.sh`, which opens a decision-specific profile and arms one ordinary bounded recovery check around the authoritative file protocol. -Only that helper arms the pickup path; a hand-authored page is not a fallback because its answers cannot reach firstmate. +The sole browser exception is `bin/fm-lavish-board.sh`, which invokes the operating system's default browser opener and exits without a dedicated profile, automation session, or submission check. +Only that helper-generated page binds the downloaded answer to the immutable request and fleet home; a hand-authored page is not a fallback because its answers cannot enter the validated intake path. -Every core Lavish command must finish its bounded local file operation and exit. +Every core Lavish command and the board wrapper must finish its bounded local operation and exit. If `lavish answer` reports `answer saved; wake not queued`, do not ask the captain to answer again. The next ordinary intake scan recovers the durable unreceipted answer. diff --git a/.agents/skills/lavish-repair/SKILL.md b/.agents/skills/lavish-repair/SKILL.md index 1232968217a..5d883c04073 100644 --- a/.agents/skills/lavish-repair/SKILL.md +++ b/.agents/skills/lavish-repair/SKILL.md @@ -1,8 +1,8 @@ --- name: lavish-repair description: >- - Agent-only recovery playbook for a self-contained Lavish board that fails answerability preflight, does not open in its isolated Chrome session, or submits without pickup. - Use before touching a Lavish board's state artifacts or Chrome session during a surface incident. + Agent-only recovery playbook for a self-contained Lavish board that fails answerability preflight, default-browser open, interaction, answer download, or bounded intake. + Use before touching a generated board or downloaded-answer artifact during a surface incident. Do not use it to create or present a decision; use `lavish-decisions` for that. user-invocable: false metadata: @@ -11,18 +11,18 @@ metadata: # Lavish repair -Prove the failing layer before changing state or stopping a process. -The Lavish fork has no server, session URL, live channel, listener, or poller to repair. -Never invoke upstream serve, poll, or server-lifecycle commands. +Prove the failing layer before changing any durable artifact. +The Lavish fork has no server, session URL, live channel, browser automation session, listener, poller, or armed submission check to repair. +Never invoke upstream serve, poll, browser, or server-lifecycle commands. ## Start from the owners -Read `bin/fm-lavish-board.sh`'s header and `--help` output for the current preflight, dedicated-browser, and pickup mechanics. -Read `tools/lavish/README.md` for the durable decision and payload protocol. -Load `lavish-decisions` before completing the normal collect and consume workflow. +Read `bin/fm-lavish-board.sh`'s header and `--help` output for the current preflight and default-browser-open mechanics. +Read `tools/lavish/README.md` for the durable decision, downloaded payload, and intake protocol. +Load `lavish-decisions` before completing the normal consume workflow. -Establish the exact decision id, resolved Firstmate home, helper output, and named Chrome session before diagnosing the incident. -Preserve any downloaded answer JSON, durable pickup payload, and unsubmitted captain input. +Establish the exact decision id, resolved Firstmate home, helper output, generated HTML path, and browser download location before diagnosing the incident. +Preserve any downloaded answer JSON, manual payload backup, and unsubmitted captain input. Do not edit `request.md` or `manifest.toon`, because their digest and ordered question or item set are immutable. ## Diagnose in route order @@ -30,54 +30,47 @@ Do not edit `request.md` or `manifest.toon`, because their digest and ordered qu ### 1. Answerability preflight When the helper refuses an unanswerable board, read its named missing components. -Do not bypass the preflight, arm pickup by hand, or substitute hand-authored HTML. +Do not bypass the preflight or substitute hand-authored HTML. Resolve checkout or installed-tool version skew against the active helper and Lavish fork before trying the helper again. -The helper has not opened Chrome or armed pickup when this check fails. +The helper has not invoked the default browser when this check fails. Surface the exact terminal fallback emitted by `lavish-axi create` instead of reporting that a board is open. -### 2. Browser launch +### 2. Browser open -When preflight succeeds but opening Chrome fails, retain the helper's exact error and session name. -The helper removes the armed check on an open failure, so do not report that submission pickup is active. -Use current `chrome-devtools-axi` help to inspect only the helper's named isolated session. -Never attach the board to the captain's main Chrome profile. - -If the isolated session cannot be restored safely, use the exact terminal fallback from the creation result. +When preflight succeeds but the operating system cannot open the generated file, retain the helper's exact error and HTML path. +Do not launch a dedicated browser profile, Chrome DevTools process, browser automation session, server, or resident helper as a workaround. +Open the existing self-contained file through the host's ordinary default-browser surface, or use the exact terminal fallback from the creation result. ### 3. Board interaction -When the board is open but visibly broken, inspect that page in the named isolated session before reloading or reopening it. -Protect any unsubmitted captain input before a page-level repair. -If the rendered controls or submit machinery are missing, treat that as generator or version drift and return to the answerability-preflight branch. - -### 4. Submission pickup +When the board opens but is visibly broken, protect any unsubmitted captain input before reloading or reopening the generated file. +If the rendered controls, review step, download button, or manual payload backup are missing, treat that as generator or version drift and return to the answerability-preflight branch. +Do not edit the generated page to manufacture an answer path. -The verified browser-profile record is the authoritative pickup route, and a matching download is optional corroboration. -Keep the helper's existing one-shot check armed so Firstmate's ordinary watcher can recover the record even after the visible browser closes. -Do not add another storage bridge, filesystem watcher, timer sweep, long poll, or resident process. +### 4. Downloaded answer -Preserve a matching download for corroboration, but do not treat it as confirmed delivery. -If the helper emits `lavish-submit: `, preserve that exact durable payload and continue through the `lavish-decisions` consume workflow. -Confirm receipt only after `lavish-axi collect` validates and saves the answer. +The board's landing record is `lavish-answer--.json` in the browser's download location. +The board also exposes the exact JSON as a manual backup so a blocked automatic download does not erase the completed batch. +If needed, save that backup under the same filename in the normal or `LAVISH_DOWNLOADS_DIR` location, preserving every byte the board produced. -### 5. Collection +Do not report automatic delivery or wait for a submission prompt; neither exists. +Do not add browser-profile storage, an armed check, filesystem watcher, timer sweep, long poll, server, or resident process. +When the captain says the answer is saved, continue immediately to one bounded intake. -Treat a named `lavish-axi collect` validation error as a payload or immutable-request mismatch, not a browser failure. -Preserve the rejected payload for diagnosis and do not weaken the schema, key, option, annotation, or request-digest checks. -Do not ask the captain to answer again when the same valid answer has already been saved durably. +### 5. Intake and collection -## Process safety gate +Run `lavish-axi intake --home ` once and inspect its complete result. +Intake discovers the home-bound download, validates the schema, decision id, request digest, ordered keys, and declared values, commits `answer.toon`, writes the declared destination, and then writes `receipt.toon`. +Confirm receipt only after that validated path succeeds. -Prefer the helper's named Chrome-session controls over process signals. -Never use `pkill -f` or signal a process selected only by a tool-name pattern, because crewmate launch commands can contain the same text. -If an explicit process signal is genuinely required, list candidates, inspect every candidate's PID, parent, elapsed time, and full command, then signal only one PID whose isolated-session identity is proven. -Never pipe unfiltered process-search output into `kill`. -Recheck the named session after the action before considering another signal. +Treat a named payload or `lavish-axi collect` validation error as a payload or immutable-request mismatch, not a browser failure. +Preserve the rejected payload for diagnosis and do not weaken the schema, key, option, annotation, home-marker, or request-digest checks. +Do not ask the captain to answer again when the same valid answer has already been saved durably. ## Recovery boundary -A surface failure does not erase the durable decision, a downloaded payload, or a collected answer. +A surface failure does not erase the durable decision, a downloaded payload, a manual payload backup, or a collected answer. Use `lavish show` and `lavish inbox` with the explicit Firstmate home to distinguish pending from already answered state. Reopen a board only when no submitted payload exists and the captain's unsubmitted input has been protected or is known to be absent. If the browser route remains unavailable, the exact `lavish answer ... --home ...` creation fallback keeps the decision answerable without browser infrastructure. diff --git a/AGENTS.md b/AGENTS.md index 26393457d8f..93be047cd05 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -186,7 +186,7 @@ If the digest reported `data/projects.md` as `ABSENT` or disagreeing with what i An `ABSENT` `data/captain.md` or `data/secondmates.md` or `data/learnings.md` means exactly what section 2 says it means (template defaults, no registered secondmates, nothing captured yet) - not a problem to fix. Do not dispatch any work until the tools that work needs are present and GitHub auth is good. -Use `gh-axi` for all GitHub operations, `chrome-devtools-axi` for all browser operations, and the firstmate-owned `lavish-axi` file protocol for durable captain decisions and annotation boards that must remain answerable asynchronously. +Use `gh-axi` for all GitHub operations, `chrome-devtools-axi` for browser automation and inspection, and the firstmate-owned `lavish-axi` file protocol for durable captain decisions and annotation boards that must remain answerable asynchronously. Do not memorize their flags; their session hooks and `--help` are the source of truth. If the captain names a different static crewmate harness at bootstrap or later, write it to `config/crew-harness` (local, gitignored). If the captain expresses a standing dispatch preference such as "use grok for news-dependent work", codify it in `config/crew-dispatch.json` instead. @@ -869,7 +869,7 @@ These skills are not captain-invocable; they are conditional operating reference - `firstmate-coding-guidelines` - load before changing firstmate's shared, tracked material, as defined by section 1's list, whether editing directly or briefing a crewmate for a firstmate-repo task. - `memory-hygiene` - load before writing, rewriting, pruning, deduplicating, or otherwise leaning `data/captain.md` or `data/learnings.md`. - `lavish-decisions` - load before creating, repairing, or presenting a multi-option captain choice, and before asking the captain to comment on material without choosing anything. -- `lavish-repair` - load when a self-contained Lavish board fails preflight, browser launch, interaction, submission pickup, or collection, and before touching its state artifacts or isolated Chrome session. +- `lavish-repair` - load when a self-contained Lavish board fails preflight, default-browser open, interaction, answer download, or bounded intake, and before touching a generated board or downloaded-answer artifact. - `eks-usage` - load before running `kubectl` or Amazon EKS commands, on an EKS IAM, authenticator, TLS, or connectivity error, or whenever the active cluster or context is uncertain. ## 14. X mode diff --git a/bin/fm-lavish-board.sh b/bin/fm-lavish-board.sh index 05e63b16370..8077931e490 100755 --- a/bin/fm-lavish-board.sh +++ b/bin/fm-lavish-board.sh @@ -1,34 +1,33 @@ #!/usr/bin/env bash -# Render one durable Lavish decision, assert its answering machinery before it -# can be surfaced, open it in a dedicated headed Chrome profile, and arm a -# one-shot watcher check for its durable browser-profile record with optional -# download corroboration. +# Render one durable Lavish decision, assert its download-based answering +# machinery before it can be surfaced, open the self-contained file through the +# operating system's default browser, and exit without owning a browser session. # # Usage: -# fm-lavish-board.sh [--home ] [--downloads ] +# fm-lavish-board.sh [--home ] # -# Internal watcher entry point: -# fm-lavish-board.sh --check --home --session -# --downloads --state --opened-at +# FM_LAVISH_OPEN_COMMAND may name an absolute executable for tests or hosts that +# do not provide macOS `open`. The executable receives the board's +# absolute HTML path as its only argument and must return after handing the file +# to the user's browser. set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)" -SUBMIT_MARKER='LAVISH-SUBMIT v2' LAVISH_BIN=${FM_LAVISH_BIN:-$REPO_ROOT/tools/lavish/src/cli.mjs} usage() { cat <<'USAGE' -Usage: fm-lavish-board.sh [--home ] [--downloads ] +Usage: fm-lavish-board.sh [--home ] -Render a self-contained Lavish board, open it in a headed dedicated Chrome -profile, and arm a watcher check that captures the verified LAVISH-SUBMIT v2 -browser-profile record first and accepts a matching download as corroboration. -The helper refuses before arming pickup or opening Chrome when the rendered -page lacks the inputs its declared mode needs or its schema-version-2 submit -path. A decision board needs radio choices with per-option and per-question -annotations; an annotation board needs one comment box per item and no choices -at all. +Render a self-contained Lavish board, verify that its declared mode has the +required inputs and schema-version-2 answer download, open the file through the +operating system's default browser, and exit. The helper creates no dedicated +browser profile, automation session, submission check, watcher, or listener. + +After saving the complete answer batch, tell firstmate it is ready. The next +bounded Lavish intake validates the downloaded payload against the immutable +request and ordered question or item set before committing it. USAGE } @@ -50,33 +49,23 @@ resolve_home() { (cd "$requested" && pwd -P) } -resolve_downloads() { - local requested=$1 - [ -d "$requested" ] && [ ! -L "$requested" ] \ - || fail "unsafe or missing downloads directory: $requested" - (cd "$requested" && pwd -P) -} - -isolated_chrome() { - local session=$1 - local profile=$2 - local headed=$3 - shift 3 - ( - unset CHROME_DEVTOOLS_AXI_AUTO_CONNECT - unset CHROME_DEVTOOLS_AXI_BROWSER_URL - unset CHROME_DEVTOOLS_AXI_CHROME_ARGS - unset CHROME_DEVTOOLS_AXI_PORT - unset CHROME_DEVTOOLS_AXI_WS_HEADERS - export CHROME_DEVTOOLS_AXI_SESSION="$session" - export CHROME_DEVTOOLS_AXI_USER_DATA_DIR="$profile" - if [ "$headed" = 1 ]; then - export CHROME_DEVTOOLS_AXI_HEADED=1 - else - unset CHROME_DEVTOOLS_AXI_HEADED - fi - chrome-devtools-axi "$@" - ) +resolve_open_command() { + local requested=${FM_LAVISH_OPEN_COMMAND:-} board_path=$1 + if [ -n "$requested" ]; then + case "$requested" in + /*) ;; + *) fail 'FM_LAVISH_OPEN_COMMAND must be an absolute executable path' ;; + esac + [ -f "$requested" ] && [ ! -L "$requested" ] && [ -x "$requested" ] \ + || fail "unsafe or missing browser opener: $requested" + printf '%s\n' "$requested" + return + fi + if [ "$(uname -s)" = Darwin ] && [ -x /usr/bin/open ]; then + printf '%s\n' /usr/bin/open + return + fi + fail "automatic default-browser handoff is unsupported on this host; open the board manually: $board_path" } assert_answerable_board() { @@ -110,8 +99,7 @@ const itemNotes = tags('textarea').filter((tag) => hasAttribute(tag, 'data-item- const cards = tags('section').filter((tag) => hasClass(tag, 'question')); const submit = tags('button').some((tag) => hasAttribute(tag, 'id', 'submit-button')); const overallNote = tags('textarea').some((tag) => hasAttribute(tag, 'id', 'overall-note')); -// An annotation board is answerable without a single choice on it, so asserting -// radios there would refuse exactly the board this mode exists to surface. +const payloadBackup = tags('textarea').some((tag) => hasAttribute(tag, 'id', 'submitted-payload')); const annotation = tags('body').some((tag) => hasAttribute(tag, 'data-lavish-mode', 'annotation')); const missing = []; @@ -133,14 +121,17 @@ if (annotation) { } } if (!overallNote) missing.push('overall note input'); -if (!submit) missing.push('submit button'); +if (!submit) missing.push('save button'); +if (!payloadBackup) missing.push('manual payload backup'); if (!/\bschema_version\s*:\s*2\b/.test(html)) missing.push('schema_version 2 payload'); if (!/querySelector\(\s*['"]#submit-button['"]\s*\)\.addEventListener\(\s*['"]click['"]/.test(html)) { - missing.push('submit click handler'); + missing.push('save click handler'); } if (!/JSON\.stringify\(\s*payload\b/.test(html)) missing.push('JSON payload serialization'); if (!/new Blob\(\s*\[\s*payloadJson\s*\]/.test(html)) missing.push('JSON download'); -if (!/window\.__lavishPayload\s*=\s*payload\b/.test(html)) missing.push('pickup payload publication'); +if (!/anchor\.download\s*=\s*downloadFilename\b/.test(html)) missing.push('download filename'); +if (!/anchor\.click\(\s*\)/.test(html)) missing.push('download activation'); +if (!/submittedPayload\.value\s*=\s*payloadJson\b/.test(html)) missing.push('manual payload publication'); if (missing.length > 0) { process.stderr.write(`missing ${missing.join(', ')}\n`); @@ -149,189 +140,16 @@ if (missing.length > 0) { NODE } -write_evaluation_payload() { # - local evaluation=$1 target=$2 decision_id=$3 home_marker=$4 result_line - result_line=$(printf '%s\n' "$evaluation" | sed -n 's/^result: //p' | sed -n '1p') - [ -n "$result_line" ] || return 1 - printf '%s\n' "$result_line" | node -e ' - const fs = require("node:fs"); - const raw = fs.readFileSync(0, "utf8").trim(); - const target = process.argv[1]; - const marker = process.argv[2]; - const decisionId = process.argv[3]; - const homeMarker = process.argv[4]; - let snapshot = raw; - try { - for (let depth = 0; depth < 4 && typeof snapshot === "string"; depth += 1) { - snapshot = JSON.parse(snapshot); - } - let payload = snapshot?.payload ?? null; - let markerMatches = snapshot?.title === marker; - if (snapshot?.durable_record != null) { - let durable = snapshot.durable_record; - if (typeof durable === "string") durable = JSON.parse(durable); - if (durable?.marker !== marker || durable?.payload == null) process.exit(3); - payload = durable.payload; - markerMatches = true; - } - if ( - !markerMatches - || payload?.decision_id !== decisionId - || payload?.home_marker !== homeMarker - ) process.exit(3); - fs.writeFileSync(target, JSON.stringify(payload) + "\n", { mode: 0o600 }); - } catch { - process.exit(4); - } - ' "$target" "$SUBMIT_MARKER" "$decision_id" "$home_marker" -} - -collect_submission() { - local decision_id=$1 - local home=$2 - local payload_path=$3 - local collected intake_out - - collected=$("$LAVISH_BIN" collect "$decision_id" --home "$home" --payload "$payload_path" 2>&1) || { - printf 'lavish-submit-error: collect failed for %s from %s: %s\n' \ - "$decision_id" "$payload_path" "$collected" - return 1 - } - [ -z "$collected" ] || printf '%s\n' "$collected" - - intake_out=$(LAVISH_SCAN_HOME_DOWNLOADS=0 "$LAVISH_BIN" intake --home "$home" 2>&1) || { - printf 'lavish-submit-error: intake failed for %s after collect: %s\n' \ - "$decision_id" "$intake_out" - return 1 - } - [ -z "$intake_out" ] || printf '%s\n' "$intake_out" -} - -check_submission() { - local decision_id=$1 - local home=$2 - local state_dir=$3 - local session=$4 - local downloads=$5 - local opened_at=$6 - local payload_path="$state_dir/lavish-board-$decision_id.payload.json" - local check_path="$state_dir/lavish-board-$decision_id.check.sh" - local profile_path="$state_dir/lavish-board-$decision_id.chrome-profile" - local html_path="$state_dir/lavish-board-$decision_id.html" - local downloaded_path evaluation temporary board_url - - [ -d "$state_dir" ] && [ ! -L "$state_dir" ] || exit 0 - [ -d "$profile_path" ] && [ ! -L "$profile_path" ] || exit 0 - [ -f "$html_path" ] && [ ! -L "$html_path" ] || exit 0 - - if ! downloaded_path=$(node -e ' - const fs = require("node:fs"); - const path = require("node:path"); - const directory = process.argv[1]; - const decisionId = process.argv[2]; - const openedAt = Number(process.argv[3]); - const homeMarker = process.argv[4]; - const pattern = new RegExp( - "^lavish-answer-" + decisionId + "(?: \\(\\d+\\))?\\.json$", - ); - const candidates = []; - for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { - if (!entry.isFile() || !pattern.test(entry.name)) continue; - const candidate = path.join(directory, entry.name); - const info = fs.statSync(candidate); - if (info.mtimeMs < openedAt) continue; - try { - const payload = JSON.parse(fs.readFileSync(candidate, "utf8")); - if ( - payload?.decision_id !== decisionId - || payload?.home_marker !== homeMarker - ) continue; - } catch { - continue; - } - candidates.push({ candidate, mtimeMs: info.mtimeMs }); - } - candidates.sort((left, right) => right.mtimeMs - left.mtimeMs); - if (candidates[0]) process.stdout.write(candidates[0].candidate); - ' "$downloads" "$decision_id" "$opened_at" "$home" 2>/dev/null); then - printf 'lavish-submit-error: download scan failed for %s in %s\n' \ - "$decision_id" "$downloads" - return 1 - fi - - temporary=$(mktemp "$state_dir/.lavish-board-$decision_id.payload.XXXXXX") - evaluation=$(isolated_chrome "$session" "$profile_path" 0 eval \ - 'JSON.stringify({title: document.title, payload: window.__lavishPayload ?? null, durable_record: typeof window.__lavishStorageKey === "string" ? localStorage.getItem(window.__lavishStorageKey) : null})' \ - 2>/dev/null) || evaluation= - if [ -n "$evaluation" ]; then - if write_evaluation_payload "$evaluation" "$temporary" "$decision_id" "$home"; then - mv "$temporary" "$payload_path" - else - rm -f "$temporary" - exit 0 - fi - else - rm -f "$temporary" - board_url=$(node -e \ - 'const { pathToFileURL } = require("node:url"); console.log(pathToFileURL(process.argv[1]).href);' \ - "$html_path") - isolated_chrome "$session" "$profile_path" 0 open "$board_url" >/dev/null 2>&1 \ - || exit 0 - evaluation=$(isolated_chrome "$session" "$profile_path" 0 eval \ - 'JSON.stringify({title: document.title, payload: window.__lavishPayload ?? null, durable_record: typeof window.__lavishStorageKey === "string" ? localStorage.getItem(window.__lavishStorageKey) : null})' \ - 2>/dev/null) || { - isolated_chrome "$session" "$profile_path" 0 stop >/dev/null 2>&1 || true - exit 0 - } - temporary=$(mktemp "$state_dir/.lavish-board-$decision_id.payload.XXXXXX") - if write_evaluation_payload "$evaluation" "$temporary" "$decision_id" "$home"; then - mv "$temporary" "$payload_path" - else - rm -f "$temporary" - isolated_chrome "$session" "$profile_path" 0 stop >/dev/null 2>&1 || true - exit 0 - fi - fi - - if [ -n "$downloaded_path" ]; then - temporary=$(mktemp "$state_dir/.lavish-board-$decision_id.payload.XXXXXX") - if cp "$downloaded_path" "$temporary" \ - && chmod 600 "$temporary" \ - && node -e ' - const fs = require("node:fs"); - const { isDeepStrictEqual } = require("node:util"); - const downloaded = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); - const durable = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); - if (!isDeepStrictEqual(downloaded, durable)) process.exit(3); - ' "$temporary" "$payload_path"; then - mv "$temporary" "$payload_path" - else - rm -f "$temporary" - fi - fi - - collect_submission "$decision_id" "$home" "$payload_path" || return - rm -f "$check_path" - isolated_chrome "$session" "$profile_path" 0 stop >/dev/null 2>&1 || true - printf 'lavish-submit: %s %s\n' "$decision_id" "$payload_path" -} - -MODE=board -if [ "${1:-}" = '--check' ]; then - MODE=check - shift +if [ "${1:-}" = --help ]; then + usage + exit 0 fi - [ "$#" -gt 0 ] || { usage >&2; exit 2; } DECISION_ID=$1 shift validate_id "$DECISION_ID" HOME_ARG=${FM_HOME:-$REPO_ROOT} -SESSION= -DOWNLOADS_ARG=${LAVISH_DOWNLOADS_DIR:-${HOME:+$HOME/Downloads}} -STATE_ARG=${FM_STATE_OVERRIDE:-} -OPENED_AT= while [ "$#" -gt 0 ]; do case "$1" in --home) @@ -339,30 +157,6 @@ while [ "$#" -gt 0 ]; do HOME_ARG=$2 shift 2 ;; - --downloads) - [ "$#" -gt 1 ] || fail '--downloads requires a path' - DOWNLOADS_ARG=$2 - shift 2 - ;; - --session) - [ "$MODE" = check ] || fail '--session is internal to the watcher check' - [ "$#" -gt 1 ] || fail '--session requires a name' - SESSION=$2 - shift 2 - ;; - --state) - [ "$MODE" = check ] || fail '--state is internal to the watcher check' - [ "$#" -gt 1 ] || fail '--state requires a path' - STATE_ARG=$2 - shift 2 - ;; - --opened-at) - [ "$MODE" = check ] || fail '--opened-at is internal to the watcher check' - [ "$#" -gt 1 ] || fail '--opened-at requires milliseconds since epoch' - [[ "$2" =~ ^[0-9]+$ ]] || fail '--opened-at must be milliseconds since epoch' - OPENED_AT=$2 - shift 2 - ;; --help) usage exit 0 @@ -374,40 +168,16 @@ while [ "$#" -gt 0 ]; do done HOME_PATH=$(resolve_home "$HOME_ARG") -[ -n "$DOWNLOADS_ARG" ] || fail 'could not resolve a Downloads directory; pass --downloads' -DOWNLOADS_PATH=$(resolve_downloads "$DOWNLOADS_ARG") -STATE_ARG=${STATE_ARG:-$HOME_PATH/state} +STATE_ARG=${FM_STATE_OVERRIDE:-$HOME_PATH/state} mkdir -p "$STATE_ARG" [ -d "$STATE_ARG" ] && [ ! -L "$STATE_ARG" ] \ || fail "unsafe state directory: $STATE_ARG" STATE_DIR=$(cd "$STATE_ARG" && pwd -P) -FM_STATE_OVERRIDE=$STATE_DIR -export FM_STATE_OVERRIDE - -if [ "$MODE" = check ]; then - [ -n "$SESSION" ] || fail '--session is required in check mode' - [ -n "$OPENED_AT" ] || fail '--opened-at is required in check mode' - check_submission \ - "$DECISION_ID" "$HOME_PATH" "$STATE_DIR" "$SESSION" "$DOWNLOADS_PATH" "$OPENED_AT" - exit 0 -fi [ -x "$LAVISH_BIN" ] || fail "Lavish fork CLI is not executable: $LAVISH_BIN" -command -v chrome-devtools-axi >/dev/null 2>&1 \ - || fail 'chrome-devtools-axi is not installed' command -v node >/dev/null 2>&1 || fail 'node is not installed' - umask 077 - -HOME_DIGEST=$(printf '%s' "$HOME_PATH" | shasum -a 256 | awk '{print substr($1,1,12)}') -SESSION="lavish-${DECISION_ID:0:36}-$HOME_DIGEST" HTML_PATH="$STATE_DIR/lavish-board-$DECISION_ID.html" -PAYLOAD_PATH="$STATE_DIR/lavish-board-$DECISION_ID.payload.json" -PROFILE_PATH="$STATE_DIR/lavish-board-$DECISION_ID.chrome-profile" -CHECK_PATH="$STATE_DIR/lavish-board-$DECISION_ID.check.sh" -OPENED_AT=$(node -e 'process.stdout.write(String(Date.now()))') - -rm -f "$CHECK_PATH" "$LAVISH_BIN" board "$DECISION_ID" --home "$HOME_PATH" --out "$HTML_PATH" ANSWERABILITY_ERROR= if ! ANSWERABILITY_ERROR=$(assert_answerable_board "$HTML_PATH" 2>&1); then @@ -415,28 +185,10 @@ if ! ANSWERABILITY_ERROR=$(assert_answerable_board "$HTML_PATH" 2>&1); then fail "refusing to surface an unanswerable board: $ANSWERABILITY_ERROR" fi -rm -f "$PAYLOAD_PATH" -mkdir -p "$PROFILE_PATH" -[ -d "$PROFILE_PATH" ] && [ ! -L "$PROFILE_PATH" ] \ - || fail "unsafe Chrome profile directory: $PROFILE_PATH" -CHECK_TMP=$(mktemp "$STATE_DIR/.lavish-board-$DECISION_ID.check.XXXXXX") -{ - printf '#!/usr/bin/env bash\n' - printf 'exec env FM_STATE_OVERRIDE=%q %q --check %q --home %q --state %q --session %q --downloads %q --opened-at %q\n' \ - "$STATE_DIR" "$SCRIPT_DIR/fm-lavish-board.sh" "$DECISION_ID" "$HOME_PATH" \ - "$STATE_DIR" "$SESSION" "$DOWNLOADS_PATH" "$OPENED_AT" -} > "$CHECK_TMP" -chmod 700 "$CHECK_TMP" -mv "$CHECK_TMP" "$CHECK_PATH" - -BOARD_URL=$(node -e \ - 'const { pathToFileURL } = require("node:url"); console.log(pathToFileURL(process.argv[1]).href);' \ - "$HTML_PATH") -if ! isolated_chrome "$SESSION" "$PROFILE_PATH" 1 open "$BOARD_URL"; then - rm -f "$CHECK_PATH" - fail 'could not open the isolated Chrome session' +OPEN_COMMAND=$(resolve_open_command "$HTML_PATH") +if ! "$OPEN_COMMAND" "$HTML_PATH"; then + fail 'could not open the board in the default browser' fi -printf 'Opened Lavish board %s in isolated Chrome session %s.\n' \ - "$DECISION_ID" "$SESSION" -printf 'Armed submission check: %s\n' "$CHECK_PATH" +printf 'Opened Lavish board %s in the default browser.\n' "$DECISION_ID" +printf 'After saving the answer, tell firstmate it is ready; bounded intake will validate the downloaded file.\n' diff --git a/docs/configuration.md b/docs/configuration.md index a28f7bb91eb..19c4f85e677 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -502,7 +502,7 @@ The universal toolchain is node, Python 3.11 or newer, git, gh with GitHub auth Bootstrap only checks that some `python3` exists, so that floor is enforced where it matters at the merge gate: `bin/fm-crosscheck.sh` probes `python3.14`, `python3.13`, `python3.12`, `python3.11`, and `python3` for a conforming interpreter instead of assuming the ambient `python3` is new enough, which makes it immune to `PATH` ordering. Set `FM_CROSSCHECK_PYTHON` to a command or absolute path to select a specific interpreter; an unavailable or pre-3.11 explicit selection is refused rather than silently replaced. This section is the single owner of that universal toolchain list; backend guides' prerequisites point here and add only their backend-specific tools. -In that list, no-mistakes runs the validation pipeline, gh-axi and chrome-devtools-axi cover GitHub and browser operations, the `lavish` and `lavish-axi` commands provide durable decision capture without a browser or resident process, and tasks-axi plus quota-axi back backlog mutations and quota-balanced dispatch. +In that list, no-mistakes runs the validation pipeline, gh-axi and chrome-devtools-axi cover GitHub operations and browser automation or inspection, the `lavish` and `lavish-axi` commands provide the durable decision protocol documented in `tools/lavish/README.md`, and tasks-axi plus quota-axi back backlog mutations and quota-balanced dispatch. `config/lavish-wake-command` is the local, gitignored absolute path to this checkout's narrow wake adapter; `bin/fm-bootstrap.sh install lavish-axi` writes it after installing the fork, and it is intentionally not inherited because another firstmate home can use a different checkout. The per-backend delta is required only for the backend resolved from `FM_BACKEND`, then `config/backend`, then runtime auto-detection, then default `tmux`, so a home is never told to install a tool an inactive backend or feature would need. That delta is owned in code by `fm_backend_required_tools` in `bin/fm-backend.sh`: the resolved backend's own session-provider CLI (`tmux`, `herdr`, `zellij`, `orca`, or `cmux`), `jq` for the JSON-emitting experimental adapters (`herdr`, `zellij`, `cmux`) whose spawn and liveness paths parse the backend's JSON output, `nohup` for Herdr's portable detached `setsid` server launcher, and the `treehouse` worktree provider for every session-provider-only backend (`tmux`, `herdr`, `zellij`, `cmux`). diff --git a/docs/scripts.md b/docs/scripts.md index c475c276543..5aa7f62db11 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -73,7 +73,7 @@ The shared no-mistakes gate refusal used by every directly invocable mutating co | `fm_bounded_io.py` | Shared Python bounds for process trees, aggregate output, JSON artifacts, and item batches | | `fm-config-inherit-lib.sh` | Shared primary-to-secondmate inheritable-config propagation | | `fm-tasks-axi-lib.sh` | Shared backlog-backend selector and `tasks-axi` compatibility probe | -| `fm-lavish-board.sh` | Open a self-contained decision or annotation board and arm its profile-first bounded submission check | +| `fm-lavish-board.sh` | Open a self-contained decision or annotation board through the default browser and exit | | `fm-lavish-intake.sh` | Invoke compatible store-and-forward Lavish intake at an existing turn boundary | | `fm-lavish-queue.sh` | Queue redundant visible delivery only to a session-lock-proven home-bound supervisor | | `fm-lavish-version-lib.sh` | Shared Lavish store-forward minimum-version compatibility check | diff --git a/tests/lavish-repair.test.sh b/tests/lavish-repair.test.sh index 9fe423c36f6..f4cebb18626 100755 --- a/tests/lavish-repair.test.sh +++ b/tests/lavish-repair.test.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash -# Behavioral contract for the lavish-repair skill's routing, diagnosis order, -# process-safety guard, and durable recovery boundaries. +# Behavioral contract for the lavish-repair skill's route order, download-only +# recovery boundary, and prohibition on browser-owned pickup machinery. set -euo pipefail ROOT=$(cd "$(dirname "$0")/.." && pwd) @@ -37,94 +37,82 @@ test_internal_skill_and_trigger() { section_count=$(printf '%s\n' "$section" | grep -Fc "\`lavish-repair\`") [ "$global_count" -eq 1 ] || fail "AGENTS.md must reference lavish-repair exactly once" [ "$section_count" -eq 1 ] || fail "lavish-repair trigger must live in section 13" - assert_grep "self-contained Lavish board fails preflight, browser launch, interaction, submission pickup, or collection" \ - "$AGENTS" "trigger must cover every self-contained board failure stage" - assert_grep "before touching its state artifacts or isolated Chrome session" \ - "$AGENTS" "trigger must fire before state or browser repair" + assert_grep "self-contained Lavish board fails preflight, default-browser open, interaction, answer download, or bounded intake" \ + "$AGENTS" "trigger must cover every file-board failure stage" + assert_grep "before touching a generated board or downloaded-answer artifact" \ + "$AGENTS" "trigger must fire before artifact repair" pass "lavish-repair is internal and has one route-complete conditional trigger" } test_route_order_and_owners() { - local preflight_line browser_line interaction_line pickup_line collection_line + local preflight_line browser_line interaction_line download_line intake_line preflight_line=$(grep -nF '### 1. Answerability preflight' "$SKILL" | cut -d: -f1) || fail "answerability-preflight diagnosis stage is missing" - browser_line=$(grep -nF '### 2. Browser launch' "$SKILL" | cut -d: -f1) || - fail "browser-launch diagnosis stage is missing" + browser_line=$(grep -nF '### 2. Browser open' "$SKILL" | cut -d: -f1) || + fail "browser-open diagnosis stage is missing" interaction_line=$(grep -nF '### 3. Board interaction' "$SKILL" | cut -d: -f1) || fail "board-interaction diagnosis stage is missing" - pickup_line=$(grep -nF '### 4. Submission pickup' "$SKILL" | cut -d: -f1) || - fail "submission-pickup diagnosis stage is missing" - collection_line=$(grep -nF '### 5. Collection' "$SKILL" | cut -d: -f1) || - fail "collection diagnosis stage is missing" + download_line=$(grep -nF '### 4. Downloaded answer' "$SKILL" | cut -d: -f1) || + fail "downloaded-answer diagnosis stage is missing" + intake_line=$(grep -nF '### 5. Intake and collection' "$SKILL" | cut -d: -f1) || + fail "intake diagnosis stage is missing" [ "$preflight_line" -lt "$browser_line" ] || fail "preflight must precede browser diagnosis" - [ "$browser_line" -lt "$interaction_line" ] || fail "browser launch must precede interaction diagnosis" - [ "$interaction_line" -lt "$pickup_line" ] || fail "interaction must precede pickup diagnosis" - [ "$pickup_line" -lt "$collection_line" ] || fail "pickup must precede collection diagnosis" + [ "$browser_line" -lt "$interaction_line" ] || fail "browser open must precede interaction diagnosis" + [ "$interaction_line" -lt "$download_line" ] || fail "interaction must precede download diagnosis" + [ "$download_line" -lt "$intake_line" ] || fail "download must precede intake diagnosis" assert_grep "Read \`bin/fm-lavish-board.sh\`'s header and \`--help\` output" \ "$SKILL" "board helper ownership is missing" - assert_grep "Read \`tools/lavish/README.md\` for the durable decision and payload protocol" \ + assert_grep "Read \`tools/lavish/README.md\` for the durable decision, downloaded payload, and intake protocol" \ "$SKILL" "durable protocol ownership is missing" - assert_grep "Load \`lavish-decisions\` before completing the normal collect and consume workflow" \ + assert_grep "Load \`lavish-decisions\` before completing the normal consume workflow" \ "$SKILL" "normal workflow handoff is missing" - assert_grep "The Lavish fork has no server, session URL, live channel, listener, or poller to repair" \ - "$SKILL" "self-contained route boundary is missing" - assert_grep "Never invoke upstream serve, poll, or server-lifecycle commands" \ - "$SKILL" "upstream server lifecycle must remain forbidden" - pass "lavish-repair diagnoses the self-contained route in owner-defined order" + assert_grep "has no server, session URL, live channel, browser automation session, listener, poller, or armed submission check" \ + "$SKILL" "removed browser-anchor boundary is missing" + pass "lavish-repair diagnoses the download-only route in owner-defined order" } test_preflight_and_browser_contracts() { assert_grep "read its named missing components" "$SKILL" \ "preflight failure must report missing answerability components" - assert_grep "Do not bypass the preflight, arm pickup by hand, or substitute hand-authored HTML" \ + assert_grep "Do not bypass the preflight or substitute hand-authored HTML" \ "$SKILL" "preflight bypasses must remain forbidden" - assert_grep "has not opened Chrome or armed pickup when this check fails" \ + assert_grep "has not invoked the default browser when this check fails" \ "$SKILL" "preflight failure boundary is missing" assert_grep "exact terminal fallback emitted by \`lavish-axi create\`" \ "$SKILL" "preflight terminal fallback is missing" - assert_grep "The helper removes the armed check on an open failure" \ - "$SKILL" "browser-open cleanup boundary is missing" - assert_grep "inspect only the helper's named isolated session" \ - "$SKILL" "browser diagnosis is not scoped to the isolated session" - assert_grep "Never attach the board to the captain's main Chrome profile" \ - "$SKILL" "main-profile isolation guard is missing" - assert_grep "inspect that page in the named isolated session before reloading or reopening it" \ - "$SKILL" "visible interaction diagnosis is missing" - assert_grep "Protect any unsubmitted captain input before a page-level repair" \ + assert_grep "Do not launch a dedicated browser profile, Chrome DevTools process, browser automation session, server, or resident helper" \ + "$SKILL" "browser-anchor workaround is not forbidden" + assert_grep "protect any unsubmitted captain input before reloading or reopening" \ "$SKILL" "unsubmitted-input guard is missing" - assert_grep "return to the answerability-preflight branch" \ - "$SKILL" "missing controls must route back through preflight" - pass "lavish-repair preserves preflight and isolated-browser boundaries" + assert_grep "download button, or manual payload backup are missing" \ + "$SKILL" "answerability drift must include the new landing surfaces" + pass "lavish-repair preserves preflight and unowned-browser boundaries" } -test_pickup_and_collection_contracts() { - assert_grep "browser-profile record is the authoritative pickup route" \ - "$SKILL" "authoritative browser-profile pickup is missing" - assert_grep "matching download is optional corroboration" \ - "$SKILL" "download corroboration boundary is missing" - assert_grep "Keep the helper's existing one-shot check armed" \ - "$SKILL" "existing bounded pickup path is missing" - assert_grep "Do not add another storage bridge, filesystem watcher, timer sweep, long poll, or resident process" \ - "$SKILL" "duplicate pickup machinery is not forbidden" - assert_grep "Confirm receipt only after \`lavish-axi collect\` validates and saves the answer" \ - "$SKILL" "validated collection boundary is missing" - assert_grep "Treat a named \`lavish-axi collect\` validation error as a payload or immutable-request mismatch" \ - "$SKILL" "collection-error classification is missing" - assert_grep "do not weaken the schema, key, option, annotation, or request-digest checks" \ +test_download_and_intake_contracts() { + assert_grep "landing record is \`lavish-answer--.json\`" \ + "$SKILL" "downloaded landing record is missing" + assert_grep "exposes the exact JSON as a manual backup" \ + "$SKILL" "manual payload recovery is missing" + assert_grep "Do not report automatic delivery or wait for a submission prompt; neither exists" \ + "$SKILL" "removed automatic pickup is still being promised" + assert_grep "Do not add browser-profile storage, an armed check, filesystem watcher, timer sweep, long poll, server, or resident process" \ + "$SKILL" "replacement pickup machinery is not forbidden" + assert_grep "Run \`lavish-axi intake --home \` once" \ + "$SKILL" "bounded intake handoff is missing" + assert_grep "validates the schema, decision id, request digest, ordered keys, and declared values" \ + "$SKILL" "immutable request and ordered-entry validation is missing" + assert_grep "commits \`answer.toon\`, writes the declared destination, and then writes \`receipt.toon\`" \ + "$SKILL" "durable answer publication order is missing" + assert_grep "do not weaken the schema, key, option, annotation, home-marker, or request-digest checks" \ "$SKILL" "collection validation must remain fail-closed" - pass "lavish-repair preserves the existing pickup and collection contracts" + pass "lavish-repair retains the downloaded answer and durable intake contracts" } -test_process_safety_and_recovery_contracts() { - assert_grep "Never use \`pkill -f\` or signal a process selected only by a tool-name pattern" \ - "$SKILL" "fleet-wide process-name signaling is not forbidden" - assert_grep "inspect every candidate's PID, parent, elapsed time, and full command" \ - "$SKILL" "explicit process identity proof is missing" - assert_grep "Never pipe unfiltered process-search output into \`kill\`" \ - "$SKILL" "unfiltered process signaling is not forbidden" - assert_grep "A surface failure does not erase the durable decision, a downloaded payload, or a collected answer" \ +test_recovery_contract() { + assert_grep "A surface failure does not erase the durable decision, a downloaded payload, a manual payload backup, or a collected answer" \ "$SKILL" "durable-state survival boundary is missing" assert_grep "Use \`lavish show\` and \`lavish inbox\` with the explicit Firstmate home" \ "$SKILL" "durable-state inspection sequence is missing" @@ -132,11 +120,11 @@ test_process_safety_and_recovery_contracts() { "$SKILL" "safe reopen gate is missing" assert_grep "the exact \`lavish answer ... --home ...\` creation fallback keeps the decision answerable" \ "$SKILL" "browser-independent answer fallback is missing" - pass "lavish-repair retains process safety and durable recovery boundaries" + pass "lavish-repair retains durable recovery without process lifecycle advice" } test_internal_skill_and_trigger test_route_order_and_owners test_preflight_and_browser_contracts -test_pickup_and_collection_contracts -test_process_safety_and_recovery_contracts +test_download_and_intake_contracts +test_recovery_contract diff --git a/tools/lavish/README.md b/tools/lavish/README.md index 82c1af261e5..e25cdec8538 100644 --- a/tools/lavish/README.md +++ b/tools/lavish/README.md @@ -4,7 +4,7 @@ Lavish is firstmate's durable decision inbox. It stores complete requests and answers under `$FM_HOME/data/decisions/`, and its authoritative store-and-forward operations are bounded local commands. The core file protocol has no server, URL, listener, long poll, idle timeout, or resident process. -The optional Firstmate board wrapper opens a bounded dedicated Chrome profile and arms one ordinary watcher check; the durable request and answer files remain authoritative. +The optional Firstmate board wrapper renders a self-contained file, hands it to the operating system's default browser, and exits without owning a browser process, automation session, or submission check. An unanswered request remains answerable until the files are deliberately removed. ## Human commands @@ -32,14 +32,17 @@ It then queues a redundant wake pointer and exits without waiting for firstmate. If wake enqueueing fails, the durable answer remains authoritative. The command prints `answer saved; wake not queued`, exits nonzero, and the next ordinary intake scan recovers it. -`bin/fm-lavish-board.sh` owns the dedicated Chrome-profile launch, fail-closed answerability preflight, and bounded pickup integration around the generated file. -`bin/fm-lavish-board.sh` renders the same immutable request into self-contained HTML and opens it with a decision-specific Chrome profile below the effective state root (`FM_STATE_OVERRIDE` or `$FM_HOME/state`). -The board form refuses to submit nothing, because `answer.toon` is write-once: a decision board requires a choice on every question, and an annotation board requires at least one item comment or the overall note. +`bin/fm-lavish-board.sh` owns the operating-system browser open and the fail-closed answerability preflight around the generated file. +It renders the same immutable request into self-contained HTML below the effective state root (`FM_STATE_OVERRIDE` or `$FM_HOME/state`), invokes the default browser opener, and exits as soon as the opener returns. +It creates no dedicated browser profile, Chrome DevTools process, automation session, armed submission check, watcher, or listener. +The board form refuses to save nothing, because the eventual `answer.toon` is write-once: a decision board requires a choice on every question, and an annotation board requires at least one item comment or the overall note. That guard is the rendered form's alone; `collect` stays permissive, so a hand-recovered payload is never refused for it. -Submit first writes and reads back a browser-profile record, then shows a confirmation describing that durable record; the optional browser download is not treated as confirmed delivery. -Both records carry the resolved absolute Firstmate home as `home_marker`, so a shared Downloads directory cannot route one home's answer into another home. -The armed check recovers the record from the same profile even after the visible browser closes, validates it through `lavish-axi collect`, and runs intake. -After an answer is committed, the Firstmate wake adapter appends the durable wake pointer and may attempt visible prompt delivery only when the session lock proves a live supervisor route belongs to the same canonical `FM_HOME`. +Saving downloads one schema-version-2 JSON payload named `lavish-answer--.json` and exposes the same bytes as a manual backup. +The payload carries the resolved absolute Firstmate home as `home_marker`, so a shared Downloads directory cannot route one home's answer into another home. +The board does not claim to notify firstmate automatically. +The captain tells firstmate after answering, and the next bounded `lavish-axi intake` scan finds the downloaded payload, validates it against the immutable manifest, commits `answer.toon`, writes the declared destination, and then writes `receipt.toon`. +The normal Downloads directory is scanned by default; a fleet whose browser saves elsewhere declares that directory through `LAVISH_DOWNLOADS_DIR` for intake. +After the answer is committed, the Firstmate wake adapter appends the durable wake pointer and may attempt visible prompt delivery only when the session lock proves a live supervisor route belongs to the same canonical `FM_HOME`. It never falls back to ambient terminal state, and a visible prompt names the manifest's declared destination rather than assuming a conventional path. If visible delivery is refused, the durable answer and wake pointer remain authoritative. Read that script's header or `--help` output for its current mechanics; the [`lavish-decisions` skill](../../.agents/skills/lavish-decisions/SKILL.md) owns when to invoke it and when to use the terminal fallback. @@ -125,9 +128,11 @@ An existing matching destination or receipt is an idempotent success. A conflicting destination fails closed. New manifests declare `destination_format`; `.json` destinations receive the schema-version-2 browser payload, while other destinations receive the authoritative TOON answer. Field-less protocol-1 manifests infer that same contract from the destination extension, so a `.json` destination never receives TOON bytes. -Payload recovery scans the effective state root plus configured download locations as one batch. +Payload recovery scans configured download locations plus the effective state root's legacy landing files as one batch. An unreadable configured location fails the batch before any candidate is committed or any intake result is published. New payloads route by `home_marker`; legacy unmarked downloads remain recoverable only in a home whose immutable decision id and request digest match. +Request-bound browser filenames isolate each immutable question set. +Schema-version-2 board payloads carry a unique landing id and canonical submission timestamp; intake uses that validated payload metadata across every scanned directory and refuses disagreements when payload authority is absent or tied. All commands require either `FM_HOME` or an explicit `--home ` and never guess a fleet home. Firstmate's internal commands use `FM_HOME`; captain-facing commands carry the resolved absolute `--home` path. @@ -163,7 +168,8 @@ TOON's strict decoder validates every encoded array count before Lavish applies - schema version `2` treats omitted annotations as empty; present question notes, item notes, and the overall note must be strings, option comments must map declared option values to strings, and explicit `null` is invalid for any annotation field - annotation answers are schema version `2` only, because annotations did not exist in version `1` -Schema-version-2 browser and JSON-destination payloads also carry the resolved absolute `home_marker` used for fail-closed cross-home routing. +Schema-version-2 browser payloads also carry the resolved absolute `home_marker` used for fail-closed cross-home routing plus landing metadata used only to authorize one candidate when downloads collide; landing metadata is invalid without that home marker. +JSON-destination payloads carry the same `home_marker` but omit transport-only landing metadata after collection. `receipt.toon` contains: diff --git a/tools/lavish/package-lock.json b/tools/lavish/package-lock.json index 88863f2450d..8729953b6c4 100644 --- a/tools/lavish/package-lock.json +++ b/tools/lavish/package-lock.json @@ -1,12 +1,12 @@ { "name": "@ruby-labs/lavish", - "version": "1.3.0", + "version": "1.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@ruby-labs/lavish", - "version": "1.3.0", + "version": "1.4.0", "license": "MIT", "dependencies": { "@toon-format/toon": "4.1.0", diff --git a/tools/lavish/package.json b/tools/lavish/package.json index 72a24aa514a..d796711b215 100644 --- a/tools/lavish/package.json +++ b/tools/lavish/package.json @@ -1,6 +1,6 @@ { "name": "@ruby-labs/lavish", - "version": "1.3.0", + "version": "1.4.0", "description": "Durable store-and-forward decision capture for Firstmate", "type": "module", "license": "MIT", diff --git a/tools/lavish/src/board.mjs b/tools/lavish/src/board.mjs index b583e051969..b1fe7e4292c 100644 --- a/tools/lavish/src/board.mjs +++ b/tools/lavish/src/board.mjs @@ -1,11 +1,10 @@ +import { randomUUID } from 'node:crypto'; import { readFile } from 'node:fs/promises'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import MarkdownIt from 'markdown-it'; import { ANNOTATION_MODE, ANSWER_SCHEMA_VERSION } from './protocol.mjs'; -export const SUBMIT_MARKER = `LAVISH-SUBMIT v${ANSWER_SCHEMA_VERSION}`; - const markdown = new MarkdownIt({ html: false, linkify: true, @@ -267,8 +266,8 @@ textarea:focus { outline: 2px solid var(--border-focus); outline-offset: 1px; bo } // The two modes differ only in what a payload carries, what the review step -// lists, and what makes a batch complete. Everything after review - durable -// persistence, download, confirmation - is deliberately one shared path. +// lists, and what makes a batch complete. Everything after review - download, +// manual backup, and confirmation - is deliberately one shared path. function annotationScriptParts() { return { buildEntries: `annotations: MANIFEST.items.map((item) => ({ @@ -288,8 +287,8 @@ function annotationScriptParts() { addReviewLine(item, annotation.note); reviewList.append(item); }`, - // Answer files are write-once, so an empty submit would burn the board - // without telling firstmate anything. + // Answer files are write-once after intake, so an empty download would burn + // that one accepted batch without telling firstmate anything. incompleteCheck: `const payload = buildPayload(); const empty = payload.annotations.every((annotation) => annotation.note.trim() === '') && payload.note.trim() === ''; @@ -360,6 +359,7 @@ function boardScript(decision) { decision_id: decision.id, home_marker: decision.home, request_sha256: decision.manifest.request_sha256, + landing_id: randomUUID(), items: decision.manifest.items.map((item) => ({ key: item.key, title: item.title, @@ -369,6 +369,7 @@ function boardScript(decision) { decision_id: decision.id, home_marker: decision.home, request_sha256: decision.manifest.request_sha256, + landing_id: randomUUID(), questions: decision.manifest.questions.map((question) => ({ key: question.key, prompt: question.prompt, @@ -379,7 +380,6 @@ function boardScript(decision) { return `(() => { 'use strict'; const MANIFEST = ${scriptJson(clientManifest)}; - const SUBMIT_MARKER = ${scriptJson(SUBMIT_MARKER)}; const form = document.querySelector('#decision-form'); const review = document.querySelector('#review-step'); const reviewList = document.querySelector('#review-list'); @@ -388,17 +388,17 @@ function boardScript(decision) { const payloadBackup = document.querySelector('#payload-backup'); const submittedPayload = document.querySelector('#submitted-payload'); const submitError = document.querySelector('#submit-error'); - const downloadFilename = 'lavish-answer-' + MANIFEST.decision_id + '.json'; - const storageKey = 'lavish-submit:' + MANIFEST.home_marker + ':' + MANIFEST.decision_id + ':' + MANIFEST.request_sha256; - window.__lavishPayload = null; - window.__lavishStorageKey = storageKey; + const requestDigest = MANIFEST.request_sha256.slice('sha256:'.length); + const downloadFilename = 'lavish-answer-' + MANIFEST.decision_id + '-' + + requestDigest + '.json'; - function buildPayload() { + function buildPayload(landing) { return { schema_version: ${ANSWER_SCHEMA_VERSION}, decision_id: MANIFEST.decision_id, home_marker: MANIFEST.home_marker, request_sha256: MANIFEST.request_sha256, + ...(landing === undefined ? {} : { landing }), ${parts.buildEntries} note: overallNote.value, }; @@ -438,14 +438,6 @@ function boardScript(decision) { window.setTimeout(() => URL.revokeObjectURL(objectUrl), 1000); } - function persistPayload(payload) { - const durableRecord = JSON.stringify({ marker: SUBMIT_MARKER, payload }); - localStorage.setItem(storageKey, durableRecord); - if (localStorage.getItem(storageKey) !== durableRecord) { - throw new Error('durable browser storage verification failed'); - } - } - document.querySelector('#review-button').addEventListener('click', () => { ${parts.incompleteCheck} formError.hidden = true; @@ -462,28 +454,22 @@ function boardScript(decision) { }); document.querySelector('#submit-button').addEventListener('click', (event) => { - const payload = buildPayload(); + const payload = buildPayload({ + id: MANIFEST.landing_id, + submitted_at: new Date().toISOString(), + }); const payloadJson = JSON.stringify(payload, null, 2) + '\\n'; submitError.hidden = true; - try { - persistPayload(payload); - } catch { - submittedPayload.value = payloadJson; - payloadBackup.hidden = false; - submitError.textContent = 'Could not durably save this answer. Keep this board open and try again.'; - submitError.hidden = false; - return; - } - window.__lavishPayload = payload; submittedPayload.value = payloadJson; payloadBackup.hidden = false; - document.title = SUBMIT_MARKER; - event.currentTarget.disabled = true; try { downloadPayload(payloadJson); } catch { - // The verified browser-profile record remains authoritative. + submitError.textContent = 'Could not download this answer. Save the manual payload backup in Downloads, then tell firstmate.'; + submitError.hidden = false; + return; } + event.currentTarget.disabled = true; document.querySelector('#confirmation').hidden = false; }); })();`; @@ -506,12 +492,12 @@ export async function renderBoard(decision) { const title = escapeHtml(decision.manifest.title); const kindLabel = annotation ? 'Lavish annotation' : 'Lavish decision'; const subtitle = annotation - ? 'Comment on any item, add an overall note, then submit one complete batch.' - : 'Review the full context, annotate any option or question, then submit one complete batch.'; + ? 'Comment on any item, add an overall note, then save one complete batch.' + : 'Review every choice, annotate any option or question, then save one complete batch.'; const reviewButtonLabel = annotation ? 'Review comments' : 'Review answers'; const reviewTitle = annotation - ? 'Submit these comments?' - : 'Submit this complete answer batch?'; + ? 'Save these comments?' + : 'Save this complete answer batch?'; return ` @@ -542,16 +528,16 @@ export async function renderBoard(decision) {

Final review

${reviewTitle}

- +
- +
diff --git a/tools/lavish/src/cli.mjs b/tools/lavish/src/cli.mjs index 0d4eef2b6f2..fc8f8299ecf 100755 --- a/tools/lavish/src/cli.mjs +++ b/tools/lavish/src/cli.mjs @@ -36,7 +36,7 @@ import { import { renderBoard } from './board.mjs'; import { migrateLegacy } from './migration.mjs'; -const VERSION = '1.3.0'; +const VERSION = '1.4.0'; const PROGRAM = basename(process.argv[1] ?? 'lavish-axi'); const SOURCE_WAKE_ADAPTER = fileURLToPath( new URL('../../../bin/fm-lavish-wake.sh', import.meta.url), @@ -333,11 +333,18 @@ function sameBatch(answer, batch) { ); } -function landingCandidateId(name) { +function landingCandidate(name) { let match = name.match(/^lavish-board-([a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?)\.payload\.json$/); - if (match !== null) return match[1]; + if (match !== null) return { id: match[1] }; + match = name.match(/^lavish-answer-([a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?)-([a-f0-9]{64})(?: \([0-9]+\))?\.json$/); + if (match !== null) { + return { + id: match[1], + requestSha256: `sha256:${match[2]}`, + }; + } match = name.match(/^lavish-answer-([a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?)(?: \([0-9]+\))?\.json$/); - if (match !== null) return match[1]; + if (match !== null) return { id: match[1] }; return undefined; } @@ -381,30 +388,29 @@ async function discoverLandingCandidates(home) { continue; } for (const entry of entries) { - const id = landingCandidateId(entry.name); - if (id === undefined) continue; + const landing = landingCandidate(entry.name); + if (landing === undefined) continue; let path = join(directory.path, entry.name); try { - validateDecisionId(id); + validateDecisionId(landing.id); path = resolve(path); const info = await lstat(path); if (!info.isFile() || info.isSymbolicLink()) { errors.push({ - id, + id: landing.id, path, error: 'payload_unsafe_file: candidate is not a regular file', }); continue; } - candidates.push({ id, path, mtimeMs: info.mtimeMs }); + candidates.push({ ...landing, path }); } catch (error) { - errors.push({ id, path, error: `payload_scan_error: ${error.message}` }); + errors.push({ id: landing.id, path, error: `payload_scan_error: ${error.message}` }); } } } candidates.sort((left, right) => ( left.id.localeCompare(right.id) - || (right.mtimeMs ?? 0) - (left.mtimeMs ?? 0) || left.path.localeCompare(right.path) )); return { candidates, errors }; @@ -507,10 +513,21 @@ async function recoverLandingPayloads(home) { } } - let selected; + const validatedCandidates = []; for (const routed of routedCandidates) { const { candidate, payload, legacy } = routed; if (legacy && payload.request_sha256 !== decision.manifest.request_sha256) continue; + if ( + candidate.requestSha256 !== undefined + && candidate.requestSha256 !== decision.manifest.request_sha256 + ) { + errors.push({ + id, + status: 'payload-invalid', + detail: `${candidate.path}: payload_stale_request: filename request digest does not match the immutable manifest`, + }); + continue; + } let batch; try { batch = validateCollectPayload(payload, decision.manifest, { @@ -521,33 +538,45 @@ async function recoverLandingPayloads(home) { errors.push({ id, status: 'payload-invalid', detail: `${candidate.path}: ${error.message}` }); continue; } - if (existingAnswer !== undefined) { - if (sameBatch(existingAnswer, batch)) { - continue; - } else { - errors.push({ - id, - status: 'payload-conflict', - detail: `${candidate.path}: decision already has different answer content`, - }); - } - continue; - } - if (selected !== undefined && !sameBatch( + validatedCandidates.push({ candidate, batch }); + } + + const payloadBound = validatedCandidates.filter((entry) => entry.batch.landing !== undefined); + const latestSubmittedAt = payloadBound.reduce( + (latest, entry) => Math.max(latest, entry.batch.landing.submittedAtMs), + Number.NEGATIVE_INFINITY, + ); + const authorizedCandidates = payloadBound.length > 0 + ? payloadBound.filter((entry) => entry.batch.landing.submittedAtMs === latestSubmittedAt) + : validatedCandidates; + const selected = authorizedCandidates[0]; + for (const contender of authorizedCandidates.slice(1)) { + if (!sameBatch( { answers: selected.batch.entries, note: selected.batch.note }, - batch, + contender.batch, )) { errors.push({ id, status: 'payload-conflict', - detail: `${candidate.path}: multiple landing payloads disagree`, + detail: payloadBound.length > 0 + ? `${contender.candidate.path}: equally authoritative landing payloads disagree` + : `${contender.candidate.path}: landing payloads disagree without payload authority`, }); - continue; } - selected ??= { candidate, batch }; } - if (existingAnswer === undefined && selected !== undefined) plans.push({ id, decision, selected }); + if (selected === undefined) continue; + if (existingAnswer !== undefined) { + if (!sameBatch(existingAnswer, selected.batch)) { + errors.push({ + id, + status: 'payload-conflict', + detail: `${selected.candidate.path}: decision already has different answer content`, + }); + } + continue; + } + plans.push({ id, decision, selected }); } if (errors.length > 0) { diff --git a/tools/lavish/src/protocol.mjs b/tools/lavish/src/protocol.mjs index 65f5f816234..c51b8330401 100644 --- a/tools/lavish/src/protocol.mjs +++ b/tools/lavish/src/protocol.mjs @@ -598,6 +598,40 @@ function payloadError(name, message) { throw new LavishError(`${name}: ${message}`, 2); } +function validatePayloadLanding(raw) { + if (raw.landing === undefined) return undefined; + if ( + raw.landing === null + || typeof raw.landing !== 'object' + || Array.isArray(raw.landing) + ) { + payloadError('payload_invalid_landing', 'landing authority must be an object'); + } + if ( + typeof raw.landing.id !== 'string' + || !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test( + raw.landing.id, + ) + ) { + payloadError('payload_invalid_landing', 'landing id must be a lowercase UUIDv4'); + } + if (typeof raw.landing.submitted_at !== 'string') { + payloadError('payload_invalid_landing', 'landing submitted_at must be a timestamp'); + } + const submittedAtMs = Date.parse(raw.landing.submitted_at); + if ( + !Number.isFinite(submittedAtMs) + || new Date(submittedAtMs).toISOString() !== raw.landing.submitted_at + ) { + payloadError('payload_invalid_landing', 'landing submitted_at must be canonical UTC'); + } + return { + id: raw.landing.id, + submittedAt: raw.landing.submitted_at, + submittedAtMs, + }; +} + export function validateCollectPayload(raw, manifest, { expectedHomeMarker = undefined, allowMissingHomeMarker = false, @@ -617,8 +651,9 @@ export function validateCollectPayload(raw, manifest, { if (raw.request_sha256 !== manifest.request_sha256) { payloadError('payload_stale_request', 'request hash does not match manifest'); } + const landing = validatePayloadLanding(raw); if (raw.home_marker === undefined) { - if (expectedHomeMarker !== undefined && !allowMissingHomeMarker) { + if (landing !== undefined || (expectedHomeMarker !== undefined && !allowMissingHomeMarker)) { payloadError('payload_missing_home', 'home marker is required'); } } else { @@ -634,7 +669,7 @@ export function validateCollectPayload(raw, manifest, { } } if (manifest.mode === ANNOTATION_MODE) { - return validateAnnotationPayload(raw, manifest); + return { ...validateAnnotationPayload(raw, manifest), landing }; } if (!Array.isArray(raw.answers)) { payloadError('payload_count_mismatch', 'answers must be an array'); @@ -734,6 +769,7 @@ export function validateCollectPayload(raw, manifest, { return { entries: manifest.questions.map((question) => answersByKey.get(question.key)), note: requirePayloadNote(raw), + landing, }; } diff --git a/tools/lavish/test-support/fake-browser.mjs b/tools/lavish/test-support/fake-browser.mjs deleted file mode 100644 index 6fc43a7042a..00000000000 --- a/tools/lavish/test-support/fake-browser.mjs +++ /dev/null @@ -1,109 +0,0 @@ -#!/usr/bin/env node - -import { readFileSync, writeFileSync } from 'node:fs'; -import { fileURLToPath } from 'node:url'; -import process from 'node:process'; -import vm from 'node:vm'; -import { parseHTML } from 'linkedom'; - -const statePath = process.env.LAVISH_FAKE_CHROME_STATE; -if (typeof statePath !== 'string' || statePath === '') { - process.stderr.write('LAVISH_FAKE_CHROME_STATE is required\n'); - process.exit(2); -} - -function loadState() { - try { - return JSON.parse(readFileSync(statePath, 'utf8')); - } catch (error) { - if (error.code !== 'ENOENT') throw error; - return { open: false, submitted: false, storage: {} }; - } -} - -function saveState(state) { - writeFileSync(statePath, `${JSON.stringify(state)}\n`, { mode: 0o600 }); -} - -function executePage(url, state, { submit }) { - const html = readFileSync(fileURLToPath(url), 'utf8'); - const { window, document } = parseHTML(html); - Object.defineProperty(window, 'localStorage', { - configurable: true, - value: { - getItem(key) { - return state.storage[key] ?? null; - }, - setItem(key, value) { - state.storage[key] = String(value); - }, - }, - }); - window.Blob = globalThis.Blob; - window.URL = { - createObjectURL: () => 'blob:lavish-fake-browser', - revokeObjectURL: () => {}, - }; - window.scrollTo = () => {}; - window.setTimeout = (callback) => { - callback(); - return 1; - }; - const script = document.querySelector('script').textContent; - vm.runInContext(script, vm.createContext(window)); - if (submit) { - const selected = document.querySelector('input[type="radio"]'); - if (selected === null) { - // An annotation board has nothing to choose, so the batch is completed by - // typing a comment instead. - const note = document.querySelector('textarea[data-item-note]'); - if (note === null) throw new Error('board offered neither a choice nor a comment box'); - note.value = 'Fake browser comment.'; - } else { - selected.checked = true; - selected.setAttribute('checked', ''); - } - document.querySelector('#review-button').click(); - document.querySelector('#submit-button').click(); - if (Object.keys(state.storage).length === 0) { - throw new Error('submit handler did not persist a browser record'); - } - } - state.title = document.title; -} - -const [command, argument] = process.argv.slice(2); -const state = loadState(); -try { - switch (command) { - case 'open': - executePage(argument, state, { - submit: process.env.LAVISH_FAKE_CHROME_AUTO_SUBMIT !== '0' && !state.submitted, - }); - state.page = argument; - state.open = true; - if (process.env.LAVISH_FAKE_CHROME_AUTO_SUBMIT !== '0') state.submitted = true; - saveState(state); - break; - case 'eval': { - if (!state.open) process.exit(1); - const durableRecord = Object.values(state.storage)[0] ?? null; - process.stdout.write(`result: ${JSON.stringify({ - title: state.title, - payload: null, - durable_record: durableRecord, - })}\n`); - break; - } - case 'stop': - state.open = false; - saveState(state); - break; - default: - process.stderr.write(`unsupported fake browser command: ${String(command)}\n`); - process.exit(2); - } -} catch (error) { - process.stderr.write(`${error.stack ?? error.message}\n`); - process.exit(1); -} diff --git a/tools/lavish/test/lavish.test.mjs b/tools/lavish/test/lavish.test.mjs index 88ecc7fe131..663c735145a 100644 --- a/tools/lavish/test/lavish.test.mjs +++ b/tools/lavish/test/lavish.test.mjs @@ -11,7 +11,6 @@ import { readdir, rm, symlink, - stat, utimes, writeFile, } from 'node:fs/promises'; @@ -32,7 +31,6 @@ const BOARD_ADAPTER = join(REPO_ROOT, 'bin/fm-lavish-board.sh'); const WAKE_ADAPTER = join(REPO_ROOT, 'bin/fm-lavish-wake.sh'); const QUEUE_ADAPTER = join(REPO_ROOT, 'bin/fm-lavish-queue.sh'); const WAKE_DRAIN = join(REPO_ROOT, 'bin/fm-wake-drain.sh'); -const FAKE_BROWSER = join(PACKAGE_ROOT, 'test-support/fake-browser.mjs'); const ONE_PIXEL_PNG = Buffer.from( 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64', @@ -223,24 +221,31 @@ function processDescendants(rootPid) { return processes.filter((processInfo) => descendants.has(processInfo.pid)); } -function executeBoardSubmission(html, { storageFailure = false, fill = undefined } = {}) { +async function executeBoardSubmission(html, { downloadFailure = false, fill = undefined } = {}) { const { window, document } = parseHTML(html); - const storage = new Map(); - Object.defineProperty(window, 'localStorage', { - configurable: true, - value: { - getItem(key) { - return storage.get(key) ?? null; - }, - setItem(key, value) { - if (storageFailure) throw new Error('durable browser storage unavailable'); - storage.set(key, String(value)); - }, - }, - }); + const blobs = new Map(); + const downloads = []; + const originalCreateElement = document.createElement.bind(document); + document.createElement = (name, options) => { + const element = originalCreateElement(name, options); + if (String(name).toLowerCase() === 'a') { + element.click = () => { + if (downloadFailure) throw new Error('browser download unavailable'); + downloads.push({ + filename: element.download, + blob: blobs.get(element.href), + }); + }; + } + return element; + }; window.Blob = globalThis.Blob; window.URL = { - createObjectURL: () => 'blob:lavish-test', + createObjectURL: (blob) => { + const url = `blob:lavish-test-${blobs.size + 1}`; + blobs.set(url, blob); + return url; + }, revokeObjectURL: () => {}, }; window.scrollTo = () => {}; @@ -259,7 +264,16 @@ function executeBoardSubmission(html, { storageFailure = false, fill = undefined } document.querySelector('#review-button').click(); document.querySelector('#submit-button').click(); - return { document, storage }; + const downloaded = await Promise.all(downloads.map(async (download) => ({ + filename: download.filename, + text: await download.blob.text(), + }))); + const backup = document.querySelector('#submitted-payload').value; + return { + document, + downloaded, + payload: backup === '' ? undefined : JSON.parse(backup), + }; } async function fakeTmux(fx) { @@ -595,6 +609,19 @@ function browserPayload(fx, manifest, overrides = {}) { }; } +function browserDownloadName(manifest, collisionIndex = 0) { + const digest = manifest.request_sha256.slice('sha256:'.length); + const suffix = collisionIndex === 0 ? '' : ` (${collisionIndex})`; + return `lavish-answer-${manifest.decision_id}-${digest}${suffix}.json`; +} + +function landingAuthority(sequence, submittedAt) { + return { + id: `00000000-0000-4000-8000-${String(sequence).padStart(12, '0')}`, + submitted_at: submittedAt, + }; +} + async function answer(fx, id, { choice = 1, note = '', @@ -911,223 +938,144 @@ test('board renders the manifest, annotations, Markdown context, visuals, and su assert.ok(payloadBackup); assert.equal(payloadBackup.hasAttribute('readonly'), true); const script = document.querySelector('script').textContent; - assert.match(script, /window\.__lavishPayload = payload/); assert.match(script, /new Blob\(\[payloadJson\]/); assert.match(script, /URL\.createObjectURL\(blob\)/); assert.match(script, /anchor\.download = downloadFilename/); assert.match(script, /anchor\.click\(\)/); assert.match(script, /submittedPayload\.value = payloadJson/); - assert.match(script, /document\.title = SUBMIT_MARKER/); - assert.match(script, /LAVISH-SUBMIT v2/); + assert.doesNotMatch(script, /localStorage|__lavish|LAVISH-SUBMIT|SUBMIT_MARKER/); + assert.equal(document.querySelector('#submit-button').textContent, 'Download answer'); + assert.match(document.querySelector('#confirmation').textContent, /Tell firstmate/); }); -test('B1 board submit persists the payload before showing durable confirmation', async () => { - const fx = await fixture('board-durable-submit'); +test('B1 board submit downloads the payload before showing its manual handoff', async () => { + const fx = await fixture('board-download-submit'); const id = await createRequest(fx); const output = join(fx.root, 'board.html'); const result = await runCli(['board', id, '--out', output], { home: fx.home }); assert.equal(result.code, 0, result.stderr); - const submitted = executeBoardSubmission(await readFile(output, 'utf8')); - assert.equal(submitted.storage.size, 1, 'submit did not persist a browser record'); - const durableRecord = JSON.parse([...submitted.storage.values()][0]); - assert.equal(durableRecord.marker, 'LAVISH-SUBMIT v2'); - assert.equal(durableRecord.payload.decision_id, id); - assert.equal(durableRecord.payload.home_marker, resolve(fx.home)); + const submitted = await executeBoardSubmission(await readFile(output, 'utf8')); + assert.equal(submitted.downloaded.length, 1, 'submit did not start one answer download'); + assert.equal(submitted.downloaded[0].filename, browserDownloadName(await manifestFor(fx, id))); + assert.deepEqual(JSON.parse(submitted.downloaded[0].text), submitted.payload); + assert.equal(submitted.payload.decision_id, id); + assert.equal(submitted.payload.home_marker, resolve(fx.home)); + assert.match(submitted.payload.landing.id, /^[0-9a-f-]{36}$/); + assert.equal( + new Date(submitted.payload.landing.submitted_at).toISOString(), + submitted.payload.landing.submitted_at, + ); const confirmation = submitted.document.querySelector('#confirmation'); assert.equal(confirmation.hidden, false); - assert.match(confirmation.textContent, /durably saved/i); + assert.match(confirmation.textContent, /Answer downloaded/); + assert.match(confirmation.textContent, /Tell firstmate/); - const rejected = executeBoardSubmission(await readFile(output, 'utf8'), { - storageFailure: true, + const rejected = await executeBoardSubmission(await readFile(output, 'utf8'), { + downloadFailure: true, }); assert.equal( rejected.document.querySelector('#confirmation').hidden, true, - 'a failed durable write still showed success', + 'a failed download still showed success', ); assert.equal(rejected.document.querySelector('#submit-button').disabled, false); - assert.equal(rejected.storage.size, 0); + assert.equal(rejected.downloaded.length, 0); + assert.equal(rejected.document.querySelector('#payload-backup').hidden, false); + assert.match(rejected.document.querySelector('#submit-error').textContent, /manual payload backup/); }); -test('B5 fm-lavish-board executes submit and recovers after immediate browser close', async () => { - const fx = await fixture('board-shell-e2e'); +test('B5 fm-lavish-board exits after opening and intake recovers the downloaded answer', async () => { + const fx = await fixture('board-shell-download'); const id = await createRequest(fx); const downloads = join(fx.root, 'Downloads'); - const fakeBin = join(fx.root, 'browser-bin'); - const fakeState = join(fx.root, 'fake-browser-state.json'); const effectiveState = join(fx.root, 'effective-state'); + const opener = join(fx.root, 'open-board'); + const openerLog = join(fx.root, 'open-board.log'); await mkdir(downloads); - await mkdir(fakeBin); - const chrome = join(fakeBin, 'chrome-devtools-axi'); await writeFile( - chrome, - `#!/bin/sh\nexec '${process.execPath}' '${FAKE_BROWSER}' "$@"\n`, + opener, + `#!/bin/sh\nprintf '%s\\n' "$#" "$1" > '${openerLog}'\n`, ); - await chmod(chrome, 0o700); - const fake = await fakeTmux(fx); - const holder = await writeSupervisorLock(fx, 'home:0', effectiveState); + await chmod(opener, 0o700); const environment = { - PATH: `${fake.bin}:${fakeBin}:${process.env.PATH}`, FM_LAVISH_BIN: CLI, - FM_LAVISH_QUEUE_DISABLE: '0', + FM_LAVISH_OPEN_COMMAND: opener, FM_STATE_OVERRIDE: effectiveState, - LAVISH_FAKE_CHROME_STATE: fakeState, - LAVISH_FAKE_TMUX_PANE_PID: String(holder.pid), - LAVISH_WAKE_COMMAND: WAKE_ADAPTER, - }; - - try { - const opened = await runExecutable( - BOARD_ADAPTER, - [id, '--home', fx.home, '--downloads', downloads], - { env: environment }, - ); - assert.equal(opened.code, 0, opened.stderr); - const checkPath = join(effectiveState, `lavish-board-${id}.check.sh`); - assert.equal(await exists(checkPath), true); - assert.equal( - await exists(join(fx.home, 'state', `lavish-board-${id}.check.sh`)), - false, - ); - const openedAtMatch = (await readFile(checkPath, 'utf8')).match(/--opened-at ([0-9]+)/); - assert.ok(openedAtMatch); - const openedAt = Number(openedAtMatch[1]); - const manifest = await manifestFor(fx, id); - const staleDownload = join(downloads, `lavish-answer-${id}.json`); - await writeFile( - staleDownload, - `${JSON.stringify(browserPayload(fx, manifest, { - answers: [{ - key: 'rollout', - value: 'green', - question_note: 'Stale answer from the prior board.', - option_comments: {}, - }], - }))}\n`, - ); - const staleTime = new Date(openedAt + 500); - await utimes(staleDownload, staleTime, staleTime); - const staleInfo = await stat(staleDownload); - assert.ok(staleInfo.mtimeMs > openedAt); - - const stopped = await runExecutable(chrome, ['stop'], { env: environment }); - assert.equal(stopped.code, 0, stopped.stderr); - const checked = await runExecutable(checkPath, [], { - env: environment, - unsetEnv: ['FM_STATE_OVERRIDE'], - }); - assert.equal(checked.code, 0, checked.stderr); - assert.match(checked.stdout, new RegExp(`lavish-submit: ${id}`)); - assert.match(checked.stdout, /lavish-delivery: prompt queued/); - assert.equal( - await exists(join(fx.home, 'data/decisions', id, 'answer.toon')), - true, - ); - const stored = decode( - await readFile(join(fx.home, 'data/decisions', id, 'answer.toon'), 'utf8'), - { strict: true }, - ); - assert.equal(stored.answers[0].value, 'blue'); - assert.equal( - await exists(join(fx.home, 'data/decisions', id, 'receipt.toon')), - true, - ); - assert.equal(await exists(join(fx.home, 'data/replies/release-choice.toon')), true); - assert.match(await readFile(fake.log, 'utf8'), /-t home:0 /); - assert.equal( - await exists(join(effectiveState, 'lavish-deliveries', `${id}.digest`)), - true, - ); - assert.equal(await exists(checkPath), false); - } finally { - const closed = new Promise((resolveClose) => holder.once('close', resolveClose)); - holder.kill(); - await closed; - } -}); - -test('B5 watcher check leaves a live unsubmitted board open and pending', async () => { - const fx = await fixture('board-live-pending'); - const id = await createRequest(fx); - const downloads = join(fx.root, 'Downloads'); - const fakeBin = join(fx.root, 'browser-bin'); - const fakeState = join(fx.root, 'fake-browser-state.json'); - await mkdir(downloads); - await mkdir(fakeBin); - const chrome = join(fakeBin, 'chrome-devtools-axi'); - await writeFile( - chrome, - `#!/bin/sh\nexec '${process.execPath}' '${FAKE_BROWSER}' "$@"\n`, - ); - await chmod(chrome, 0o700); - const environment = { - PATH: `${fakeBin}:${process.env.PATH}`, - FM_LAVISH_BIN: CLI, - LAVISH_FAKE_CHROME_STATE: fakeState, - LAVISH_FAKE_CHROME_AUTO_SUBMIT: '0', LAVISH_WAKE_COMMAND: WAKE_ADAPTER, }; const opened = await runExecutable( BOARD_ADAPTER, - [id, '--home', fx.home, '--downloads', downloads], + [id, '--home', fx.home], { env: environment }, ); assert.equal(opened.code, 0, opened.stderr); - const checkPath = join(fx.home, 'state', `lavish-board-${id}.check.sh`); - const checked = await runExecutable(checkPath, [], { env: environment }); - assert.equal(checked.code, 0, checked.stderr); - const browserState = JSON.parse(await readFile(fakeState, 'utf8')); - assert.equal(browserState.open, true); - assert.equal(Object.keys(browserState.storage).length, 0); - assert.equal( - await exists(join(fx.home, 'data/decisions', id, 'answer.toon')), - false, - ); - assert.equal(await exists(checkPath), true); + assert.match(opened.stdout, /Opened Lavish board release-choice in the default browser/); + assert.match(opened.stdout, /tell firstmate it is ready/); + + const openerLines = (await readFile(openerLog, 'utf8')).trimEnd().split('\n'); + assert.equal(openerLines[0], '1'); + const htmlPath = openerLines[1]; + assert.equal(htmlPath.endsWith(`/effective-state/lavish-board-${id}.html`), true); + assert.equal(await exists(htmlPath), true); + assert.equal(await exists(join(effectiveState, `lavish-board-${id}.check.sh`)), false); + assert.equal(await exists(join(effectiveState, `lavish-board-${id}.payload.json`)), false); + assert.equal(await exists(join(effectiveState, `lavish-board-${id}.chrome-profile`)), false); + + const adapterSource = await readFile(BOARD_ADAPTER, 'utf8'); + assert.doesNotMatch(adapterSource, /chrome-devtools-axi|LAVISH-SUBMIT|--session|--opened-at/); + assert.doesNotMatch(adapterSource, /check\.sh|chrome-profile|Armed submission check/); + assert.doesNotMatch(adapterSource, /xdg-open/); + + const submitted = await executeBoardSubmission(await readFile(htmlPath, 'utf8')); + assert.equal(submitted.downloaded.length, 1); + const downloadPath = join(downloads, submitted.downloaded[0].filename); + await writeFile(downloadPath, submitted.downloaded[0].text); + + const intake = await runCli(['intake'], { + home: submitted.payload.home_marker, + env: { + FM_STATE_OVERRIDE: effectiveState, + LAVISH_DOWNLOADS_DIR: downloads, + }, + }); + assert.equal(intake.code, 0, intake.stderr); + assert.match(intake.stdout, new RegExp(`${id},payload-collected`)); + assert.match(intake.stdout, new RegExp(`${id},consumed`)); + assert.equal(await exists(join(fx.home, 'data/decisions', id, 'answer.toon')), true); + assert.equal(await exists(join(fx.home, 'data/decisions', id, 'receipt.toon')), true); + assert.equal(await exists(join(fx.home, 'data/replies/release-choice.toon')), true); }); -test('watcher check fails closed when its download location becomes unreadable', async () => { - const fx = await fixture('board-unreadable-downloads'); +test('fm-lavish-board fails closed with the manual path on unsupported hosts', async () => { + const fx = await fixture('board-shell-unsupported-host'); const id = await createRequest(fx); - const downloads = join(fx.root, 'Downloads'); - const fakeBin = join(fx.root, 'browser-bin'); - const fakeState = join(fx.root, 'fake-browser-state.json'); - await mkdir(downloads); + const effectiveState = join(fx.root, 'effective-state'); + const fakeBin = join(fx.root, 'fake-bin'); await mkdir(fakeBin); - const chrome = join(fakeBin, 'chrome-devtools-axi'); - await writeFile( - chrome, - `#!/bin/sh\nexec '${process.execPath}' '${FAKE_BROWSER}' "$@"\n`, - ); - await chmod(chrome, 0o700); - const environment = { - PATH: `${fakeBin}:${process.env.PATH}`, - FM_LAVISH_BIN: CLI, - LAVISH_FAKE_CHROME_STATE: fakeState, - LAVISH_FAKE_CHROME_AUTO_SUBMIT: '0', - LAVISH_WAKE_COMMAND: WAKE_ADAPTER, - }; + await writeFile(join(fakeBin, 'uname'), '#!/bin/sh\nprintf "Linux\\n"\n'); + await chmod(join(fakeBin, 'uname'), 0o700); const opened = await runExecutable( BOARD_ADAPTER, - [id, '--home', fx.home, '--downloads', downloads], - { env: environment }, - ); - assert.equal(opened.code, 0, opened.stderr); - const checkPath = join(fx.home, 'state', `lavish-board-${id}.check.sh`); - await rm(downloads, { recursive: true }); - await writeFile(downloads, 'not a directory\n'); - - const checked = await runExecutable(checkPath, [], { env: environment }); - assert.equal(checked.code, 2, checked.stderr); - assert.match(checked.stderr, /unsafe or missing downloads directory/); - assert.equal( - await exists(join(fx.home, 'data/decisions', id, 'answer.toon')), - false, + [id, '--home', fx.home], + { + env: { + FM_LAVISH_BIN: CLI, + FM_STATE_OVERRIDE: effectiveState, + PATH: `${fakeBin}:${dirname(process.execPath)}:/usr/bin:/bin`, + }, + unsetEnv: ['FM_LAVISH_OPEN_COMMAND'], + }, ); - assert.equal(await exists(checkPath), true); + const htmlPath = join(effectiveState, `lavish-board-${id}.html`); + assert.equal(opened.code, 2); + assert.match(opened.stdout, /Board written:/); + assert.doesNotMatch(opened.stdout, /Opened Lavish board/); + assert.match(opened.stderr, /automatic default-browser handoff is unsupported/); + assert.match(opened.stderr, new RegExp(`lavish-board-${id}\\.html`)); + assert.equal(await exists(htmlPath), true); }); test('board renders a conventional visuals directory on an existing manifest', async () => { @@ -1304,12 +1252,12 @@ test('annotation board refuses to submit an entirely empty batch', async () => { ); // One comment is enough; the rest may stay blank. - const submitted = executeBoardSubmission(html, { + const submitted = await executeBoardSubmission(html, { fill: (page) => { page.querySelectorAll('textarea[data-item-note]')[1].value = 'Only this one matters.'; }, }); - const payload = JSON.parse([...submitted.storage.values()][0]).payload; + const payload = submitted.payload; assert.deepEqual(payload.annotations, [ { key: 'welcome-timing', note: '' }, { key: 'quiet-hours', note: 'Only this one matters.' }, @@ -1437,21 +1385,18 @@ test('an annotation manifest refuses a decision-shaped answer', async () => { assert.match(shown.stderr, /answer\.annotations must be an array/); }); -test('fm-lavish-board opens and collects an annotation board end to end', async () => { +test('fm-lavish-board download reaches intake for an annotation board end to end', async () => { const fx = await annotationFixture('annotation-board-shell'); const id = await createAnnotation(fx); const downloads = join(fx.root, 'Downloads'); - const fakeBin = join(fx.root, 'browser-bin'); - const fakeState = join(fx.root, 'fake-browser-state.json'); + const opener = join(fx.root, 'open-board'); + const openerLog = join(fx.root, 'open-board.log'); await mkdir(downloads); - await mkdir(fakeBin); - const chrome = join(fakeBin, 'chrome-devtools-axi'); - await writeFile(chrome, `#!/bin/sh\nexec '${process.execPath}' '${FAKE_BROWSER}' "$@"\n`); - await chmod(chrome, 0o700); + await writeFile(opener, `#!/bin/sh\nprintf '%s\\n' "$1" > '${openerLog}'\n`); + await chmod(opener, 0o700); const environment = { - PATH: `${fakeBin}:${process.env.PATH}`, FM_LAVISH_BIN: CLI, - LAVISH_FAKE_CHROME_STATE: fakeState, + FM_LAVISH_OPEN_COMMAND: opener, LAVISH_WAKE_COMMAND: WAKE_ADAPTER, }; @@ -1459,24 +1404,34 @@ test('fm-lavish-board opens and collects an annotation board end to end', async // no radio on it. const opened = await runExecutable( BOARD_ADAPTER, - [id, '--home', fx.home, '--downloads', downloads], + [id, '--home', fx.home], { env: environment }, ); assert.equal(opened.code, 0, opened.stderr); - const checkPath = join(fx.home, 'state', `lavish-board-${id}.check.sh`); - assert.equal(await exists(checkPath), true); - - const stopped = await runExecutable(chrome, ['stop'], { env: environment }); - assert.equal(stopped.code, 0, stopped.stderr); - const checked = await runExecutable(checkPath, [], { env: environment }); - assert.equal(checked.code, 0, checked.stderr); - assert.match(checked.stdout, new RegExp(`lavish-submit: ${id}`)); + const htmlPath = (await readFile(openerLog, 'utf8')).trim(); + const submitted = await executeBoardSubmission(await readFile(htmlPath, 'utf8'), { + fill: (page) => { + page.querySelector('textarea[data-item-note]').value = 'Downloaded board comment.'; + }, + }); + assert.equal(submitted.downloaded.length, 1); + await writeFile( + join(downloads, submitted.downloaded[0].filename), + submitted.downloaded[0].text, + ); + const intake = await runCli(['intake'], { + home: submitted.payload.home_marker, + env: { LAVISH_DOWNLOADS_DIR: downloads }, + }); + assert.equal(intake.code, 0, intake.stderr); + assert.match(intake.stdout, new RegExp(`${id},payload-collected`)); + assert.match(intake.stdout, new RegExp(`${id},consumed`)); const stored = decode( await readFile(join(fx.home, 'data/decisions', id, 'answer.toon'), 'utf8'), { strict: true }, ); - assert.equal(stored.annotations[0].note, 'Fake browser comment.'); + assert.equal(stored.annotations[0].note, 'Downloaded board comment.'); assert.equal(await exists(join(fx.home, 'data/replies/welcome-review.toon')), true); }); @@ -1529,6 +1484,219 @@ test('intake recovers a browser download payload without manual copy', async () assert.equal(again.stdout, ''); }); +test('intake uses payload-bound authority for numbered download collisions', async () => { + const fx = await fixture('download-numbered-collision'); + const id = await createRequest(fx); + const manifest = await manifestFor(fx, id); + const downloads = join(fx.root, 'Downloads'); + await mkdir(downloads); + await writeFile( + join(downloads, browserDownloadName(manifest)), + `${JSON.stringify(browserPayload(fx, manifest, { + landing: landingAuthority(1, '2026-08-08T12:00:00.000Z'), + }))}\n`, + ); + await writeFile( + join(downloads, browserDownloadName(manifest, 1)), + `${JSON.stringify(browserPayload(fx, manifest, { + answers: [{ + key: 'rollout', + value: 'green', + question_note: 'This later download is authoritative.', + option_comments: {}, + }], + landing: landingAuthority(2, '2026-08-08T12:01:00.000Z'), + }))}\n`, + ); + + const intake = await runCli(['intake'], { + home: fx.home, + env: { LAVISH_DOWNLOADS_DIR: downloads }, + }); + assert.equal(intake.code, 0, intake.stderr); + assert.match(intake.stdout, /release-choice,payload-collected/); + const stored = decode( + await readFile(join(fx.home, 'data/decisions', id, 'answer.toon'), 'utf8'), + { strict: true }, + ); + assert.equal(stored.answers[0].value, 'green'); + + const again = await runCli(['intake'], { + home: fx.home, + env: { LAVISH_DOWNLOADS_DIR: downloads }, + }); + assert.equal(again.code, 0, again.stderr); + assert.equal(again.stdout, ''); +}); + +test('intake refuses cross-directory disagreements without payload authority', async () => { + const fx = await fixture('download-equal-collision'); + const id = await createRequest(fx); + const manifest = await manifestFor(fx, id); + const downloads = join(fx.root, 'Downloads'); + await mkdir(downloads); + await mkdir(join(fx.home, 'state'), { recursive: true }); + await writeFile( + join(downloads, browserDownloadName(manifest)), + `${JSON.stringify(browserPayload(fx, manifest))}\n`, + ); + await writeFile( + join(fx.home, 'state', browserDownloadName(manifest)), + `${JSON.stringify(browserPayload(fx, manifest, { + answers: [{ + key: 'rollout', + value: 'green', + question_note: '', + option_comments: {}, + }], + }))}\n`, + ); + + const intake = await runCli(['intake'], { + home: fx.home, + env: { LAVISH_DOWNLOADS_DIR: downloads }, + }); + assert.equal(intake.code, 6, intake.stderr); + assert.match(intake.stdout, /payload-conflict/); + assert.match(intake.stdout, /landing payloads disagree without payload authority/); + assert.equal(await exists(join(fx.home, 'data/decisions', id, 'answer.toon')), false); + assert.equal(await exists(join(fx.home, 'data/decisions', id, 'receipt.toon')), false); +}); + +test('intake ignores cross-directory filename rank when payload authority is newer', async () => { + const fx = await fixture('download-cross-directory-authority'); + const id = await createRequest(fx); + const manifest = await manifestFor(fx, id); + const downloads = join(fx.root, 'Downloads'); + await mkdir(downloads); + await mkdir(join(fx.home, 'state'), { recursive: true }); + await writeFile( + join(downloads, browserDownloadName(manifest)), + `${JSON.stringify(browserPayload(fx, manifest, { + landing: landingAuthority(2, '2026-08-08T12:01:00.000Z'), + }))}\n`, + ); + await writeFile( + join(fx.home, 'state', browserDownloadName(manifest, 1)), + `${JSON.stringify(browserPayload(fx, manifest, { + answers: [{ + key: 'rollout', + value: 'green', + question_note: 'Stale answer with a higher directory-local suffix.', + option_comments: {}, + }], + landing: landingAuthority(1, '2026-08-08T12:00:00.000Z'), + }))}\n`, + ); + + const intake = await runCli(['intake'], { + home: fx.home, + env: { LAVISH_DOWNLOADS_DIR: downloads }, + }); + assert.equal(intake.code, 0, intake.stderr); + assert.match(intake.stdout, /release-choice,payload-collected/); + const stored = decode( + await readFile(join(fx.home, 'data/decisions', id, 'answer.toon'), 'utf8'), + { strict: true }, + ); + assert.equal(stored.answers[0].value, 'blue'); +}); + +test('intake refuses tied payload authority when answer content differs', async () => { + const fx = await fixture('download-tied-authority'); + const id = await createRequest(fx); + const manifest = await manifestFor(fx, id); + const downloads = join(fx.root, 'Downloads'); + await mkdir(downloads); + await mkdir(join(fx.home, 'state'), { recursive: true }); + await writeFile( + join(downloads, browserDownloadName(manifest)), + `${JSON.stringify(browserPayload(fx, manifest, { + landing: landingAuthority(1, '2026-08-08T12:00:00.000Z'), + }))}\n`, + ); + await writeFile( + join(fx.home, 'state', browserDownloadName(manifest)), + `${JSON.stringify(browserPayload(fx, manifest, { + answers: [{ + key: 'rollout', + value: 'green', + question_note: '', + option_comments: {}, + }], + landing: landingAuthority(2, '2026-08-08T12:00:00.000Z'), + }))}\n`, + ); + + const intake = await runCli(['intake'], { + home: fx.home, + env: { LAVISH_DOWNLOADS_DIR: downloads }, + }); + assert.equal(intake.code, 6, intake.stderr); + assert.match(intake.stdout, /equally authoritative landing payloads disagree/); + assert.equal(await exists(join(fx.home, 'data/decisions', id, 'answer.toon')), false); +}); + +test('intake refuses landing authority without a home marker', async () => { + const fx = await fixture('download-unbound-authority'); + const id = await createRequest(fx); + const manifest = await manifestFor(fx, id); + const downloads = join(fx.root, 'Downloads'); + const unbound = browserPayload(fx, manifest, { + answers: [{ + key: 'rollout', + value: 'green', + question_note: 'This unbound answer must never outrank the home-bound answer.', + option_comments: {}, + }], + landing: landingAuthority(2, '2026-08-08T12:01:00.000Z'), + }); + delete unbound.home_marker; + await mkdir(downloads); + await writeFile( + join(downloads, browserDownloadName(manifest)), + `${JSON.stringify(browserPayload(fx, manifest, { + landing: landingAuthority(1, '2026-08-08T12:00:00.000Z'), + }))}\n`, + ); + await writeFile( + join(downloads, browserDownloadName(manifest, 1)), + `${JSON.stringify(unbound)}\n`, + ); + + const intake = await runCli(['intake'], { + home: fx.home, + env: { LAVISH_DOWNLOADS_DIR: downloads }, + }); + assert.equal(intake.code, 6, intake.stderr); + assert.match(intake.stdout, /payload_missing_home/); + assert.equal(await exists(join(fx.home, 'data/decisions', id, 'answer.toon')), false); + assert.equal(await exists(join(fx.home, 'data/decisions', id, 'receipt.toon')), false); +}); + +test('intake refuses a request-bound filename for another question set', async () => { + const fx = await fixture('download-filename-request-mismatch'); + const id = await createRequest(fx); + const manifest = await manifestFor(fx, id); + const downloads = join(fx.root, 'Downloads'); + const wrongDigest = '0'.repeat(64); + await mkdir(downloads); + await writeFile( + join(downloads, `lavish-answer-${id}-${wrongDigest}.json`), + `${JSON.stringify(browserPayload(fx, manifest))}\n`, + ); + + const intake = await runCli(['intake'], { + home: fx.home, + env: { LAVISH_DOWNLOADS_DIR: downloads }, + }); + assert.equal(intake.code, 6, intake.stderr); + assert.match(intake.stdout, /payload_stale_request/); + assert.match(intake.stdout, /filename request digest does not match/); + assert.equal(await exists(join(fx.home, 'data/decisions', id, 'answer.toon')), false); + assert.equal(await exists(join(fx.home, 'data/decisions', id, 'receipt.toon')), false); +}); + test('intake ignores a home-bound download in unrelated homes', async () => { const fx = await fixture('download-home-routing'); const unrelated = await fixture('download-unrelated-home'); @@ -1741,6 +1909,12 @@ test('collect fails closed with named errors for count, key, option, and request name: 'payload_wrong_home', payload: browserPayload(fx, manifest, { home_marker: resolve(fx.root, 'other-home') }), }, + { + name: 'payload_invalid_landing', + payload: browserPayload(fx, manifest, { + landing: landingAuthority(1, '2026-08-08T12:00:00Z'), + }), + }, ]; for (const failure of cases) { await writeFile(payloadPath, `${JSON.stringify(failure.payload)}\n`);