Skip to content

Install alula's Artemis Launcher as a separate, verified option - #8

Merged
dougchansan merged 20 commits into
mainfrom
feature/alula-artemis-launcher
Nov 2, 2026
Merged

dougchansan merged 20 commits into
mainfrom
feature/alula-artemis-launcher

Conversation

@dougchansan

@dougchansan dougchansan commented Nov 2, 2026 •

Copy link
Copy Markdown
Owner

What this PR adds

A separate, verified installer for alula's switch-artemis-launcher, plus the 2026-11-01 hardware report. It does not modify the existing payload installer (kit entry 4) or the Seedsow Lua Editor entry (kit entry 5), and it does not touch the upstream Lua.

The launcher is downloaded at install time from a pinned revision (b9e6de8, tag v1), every file is checked against a recorded SHA-256, and nothing third-party is vendored.

Final review round — restore staging path

Restore staging path (P1). Both restore paths staged save9999.dat beside a
manifest whose local is relative, then invoked save-write-stage.cjs without a
cwd; the writer resolves entry.local with fs.readFile against its process
directory, so restore could read the wrong file or fail. stageTriggerForRestore()
now writes the trigger + manifest, and writeRestoreTrigger() invokes the writer
with cwd=<staging> and removes the directory in a finally (success, failure,
exception). Both modern and legacy paths use it.

Tests: 47 installer + 14 delete-result = 157 Node assertions on this branch.
A faithful writer mock is spawned from the captured cwd to prove the staged
trigger is read; the same invocation from an unrelated directory fails with
ENOENT (the old bug). Also covers modern/migrated/legacy staging, missing and
altered triggers, writer failure propagation, cleanup, and backup/baseline
preservation.

CI: added .github/workflows/offline-tests.yml (Node 20, Python 3.11, Lua 5.4,
shell). The initial run failed because the tests use Lua 5.2+ load(string);
the workflow now installs/runs Lua 5.4 and both PRs' latest checks pass.

Final review round

  1. Migrated legacy restore now works (P1). restoreFromOriginal() required
    identity-bearing backups, so a successful migrate-legacy could not be
    restored. New loadOriginalBackupForRestore() has two narrow paths:
    • modern: full title/account identity plus every existing backup check;
    • migrated: the identity-less legacy backup is accepted only when its
      manifest SHA-256 and trigger equal the immutable original record, the
      record's title/account match, the installed payload equals the pinned
      release, the trigger equals a freshly recomputed deterministic
      transformation, and the attestation names the same backup, trigger,
      installed-snapshot manifest, identity evidence and pinned commit.
      Any mismatch is refused (exit 6); identity is never invented and the backup
      is never modified. Covered by positive and 9 negative tests (missing
      attestation, empty evidence, wrong account/title, changed file, changed
      manifest, changed trigger, wrong pinned hash, wrong attestation trigger).
  2. Delete success requires an explicit commit (P2). decideDelete() no
    longer returns success for a deleted file when commitResult is missing.
    Success = DeleteFile 0, explicit Commit 0, verified-absent read-back;
    missing commit is commit-missing (exit 1). Already-absent needs a verified
    read-back and no commit. 0x202 remains the canonical not-found code; bare
    0x2 and unknown codes fail closed. Tests cover missing/failed commit,
    failed read-back, unknown result and already-absent.
  3. Restore ownership follows the record. restoreTarget() deletes only
    entries recorded as add; a non-trigger replace is no longer silently
    removed. Added a full lifecycle test (fresh -> reinstall no-op -> update ->
    interrupted update -> restore preflight -> restore -> verify) and a
    malformed-legacy-receipt refusal test.

Test totals on this branch: 43 installer + 14 delete-result = 153 Node
assertions
, Python 8, Lua 13 + 12.

Second review round

Four follow-up findings are fixed on this head:

  1. Immutable original rollback baseline. Records are now split into an
    immutable ORIGINAL (pre-first-install state, written with create-if-absent)
    and per-attempt TRANSACTIONS. A reinstall pulls the live save, no-ops when
    every staged byte already matches, otherwise writes an update transaction;
    the original and its backup are never rewritten. First install refuses a
    post-install snapshot as the baseline and writes the original before the
    console transaction. Restore targets the pre-first-install state, matches
    the live save against every recorded state, refuses modified files and
    resumes after partial deletes. Legacy single receipts are copied into the
    original record, not replaced by a newer snapshot.
  2. Conservative legacy migration. New migrate-legacy: validates the
    original backup, verifies the installed files against the pinned release and
    the installed trigger against the deterministic retarget of the original
    trigger, requires independently verified identity (identity-bearing pull or
    local config/uids.json; never the truncated archive uid), and writes an
    immutable original record plus attestation. The original backup is read-only.
  3. Filesystem result encoding. The canonical FS not-found result is 0x202
    (module 1, description 2), which domain.cjs returns as canonicalResult
    and save-write-stage.cjs compares. A bare 0x2 is now unknown and fails
    closed; delete still cannot report success without commit 0 and a verified
    read-back/inventory.
  4. Legacy staging adoption. Unmarked staging directories from older
    versions are adopted only when they contain exactly the expected files,
    every launcher file matches the pinned release and the trigger parses as our
    retarget. Adoption writes the marker; it never deletes or overwrites
    unrelated content.

Review fixes in this revision

P1 — unsafe staging deletion

stagePackage() used to rm -rf a caller-supplied --out. Staging now:

  • lives only under <repo>/staging or the OS temp dir; the repo root, home, /, and unrelated existing directories are refused;
  • only replaces a directory that carries .artemis-staging.json for the same title;
  • builds in a marked sibling and promotes with rename(); a failed build discards only that sibling and leaves the previous payload and manifest byte-identical;
  • refuses symlinked targets and symlinked parents that escape the allowed roots.

P1 — rollback file ownership

Installs now write a local installation receipt before the console transaction:

  • full title id and 32-hex account uid, backup path + manifest SHA-256 + entry count + trigger SHA-256 + archive identity, pinned upstream commit;
  • every installed path with SHA-256, size and kind: add|replace, plus the original hash for the replaced trigger;
  • status (installing/installed/install-failed/restoring/restored/restore-failed) and restore progress (trigger, deleted[]);
  • stored at ~/.local/state/ns2-savekit/artemis-launcher/receipts/ (0700, files 0600), override ARTEMIS_STATE; never committed.

Restore pulls the live save first and refuses to delete a file whose live hash differs from the receipt, is missing, is an unowned launcher-named file, or whose trigger is not the expected one. Deletions update the receipt so an interrupted restore resumes safely.

Installs made before receipts use a conservative --adopt-legacy path: ownership must be established from pinned content (live files equal the pinned upstream bytes, live trigger equals the deterministic retarget of the backup). No receipt data is fabricated. Without that, destructive cleanup is refused.

P1 — backup integrity

tools/lib/artemis-backup.cjs validates before any console operation, for stage/install/verify/restore:

  • manifest parses; entries non-empty; partial false; failed empty; files/bytes consistent; paths safe and unique; every file is a regular non-symlink whose size and SHA-256 match; the trigger exists and matches;
  • install/verify/restore require the full 16-hex program id and 32-hex account uid and compare them with --title/--uid;
  • the truncated uid=… in the human-readable archive line is explicitly not identity.

save-pull-root.cjs now writes programId, uid, space, type, rank and index into new manifests.

P2 — delete failure propagation

save-delete-root.cjs now classifies the canonical DeleteFile result and feeds it to a pure decideDelete({fsResult, commitResult, stillPresent}):

  • skips the commit on a refused delete;
  • cannot return success without commit 0 and a verified-absent reopen;
  • reopens the file read-only and checks the root directory listing, so a failed open cannot be mistaken for absence;
  • distinct non-zero codes: 10 already absent, 11 refused/still present, 12 commit failed, 3/4 transport.

Restore treats 10 as already done and stops on anything else. Tests cover refused deletes, commit failures, a contradicting not-found, still-present files, and the refusal to claim success before verification.

Tests

  • tests/test_artemis_launcher.cjs: 47 passed (was 14) — staging paths/symlinks/interrupted builds, corrupt and legacy backups, identity, receipts, modified/missing/unexpected files, partial restore resume (including the delete/receipt and trigger/receipt crash windows), post-restore check, editor/ns2jb compatibility, repeated install over the exact pinned bytes, repeated restore, authorization, unsupported titles.
  • tests/test_delete_result.cjs: 14 passed.
  • Existing suites: 12 other Node files (103 assertions), Python 8, Lua 13 + 12, shell 3 — all pass. (132 Node assertions total across 14 files.)

Docs

  • fixed the transcript link (.log → committed .txt);
  • removed the “always option 8” claim — the kit menu is conditional, so the docs say to read the label and show both layouts;
  • documented receipt format/location, backup integrity, staging rules, conflicts, interrupted restore/resume, --adopt-legacy, and the removed inject2.lua/inject2-ran.txt test files with safe cleanup commands;
  • attribution to alula, Proggy/Tristan Grimmer and upstream's credits is unchanged.

Stage-2 investigation (2026-11-02)

Instead of retrying blindly, the acquisition failures were analyzed with the new
read-only tools/diagnose-ns2jb-acquisition.cjs:

  • 52 historical successes and 177 failures; the 2026-11-01 successes used the
    same engine hash (b989e4adafa2) and identical host files as the failures.
  • No SD card; save on internal storage (pin cost 13616); host is Wi-Fi-only,
    while upstream requires Ethernet.
  • A deterministic defect was proven: the local engine predates upstream
    81d5bb1's qualifyOverlap. Replaying the 30 recorded extent-present
    waves, the old allPending test rejects 30/30; the upstream predicate accepts
    27/30.
  • An isolated host with that fix advanced past extent requalification
    (qualifiedReadSize set) but then missed the canary. Three controlled
    attempts were made; the console was recovered to HOME after each and no save
    was written.

Details and the next experiments are in
docs/NS2JB-STAGE2-RELIABILITY.md. H0–H12 remain BLOCKED.

Hardware rollback validation — BLOCKED

The destructive rollback path has not been run on hardware. On 2026-11-02,
three controlled ns2jb acquisitions (one session per boot, nine-argument
V=START, no --keep-open, cold boot each time) failed at stage 2/4 before any
save operation:

  1. The bounded native leak contains no qualified, encodable link candidate
  2. The planned bucket period did not qualify on this candidate
  3. General-heap allocation changed while the cache was retained; stop overwrites and recover

No save write was attempted, the console was recovered to HOME after each
attempt, and the existing Artemis installation is intact. Consequently the
restore dry-run never ran on hardware, no destructive restore was authorized,
and the migrated-legacy hardware path was not exercised.

docs/ARTEMIS-ROLLBACK-HARDWARE.md records the sanitized H0–H12 matrix, the
failure messages, the offline coverage that protects these paths, and the exact
hardware steps still required. This PR does not claim hardware rollback
validation.

Hardware validation

The original 2026-11-01 run still stands: firmware 23.0.1, Seedsow Lullaby Demo 1.0.0, upstream b9e6de8; launcher screen, Hello World, HTTP /status + /eval, save upload/delete, close/relaunch and cold-boot persistence all passed. Backup 290 files / 4,665,778 B; install 7 files / 104,236 B; post-install pull 6 added, 1 replaced, 289 unchanged.

For this revision, no live RPC session could be obtained. Six fresh-boot acquisitions failed at ns2jb stage 2/4, each with a different timing miss:

  1. Native batch did not terminate; resources retained
  2. Clean alias did not qualify within its attempt limit
  3. The bounded native leak contains no qualified, encodable link
  4. General-heap allocation changed while the cache was retained
  5. Overwrite missed the qualified canary
  6. Native batch did not terminate; resources retained

A further attempt used NS2_TMP_FALLBACK=1 after a fresh power cycle; the tmp mount was above the fallback threshold, so that was not the cause. The 5-argument V=START was also tried and rejected by the console (Wrong number of arguments); the 9-argument form is required. No save operation was issued in any attempt, so nothing on the console changed; it was restored to HOME by a power cycle.

What was re-validated on hardware without a session (2026-11-01 22:32): the installed launcher boots, persists across close/relaunch, and its HTTP server answers /status (200), /eval (hello world, Lua 5.1, Ounce), a uniquely named save file written/read/deleted, and the protected-file and path-traversal refusals. All seven installed files hash identically to the staged payload (transcript section 9). A further /eval check at 22:50 confirmed the recovery-test files inject2.lua and inject2-ran.txt are absent from the live save while core.lua is present (transcript section 10).

The legacy migration path was then exercised on the real installation without RPC: an explicit-file HTTP snapshot (9 files, 114,443 bytes) plus the pre-install backup and the local account mapping produced an immutable original record (written to a test ARTEMIS_STATE directory; run the same migrate-legacy command without that override to persist it locally). The installed trigger verified as the deterministic retarget of the original trigger (d149d71b… -> ca472410…), the pre-install backup's manifest SHA-256 was unchanged before and after, and wrong-account/tampered-payload attempts were refused with exit 6. Receipt-driven restore, modified-file refusal and delete propagation remain offline-only until a live RPC session is available.

Compatibility implications

  • New installs need an identity-bearing backup; the current save-pull-root.cjs produces one. Legacy backups are still readable/stageable but cannot authorize a write or restore.
  • The existing Seedsow install predates receipts, so its rollback uses --adopt-legacy (content-checked) rather than a receipt.
  • Kit entry numbers move with conditional entries; the docs no longer promise “8”.
  • Existing launchers: entry 4 and the editor entry are untouched; editor/ns2jb files are not overwritten. Both launchers still share save9999.dat, so boot ownership remains exclusive by design.

Outstanding limitations

  • Receipt-driven restore/delete and modified-file refusal are not hardware-proven in this revision (no session). Nora 2 remains offline-only.
  • Startup minus update bypass still does not trigger on demo 1.0.0 (unchanged finding).
  • HTTP server remains unauthenticated on TCP 8080; trusted LAN only.

Not merged, not draft. Base is main.

Add tools/artemis-launcher.cjs, a separate installer from
inject-launcher.sh (which writes our own ns2jb payload). It:

- downloads upstream commit b9e6de8 at run time and verifies every file
  against a recorded sha256 (no third-party bytes in the repo);
- rebuilds save9999.dat from the game's own suspend save by retargeting
  it at save:/inject.iet, so upstream's older v1002 container is never
  written over a newer save;
- writes only six launcher files plus that trigger, and reports any
  filename conflict instead of overwriting it;
- requires a complete backup and an explicit --confirm, reuses
  save-write-stage for the single commit, then pulls the save again and
  verifies it against a deterministic manifest;
- supports restore of the trigger plus deletion of the added files.

Wire it into the kit as its own menu entry (option 8) for the two demos
upstream supports; the existing payload and editor options are unchanged.

tests/test_artemis_launcher.cjs covers title selection, the pinned file
set, upstream tamper detection, staging completeness, manifest
determinism, conflicts, missing backup/trigger, rollback list and exit
codes (14 tests).
Document the separate launcher and keep the three launchers distinct:
the Seedsow Lua Editor, our ns2jb payload (option 4) and alula's
upstream Artemis Launcher (option 8).

The report records the 2026-11-01 hardware run: firmware 23.0.1,
Seedsow Lullaby Demo 1.0.0, upstream b9e6de8. Backup 290 files /
4,665,778 B; install 7 files / 104,236 B, commit 0x0, 7/7 read-back
hashes; post-install pull = 6 added, 1 replaced, 289 unchanged.

What passed: launcher screen, Hello World slot with 'running on:
Ounce', HTTP /status and /eval, upload/read/delete of a uniquely named
save file, close+relaunch, full power cycle+relaunch. What did not:
the startup minus-button update bypass never triggered on demo 1.0.0,
so the fail-safe is documented as unverified and the backup is the
rollback. Nora 2 is implemented and unit-tested but not run on
hardware in this session.

Screenshots are the console's own frames, with no account or network
identifiers beyond what the firmware page shows. Upstream files are
downloaded at install time and not vendored; NOTICE records the
attribution, including the Proggy font and upstream's own credits.
The fixture only needs a uid-shaped string; it must not carry this
console's account id into the public repository.
Command/output evidence for the backup, the seven-file install, the
post-install pull (6 added, 1 replaced, 289 unchanged), the launcher
screen OCR, the HTTP /status and /eval replies, the save
upload/read/delete test, and the persistence and minus-button
observations. The console address and account uid are placeholders.
The menu positions are part of the documented workflow: 7 is Verify and
8 is Install alula Artemis Launcher for the two supported demos, with
Back at 9. Insert before the final Back entry, not before Verify.
The 2026-11-01 21:40-21:50 attempt never reached the installer: ns2jb
stage-2/4 FS-root acquisition failed twice on fresh boots (native batch
did not terminate; then the clean alias never qualified). No Nora save
was opened or written. Keeping this in the report is more useful than
silently listing Nora as untested.
Address the P1/P2 review findings without changing the exploit chain or
the upstream Lua.

Staging (P1): stagePackage no longer rm -rf's a caller-supplied --out.
Staging lives under <repo>/staging or the OS temp dir; the repo root,
home, / and unrelated existing directories are refused. A directory is
only replaced when it carries our per-title marker, the payload is built
in a marked sibling and promoted with rename(), and a failed build
leaves the previous payload and manifest untouched. Symlinked targets
and escaping parents are refused.

Rollback ownership (P1): installs now write a local receipt (full
account uid, backup manifest hash, upstream commit, per-file hashes and
add/replace kinds, progress) before the console transaction and update
it after each destructive step. Restore pulls the live save first and
refuses to delete a file whose bytes changed, is missing, or is an
unowned launcher-named file. Installs without a receipt take a
conservative --adopt-legacy path that establishes ownership from pinned
content only; no receipt data is invented.

Backup integrity (P1): tools/lib/artemis-backup.cjs rejects partial or
failed pulls, inconsistent counts, unsafe/duplicate paths, missing or
mismatched files, symlinked entries and a missing trigger, and requires
the full program id + 32-hex uid for install/verify/restore. The
truncated archive uid is explicitly not identity. save-pull-root now
writes programId/uid/space/type/rank/index into new manifests.

Delete propagation (P2): save-delete-root classifies the canonical
DeleteFile result, skips the commit on a refused delete, returns
distinct non-zero codes for failure/commit/not-found, and reopens the
file read-only to prove it is gone. Restore treats not-found as already
done and stops on anything else.

Tests: test_artemis_launcher.cjs grows from 14 to 29 cases (staging
paths, symlinks, interrupted builds, corrupt/legacy backups, receipts,
modified files, partial restore, post-restore check, compat); new
test_delete_result.cjs (7). All existing Node/Python/Lua/shell suites
still pass.
- point the hardware transcript at the committed .txt (the link said
  .log);
- stop claiming the alula entry is always option 8: the kit's menu is
  conditional, so the docs now say to read the label and show both the
  Artemis-save and non-Artemis layouts;
- document the installation receipt (location, 0600, fields, statuses),
  backup-integrity requirements (partial/failed/hash/identity; the
  truncated archive uid is not identity), staging rules, conflict and
  modified-file refusal, interrupted restore/resume, and the conservative
  --adopt-legacy path for pre-receipt installs;
- record that the 2026-11-01 inject2.lua/inject2-ran.txt test files were
  removed at 21:19 and give safe cleanup commands if a later experiment
  leaves them, without deleting anything automatically;
- state that the review fixes are offline-validated only (no live session
  was available) and list the remaining hardware checks.

The hardware report and upstream attribution are unchanged.
Three fresh boots failed stage 2/4 with three different allocator errors;
no save operation was issued, so the console state did not change. This
keeps the review-fix hardware status honest: offline validation only.
An interrupted restore can land after the trigger write or after a
delete but before the receipt records it. While the receipt status is
'restoring', preflight now accepts the backup trigger as a valid live
state and treats an absent add-file as already removed, instead of
refusing to resume. The trigger progress marker is repaired before the
restore continues. Modified files are still refused.

Adds two resume tests (31 installer tests total) and updates the counts.
A second install used to hit the filename-conflict rule because the
fresh backup already contained the launcher files. Those files are ours
only when their bytes equal the pinned release: in that case the plan
marks them replace (the writer still verifies live == backup), and a
file that differs is still reported as a conflict and skipped. Restore
of such an install returns to the previous install state, because
replace files are not deleted.

Adds the repeated-install test (32 installer tests total).
Re-validated the installed launcher on hardware without an RPC session:
/status, /eval (hello world, Lua 5.1, Ounce), a uniquely named save file
written/read/deleted, protected-file and traversal refusals, close+
relaunch, and a byte-for-byte hash match of all seven installed files
against the staged payload. This proves the installation survived the
review work; it does not exercise the new receipt/restore code.

Record all six failed stage-2/4 acquisitions and the NS2_TMP_FALLBACK
attempt whose threshold was not met.
Strengthen the P2 coverage rather than leaving commit/still-present as
constants:

- extract decideDelete({fsResult, commitResult, stillPresent}) and use it
  in save-delete-root. It cannot return success without commit 0 and a
  verified-absent reopen; a still-present file, a commit failure, a
  refused delete and a not-found contradiction all have distinct,
  non-zero outcomes;
- save-delete-root now also checks the root directory listing after the
  delete, so a failed open cannot be mistaken for absence;
- add a repeated-restore test: after a completed restore, preflight sees
  the backup trigger, all added files already gone, and an unrelated
  editor file left alone;
- counts: 33 installer tests, 11 delete-result tests (140 Node
  assertions across 14 files).
A /eval check on 2026-11-01 22:50 reports inject2.lua ABSENT and
inject2-ran.txt ABSENT while core.lua is present. This replaces the
historical delete-log claim with a current observation and is recorded
as transcript section 10.
Fix 1 (P1) preserve the original rollback baseline. Records are split into
an immutable ORIGINAL (pre-first-install state, written once with
create-if-absent) and per-attempt TRANSACTIONS. A reinstall pulls the live
save, no-ops if every staged byte already matches, otherwise writes an
update transaction; the original and its backup are never rewritten.
First install refuses a backup that already contains the pinned payload
(a post-install snapshot cannot become the baseline) and writes the
original before the console transaction so an interruption is recoverable.
Restore targets the pre-first-install state, matches the live save against
every recorded state, never deletes a modified file, and resumes after
partial deletes. Legacy single receipts are adopted as the original by
copying their data.

Fix 2 (P1) legacy migration. New 'migrate-legacy' command validates the
original backup, verifies installed files against the pinned release and
the installed trigger against the deterministic retarget of the original
trigger, requires independent identity evidence (identity-bearing pull or
local config; never the truncated archive uid), and writes an immutable
original record plus attestation. The original backup is only read.

Fix 3 (P2) filesystem results. The canonical FS 'not found' result is
0x202 (module 1, description 2), which is what domain.cjs returns as
canonicalResult and what save-write-stage compares; a bare 0x2 now fails
closed as an unknown result.

Fix 4 (P2) staging migration. Unmarked staging directories from older
versions are adopted only when they contain exactly the expected files,
every launcher file matches the pinned release and the trigger parses as
our retarget. Adoption writes the marker and never deletes unrelated
content.

Tests: 36 installer tests (up from 33) + 11 delete-result tests; updated
docs for the new record layout, migration procedure and FS code.
…mmit

P1 migrated restore. restoreFromOriginal() used loadBackup(requireIdentity:
true), which rejects the pre-identity backup that migrate-legacy deliberately
leaves untouched, so migration could succeed but restoration could not.
Add loadOriginalBackupForRestore(), a narrow validator:

- modern: backup must carry full title/account identity and pass every
  existing check;
- migrated: the legacy backup is accepted only when the immutable original
  record's manifest SHA-256 and trigger match, the record's title/account
  match, the installed payload equals the pinned release, the trigger equals
  a freshly recomputed deterministic transformation, and the migration
  attestation names the same backup, trigger, installed-snapshot manifest,
  identity evidence and pinned upstream revision.
Any mismatch is refused; identity is never invented and the backup is never
modified.

P2 delete contract. decideDelete() could return success for a deleted file
with commitResult=null when the read-back showed absence. A real deletion now
requires DeleteFile==0, an explicit Commit==0 and a verified-absent read-back;
a missing commit is 'commit-missing' (exit 1, not success). Already-absent
still needs a verified read-back and no commit. 0x202 remains the canonical
not-found code; bare 0x2 and unknown results fail closed.

Records: restoreTarget() now derives delete ownership from the original
record's add/replace kinds, so a non-trigger replace is not silently deleted.
Added installMode() and a full lifecycle test (fresh -> reinstall no-op ->
update -> interrupted update -> restore preflight -> restore -> verify),
migrated restore positive/negative tests, malformed legacy receipt refusal,
and delete tests for missing/failed commit, failed read-back and already
absent.

Tests: 43 installer + 14 delete-result (153 Node assertions on this branch),
Python 8, Lua 13+12. Docs updated.
restoreFromOriginal() and restoreLegacy() staged save9999.dat beside a
manifest whose local field is relative, but invoked save-write-stage.cjs
without a cwd. The writer resolves entry.local with fs.readFile against its
process directory, so restore from any directory other than the staging
directory could read the wrong file or fail.

- stageTriggerForRestore() writes the trigger + manifest;
- writeRestoreTrigger() invokes the writer with cwd=<staging>, and removes
  the staging directory in a finally block (success, failure and exception);
- both modern and legacy restore paths use it.

Tests exercise the real child-process resolution with a faithful writer
mock: the staged trigger is read with cwd=staging, and the same invocation
from an unrelated directory fails with ENOENT. Also covers modern/migrated/
legacy staging, missing and altered triggers, writer failure propagation,
cleanup, and backup/baseline preservation.

Adds a lightweight offline-tests GitHub workflow (Node, Python, Lua, shell).
Tests: 47 installer + 14 delete-result (157 Node assertions on this branch).
The payload tests use load(string), which is Lua 5.2+ syntax; the Ubuntu
lua5.1 package provides the 5.1 load(function) form, so CI failed while the
local Lua 5.4 runner passed. Install and run lua5.4 instead.
Three controlled ns2jb acquisitions (2026-11-02) failed at stage 2/4 with
three different allocator qualifications before any save operation, so the
destructive restore was never authorized or run. Add a sanitized test
matrix covering H0-H12, the exact failure messages, what is validated
offline, and what remains required on hardware. Explicitly do not claim
hardware rollback validation.
Investigate the repeated stage-2/4 failures instead of retrying blindly:

- add tools/diagnose-ns2jb-acquisition.cjs (read-only; classifies run
  artifacts) with tests;
- prove the local engine predates upstream 81d5bb1's qualifyOverlap fix:
  replaying the 30 recorded extent-present waves, the old allPending test
  rejects all 30 while the upstream predicate accepts 27;
- record three controlled 2026-11-02 attempts and an isolated overlap-fix
  experiment: extent requalification then passes (qualifiedReadSize set),
  but the run still misses the canary;
- document the environment checks (no SD card, internal save, Wi-Fi-only
  host vs upstream's Ethernet requirement) and the next experiments;
- keep H0-H12 BLOCKED; no claim of hardware rollback validation.

No exploit patch is added to this PR.
@dougchansan
dougchansan merged commit ac490c2 into main Nov 2, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant