From 65aa52115fe2a3273c60408df00ec19bfc8ae56f Mon Sep 17 00:00:00 2001 From: mptyl Date: Wed, 5 Aug 2026 09:23:44 +0200 Subject: [PATCH] fix: harden local installation guide --- docs/install/local.md | 128 ++++++++++++++++- docs/install/pi-management.md | 15 +- docs/install/windows-line-endings.md | 57 +++++++- scripts/test-verify-workspace-install-docs.sh | 91 +++++++++++- scripts/verify-workspace-install-docs.sh | 134 +++++++++++++++++- 5 files changed, 401 insertions(+), 24 deletions(-) diff --git a/docs/install/local.md b/docs/install/local.md index f81751d6..bf46b48a 100644 --- a/docs/install/local.md +++ b/docs/install/local.md @@ -89,6 +89,25 @@ mkdir -p /absolute/path/to/thothii-operator/secrets chmod 0700 /absolute/path/to/thothii-operator/secrets ``` +Native Windows PowerShell performs the same setup without POSIX utilities. The ACL commands remove +inherited access from the new operator directory and grant full control only to the current Windows +identity. Stop if either `icacls.exe` command returns a nonzero exit code: + +```powershell +$OperatorDir = Join-Path $env:USERPROFILE 'thothii-operator' +$SecretsDir = Join-Path $OperatorDir 'secrets' +$CurrentUser = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name +if (Test-Path $OperatorDir) { throw 'Use a new operator directory or review its ACLs manually.' } +New-Item -ItemType Directory -Force -Path $OperatorDir, $SecretsDir | Out-Null +icacls.exe $OperatorDir /inheritance:r +if ($LASTEXITCODE -ne 0) { throw 'Could not remove inherited operator-directory ACLs.' } +icacls.exe $OperatorDir /grant:r "${CurrentUser}:(OI)(CI)F" +if ($LASTEXITCODE -ne 0) { throw 'Could not grant the current user the operator-directory ACL.' } +Copy-Item deploy/env/local.env.example deploy/env/local.env +Copy-Item docs/install/examples/thothii-installation.local.yaml ` + (Join-Path $OperatorDir 'thothii-installation.yaml') +``` + Edit `deploy/env/local.env`. At minimum set the workspace Git remote, `PI_AUTH_FILE`, `THT_SECRETS_FILE`, external service endpoints, and the absolute `THT_WORKSPACE_BINDINGS_ENV_FILE`. Create each secret as a separate regular file under the @@ -206,30 +225,112 @@ curl --fail http://127.0.0.1:8787/health "$THTCTL" --installation "$INSTALLATION" pi test ``` +Native PowerShell must call `curl.exe` explicitly; Windows PowerShell may otherwise resolve `curl` +to `Invoke-WebRequest`: + +```powershell +curl.exe --fail --silent --show-error http://127.0.0.1:8080/health +curl.exe --fail --silent --show-error http://127.0.0.1:8787/health +& $THTCTL --installation $INSTALLATION pi doctor +& $THTCTL --installation $INSTALLATION pi test +``` + Open . If a check fails, run `thothctl ... logs` or `pi logs`; these are bounded and sanitize declared secrets. Do not publish either loopback port. ## Update an installation -Commit or back up local operator changes first. Application source updates are separate from Pi -lifecycle updates: +Commit or back up local operator changes first and finish active sessions. A promoted Pi image is +selected by the durable, installation-specific `current-image.yaml` after every base/profile file. +Therefore rebuilding `thothii-core:local` followed by `update --check-only` does not reconcile a +previous `pi update`: the old promoted core would remain selected. + +Do not delete or edit the selector. The supported source-update path is a transactional +`pi update --source build` from a clean pulled checkout whose `docker/core.Dockerfile` pins a +different Pi version than the running installation. `thothctl` currently treats the same requested +Pi version as a no-op. The explicit comparison below therefore stops instead of silently deploying +only part of a revision. If it stops, keep the current installation running and wait for a release +with a new Pi pin or a future supported reconciliation command; there is no supported manual +same-version selector-removal procedure. + +macOS, Linux, and WSL2: ```sh -"$THTCTL" --installation "$INSTALLATION" stop git status --short +git diff --quiet +git diff --cached --quiet git pull --ff-only git config --local core.autocrlf false bash scripts/verify-line-endings.sh +SOURCE_REVISION="$(git rev-parse HEAD)" +NEXT_PI_VERSION="$(sed -n 's/^ARG PI_VERSION=//p' docker/core.Dockerfile)" +RUNNING_PI_VERSION="$("$THTCTL" --installation "$INSTALLATION" pi status)" +RUNNING_PI_VERSION="${RUNNING_PI_VERSION#Pi version: }" +if [[ -z "$NEXT_PI_VERSION" || "$NEXT_PI_VERSION" == "$RUNNING_PI_VERSION" ]]; then + echo "Source update stopped: the pulled revision must pin a new Pi version." >&2 + exit 1 +fi bash scripts/build-local.sh bash scripts/build-thothctl.sh "$THTCTL" --installation "$INSTALLATION" update --check-only +"$THTCTL" --installation "$INSTALLATION" pi update \ + --version "$NEXT_PI_VERSION" --source build --yes --drain "$THTCTL" --installation "$INSTALLATION" start curl --fail http://127.0.0.1:8080/health +curl --fail http://127.0.0.1:8787/health +printf 'Built source revision: %s\n' "$SOURCE_REVISION" +"$THTCTL" --installation "$INSTALLATION" status +"$THTCTL" --installation "$INSTALLATION" pi status +"$THTCTL" --installation "$INSTALLATION" doctor ``` -On native Windows use the PowerShell build launcher and the Git-for-Windows Bash LF check. Review -release notes before updating. Pi has its own transactional `pi update` and rollback workflow in -[Pi management](pi-management.md); never install a package in the running container. +Native Windows PowerShell uses the same fail-closed version comparison and transactional promotion: + +```powershell +git status --short +git diff --quiet +if ($LASTEXITCODE -ne 0) { throw 'Commit or back up tracked source changes before update.' } +git diff --cached --quiet +if ($LASTEXITCODE -ne 0) { throw 'Commit or back up staged source changes before update.' } +git pull --ff-only +if ($LASTEXITCODE -ne 0) { throw 'The source pull failed.' } +git config --local core.autocrlf false +& "C:\Program Files\Git\bin\bash.exe" scripts/verify-line-endings.sh +if ($LASTEXITCODE -ne 0) { throw 'The pulled checkout contains CRLF files.' } +$SourceRevision = git rev-parse HEAD +$VersionLine = @(Select-String -Path docker/core.Dockerfile -Pattern '^ARG PI_VERSION=(.+)$') +if ($VersionLine.Count -ne 1) { throw 'Expected exactly one pinned default PI_VERSION.' } +$NextPiVersion = $VersionLine.Matches[0].Groups[1].Value +$RunningPiVersion = (& $THTCTL --installation $INSTALLATION pi status) ` + -replace '^Pi version:\s*', '' +if ([string]::IsNullOrWhiteSpace($NextPiVersion) -or $NextPiVersion -eq $RunningPiVersion) { + throw 'Source update stopped: the pulled revision must pin a new Pi version.' +} +powershell -ExecutionPolicy Bypass -File scripts/build-local.ps1 +if ($LASTEXITCODE -ne 0) { throw 'The local image build failed.' } +& "C:\Program Files\Git\bin\bash.exe" scripts/build-thothctl.sh +if ($LASTEXITCODE -ne 0) { throw 'The thothctl build failed.' } +& $THTCTL --installation $INSTALLATION update --check-only +if ($LASTEXITCODE -ne 0) { throw 'The installation render check failed.' } +& $THTCTL --installation $INSTALLATION pi update ` + --version $NextPiVersion --source build --yes --drain +if ($LASTEXITCODE -ne 0) { throw 'The transactional core update failed.' } +& $THTCTL --installation $INSTALLATION start +if ($LASTEXITCODE -ne 0) { throw 'The installation start failed.' } +curl.exe --fail --silent --show-error http://127.0.0.1:8080/health +curl.exe --fail --silent --show-error http://127.0.0.1:8787/health +Write-Output "Built source revision: $SourceRevision" +& $THTCTL --installation $INSTALLATION status +& $THTCTL --installation $INSTALLATION pi status +& $THTCTL --installation $INSTALLATION doctor +``` + +The recorded Git revision identifies the clean worktree used for the candidate build. In +`thothctl status`, confirm that `core` reports the installation lifecycle candidate image, then +require `pi status` to equal the new pin and `doctor` to pass. This is the supported running-image +and source-revision evidence; `update --check-only` alone proves only that Compose renders. +Review release notes before updating. See [Pi management](pi-management.md) for rollback; never +install a package in the running container. ## Back up and restore @@ -278,6 +379,21 @@ docker run --rm -v "$TARGET_VOLUME:/target" -v "$(dirname "$ARCHIVE"):/backup:ro alpine:3.22 tar -C /target -xzf "/backup/$(basename "$ARCHIVE")" ``` +Native PowerShell uses `Split-Path` to produce the read-only archive mount and archive name: + +```powershell +$TargetVolume = 'exact-empty-target-volume-name' +$Archive = 'C:\Users\operator\thothii-backups\2026-08-05\exact-volume-name.tgz' +$ArchiveDir = Split-Path -Parent $Archive +$ArchiveName = Split-Path -Leaf $Archive +docker run --rm -v "${TargetVolume}:/target" alpine:3.22 ` + sh -ceu 'test -z "$(ls -A /target)"' +if ($LASTEXITCODE -ne 0) { throw 'The restore target volume is not empty.' } +docker run --rm -v "${TargetVolume}:/target" -v "${ArchiveDir}:/backup:ro" ` + alpine:3.22 tar -C /target -xzf "/backup/${ArchiveName}" +if ($LASTEXITCODE -ne 0) { throw 'The volume restore failed.' } +``` + Restore all four volumes from the same backup set, restore protected operator files separately, then run `update --check-only`, `start`, `doctor`, registry status/diagnostics, and a known session before normal use. Never merge an archive into a non-empty volume. diff --git a/docs/install/pi-management.md b/docs/install/pi-management.md index 3b6ee28d..de351382 100644 --- a/docs/install/pi-management.md +++ b/docs/install/pi-management.md @@ -134,10 +134,13 @@ editing state files. ## Direct support access -Advanced support may inspect the bundled executable directly with the installation's exact -validated Compose file set, for example `docker compose exec core pi --version`. This is read-only -diagnosis, not an update mechanism. Do not run package installers, alter Pi files inside the live -container, mount the Docker socket, expose a browser shell, or use a host Pi as a substitute. +Raw Compose access is unsupported: there is no public operator command that safely reconstructs +the installation's hashed project name, project directory, environment file, optional overrides, +and durable current-image selector for ad-hoc Pi execution. Do not approximate those arguments or +delete/edit lifecycle state for support. -Prefer `thothctl pi status`, `pi doctor`, `pi test`, and `pi logs`, because they include the durable -image selector and redact declared secret values. Share only their sanitized output. +Route direct executable/version checks through `thothctl pi status`, and collect diagnostics with +`thothctl pi doctor`, `thothctl pi test`, and `thothctl pi logs`. These commands are +installation-aware and redact declared secret values. Share only their sanitized output. Do not +run package installers, alter Pi files inside the live container, mount the Docker socket, expose +a browser shell, or use a host Pi as a substitute. diff --git a/docs/install/windows-line-endings.md b/docs/install/windows-line-endings.md index f0230295..4a1582c0 100644 --- a/docs/install/windows-line-endings.md +++ b/docs/install/windows-line-endings.md @@ -64,8 +64,17 @@ The safest recovery is to reclone into a new directory. First commit wanted work backup outside both clones. Then clone with conversion disabled, run the verifier, and copy back only reviewed changes. -If a reviewed working tree must be repaired in place, make a backup or commit all wanted changes -before continuing. Then use Git's repository attributes to stage a renormalization: +If a reviewed working tree must be repaired in place, Git must first normalize the index, export +that exact index to a separate repair directory, verify the exported bytes, and only then copy the +verified tracked files over the worktree. `git add --renormalize .` alone does not change existing +worktree bytes. + +> **WARNING — destructive worktree rewrite.** Make a backup outside the clone or commit every +> wanted tracked change before continuing. The copy step below overwrites tracked worktree bytes +> from the staged index export. Stop if the staged diff does not contain exactly the wanted content; +> untracked files are neither exported nor repaired. + +From WSL2, Git Bash, macOS, or Linux: ```sh git status --short @@ -73,8 +82,48 @@ git config --local core.autocrlf false git add --renormalize . git diff --cached --check git diff --cached +REPAIR_DIR="$(cd .. && pwd -P)/ThothII-lf-repair" +if [[ -e "$REPAIR_DIR" ]]; then + echo "Choose a new empty LF repair directory: $REPAIR_DIR" >&2 + exit 1 +fi +mkdir -p "$REPAIR_DIR" +REPAIR_PREFIX="$REPAIR_DIR/" +git checkout-index --all --force --prefix="$REPAIR_PREFIX" +bash scripts/verify-line-endings.sh "$REPAIR_DIR" +# WARNING: destructive copy; make a backup or commit wanted changes before this command. +git ls-files -z | while IFS= read -r -d '' path; do + cp "$REPAIR_DIR/$path" "$path" +done bash scripts/verify-line-endings.sh ``` -Review every staged change before committing. The procedure intentionally avoids destructive Git -resets; replacing the clone is easier to audit and much safer for uncommitted work. +Native Windows PowerShell runs the same Git operations and invokes the byte verifier through Git +for Windows: + +```powershell +git status --short +git config --local core.autocrlf false +git add --renormalize . +git diff --cached --check +git diff --cached +$RepairDir = Join-Path (Split-Path -Parent (Get-Location).Path) 'ThothII-lf-repair' +if (Test-Path $RepairDir) { throw 'Choose a new empty LF repair directory.' } +New-Item -ItemType Directory -Path $RepairDir | Out-Null +$RepairPrefix = $RepairDir.Replace('\', '/') + '/' +git checkout-index --all --force --prefix=$RepairPrefix +& "C:\Program Files\Git\bin\bash.exe" scripts/verify-line-endings.sh $RepairDir +if ($LASTEXITCODE -ne 0) { throw 'The staged index export does not satisfy the LF policy.' } +# WARNING: destructive copy; make a backup or commit wanted changes before this command. +git ls-files | ForEach-Object { + Copy-Item -LiteralPath (Join-Path $RepairDir $_) -Destination $_ -Force +} +& "C:\Program Files\Git\bin\bash.exe" scripts/verify-line-endings.sh +if ($LASTEXITCODE -ne 0) { throw 'Tracked worktree bytes were not repaired to the LF policy.' } +``` + +The first verifier proves the exported index bytes before any overwrite; the final verifier +examines the repaired worktree bytes and must also exit `0`. Review the staged diff again before +committing, then remove the separate repair directory only after inspecting it. The procedure +intentionally avoids `git reset --hard`; replacing the clone is easier to audit and much safer for +uncommitted work. diff --git a/scripts/test-verify-workspace-install-docs.sh b/scripts/test-verify-workspace-install-docs.sh index f0c75d9b..1e09adc4 100755 --- a/scripts/test-verify-workspace-install-docs.sh +++ b/scripts/test-verify-workspace-install-docs.sh @@ -4,7 +4,9 @@ set -euo pipefail root="$(cd "$(dirname "$0")/.." && pwd -P)" output="$(mktemp "${TMPDIR:-/tmp}/thoth-install-docs-test.XXXXXX")" -trap 'rm -f "$output"' EXIT HUP INT TERM +verifier_functions="$(mktemp "${TMPDIR:-/tmp}/thoth-install-docs-functions.XXXXXX")" +negative_root="$(mktemp -d "${TMPDIR:-/tmp}/thoth-install-docs-negative.XXXXXX")" +trap 'rm -f "$output" "$verifier_functions"; rm -rf "$negative_root"' EXIT HUP INT TERM "$root/scripts/verify-workspace-install-docs.sh" --fixtures-only >"$output" @@ -17,7 +19,8 @@ for fixture in \ "server manual canonical base+override references" \ "canonical local base+override fixture" \ "canonical server base+override fixture" \ - "relative secret-source fixture rejected"; do + "relative secret-source fixture rejected" \ + "CRLF recovery rewrites worktree bytes"; do grep -Fqx "$fixture passed" "$output" >/dev/null || { echo "missing fixture verification: $fixture" >&2 cat "$output" >&2 @@ -48,3 +51,87 @@ if rg -n 'local-compose\.workspace-registry|server-compose\.workspace-registry|c echo "installation manuals still document a bypassed Compose or copied connector override path" >&2 exit 1 fi + +# Load only the verifier's function definitions so each deliberately unsafe guide can be checked +# in isolation without invoking Docker-backed Compose fixtures. +sed '/^case "\$mode" in/,$d' "$root/scripts/verify-workspace-install-docs.sh" >"$verifier_functions" +# shellcheck source=/dev/null +source "$verifier_functions" + +negative_failures=0 +expect_guide_rejected() { + local label="$1" validator="$2" source_guide="$3" relative_path="$4" + local mutation="$5" expected_error="$6" + local fixture_root="$negative_root/${label// /-}" + local fixture_output="$fixture_root/output" + mkdir -p "$fixture_root/$(dirname "$relative_path")" + cp "$source_guide" "$fixture_root/$relative_path" + node - "$fixture_root/$relative_path" "$mutation" <<'NODE' +const fs = require("fs"); +const [path, mutation] = process.argv.slice(2); +const original = fs.readFileSync(path, "utf8"); +let changed = original; +switch (mutation) { + case "durable-selector": + changed = original.replaceAll("--source build", "--source stale-build"); + break; + case "dangerous-volumes": + changed = original.replace("Do **not** run `docker compose down --volumes`", "Run `docker compose down --volumes`"); + break; + case "incomplete-powershell": + changed = original.replaceAll("icacls.exe", "Write-Output"); + break; + case "broken-crlf": + changed = original.replaceAll("git checkout-index --all --force --prefix=", "git add --renormalize . # "); + break; + case "raw-pi": + changed += "\n```sh\ndocker compose exec core pi --version\n```\n"; + break; + default: + throw new Error(`unknown negative-fixture mutation: ${mutation}`); +} +if (changed === original) throw new Error(`negative-fixture mutation made no change: ${mutation}`); +fs.writeFileSync(path, changed); +NODE + set +e + (root="$fixture_root"; set -e; "$validator") >"$fixture_output" 2>&1 + local status=$? + set -e + if [[ $status -eq 0 ]]; then + echo "negative fixture accepted: $label" >&2 + cat "$fixture_output" >&2 + negative_failures=$((negative_failures + 1)) + elif ! grep -Fq -- "$expected_error" "$fixture_output"; then + echo "negative fixture failed for the wrong reason: $label" >&2 + cat "$fixture_output" >&2 + negative_failures=$((negative_failures + 1)) + fi +} + +expect_guide_rejected \ + "durable selector keeps old core" verify_local_guide \ + "$root/docs/install/local.md" docs/install/local.md durable-selector \ + "installation-aware source update lacks structural token: --source build" +expect_guide_rejected \ + "dangerous down volumes instruction" verify_local_guide \ + "$root/docs/install/local.md" docs/install/local.md dangerous-volumes \ + "docker compose down --volumes must appear only in an explicit prose prohibition" +expect_guide_rejected \ + "incomplete native PowerShell path" verify_local_guide \ + "$root/docs/install/local.md" docs/install/local.md incomplete-powershell \ + "native PowerShell setup lacks structural token: icacls.exe" +expect_guide_rejected \ + "renormalize leaves CRLF worktree bytes" verify_windows_line_endings_guide \ + "$root/docs/install/windows-line-endings.md" docs/install/windows-line-endings.md broken-crlf \ + "Windows line-ending guide lacks required instruction: git checkout-index --all --force" +expect_guide_rejected \ + "raw non-installation-aware Pi access" verify_pi_management_guide \ + "$root/docs/install/pi-management.md" docs/install/pi-management.md raw-pi \ + "raw non-installation-aware Compose Pi access is forbidden" + +if (( negative_failures != 0 )); then + echo "$negative_failures unsafe installation-document fixtures were accepted" >&2 + exit 1 +fi + +echo "unsafe installation-document fixtures rejected passed" diff --git a/scripts/verify-workspace-install-docs.sh b/scripts/verify-workspace-install-docs.sh index 5e6dde68..dce822c1 100755 --- a/scripts/verify-workspace-install-docs.sh +++ b/scripts/verify-workspace-install-docs.sh @@ -100,6 +100,82 @@ verify_local_guide() { "http://127.0.0.1:8080" \ "git pull --ff-only" \ "docker compose down --volumes" + node - "$guide" <<'NODE' +const fs = require("fs"); +const source = fs.readFileSync(process.argv[2], "utf8"); + +function section(name) { + const marker = `## ${name}`; + const start = source.indexOf(marker); + if (start < 0) throw new Error(`missing section: ${name}`); + const next = source.indexOf("\n## ", start + marker.length); + return source.slice(start, next < 0 ? source.length : next); +} + +function blocks(name, language) { + const expression = new RegExp("```" + language + "\\n([\\s\\S]*?)```", "g"); + return [...section(name).matchAll(expression)].map((match) => match[1]); +} + +function requireTokens(label, text, tokens) { + for (const token of tokens) { + if (!text.includes(token)) throw new Error(`${label} lacks structural token: ${token}`); + } +} + +let inCodeFence = false; +for (const line of source.split(/\n/)) { + if (line.trimStart().startsWith("```")) { + inCodeFence = !inCodeFence; + continue; + } + if (!line.includes("docker compose down --volumes")) continue; + const normalized = line.toLowerCase().replaceAll("*", ""); + if (inCodeFence || !/(do not|never)/.test(normalized) || /^\s*(docker|&?\s*docker)/.test(normalized)) { + throw new Error("docker compose down --volumes must appear only in an explicit prose prohibition"); + } +} + +const setupPowerShell = blocks("Create the local operator files", "powershell").join("\n"); +requireTokens("native PowerShell setup", setupPowerShell, [ + "Copy-Item", "New-Item", "icacls.exe", "/inheritance:r", "/grant:r", + "WindowsIdentity", "deploy/env/local.env.example", "thothii-installation.yaml", +]); + +const healthPowerShell = blocks("Start and verify", "powershell").join("\n"); +requireTokens("native PowerShell health", healthPowerShell, [ + "curl.exe --fail", "http://127.0.0.1:8080/health", "http://127.0.0.1:8787/health", + "pi doctor", "pi test", +]); + +const updateShell = blocks("Update an installation", "sh").join("\n"); +requireTokens("installation-aware source update", updateShell, [ + "NEXT_PI_VERSION", "RUNNING_PI_VERSION", "--source build", "git rev-parse HEAD", + "pi status", "status", "doctor", "curl --fail", +]); +if (!updateShell.includes("NEXT_PI_VERSION\" == \"$RUNNING_PI_VERSION") || + !updateShell.includes("exit 1")) { + throw new Error("source update must fail closed when thothctl would no-op on the current Pi version"); +} + +const updatePowerShell = blocks("Update an installation", "powershell").join("\n"); +requireTokens("native PowerShell source update", updatePowerShell, [ + "$NextPiVersion", "$RunningPiVersion", "--source build", "git rev-parse HEAD", + "pi status", "status", "doctor", "curl.exe --fail", "throw", +]); + +const backupPowerShell = blocks("Back up and restore", "powershell").join("\n"); +requireTokens("native PowerShell backup/restore", backupPowerShell, [ + "$BackupDir", "$Volume", "-czf", "$TargetVolume", "$Archive", "Split-Path -Parent", + "Split-Path -Leaf", "test -z", "-xzf", +]); + +for (const block of [setupPowerShell, healthPowerShell, updatePowerShell, backupPowerShell]) { + if (/\$\((dirname|basename)\b|\bmkdir -p\b|\bchmod\s+[0-7]/.test(block)) { + throw new Error("native PowerShell block contains a POSIX-only command sequence"); + } +} +NODE echo "local installation guide contract passed" } @@ -118,22 +194,52 @@ verify_windows_line_endings_guide() { "git config --local core.autocrlf false" \ "bash scripts/verify-line-endings.sh" \ "git add --renormalize ." \ + "git checkout-index --all --force" \ "git diff --cached --check" \ "reclone" - if grep -Fq 'git reset --hard' "$guide"; then - node - "$guide" <<'NODE' + node - "$guide" <<'NODE' const fs = require("fs"); const lines = fs.readFileSync(process.argv[2], "utf8").split(/\n/); +const commands = [ + "git add --renormalize .", + "git checkout-index --all --force --prefix=", + "bash scripts/verify-line-endings.sh", +]; +let prior = -1; +for (const command of commands) { + const index = lines.findIndex((line, candidate) => candidate > prior && line.trim().includes(command)); + if (index < 0) throw new Error(`CRLF recovery lacks ordered command: ${command}`); + prior = index; +} for (let index = 0; index < lines.length; index += 1) { - if (!lines[index].includes("git reset --hard")) continue; + const command = lines[index].trim(); + if (command !== 'cp "$REPAIR_DIR/$path" "$path"' && + !command.startsWith("Copy-Item -LiteralPath") && + command !== "git reset --hard") continue; const warning = lines.slice(Math.max(0, index - 4), index).join(" ").toLowerCase(); if (!warning.includes("warning") || !warning.includes("destructive") || !warning.includes("backup") || !warning.includes("commit")) { - throw new Error("git reset --hard lacks an immediate destructive warning requiring backup/commit"); + throw new Error("worktree rewrite lacks an immediate destructive warning requiring backup/commit"); } } NODE - fi + local fixture + fixture="$(mktemp -d "${TMPDIR%/}/thoth-crlf-repair.XXXXXX")" + trap 'rm -rf "$fixture"' RETURN + git -C "$fixture" init -q + printf '*.sh text eol=lf\n' >"$fixture/.gitattributes" + printf '#!/bin/sh\r\nexit 0\r\n' >"$fixture/repair.sh" + git -C "$fixture" add .gitattributes repair.sh 2>/dev/null + git -C "$fixture" config --local core.autocrlf false + git -C "$fixture" add --renormalize . + local export_dir="$fixture-export" + mkdir -p "$export_dir" + git -C "$fixture" checkout-index --all --force --prefix="$export_dir/" + "$root/scripts/verify-line-endings.sh" "$export_dir" + cp "$export_dir/repair.sh" "$fixture/repair.sh" + "$root/scripts/verify-line-endings.sh" "$fixture" + rm -rf "$export_dir" + echo "CRLF recovery rewrites worktree bytes passed" echo "Windows line-ending recovery guide contract passed" } @@ -163,9 +269,25 @@ verify_pi_management_guide() { "pi maintenance recover --yes" \ "pi logs" \ "/run/secrets" \ - "docker compose exec core pi" \ + "Raw Compose access is unsupported" \ "no browser shell" \ "does not mount the Docker socket" + node - "$guide" <<'NODE' +const fs = require("fs"); +const source = fs.readFileSync(process.argv[2], "utf8"); +if (/docker\s+compose(?:.|\n){0,160}\bexec\b(?:.|\n){0,80}\bcore\b(?:.|\n){0,80}\bpi\b/i.test(source)) { + throw new Error("raw non-installation-aware Compose Pi access is forbidden"); +} +const marker = "## Direct support access"; +const start = source.indexOf(marker); +const support = start < 0 ? "" : source.slice(start, source.indexOf("\n## ", start + marker.length) < 0 + ? source.length : source.indexOf("\n## ", start + marker.length)); +for (const token of ["unsupported", "thothctl", "pi status", "pi doctor", "pi test", "pi logs"]) { + if (!support.toLowerCase().includes(token.toLowerCase())) { + throw new Error(`direct support section lacks installation-aware diagnostic: ${token}`); + } +} +NODE echo "Pi management guide contract passed" }