Repository navigation
Install alula's Artemis Launcher as a separate, verified option - #8
Merged
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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, tagv1), 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.datbeside amanifest whose
localis relative, then invokedsave-write-stage.cjswithout acwd; the writer resolvesentry.localwithfs.readFileagainst its processdirectory, so restore could read the wrong file or fail.
stageTriggerForRestore()now writes the trigger + manifest, and
writeRestoreTrigger()invokes the writerwith
cwd=<staging>and removes the directory in afinally(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
restoreFromOriginal()requiredidentity-bearing backups, so a successful
migrate-legacycould not berestored. New
loadOriginalBackupForRestore()has two narrow paths: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).
decideDelete()nolonger returns success for a deleted file when
commitResultis missing.Success =
DeleteFile 0, explicitCommit 0, verified-absent read-back;missing commit is
commit-missing(exit 1). Already-absent needs a verifiedread-back and no commit.
0x202remains the canonical not-found code; bare0x2and unknown codes fail closed. Tests cover missing/failed commit,failed read-back, unknown result and already-absent.
restoreTarget()deletes onlyentries recorded as
add; a non-triggerreplaceis no longer silentlyremoved. 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:
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.
migrate-legacy: validates theoriginal 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 animmutable original record plus attestation. The original backup is read-only.
0x202(module 1, description 2), which
domain.cjsreturns ascanonicalResultand
save-write-stage.cjscompares. A bare0x2is now unknown and failsclosed; delete still cannot report success without commit
0and a verifiedread-back/inventory.
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 torm -rfa caller-supplied--out. Staging now:<repo>/stagingor the OS temp dir; the repo root, home,/, and unrelated existing directories are refused;.artemis-staging.jsonfor the same title;rename(); a failed build discards only that sibling and leaves the previous payload and manifest byte-identical;P1 — rollback file ownership
Installs now write a local installation receipt before the console transaction:
kind: add|replace, plus the original hash for the replaced trigger;installing/installed/install-failed/restoring/restored/restore-failed) and restore progress (trigger,deleted[]);~/.local/state/ns2-savekit/artemis-launcher/receipts/(0700, files 0600), overrideARTEMIS_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-legacypath: 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.cjsvalidates before any console operation, for stage/install/verify/restore:entriesnon-empty;partialfalse;failedempty;files/bytesconsistent; paths safe and unique; every file is a regular non-symlink whose size and SHA-256 match; the trigger exists and matches;--title/--uid;uid=…in the human-readable archive line is explicitly not identity.save-pull-root.cjsnow writesprogramId,uid,space,type,rankandindexinto new manifests.P2 — delete failure propagation
save-delete-root.cjsnow classifies the canonicalDeleteFileresult and feeds it to a puredecideDelete({fsResult, commitResult, stillPresent}):0and a verified-absent reopen;10already absent,11refused/still present,12commit failed,3/4transport.Restore treats
10as 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.Docs
.log→ committed.txt);--adopt-legacy, and the removedinject2.lua/inject2-ran.txttest files with safe cleanup commands;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:same engine hash (
b989e4adafa2) and identical host files as the failures.while upstream requires Ethernet.
81d5bb1'squalifyOverlap. Replaying the 30 recordedextent-presentwaves, the old
allPendingtest rejects 30/30; the upstream predicate accepts27/30.
(
qualifiedReadSizeset) but then missed the canary. Three controlledattempts 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 anysave operation:
The bounded native leak contains no qualified, encodable link candidateThe planned bucket period did not qualify on this candidateGeneral-heap allocation changed while the cache was retained; stop overwrites and recoverNo 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.mdrecords the sanitized H0–H12 matrix, thefailure 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:
Native batch did not terminate; resources retainedClean alias did not qualify within its attempt limitThe bounded native leak contains no qualified, encodable linkGeneral-heap allocation changed while the cache was retainedOverwrite missed the qualified canaryNative batch did not terminate; resources retainedA further attempt used
NS2_TMP_FALLBACK=1after a fresh power cycle; the tmp mount was above the fallback threshold, so that was not the cause. The 5-argumentV=STARTwas 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/evalcheck at 22:50 confirmed the recovery-test filesinject2.luaandinject2-ran.txtare absent from the live save whilecore.luais 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_STATEdirectory; run the samemigrate-legacycommand 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
save-pull-root.cjsproduces one. Legacy backups are still readable/stageable but cannot authorize a write or restore.--adopt-legacy(content-checked) rather than a receipt.save9999.dat, so boot ownership remains exclusive by design.Outstanding limitations
Not merged, not draft. Base is
main.