From 2c38eff6d1177d629f005492cc925223372ac165 Mon Sep 17 00:00:00 2001 From: twilightsaurus <133054450+twilightsaurus@users.noreply.github.com> Date: Mon, 19 Oct 2026 08:58:38 +0300 Subject: [PATCH 1/2] fix: make TinyFugue startup onboarding explicit and early --- README.md | 32 ++- deploy/README.md | 66 ++++-- deploy/install.sh | 123 ++++++++-- docs/roadmap.md | 24 +- integrations/tinyfugue/README.md | 40 +++- scripts/test-installer-connections.py | 221 +++++++++++++++++- scripts/test-server-bundle.sh | 2 + .../relay/tests/test_installer_fixtures.py | 31 +++ 8 files changed, 461 insertions(+), 78 deletions(-) diff --git a/README.md b/README.md index 505aa38..fa9ad3e 100644 --- a/README.md +++ b/README.md @@ -116,16 +116,29 @@ Never redirect or capture that terminal output. Fresh SSH and install-only installations leave Direct disabled. Existing installations with no connection option preserve their connection state and credentials without prompting. -The installer uses an existing `~/.tfrc` by default and adds the Imp capture -hook to it. - -If TinyFugue uses a different startup file: +The installer uses an existing `~/.tfrc` by default. If it is absent and both +stdin and stdout are interactive, it asks for the explicit path of an existing +startup file; it never creates one or guesses a custom file. For a deterministic +selection (required without an interactive terminal when the default is absent): ```bash -./install.sh --tf-startup /path/to/your/tinyfugue/startup-file +./install.sh --tf-startup "/path/to/your TinyFugue/startup-file" ``` -The installer does not create a missing TinyFugue startup file. +An invalid or missing explicit file fails early. When startup inspection is safe, +the installer backs up the selected file and prepends +`/load ~/.config/imp/capture.tf` before its original commands. Duplicate detection +covers one level of literal absolute or `~/` `/load` paths. An existing exact hook +is preserved without rewriting; its ordering is not verified. Recognized unsafe +includes, BOM-prefixed files, and legacy plain-world lists are left unchanged +for manual setup. Arbitrary startup macros cannot be fully inspected. +Confirm that your actual launch loads this file before any connection/login: +`tf -f FILE` uses `FILE`, not the normal personal startup file. If managing the +hook manually, put the load line first in a macro-format startup file. For a +legacy plain-world list, put it after all world definitions but before connecting, +or convert the definitions to `/addworld` first. Do not blindly prepend a command: +that ends TinyFugue's legacy loader mode. Installer success alone does not +establish runtime startup ordering. Check the required services: @@ -158,8 +171,11 @@ loginctl enable-linger "$USER" Otherwise an administrator can enable lingering for your account. -After installing the hook, restart TinyFugue and connect to the MUD so Imp sees -a fresh login and world-selection context. +For an already-running TinyFugue, run `/load ~/.config/imp/capture.tf` now; +installing files does not reload that process. A matching normalized checkpoint +can recover previously captured identity without reconnecting, but cannot recover +identity the hook never captured. Otherwise, restart with the hook loaded early +and make a fresh identity-bearing MUD login. For custom TinyFugue layouts, detailed verification, diagnostics, upgrades, or Direct WSS deployment, see [VPS deployment](deploy/README.md). diff --git a/deploy/README.md b/deploy/README.md index cdd672c..b3f4686 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -97,26 +97,49 @@ the existing connection state. Existing Imp runtime/configuration paths, credentials, helper links, and user-service state all count as prior installation evidence; one missing file does not make an install fresh. -The installer uses the existing `~/.tfrc` by default. Imp backs it up before -adding: +### TinyFugue startup file + +The installer uses an existing `~/.tfrc` by default. When startup inspection is +safe, Imp backs it up, then prepends this line before all original commands: ```text /load ~/.config/imp/capture.tf ``` -If `~/.tfrc` does not exist, the installer stops rather than creating it. -TinyFugue can fall back to startup files relative to its working directory, so -creating a new `~/.tfrc` could otherwise change which configuration TinyFugue -loads. +The original bytes remain after the new line; ownership and permissions are +preserved. Normal installation rollback restores the original content. Duplicate +detection covers one level of literal absolute or `~/` `/load` paths. An existing +exact hook is retained without rewriting; its ordering is not established. + +Recognized relative, deeper, or computed includes and `/eval` or `/require` +commands make inspection uncertain. BOM-prefixed files and legacy plain-world +lists are also not rewritten. In these cases the installer leaves the selected +file unchanged, reports no insertion, and requires manual early-loading setup. +Arbitrary startup macros cannot be fully inspected; this is not a TinyFugue +configuration parser. -If TinyFugue uses a different startup file, provide that existing file -explicitly: +If `~/.tfrc` is absent and both stdin and stdout are interactive, the installer +asks for an explicit path to an existing startup file. It never creates one or +guesses a custom file. TinyFugue can fall back to working-directory startup files, +so creating `~/.tfrc` could change which configuration it loads. Without an +interactive terminal, select the existing file explicitly: ```bash -./install.sh --tf-startup /path/to/active/tinyfugue/startup-file +./install.sh --tf-startup "/path/to/active TinyFugue/startup-file" ``` -This also covers custom layouts and TinyFugue launches that use `-f FILE`. +`--tf-startup` is deterministic: an invalid or nonexistent file fails early, +without falling back to another file. For `tf -f FILE`, select that actual +`FILE`, not an assumed `~/.tfrc`. + +Always confirm that your actual TinyFugue invocation loads the selected file +before any world connects or logs in. Installer success does not verify runtime +invocation or ordering of an existing hook. For manual setup, copy the supplied +hook to `~/.config/imp/capture.tf` and put the load line above first in a +macro-format startup file, ahead of connection/login commands. For a legacy +plain-world list, place the hook after all world definitions but before any +connection/login command, or convert those definitions to `/addworld` first. +Do not blindly prepend a command: it ends TF's legacy loader mode. The installer: @@ -148,9 +171,9 @@ that old release directory in place; a failed activation restores the prior units, links, and `current` selection, so rollback returns to the old release with its historical runtime prerequisites. -For an already-running TinyFugue using the old hook, run -`/load ~/.config/imp/capture.tf` once after installing this update, before -testing live restart recovery. Installing the hook file does not reload TF. +For any already-running TinyFugue, run `/load ~/.config/imp/capture.tf` once +after installing this update, before testing live restart recovery. Installing +the hook file does not reload TF. The updated hook owns one reload-safe `imp-feed-watch` quote job, using the stable CLI link above (packaged role `imp-server feed-watch`); it checks the private readiness marker once per second with bounded reads and exits when @@ -159,9 +182,11 @@ TF closes its stdout pipe. After feed restart, readiness is not authority: the live TF hook reasserts its actual foreground context or null selection, never blindly trusting a saved selection. Matching normalized checkpoint values return as stale until fresh -authoritative GMCP arrives. No MUD reconnect is required while cached identity -survives. The checkpoint is ephemeral: reboot or checkpoint loss still requires -fresh identity-bearing login GMCP under the documented TinyFugue prerequisites. +authoritative GMCP arrives. No MUD reconnect is required while matching cached +identity survives. This cannot recover identity that was never captured because +the hook loaded too late. The checkpoint is ephemeral: reboot or checkpoint loss +still requires fresh identity-bearing login GMCP under the documented TinyFugue +prerequisites, with the hook loaded before login. Configuration and state directories are kept private to the user. @@ -748,13 +773,8 @@ The installer copies the shipped hook to: ~/.config/imp/capture.tf ``` -By default the installer adds the Imp `/load` line idempotently to an existing -`~/.tfrc`. With `--tf-startup FILE`, it instead backs up and updates the -explicitly selected existing startup file. - -TinyFugue's documented personal configuration is `$HOME/.tfrc`, but TinyFugue -also supports alternate startup files. Operators using a different startup -file should pass that same file to `--tf-startup`. +See [TinyFugue startup file](#tinyfugue-startup-file) for existing-file selection, +safe automatic insertion, and cases requiring manual early-loading setup. Load the hook before anything in that startup path can connect or log in to the MUD. AVATAR sends the full identity-bearing `Char.Status` only once, during diff --git a/deploy/install.sh b/deploy/install.sh index 6c294f1..c24e046 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -183,13 +183,73 @@ if ((BEHIND_NAT)) && [[ "$CONNECTION" != direct ]]; then die "--behind-nat is supported only with --connection direct" fi -if [[ ! -f "$TF_STARTUP" ]]; then - if ((TF_STARTUP_EXPLICIT)); then - die "TinyFugue startup file does not exist: $TF_STARTUP" +if [[ ! -e "$TF_STARTUP" && ! -L "$TF_STARTUP" ]] && ((!TF_STARTUP_EXPLICIT)); then + if [[ -t 0 && -t 1 ]]; then + printf '\nDefault TinyFugue startup file does not exist: %s\n' "$TF_STARTUP" + read -r -p 'Enter an existing TinyFugue startup file: ' TF_STARTUP || + die "TinyFugue startup selection interrupted" + case "$TF_STARTUP" in '~/'*) TF_STARTUP="$HOME/${TF_STARTUP:2}" ;; esac + else + die "default TinyFugue startup file does not exist: $TF_STARTUP; use --tf-startup FILE for an existing startup file" fi - - die "default TinyFugue startup file does not exist: $TF_STARTUP; use --tf-startup FILE for a different startup file" fi +[[ -f "$TF_STARTUP" ]] || die "TinyFugue startup must be an existing regular file: $TF_STARTUP" +[[ -O "$TF_STARTUP" && -r "$TF_STARTUP" && -w "$TF_STARTUP" ]] || + die "TinyFugue startup must be owned by the current user, readable and writable: $TF_STARTUP" + +LOAD_LINE='/load ~/.config/imp/capture.tf' +TF_DIRECT_HOOKS=0 +TF_EXACT_HOOK=0 +TF_NESTED_HOOKS=0 +TF_UNCERTAIN=0 +TF_BOM=0 +TF_LEGACY=0 +TF_INCLUDES=() +TF_NEWLINE=$'\n' +# shortcut: inspect only one level of literal /load paths; complex TF programs need operator review. +inspect_tf_startup() { + local file="$1" depth="$2" line first=1 include + while IFS= read -r line || [[ -n "$line" ]]; do + if ((!depth)) && [[ "${line%$'\r'}" == "$LOAD_LINE" ]]; then TF_EXACT_HOOK=1; fi + if ((first)); then + if [[ "$line" == $'\xef\xbb\xbf'* ]]; then TF_BOM=1; TF_UNCERTAIN=1; fi + if ((!depth)) && [[ "$line" == *$'\r' ]]; then TF_NEWLINE=$'\r\n'; fi + line="${line#$'\xef\xbb\xbf'}" + first=0 + fi + line="${line%$'\r'}" + case "$line" in ';'*|'#'*) continue ;; esac + line="${line#"${line%%[![:space:]]*}"}" + case "$line" in + '') continue ;; + /eval|/eval[[:space:]]*|/require|/require[[:space:]]*) TF_UNCERTAIN=1; continue ;; + /*) ;; + *) TF_LEGACY=1; TF_UNCERTAIN=1; continue ;; + esac + if [[ "$line" == /load[[:space:]]* ]]; then + include="${line#/load}" + include="${include#"${include%%[![:space:]]*}"}" + case "$include" in + '~/'*) include="$HOME/${include:2}" ;; + /*) ;; + *) TF_UNCERTAIN=1; continue ;; + esac + if [[ "$include" == "$CONFIG_DIR/capture.tf" || "$include" -ef "$CONFIG_DIR/capture.tf" ]]; then + if ((depth)); then + TF_NESTED_HOOKS=$((TF_NESTED_HOOKS + 1)) + else + TF_DIRECT_HOOKS=$((TF_DIRECT_HOOKS + 1)) + fi + elif ((depth)) || [[ ! -f "$include" || ! -r "$include" ]]; then + TF_UNCERTAIN=1 + else + TF_INCLUDES+=("$include") + fi + fi + done < "$file" +} +inspect_tf_startup "$TF_STARTUP" 0 +for include in "${TF_INCLUDES[@]}"; do inspect_tf_startup "$include" 1; done DIRECT_HAS_CREDENTIAL=0 if [[ -e "$CONFIG_DIR/gateway.env" || -L "$CONFIG_DIR/gateway.env" ]]; then @@ -517,23 +577,24 @@ for unit in imp-relay.service imp-feed.service imp-gateway.service; do "$SYSTEMD_DIR/$unit" done -LOAD_LINE='/load ~/.config/imp/capture.tf' -TF_CONFIGURED=0 - -if grep -Fxq "$LOAD_LINE" "$TF_STARTUP"; then - printf 'TinyFugue startup already contains the Imp hook: %s\n' "$TF_STARTUP" +if ((TF_EXACT_HOOK)); then + TF_STATUS='already exact; preserved without moving or rewriting' +elif ((TF_DIRECT_HOOKS)); then + TF_STATUS='existing hook preserved; no duplicate added' +elif ((TF_NESTED_HOOKS)); then + TF_STATUS='nested hook preserved; no duplicate added' +elif ((TF_UNCERTAIN)); then + TF_STATUS='load order not established; manual integration required' else - BACKUP="${TF_STARTUP}.imp-backup.$(date +%Y%m%d%H%M%S)" - cp -p -- "$TF_STARTUP" "$BACKUP" - TF_STARTUP_BACKUP="$BACKUP" + TF_STARTUP_BACKUP="$(mktemp "${TF_STARTUP}.imp-backup.XXXXXXXX")" + cp -p -- "$TF_STARTUP" "$TF_STARTUP_BACKUP" TF_STARTUP_CHANGED=1 - printf '\n%s\n' "$LOAD_LINE" >> "$TF_STARTUP" - printf 'Updated TinyFugue startup file: %s\n' "$TF_STARTUP" - printf 'Backup: %s\n' "$BACKUP" + { printf '%s%s' "$LOAD_LINE" "$TF_NEWLINE"; cat -- "$TF_STARTUP_BACKUP"; } > "$TF_STARTUP" + chmod --reference="$TF_STARTUP_BACKUP" -- "$TF_STARTUP" + TF_STATUS='inserted before original startup commands' + printf 'Backup: %s\n' "$TF_STARTUP_BACKUP" fi -TF_CONFIGURED=1 - if ((START_SERVICES)); then printf 'Installing Imp systemd user services...\n' @@ -635,13 +696,27 @@ else printf 'Services: installed but not started (--no-start).\n' fi -if ((TF_CONFIGURED)); then - printf 'TinyFugue startup integration: configured.\n' -else - printf '\nTinyFugue startup integration still requires this line:\n' - printf ' %s\n' "$LOAD_LINE" - printf 'Re-run with --tf-startup FILE to install it automatically.\n' +printf 'TinyFugue startup: %s\n' "$TF_STARTUP" +printf 'TinyFugue integration: %s.\n' "$TF_STATUS" +if ((TF_DIRECT_HOOKS || TF_NESTED_HOOKS || TF_UNCERTAIN)); then + printf 'warning: TinyFugue load order not established; inspect startup commands and nested loads.\n' +fi +if ((TF_BOM)); then + printf 'warning: BOM-prefixed startup preserved; native TinyFugue BOM handling is not established. Review encoding and hook execution manually.\n' +fi +if ((TF_DIRECT_HOOKS + TF_NESTED_HOOKS > 1)); then + printf 'warning: multiple Imp hook loads found; duplicate loading is possible; preserved unchanged.\n' +fi +if [[ "$TF_STATUS" == 'load order not established; manual integration required' ]]; then + printf 'No new hook inserted: legacy syntax, unsupported loads, or a BOM require manual review; preserve existing file encoding when editing.\n' + printf 'Manually arrange this before connection/login commands: %s\n' "$LOAD_LINE" +fi +if ((TF_LEGACY)); then + printf 'Possible legacy world-list startup preserved: place the hook after legacy world definitions but before the first connection/login command, or convert world definitions to /addworld before using an early /load. Do not blindly prepend the hook.\n' fi +printf 'Confirm your actual TinyFugue invocation (for example, tf -f FILE) loads this selected file and the hook before connection/login commands.\n' +printf 'For the current TF session, manually run: %s\n' "$LOAD_LINE" +printf 'A matching normalized checkpoint can recover prior authoritative identity without reconnecting; without that identity, recovery is not guaranteed.\n' if command -v loginctl >/dev/null 2>&1; then LINGER="$(loginctl show-user "$USER" -p Linger --value 2>/dev/null || true)" diff --git a/docs/roadmap.md b/docs/roadmap.md index 83ed570..916f62c 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -44,6 +44,7 @@ claims belong in `status.md` and the relevant architecture documents. | Slice 23 - ActionBroker timeout validation | Complete | `actionbroker-timeout-validation` | | Slice 24 - Direct WSS provisioning and onboarding | Complete | No slice marker; accepted in `v0.3.2` | | Slice 25 - Connection-oriented server installation | Complete | No slice marker; accepted in `v0.4.1` | +| Slice 26 - Installation reliability and onboarding | In progress | No slice marker | Slice numbers and milestone tags are development-history markers. They have no relationship to release versions. Release versions are derived independently @@ -54,8 +55,8 @@ The Slices 17–23 remediation program is complete; see the separate PR identities, verification, and retained limitations. Slice 24 is complete with non-blocking follow-ups. Slice 25 is complete: its implementation shipped in `v0.4.0` and was accepted on published artifacts, with a DNS preflight fix in -`v0.4.1`, plus retained follow-ups. Other candidates remain provisional; no next -slice is assigned. +`v0.4.1`, plus retained follow-ups. Slice 26 is in progress with the bounded +scope below. Other candidates remain provisional. ## ✅ Slice 1 - Bootstrap baseline @@ -765,6 +766,25 @@ These are retained priorities, not failed acceptance checks or a new slice: - **Real-runtime CI:** run the real Caddy routing matrix and native TinyFugue restart regression in CI rather than relying only on deterministic substitutes. +## In progress + +### Slice 26 - Installation reliability and onboarding + +In progress. Bounded scope: + +1. TinyFugue onboarding/startup integration (**only this PR**): explicit + existing-file selection, early hook loading, one-level literal absolute/`~/` + include duplicate detection, manual setup when inspection is unsafe, and + honest operator confirmation of actual startup ordering. +2. Warning before discarding saved Direct credentials. +3. Correct NAT preflight error classification. +4. Recovery of an exact-owned inactive legacy edge when `gateway.env` is missing. +5. Installation documentation and completion-message corrections. + +This PR does not change Direct provisioning, credentials, or the protocol, and +does not claim live infrastructure acceptance. The remaining Slice 26 items are +not implemented by this PR. + ## Candidate work Unordered, deliberately without slice numbers, and provisional. Live evidence diff --git a/integrations/tinyfugue/README.md b/integrations/tinyfugue/README.md index df49cb8..40205ef 100644 --- a/integrations/tinyfugue/README.md +++ b/integrations/tinyfugue/README.md @@ -78,8 +78,9 @@ which remains `stale` until fresh authoritative GMCP is published. ## Running (normal production path) -Copy the unchanged hook to `~/.config/imp/capture.tf`, then add this line -to the startup file used by the operator's actual TinyFugue invocation: +Copy the unchanged hook to `~/.config/imp/capture.tf`, then put this line first +in the macro-format startup file used by the operator's actual TinyFugue +invocation, before any connection/login commands: ```text /load ~/.config/imp/capture.tf @@ -88,16 +89,33 @@ to the startup file used by the operator's actual TinyFugue invocation: TinyFugue's `-f FILE` option loads `FILE` instead of the normal personal config. For example, when starting from `~/avatar/tf` with `tf -f./.tfrc -n`, the load belongs in `~/avatar/tf/.tfrc`, not an assumed -`~/.tfrc`. Load it before any world connects or logs in. The hook is additive: +`~/.tfrc`. Confirm that your actual invocation loads it early. The hook is additive: it does not replace the operator's GMCP negotiation or connection macros and -sends no GMCP of its own. Installation must not create or overwrite an -operator startup file. See [`../../deploy/README.md`](../../deploy/README.md) -for the VPS systemd setup. - -After installing the updated hook into an already-running TinyFugue that still -has the old hook loaded, run `/load ~/.config/imp/capture.tf` once before testing -restart recovery. Installing the file does not reload that process. No MUD -reconnect is needed when the normalized checkpoint still contains identity. +sends no GMCP of its own. + +The installer uses an existing `~/.tfrc` by default. If it is absent, interactive +stdin and stdout allow an explicit existing-file selection; otherwise supply +`./install.sh --tf-startup "/path/to/your TinyFugue/startup-file"`. +An invalid explicit file fails early; no startup file is created or guessed. +When startup inspection is safe, the installer backs up the selected file and +prepends the load line, preserving its original bytes, ownership and permissions. +Duplicate detection covers one level of literal absolute or `~/` `/load` paths. +An existing exact hook is left unchanged with an ordering warning. Recognized unsafe +includes, BOM-prefixed files, and legacy plain-world lists remain unchanged for +manual early-loading setup. For a legacy world list, place the hook after all +world definitions but before connection/login, or convert the definitions to +`/addworld` first. Do not blindly prepend a command: it ends TF's legacy loader +mode. Arbitrary startup macros cannot be fully inspected. Neither existing-hook +ordering nor the runtime invocation is verified by installation. See +[`../../deploy/README.md`](../../deploy/README.md#tinyfugue-startup-file) +for startup selection and the VPS systemd setup. + +For any already-running TinyFugue, run `/load ~/.config/imp/capture.tf` once +after installation, before testing restart recovery. Installing the file does +not reload that process. No MUD reconnect is needed when the matching normalized +checkpoint still contains previously captured identity. It cannot recover +identity never captured by a late-loaded hook; checkpoint loss or reboot also +requires a fresh identity-bearing login with the hook loaded early. The hook writes to the fixed path `~/.local/state/imp/spool`. `imp-feed` creates that path as a symlink into its private runtime diff --git a/scripts/test-installer-connections.py b/scripts/test-installer-connections.py index 1559a2e..3767da2 100755 --- a/scripts/test-installer-connections.py +++ b/scripts/test-installer-connections.py @@ -1114,6 +1114,212 @@ def retained(result=None): retained(result) check(sha(data / "releases" / va / "bin/imp-server") == ha, "A runtime was not retained for rollback") +def startup_cases(work, bundle, *, guarded=True): + """Real installer selection, byte preservation, ordering, and rollback.""" + hook = b"/load ~/.config/imp/capture.tf" + serial = itertools.count() + + def site(): + return Site(work, f"startup-{next(serial)}") + + def install(s, *args, **kwargs): + result = s.install(bundle, ["--connection", "none", *args], guarded=guarded, **kwargs) + return result + + def metadata(path): + st = path.stat() + return st.st_uid, st.st_gid, st.st_mode + + def completed(result, path, status): + check(result.rc == 0, "startup install failed", result) + for text in (str(path), status, "tf -f FILE", "current TF session", + "matching normalized checkpoint", "without that identity"): + check(text in result.out, f"missing onboarding output: {text}", result) + check("integration: configured" not in result.out, "unqualified configured claim", result) + + for original in (b"/connect game.example 1234\n/echo retained\n", + b"/connect game.example 1234\r\n/echo retained\r\n", + b"/connect game.example 1234", b"; comment\n\n /connect retained\n", b""): + s = site() + path = s.home / ".tfrc" + path.write_bytes(original) + path.chmod(0o640) + before = metadata(path) + result = install(s, "--no-start") + completed(result, path, "inserted before original startup commands") + newline = b"\r\n" if original.startswith(b"/connect game.example 1234\r\n") else b"\n" + expected = hook + newline + original + check(path.read_bytes() == expected and metadata(path) == before, "prepend lost bytes or metadata", result) + backups = list(s.home.glob(".tfrc.imp-backup.*")) + check(len(backups) == 1 and backups[0].read_bytes() == original, "original backup missing", result) + result = install(s, "--no-start") + completed(result, path, "already exact") + check(path.read_bytes() == expected and len(list(s.home.glob(".tfrc.imp-backup.*"))) == 1, + "reinstall duplicated or rewrote hook", result) + path.write_bytes(original) + result = install(s, "--no-start") + check(result.rc == 0 and len(list(s.home.glob(".tfrc.imp-backup.*"))) == 2 + and backups[0].read_bytes() == original, "reinstall overwrote prior backup", result) + + s = site() + default = s.home / ".tfrc" + default.unlink() + custom = s.home / "custom startup.tf" + custom.write_bytes(b"/connect retained\n") + result = install(s, "--no-start", tty=True, keys=str(custom) + "\n") + completed(result, custom, "inserted before original startup commands") + check("Enter an existing TinyFugue startup file" in result.out and not default.exists(), + "missing default did not select an existing file", result) + check(custom.read_bytes() == hook + b"\n/connect retained\n", "custom path with spaces mishandled", result) + + s = site() + default = s.home / ".tfrc" + untouched = default.read_bytes() + custom = s.home / "explicit startup.tf" + custom.write_bytes(b"/connect retained\n") + result = install(s, "--no-start", "--tf-startup", str(custom)) + completed(result, custom, "inserted before original startup commands") + check(default.read_bytes() == untouched and custom.read_bytes() == hook + b"\n/connect retained\n", + "explicit startup override changed wrong file", result) + + for kind in ("missing-default", "missing-explicit", "directory", "dangling", "fifo", "readonly"): + s = site() + default = s.home / ".tfrc" + default.unlink() + path = s.home / "invalid startup" + if kind == "directory": + path.mkdir() + elif kind == "dangling": + path.symlink_to(s.home / "absent") + elif kind == "fifo": + os.mkfifo(path) + elif kind == "readonly": + path.write_bytes(b"/echo retained\n") + path.chmod(0o400) + before = home_tree(s.home) + args = [] if kind == "missing-default" else ["--tf-startup", str(path)] + result = install(s, "--no-start", *args, tty=kind == "missing-explicit", timeout=30) + check(result.rc != 0 and "TinyFugue startup" in result.out, f"{kind} not rejected", result) + if kind == "missing-explicit": + check("Enter an existing TinyFugue startup file" not in result.out, + "explicit nonexistent startup prompted instead of failing", result) + check(home_tree(s.home) == before and not (s.home / ".local").exists(), + f"{kind} mutated installation before rejection", result) + + for original in (hook + b"\n/connect retained\n", b"/connect retained\n" + hook + b"\n", + b" \t" + hook + b"\n/connect retained\n", + hook + b"\r\n" + hook + b"\r\n", b"\xef\xbb\xbf/connect retained\r\n"): + s = site() + path = s.home / ".tfrc" + path.write_bytes(original) + result = install(s, "--no-start", "--tf-startup", str(path)) + completed(result, path, "load order not established") + check(path.read_bytes() == original and not list(s.home.glob(".tfrc.imp-backup.*")), + "existing hook or BOM file rewritten", result) + if original.count(hook) > 1: + check("multiple Imp hook loads found" in result.out, "duplicate hook warning absent", result) + if original.startswith(b"\xef\xbb\xbf"): + check("No new hook inserted" in result.out and "Manually arrange" in result.out, + "BOM manual action missing", result) + if original.startswith(b" \t"): + check("existing hook preserved; no duplicate added" in result.out and "already exact" not in result.out, + "indented hook falsely described as exact canonical line", result) + + for original in (b"# legacy worlds\nworld character password game.example 1234\n", + b" world character password game.example 1234\n/connect retained\n", + b"\t/eval /load %{startup}\n/connect retained\n", + b" /require %{startup}\n/connect retained\n"): + s = site() + path = s.home / ".tfrc" + path.write_bytes(original) + result = install(s, "--no-start") + completed(result, path, "load order not established") + check(path.read_bytes() == original and not list(s.home.glob(".tfrc.imp-backup.*")), + "legacy or computed startup content rewritten", result) + check("No new hook inserted" in result.out and "Manually arrange" in result.out, + "legacy or computed startup lacks manual action", result) + if b"world character" in original: + check("after legacy world definitions" in result.out and "/addworld" in result.out + and "Do not blindly prepend" in result.out, "legacy format guidance missing", result) + + for alias in (False, True): + s = site() + path = s.home / ".tfrc" + capture = s.home / ".config/imp/capture.tf" + target = capture + if alias: + capture.parent.mkdir(parents=True) + capture.write_bytes(b"; existing capture fixture\n") + target = s.home / "capture alias.tf" + target.symlink_to(capture) + original = f" /load {target}\n/connect retained\n".encode() + path.write_bytes(original) + for _ in range(2): + result = install(s, "--no-start") + completed(result, path, "existing hook preserved; no duplicate added") + check("already exact" not in result.out, "absolute or alias hook falsely described as exact", result) + check(path.read_bytes() == original and not list(s.home.glob(".tfrc.imp-backup.*")), + "absolute or alias hook duplicated on fresh install/reinstall", result) + + for prefix in ("absolute", "home", "relative", "deeper", "duplicates"): + s = site() + path = s.home / ".tfrc" + nested = s.home / "nested startup.tf" + nested.write_bytes(hook + b"\r\n") + include = str(nested) if prefix == "absolute" else "~/nested startup.tf" + if prefix == "relative": + include = "nested startup.tf" + elif prefix == "deeper": + nested.write_bytes(b"/load ~/deeper.tf\n") + (s.home / "deeper.tf").write_bytes(hook + b"\n") + elif prefix == "duplicates": + nested.write_bytes(hook + b"\n" + hook + b"\n") + original = f"/load {include}\n/connect retained\n".encode() + path.write_bytes(original) + result = install(s, "--no-start") + completed(result, path, "load order not established") + check(path.read_bytes() == original, "nested integration rewritten or duplicated", result) + if prefix in ("relative", "deeper"): + check("No new hook inserted" in result.out and "Manually arrange" in result.out, + "unsupported include lacked manual action", result) + else: + check("nested hook preserved" in result.out, "nested hook was not detected", result) + if prefix == "duplicates": + check("multiple Imp hook loads found" in result.out, "nested duplicate warning absent", result) + + s = site() + path = s.home / ".tfrc" + original = b"/connect retained\r\n/echo exact rollback\r\n" + path.write_bytes(original) + path.chmod(0o640) + before = metadata(path) + result = install(s, extra={"IMP_TEST_FAIL_RESTART": "imp-feed.service"}) + check(result.rc != 0 and (s.units / "failed-once").exists(), "post-mutation failure not reached", result) + check(path.read_bytes() == original and metadata(path) == before, "startup rollback lost bytes or metadata", result) + check(not list(s.home.glob(".tfrc.imp-backup.*")), "rollback stranded startup backup", result) + print("TinyFugue startup onboarding: PASS") + + +def installer_shims(work): + shims = work / "shims" + shims.mkdir() + for name, text in ( + ("systemctl", SYSTEMCTL), + ("cp", CP_SHIM), + *((n, PYTHON_GUARD) for n in ("python", "python3", "python3.12", "pip", "pip3")), + ): + (shims / name).write_text(text) + (shims / name).chmod(0o755) + return shims + + +def startup_main(bundle): + with tempfile.TemporaryDirectory(prefix="imp-tf-startup-") as directory: + work = Path(directory) + installer_shims(work) + startup_cases(work, Path(bundle)) + + def main(bundle, next_bundle): work = Path(tempfile.mkdtemp(prefix="imp-connections-")) @@ -1144,15 +1350,8 @@ def edge_fixture_bundle(bundle, work, name): def run_all(work, shipping, next_shipping): - shims = work / "shims" - shims.mkdir() - for name, text in ( - ("systemctl", SYSTEMCTL), - ("cp", CP_SHIM), - *((n, PYTHON_GUARD) for n in ("python", "python3", "python3.12", "pip", "pip3")), - ): - (shims / name).write_text(text) - (shims / name).chmod(0o755) + shims = installer_shims(work) + startup_cases(work, shipping) bundle = edge_fixture_bundle(shipping, work / "fixtures", "bundle") next_bundle = edge_fixture_bundle(next_shipping, work / "fixtures", "next") if next_shipping else None uid = os.getuid() @@ -2450,7 +2649,9 @@ def prepare(directory=directory): if __name__ == "__main__": if sys.argv[1:2] == ["--edge-driver"]: edge_driver(sys.argv[2:]) + elif sys.argv[1:2] == ["--tf-startup-only"] and len(sys.argv) == 3: + startup_main(sys.argv[2]) elif len(sys.argv) in (2, 3): main(sys.argv[1], sys.argv[2] if len(sys.argv) == 3 else None) else: - sys.exit(f"usage: {sys.argv[0]} BUNDLE_DIR [NEXT_BUNDLE_DIR]") + sys.exit(f"usage: {sys.argv[0]} BUNDLE_DIR [NEXT_BUNDLE_DIR] | --tf-startup-only BUNDLE_DIR") diff --git a/scripts/test-server-bundle.sh b/scripts/test-server-bundle.sh index 192c3a4..1fb34d3 100755 --- a/scripts/test-server-bundle.sh +++ b/scripts/test-server-bundle.sh @@ -214,6 +214,8 @@ printf '\n=== frozen relay/feed/gateway/action-consumer flow + native user syste grep -Fxc '/load ~/.config/imp/capture.tf' "$HOME_DIR/.tfrc" )" -eq 1 ]] || die "TinyFugue load line is not idempotent" +[[ "$(< "$HOME_DIR/.tfrc")" == $'/load ~/.config/imp/capture.tf\n/echo existing TinyFugue startup' ]] || + die "TinyFugue hook was not prepended before original startup commands" mapfile -t TF_BACKUPS < <( find "$HOME_DIR" \ diff --git a/services/relay/tests/test_installer_fixtures.py b/services/relay/tests/test_installer_fixtures.py index 056591c..d49a25a 100644 --- a/services/relay/tests/test_installer_fixtures.py +++ b/services/relay/tests/test_installer_fixtures.py @@ -1,11 +1,15 @@ from __future__ import annotations +import hashlib import importlib.util import os +import shutil import subprocess from pathlib import Path from types import ModuleType +import pytest + SCRIPT = Path(__file__).resolve().parents[3] / "scripts" / "test-installer-connections.py" @@ -91,3 +95,30 @@ def test_systemctl_double_signals_its_parent(tmp_path: Path) -> None: done = subprocess.run(["sh", "-c", script, str(double.script)], env=env, capture_output=True) assert done.returncode == 42 assert double.has("active", "imp-feed.service") + + +@pytest.mark.skipif(os.getuid() == 0, reason="installer must run as the target non-root user") +def test_tinyfugue_startup_onboarding(tmp_path: Path) -> None: + """Exercise real install.sh without building the frozen runtime.""" + root = tmp_path / "bundle" + root.mkdir() + shutil.copyfile(SCRIPT.parents[1] / "deploy/install.sh", root / "install.sh") + (root / "install.sh").chmod(0o755) + (root / "VERSION").write_text("0.0.0+startup.fixture\n") + (root / "capture.tf").write_text("; capture payload fixture\n") + (root / "bin").mkdir() + runtime = root / "bin/imp-server" + runtime.write_text("#!/bin/sh\nexit 0\n") + runtime.chmod(0o755) + (root / "systemd").mkdir() + for name in ("relay", "feed", "gateway"): + (root / f"systemd/imp-{name}.service").write_text("[Service]\nExecStart=/bin/true\n") + (root / "SHA256SUMS").write_text( + "".join( + f"{hashlib.sha256(path.read_bytes()).hexdigest()} {path.relative_to(root)}\n" + for path in sorted(root.rglob("*")) + if path.is_file() + ) + ) + harness.installer_shims(tmp_path) + harness.startup_cases(tmp_path, root, guarded=False) From 0482fd66993e467c7f0195f1dd1334670bbed878 Mon Sep 17 00:00:00 2001 From: twilightsaurus <133054450+twilightsaurus@users.noreply.github.com> Date: Mon, 19 Oct 2026 11:43:24 +0300 Subject: [PATCH 2/2] test(installer): preserve TinyFugue comment syntax safeguards --- deploy/README.md | 3 +++ deploy/install.sh | 1 + scripts/test-installer-connections.py | 15 +++++++++++---- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/deploy/README.md b/deploy/README.md index b3f4686..58114b0 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -117,6 +117,9 @@ lists are also not rewritten. In these cases the installer leaves the selected file unchanged, reports no insertion, and requires manual early-loading setup. Arbitrary startup macros cannot be fully inspected; this is not a TinyFugue configuration parser. +Only column-zero `;` and `#` are treated as comments, matching TinyFugue's +startup reader. Indented comment markers are not valid comments; they leave the +file unchanged and require manual review, including in legacy world lists. If `~/.tfrc` is absent and both stdin and stdout are interactive, the installer asks for an explicit path to an existing startup file. It never creates one or diff --git a/deploy/install.sh b/deploy/install.sh index c24e046..6763bfa 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -218,6 +218,7 @@ inspect_tf_startup() { first=0 fi line="${line%$'\r'}" + # TF checks column-zero comments before stripping whitespace (src/command.c). case "$line" in ';'*|'#'*) continue ;; esac line="${line#"${line%%[![:space:]]*}"}" case "$line" in diff --git a/scripts/test-installer-connections.py b/scripts/test-installer-connections.py index 3767da2..3d6fcd6 100755 --- a/scripts/test-installer-connections.py +++ b/scripts/test-installer-connections.py @@ -1139,7 +1139,9 @@ def completed(result, path, status): for original in (b"/connect game.example 1234\n/echo retained\n", b"/connect game.example 1234\r\n/echo retained\r\n", - b"/connect game.example 1234", b"; comment\n\n /connect retained\n", b""): + b"/connect game.example 1234", b"; comment\n\n /connect retained\n", + b"; column-zero comment\n \t \n \t/connect retained\n\t /echo retained\n", + b"# column-zero comment\n\t \t\n\t /connect retained\n \t/echo retained\n", b""): s = site() path = s.home / ".tfrc" path.write_bytes(original) @@ -1228,16 +1230,21 @@ def completed(result, path, status): for original in (b"# legacy worlds\nworld character password game.example 1234\n", b" world character password game.example 1234\n/connect retained\n", b"\t/eval /load %{startup}\n/connect retained\n", - b" /require %{startup}\n/connect retained\n"): + b" /require %{startup}\n/connect retained\n", + *(prefix + marker + b" indented comment\n" + commands + for prefix in (b" ", b"\t", b" \t ") + for marker in (b";", b"#") + for commands in (b"/connect retained\n", + b"world character password game.example 1234\n/connect retained\n"))): s = site() path = s.home / ".tfrc" path.write_bytes(original) result = install(s, "--no-start") completed(result, path, "load order not established") check(path.read_bytes() == original and not list(s.home.glob(".tfrc.imp-backup.*")), - "legacy or computed startup content rewritten", result) + "legacy, indented comment, or computed startup content rewritten", result) check("No new hook inserted" in result.out and "Manually arrange" in result.out, - "legacy or computed startup lacks manual action", result) + "legacy, indented comment, or computed startup lacks manual action", result) if b"world character" in original: check("after legacy world definitions" in result.out and "/addworld" in result.out and "Do not blindly prepend" in result.out, "legacy format guidance missing", result)