Repository navigation
Build Scripts And Host Tools
Source: NebulaOS-firmware at b95b156. 21 build scripts, 62 host tools.
scripts/build/ produces the image. tools/ operates on a finished build or on a printer and never changes what the image contains.
That distinction is load-bearing here rather than tidy-minded. tools/product-inputs.py exists to decide whether a change invalidates an already-built image, and it classifies by a deny-list: every path is a product input unless it is listed as host-side, so a new directory errs towards rebuild rather than towards silently reusing a stale image. Listing all of these together alphabetically would blur the one line the repository works hardest to keep sharp.
The script's own header, kept whole, and a usage line only where the header states one. These authors write the invocation into the comment, so it is quoted rather than reconstructed; a script whose header shows no usage gets no invented one.
Python files with no entry point are marked as library modules. That is the difference between we could not find how to run it and it is not run - most of tools/hardware/ is the second, and a reader hunting for a command line there would be hunting for something that does not exist.
Nothing here is hardware-verified, and nothing here was run. This agent does not build firmware - that is the foreground session's launcher - so every statement is read from the script.
The numbered scripts run in order; the unnumbered ones are gates, packaging and supply-chain steps invoked around them.
Clone/download every third-party source this build needs, pinned to the exact refs this project used. See FIRMWARE.md for why each one was chosen.
2026-08-07 baseline-repair mission: pins now live in one authoritative file, manifests/dependencies.conf, sourced below - not scattered as hardcoded values across this script. See that file's own header for the reasoning and the format.
Verify this project's kernel-source changes (touch DT wiring, the new display panel driver, the new Bluetooth H5 Broadcom vendor extension, WiFi/BT/display Kconfig, the ported NS2009 driver, the binder.h build fix) are present in the checked-out kernel tree.
FIRMWARE.md sec 39: these changes used to be applied here at build time from
patches/x2000_kernel_6.6-openke.patch. They're now real, reviewable commits on the openke
branch of a genuine fork (github.com/NebulaOS-KE/NebulaOS-kernel, renamed 2026-08-14 from
NebulaOS-KE/NebulaOS - forked from the original upstream Llixuma/ingenic-linux-
kernel6.6-x2000-v1.0-20250221) - 00-fetch-vendor-sources.sh checks out that branch directly, so
there's nothing left to apply here. This script stays as stage "01" (kept numbered/in-sequence
on purpose, so existing docs/muscle-memory still work) purely as a sanity check that the fork's
content actually landed correctly.
Wire up Buildroot's configuration: the real, already-verified .config this project produced (artifacts/buildroot-halley5-v30-image/buildroot.config), the kernel config fragment (FIRMWARE.md sec 10-12's additions on top of the vendor x2000_halley5_v30_linux defconfig), the LINUX_OVERRIDE_SRCDIR pointer, and this repo's own hand-written overlay content.
Reusing the exact verified .config here (rather than re-deriving every BR2_PACKAGE_* option
from scratch) is deliberate: several of those options hit a real class of bug this session
where a naive echo "X=y" >> .config landed on a duplicate line that a later make olddefconfig pass then lost to the file's other, unedited copy of the same symbol (see
FIRMWARE.md sec 14) - copying the known-good, already-normalized file sidesteps that whole
class of mistake rather than risking reintroducing it.
IMPORTANT: always re-run this script after ANY change to scripts/build/overlay/ or the kernel fragment/buildroot.config artifacts, and before 03/05 - a real bug this session (FIRMWARE.md sec 24): editing the git-tracked overlay template alone does nothing, since Buildroot only ever reads from vendor/buildroot-x2000/board/halley5-nebulaos-overlay/ (gitignored), which this script is what syncs the template into. A rebuild after only touching the template, without re- running this first, silently uses whatever this script last copied there.
IMPORTANT: renaming or deleting a file from scripts/build/overlay/ does NOT remove it from a real build - a genuinely separate bug from the one above, found for real deleting S01tmpfs- datastore in favor of S01persistent-datastore (the GuppyScreen persistent-storage work): this script re-syncs the overlay TEMPLATE cleanly every time (the rm -rf above), but Buildroot's own output/target/ staging directory only ever gets files ADDED or OVERWRITTEN by the rootfs- overlay step, never removed, and accumulates across every build since the last full clean. Both S01tmpfs-datastore (deleted from the overlay days earlier) and S01persistent-datastore (its replacement) ended up in the same built image at once - actively dangerous here specifically, since the stale script re-mounted tmpfs right after the new one finished setting up real persistent storage, silently undoing it. Buildroot has no cheap way to selectively re-sync output/target/ (its package install stamps live elsewhere and do not get invalidated by removing target files directly, so deleting output/target/ alone leaves it mostly empty instead of clean) - a renamed or deleted overlay file must also be removed by hand from vendor/buildroot-x2000/output/target/ before the next 05-final-build.sh, or the build needs a full clean. 06-verify.sh also cannot catch this on its own: it only inspects rootfs.ext2, and both rootfs.ext2 and rootfs.squashfs are built from this same stale output/target/, so a leftover file is wrong in both images identically - checking the actual packaged rootfs.squashfs directly (e.g. via unsquashfs) is the only real way to confirm a removed file is genuinely gone.
Phase 11 (2026-08-15, unified-build-environment migration): this script used to wrap every step
in docker run pellcorp/k1-bash-build ..., crossing a container boundary that meant paths
differed between the host view (/repo/vendor/...) and the container's own view (/repo/...
mounted from the host root). Now that the whole 00-06 pipeline already runs inside ONE unified
nebulaos-build container (or directly on a host that has build-env/'s tools installed), there
is no second boundary to cross - every path below is just the real filesystem path, and the
root/non-root chown dance that used to follow every docker --user root call is gone because
there's no longer a second UID entering the picture. The per-container --label openke-build- pid=$$ / orphan-container-cleanup logic is gone for the same reason: nothing here spawns a
container of its own to leak.
Main kernel + base rootfs build - touch, display, WiFi, Bluetooth, camera- kernel-side, and Core SoC infra (RNG/MMC/I2C/DMA) all come from this one pass, since they're all just kernel config + device-tree, nothing cross-compiled outside Buildroot's own package builds.
IMPORTANT: never move/rename this checkout mid-build. A real bug this session (FIRMWARE.md sec
14): several of Buildroot's own host tools (e.g. glib-compile-schemas) get built with their
RUNPATH hardcoded to whatever absolute path buildroot-x2000/ was actually AT the first time
they were built - a later stage run against a relocated/renamed checkout will fail with "cannot
open shared object file" even though nothing about the build itself changed. Pick a checkout
location and never move it mid-build. (Phase 11 note, 2026-08-15: before the unified-build-
environment migration this was framed as "always mount at /src inside the container" - same
underlying constraint, just expressed as a container-mount-path requirement back when a
container boundary existed here. The real constraint was always "one consistent absolute path
for the life of one build," not the specific string /src - flattening away the container
boundary means REPO_ROOT below now legitimately differs between a Phase 9-era build (container
path) and this build (real host path), which is exactly the BUILD_PATH_EMBEDDING-class artifact
difference the Phase 11 comparison tooling expects and accounts for, not a regression.)
IMPORTANT: this always force-cleans and rebuilds the kernel from scratch (make linux-dirclean
before make), rather than relying on plain make alone. A real, previously-silent bug this
session (FIRMWARE.md sec 24): LINUX_OVERRIDE_SRCDIR points Buildroot at this project's own
kernel checkout, but Buildroot's own .stamp_built/.stamp_rsynced files don't detect source
changes there - a plain make after editing a DTS/Kconfig file will silently keep using the
last-built kernel binary, reporting success while shipping stale, unpatched code. The dirclean
costs a slower, full kernel rebuild every time this script runs, which is a real, deliberate
tradeoff in favor of correctness - re-run 02-configure-buildroot.sh first if
scripts/build/overlay/ or the config artifacts changed, since this script does not re-sync
those itself.
IMPORTANT: also force-cleans wpa_supplicant specifically, for the same reason but a different,
more general cause (FIRMWARE.md sec 24/27): Buildroot does not automatically rebuild an
already-built package just because its own Kconfig options
(BR2_PACKAGE_WPA_SUPPLICANT_CTRL_IFACE/ _CLI in this case) changed after it was first built -
only source changes for override-srcdir packages get this same treatment, and even that needs
an explicit dirclean as above. This bit us for real: wpa_supplicant was already built once with
CTRL_IFACE/CLI disabled, the .config was fixed to enable them, and a later plain make
silently kept shipping the old, disabled build - passing every check except 06-verify.sh's
explicit wpa_cli presence check. If any other package's Kconfig options get changed after
it's already been built once, the same <pkg>-dirclean treatment is needed - this project has
hit this exact class of bug twice now, for two different packages, for the same underlying
reason.
IMPORTANT: also force-reinstalls gcc-final specifically, a third instance of the same
underlying bug (found 2026-07-23, real-hardware testing): this project's base defconfig has
always had BR2_INSTALL_LIBSTDCPP=y, but the very first build's gcc-final
.stamp_target_installed predates whatever point that became load-bearing (greenlet, Klipper's
own C extension dependency, needs libstdc++.so.6 at runtime) - every build since silently kept
reusing that stamp, so gcc-final's own INSTALL_TARGET_CMDS (the step that actually copies
libstdc++.so* into the rootfs) never ran again, even though libstdc++ was genuinely compiled
and sitting in the toolchain's own sysroot the whole time. Symptom: Klipper (and anything else
linking a C++ extension) fails ImportError: libstdc++.so.6: cannot open shared object file,
with no log line at all (dies before its own log file opens). gcc-final-reinstall re-runs
just the install steps (cheap - the compiler itself doesn't need rebuilding), unlike gcc- final-dirclean which would force a full toolchain rebuild for no reason.
Phase 11 (2026-08-15): flattened out of pellcorp/k1-bash-build - see 02-configure- buildroot.sh's own Phase 11 note for why (one unified container now, no per-stage container boundary, no per-stage apt-get).
Cross-compile the handful of things that need the Buildroot-built toolchain directly (Klipper's chelper C extension, Moonraker's streaming-form-data C extension, ustreamer itself), download the pure-Python wheels with no Buildroot package, and assemble the full app-stack overlay - Klipper/Moonraker source, Mainsail's static build, and everything above - on top of the hand- written files stage 2 already put in place.
Must run after 03-build-kernel-and-rootfs.sh - needs the Buildroot toolchain and target Python headers to already be built.
Final rootfs build - bakes everything stage 4 assembled in the overlay (Klipper, Moonraker, ustreamer, Mainsail, the cross-compiled extras) into the actual rootfs.ext2/rootfs.squashfs. Assumes 02 and 03 already ran in this same session (02 for any overlay/config changes, 03 for any kernel source changes with its own forced dirclean) - this script does not re-sync the overlay or force a kernel rebuild itself, so a change to either that hasn't gone through 02/03 first will silently not appear here.
Confirm every piece actually landed in the built rootfs.ext2, the same way this whole project verified things without real hardware: debugfs presence checks plus readelf/file architecture checks on anything compiled. This is NOT a substitute for the real boot test (needs the user present) - it only proves the image contains what it's supposed to.
sh scripts/build/assert-baseline-config.sh <pre-build|post-build|candidate-post-build>Phase 2 baseline assertions (baseline-canonicalization-and-z_compensate- deployment mission, 2026-08-06/07). Fails loudly and immediately if any qualified-baseline feature is missing - "do not accept script exit status as proof" (the mission's own words): apply-qualified-baseline.sh exiting 0 only means each variant script itself didn't error, not that the resulting build actually contains what it's supposed to. This script checks the real, resolved artifacts instead.
Three modes: sh scripts/build/assert-baseline-config.sh pre-build Run AFTER apply-qualified- baseline.sh, BEFORE 02/03/05 - checks the vendor kernel tree's source-level state (Kconfig symbols exist, DTS nodes present) so a missing patch is caught before spending build time. This is what "SOURCE_VERIFIED" means (see Phase 1 overnight closure mission, Mission H): every accepted variant's source-level change is actually present, nothing about whether it compiled or matches any prior hardware-qualified state. sh scripts/build/assert-baseline-config.sh post- build Run AFTER 05-final-build.sh to REPRODUCE an ALREADY hardware-qualified baseline byte/semantically-exactly (used by build-qualified- baseline.sh's own wrapper, and for regression-testing a change against a frozen, already-qualified reference). Any difference from QUALIFIED_BASELINE_TAG is a FAILURE here, by design - this mode's entire purpose is proving nothing drifted from what was already qualified. Do NOT use this mode to evaluate a candidate that legitimately contains new, not-yet-hardware-qualified source changes - it will always and correctly report FAIL for those, since "identical to the last qualified baseline" is precisely what a real candidate is not yet. sh scripts/build/assert-baseline-config.sh candidate-post- build Added by the Phase 1 overnight closure mission (Mission H): the same resolved-artifact Kconfig/DTS assertions as post-build (so a real candidate is still proven to actually contain everything every accepted variant is supposed to produce - this is "BUILD_VERIFIED"), but the QUALIFIED_BASELINE_TAG comparison is reported as an INFORMATIONAL diff, never a gate - a legitimate candidate is EXPECTED to differ from the last hardware-qualified baseline (that's what makes it a candidate). Nothing in this repository calls a build "hardware-qualified" merely because this mode passes; promoting QUALIFIED_BASELINE_TAG to a new value remains a separate, deliberate, manually-reviewed edit to manifests/dependencies.conf made only after real hardware qualification - this mode never touches that file.
Usage: sh scripts/build/assert-baseline-config.sh <pre-build|post-build|candidate-post-build>
sh scripts/build/baseline-difference-gate.shPhase 5 baseline-difference gate. Compares the just-built package against the pinned qualified baseline package and hard-stops on any unexplained difference. Allowed differences are exactly: the guppyscreen binary/hash (NOT guppybeep - see the Allowed differences section below), z_compensate.py, explicit build/version metadata, and associated tests/manifests.
Scope of what is actually GATED, stated plainly because the wording used to overstate it: the pass/fail signal in this script is the tracked config/DTS comparison. The "Allowed differences" section is prose echoed into baseline-difference.txt for a human reader - it documents expectations, it does not enforce them.
Requires unsquashfs (squashfs-tools) on the host to compare rootfs contents; falls back to a kernel.config/DTS-only comparison with a loud warning if unavailable.
Usage: sh scripts/build/baseline-difference-gate.sh Run AFTER 05-final-build.sh.
sh scripts/build/build-qualified-baseline.shRepository + Canonical Baseline Repair mission, Phase 7 (2026-08-07): the single documented command that reproduces the current qualified NebulaOS production baseline (tag nebulaos-wifi- camera-irq-fix-2026-08-04, plus the non-tagged accepted work since it - GuppyScreen/z_compensate, see docs/NEBULAOS_QUALIFIED_BASELINE_VARIANT_AUDIT.md) from nothing but this checkout, the pinned manifest, and the network.
Deliberately the smallest thing that fits this repo's existing conventions
- it just sequences the pipeline stages already used individually all
along, in the order they must run, with the baseline-composition and
assertion steps at the points that actually matter:
00-fetch-vendor-sources.sh fetch every pinned source (fails loudly on
any unpushed/unresolvable pin - see
manifests/dependencies.conf)
assert-baseline-config.sh pre-build fail fast, before spending build
time, if the pinned kernel checkout is
modified or lacks an accepted change (every
kernel change is a NebulaOS-kernel commit;
nothing is applied at build time)
01 -> 06 the existing numbered pipeline, unchanged
assert-baseline-config.sh post-build prove the resolved artifact
actually contains the accepted changes
This does NOT reuse any existing vendor/, build-work/, or artifacts/ state - run it against a genuinely fresh clone (a dirty/reused checkout defeats the entire point of a clean-room build; 00-fetch-vendor-sources.sh will happily reuse an already-present vendor/ directory if one exists, which is convenient for iteration but not what this script is for).
Usage: sh scripts/build/build-qualified-baseline.sh
Exits non-zero if any pin fails to resolve, either assertion fails, or any build stage fails.
Supply-chain closure artifacts for a configured NebulaOS Buildroot tree.
Mission section 26. Produces, into artifacts/sbom/:
show-info.json Buildroot's own package inventory, verbatim
sbom.cyclonedx.json CycloneDX 1.5 SBOM
source-manifest.txt one line per downloaded artifact, with its hash
packages.txt name, version, licence - human-readable
Buildroot 2025.02.18 emits make show-info (JSON) and make legal-info, but has no CycloneDX
generator, so the conversion lives here.
make show-info needs only a CONFIGURED tree - it does not build or download anything, so this
is cheap and safe to run at any point after stage 02. make legal-info is NOT run here: it
downloads the full source of every package, which is a deliberate, separate, network-heavy
step.
Phase 1.5 closure mission (2026-08-19). Semantic (not byte-identical) comparison of a resolved build artifact against the qualified baseline tag, for the narrow, named, PROVEN non-behavioral fields below only. Everything else in the file is still compared byte-for-byte - this is deliberately not a general "ignore version strings" or "ignore paths" filter, which would weaken the safety gate this exists to be. See _project/missions/2026-08-phase1.5-persistent- namespace.md's closure section for the full root-cause investigation behind each entry below.
kernel.config: CONFIG_CC_VERSION_TEXT - the cross-compiler's own self-identification banner string, baked in at TOOLCHAIN BUILD time from Buildroot's BR2_VERSION_FULL. Proven non- behavioral: it does not affect what code the compiler generates, and /etc/os-release's PRETTY_NAME (Buildroot's Makefile line ~766) uses the separate, always-static $(BR2_VERSION) directly, confirming this field is purely cosmetic self-description, not a real version gate anything else depends on. CONFIG_EXTRA_FIRMWARE_DIR - the build CONTAINER's internal mount path for firmware source files (build.sh's own $NEBULAOS_REPO_ROOT, changed 2026-08-15 by the unrelated Final Closure mission specifically to stop host-path leakage into artifacts - this field is exactly that leakage, just relocated to a fixed, still-non-behavioral internal path). Only read during the kernel BUILD to locate firmware blobs to embed; the actual embedded firmware content is identical either way (both build from the same pinned, hash-verified blobs
- see 00-fetch-vendor- sources.sh) and this string does not appear anywhere in the running kernel's actual behavior.
buildroot.config:
The "# Buildroot <version> Configuration" auto-generated header comment
- same BR2_VERSION_FULL string as CONFIG_CC_VERSION_TEXT above, same
reasoning, same conclusion.
BR2_HOST_GCC_AT_LEAST_* - Buildroot's own auto-detection of the BUILD
CONTAINER's installed host gcc version (Config.in probes the host
compiler directly), which is newer in the current unified build image
than whatever produced the baseline. Checked, not assumed: grepped the
entire vendored Buildroot tree for every real Config.in reference to
BR2_HOST_GCC_AT_LEAST_10/11 - the ONLY package in the whole tree that
conditions anything on them is nodejs's own Config.in, and
BR2_PACKAGE_NODEJS is not set anywhere in this project's
buildroot.config (confirmed directly, not assumed) - so these two
extra flags being set changes the resolved configuration of zero
packages this project actually builds.
All of the above trace to ONE underlying event, not three unrelated ones: this qualified
baseline tag is dated 2026-08-14, one day BEFORE the unrelated Final Closure mission
(2026-08-15) promoted the current unified build image (ghcr.io/nebulaos-ke/nebulaos-build) to
canonical and retired the previous pellcorp/k1-bash-build + guppydev container pair. The
baseline's own build-manifest.txt (read directly via git show nebulaos-canonical- baseline-2026-08-14-prtouch-qualified:artifacts/.../ build-manifest.txt) has no
build_image_repo/build_image_digest fields at all - that tracking was added BY the Final
Closure mission - confirming the baseline predates the current pinned build image and was never
re-captured after the switch. Classification: BASELINE_CAPTURE_DEFECT at the root (a
legitimate, already-completed, unrelated infrastructure migration whose baseline was never
refreshed), producing symptoms that are each individually proven EXPECTED_AND_NON_BEHAVIORAL
above.
Both root causes trace to the SAME underlying, already-existing, already- intentional fact,
independently confirmed from Buildroot's own source
(vendor/buildroot-x2000/support/scripts/setlocalversion, borrowed unmodified from the Linux
kernel's identical convention): BR2_VERSION_FULL only falls back to the clean, static
"2023.11.1" when Buildroot's own setlocalversion script produces empty output, which requires
EITHER no .git directory in the checkout, OR (with .git present) both a reachable git tag AND a
fully clean tree. The pinned fork (https://github.com/lone0/buildroot-x2000.git) has zero tags
anywhere in its history (git tag | wc -l = 0, checked directly) - so this string can NEVER be
a clean tag name for ANY build from this pin, only ever "-g[-dirty]". The tree is
also deterministically "dirty" on every single build for a second, independently-confirmed,
already- documented reason: 02-configure-buildroot.sh intentionally overwrites package/python-
matplotlib/python-matplotlib.mk with a tracked NebulaOS fix for a real cross-compilation bug
(numpy build-isolation poisoning under _PYTHON_HOST_PLATFORM overrides) on every build -
already allowlisted as "expected every time, not accidental drift" in this same script's own
check_vendor_pin call for buildroot-x2000, just never connected to this separate baseline
comparison until now.
Usage: baseline_config_semantic_diff Returns 0 if semantically identical (after stripping only the field(s) named for that file above), 1 and prints a diff otherwise.
Klipper's compiled helper (klippy/chelper -> c_helper.so), cross-compiled. The ONE implementation of this recipe (G72, 2026-10-17): stage 04 builds the image's copy with it, export-sdk.sh puts it into the SDK, and the package index CI (NebulaOS-KE/nebulaos-packages) runs the SDK's copy to build the helpers for Klipper updates - so a helper from the index is built exactly like the one the image ships.
build-chelper.sh <klippy/chelper dir> []
e.g. /path/host/bin/mipsel-buildroot-linux-gnu- (gcc and strip are gcc, strip). Writes c_helper.so in the chelper directory; with , the unstripped library is copied there first.
Upstream builds this library from klippy/chelper/init.py's own check_build_c_library(),
with a hardcoded gcc invocation. So do exactly that, with the cross compiler substituted for
gcc - and read SOURCE_FILES and COMPILE_ARGS OUT OF THAT FILE rather than restating them
here. That is the whole point: a hardcoded list in the firmware would silently miss the next
file upstream adds, and upstream has already added one (steppersync.c, flagged as unknown 5 in
the analysis mission's section 27). If the list ever stops parsing, the build fails loudly
instead of linking a library with a missing translation unit.
SSE_FLAGS is deliberately not passed: upstream only adds it when check_gcc_option() says the compiler accepts it, and a mipsel cross compiler does not. Everything else - the -flto -fwhole- program link, -fPIC, -O2 - is byte-for-byte upstream's own recipe.
Production optimization mission, Phase 6 (2026-07-30): the library is stripped with --strip- unneeded - Buildroot's own TARGET_FINALIZE strip pass never reaches it (it is not a Buildroot package).
Needs a host python3 (for the ast read of the recipe).
check-python-dist-closure.py <site-packages-dir> <target-python-x.y>Every installed distribution's hard requirements are installed too.
Usage: check-python-dist-closure.py <target-python-x.y>
Reads each *.dist-info/METADATA under (a copy extracted from the built
image - only the METADATA files are needed) and checks every Requires-Dist that applies on the
target. Requirements behind an extra are optional and are not checked.
Why this exists: Buildroot packages a Python distribution without installing its declared
dependencies, so nothing in the build notices a missing one. The Buildroot 2025.02.18 image
shipped streaming-form-data 1.19.1, which declares smart-open>=7.0.5 and imports it at module
load, without smart_open. Moonraker died at import on the printer. The ELF and ABI-tag gates
passed, because the missing piece was a pure-Python module that was simply not there. Importing
on the build host is not possible (the image is MIPS), but the metadata says exactly what each
distribution needs.
Output: one OK/UNMET line per finding, then UNMET_REQUIREMENTS=. Exit status: 0 when n == 0, 1 otherwise, 2 on a usage or environment error.
Phase 1.5 closure mission (2026-08-19). Extracted from 05-final-build.sh's inline git_field() so tests/git-provenance-tests.sh can exercise the exact function a real build uses, not a second, parallel reimplementation (same convention as scripts/build/lib/validate-frontend- controls.sh).
Prints two manifest lines for a given git checkout: = and _dirty=<yes|no|unknown>. Every one of git_provenance_field's callers relies on BOTH lines always being printed, in that order, even on failure - artifact_sha256() and the rest of build- manifest.txt's generator treat a partial/missing line as a manifest-format bug, not a soft failure.
Usage: git_provenance_field
NebulaOS auto-updates-camera-complete mission (2026-07-28, see docs/NEBULAOS_MOONRAKER_UPDATE_AND_CAMERA_ANALYSIS.md). Shared by scripts/build/04-cross- compile-app-stack.sh (real build - packages vendor/klipper and vendor/moonraker) and tests/factory-seed-git-tests.sh (offline fixture repos) - kept in its own file specifically so the tests exercise this exact function, not a second/parallel reimplementation of its validation rules.
PRIOR APPROACH (removed): each vendor checkout was flattened into a single synthetic orphan
commit ("NebulaOS factory seed snapshot of @ <true_commit>") before bundling, because
a plain git bundle create of vendor/klipper's shallow clone (1-2 commits deep, 00-fetch-
vendor-sources.sh's clone_pinned) produces a bundle that git bundle verify reports as fine
but a real git clone of rejects with "Failed to traverse parents of commit ..." / "remote did
not send all necessary objects" (confirmed again against git 2.55.0 - a genuine, still-present
git limitation, not a syntax mistake). That synthetic commit had no shared ancestry with the
real coreflake1/NebulaOS-klipper or Arksine/moonraker history on GitHub, which made Moonraker's
own git merge-base --is-ancestor HEAD origin/<branch> check permanently fail (return code 1)
on every freshly-seeded device - HEAD could never be an ancestor of a real remote branch it
shared no history with. This set diverged=true -> has_recoverable_errors()=true ->
is_valid()=false (vendor/moonraker/moonraker/components/update_manager/ git_deploy.py)
permanently, blocking every real Klipper/Moonraker update.
FIX: stop bundling/flattening entirely. Archive each vendor checkout's REAL .git directory
(shallow boundary, real branch, real commits) plus its working tree as a plain tar file, with
the local branch renamed to match Moonraker's hardcoded reserved-slot expectation ("master" -
see BASE_CONFIG in update_manager/common.py, not configurable) and origin rewritten to the real
public remote. On-device seeding (S04) then extracts the tar directly into place - no git clone at all, which is also strictly cheaper on this 208MB device than the clone-from-bundle
step it replaces (plain tar extraction does no object repacking).
chelper_enforce_mtime() lives in the same overlay file the device itself uses at boot (/etc/nebulaos-chelper-preflight.sh), sourced here rather than reimplemented, so the build-time and boot-time definitions of the invariant cannot drift apart - the same reasoning that put make_seed_archive() itself in a shared file rather than duplicating it between the build and its tests. Only sourced if not already defined, so a caller that has already sourced it (04-cross-compile-app-stack.sh does) is unaffected.
G50 (2026-10-17): the image's Python ABI fingerprint - the ONE place it is derived (architecture review 2026-10-17). nebulaos-post-build.sh writes it to /usr/share/nebulaos/python-abi and builds /etc/pip.conf from it; the SDK export copies that file; the NebulaOS package index serves wheels under // and checks every wheel against it.
A wheel tag (cp312-cp312-linux_mips) says nothing about endianness, FP mode, NaN encoding, glibc or the libraries a .so links, so the fingerprint does:
python, soabi, platform_tag from the target's _sysconfigdata
glibc from the target libc's own banner
gcc, arch, abi, fp32_mode, from the build's .config
nan, endian
linkable the image's sonames a wheel may link
(LINKABLE below, those present)
id sha256 over all of the above, 16 hex
Sourced. python_abi_fingerprint BR2_CONFIG TARGET_DIR STAGING_DIR prints the file (the target holds the sysconfig only as .pyc; staging has the .py).
Libraries a published wheel may link against besides glibc itself. Adding one here changes the id (a new index path) - deliberately.
NebulaOS mainline print-controls mission (2026-07-29, see docs/NEBULAOS_FRONTEND_PRINT_CONTROLS.md). Shared by scripts/build/04-cross-compile-app- stack.sh (real build, validates the tracked overlay source) and tests/nebulaos-frontend- controls-validation- tests.sh (offline fixture configs) - kept in its own file specifically so the tests exercise this exact function, not a second/parallel reimplementation of its validation rules (same convention as scripts/build/lib/make-seed-archive.sh).
Every function here prints its own OK/FATAL lines and returns 0/1 - none of them call exit, so callers (a real build script, or a test harness that wants to keep going after a failure) decide what a failure means.
Resolves every [include ...] starting from a given config file, relative to a config source directory, into a single flat file - this project's configs only ever use plain literal filenames in includes (one level of GuppyScreen/ nesting), never glob patterns, so this is a deliberately simple closure builder, not a general Klipper config parser.
Phase 1.5 persistent-namespace mission (2026-08): printer.cfg now also uses ABSOLUTE-path includes for the immutable, slot-owned config under /etc/nebulaos/klipper/ - a real Klipper feature (configfile.py's os.path.join(dirname, include_spec) returns include_spec unchanged when it's absolute), not a workaround. At real device boot time that resolves against the live rootfs. At BUILD time there is no live rootfs yet - only the tracked overlay source tree this same build is assembling one from - so an absolute include starting with /etc/nebulaos/ is resolved against the 4th (optional) <overlay_root> argument instead of <config_src_dir>, mirroring exactly how scripts/build/04-cross-compile-app-stack.sh's own overlay-copy step will place it at runtime (scripts/build/overlay/X -> /X in the image, 1:1). Any other absolute path is left unresolved and reported FATAL - this project's build never expects a config to reach outside its own overlay tree.
Usage: frontend_controls_resolve_closure <config_src_dir> <entry_file> <closure_out_file> [overlay_root]
Buildroot BR2_ROOTFS_POST_BUILD_SCRIPT for NebulaOS.
Runs with $1 = TARGET_DIR, after TARGET_FINALIZE_HOOKS and after the rootfs overlay copy (Buildroot's own Makefile order: hooks, then overlays, then this script). That ordering is why the root password is fixed here rather than in the overlay or in BR2_TARGET_GENERIC_ROOT_PASSWD.
WHY NOT BR2_TARGET_GENERIC_ROOT_PASSWD. Buildroot only treats that value as an already-hashed
password when it matches $5 and
friends as variables before that match ever runs. Measured: the hash
$5$NebulaOSrootsalt$loBE3...
arrives at the match as ebulaOSrootsaltoBE3..., fails it, and falls through to mkpasswd,
which salts randomly - rewriting /etc/shadow on every build. Doubling or backslash-escaping the
dollars does not survive either, because $(call qstrip,...) expands the value a second time.
WHY NOT THE OVERLAY. /etc/shadow is mode 0600 in the image. Git records only 0644 or 0755, so shipping it through the overlay would publish a world-readable shadow file. That is a worse defect than the one being fixed.
So: edit the line in place, here, preserving the file's existing mode and every other account. Only the salt stops being random.
sh scripts/build/package-deployment.sh [package-root]Phase 6 packaging (baseline-canonicalization-and-z_compensate-deployment mission). Assembles a complete, self-contained deployment package: images, configs, decompiled DTB, manifest, checksums, baseline-difference report, and deployment/rollback instructions. Does NOT flash anything.
Usage: sh scripts/build/package-deployment.sh [package-root]
sh scripts/build/package-variant-artifacts.sh <variant-label> [package-root]Packages a completed build's artifacts (xImage, rootfs.squashfs, build-manifest.txt, and the DTS/Kconfig fragment used to produce it) into a clearly-labeled deployment package directory, for the later Mode B hardware qualification (pre-qualification mission Phase A12, 2026-07-31).
This does NOT flash anything, does NOT touch the device, and does NOT duplicate scripts/flash- spare-slot.sh's own already-hardware-proven safety logic (real checksum verification, refusal to overwrite the live slot, --check-only support) - it only organizes build output into an unambiguous, labeled package so a later flash invocation can point at the exact right files without guessing which build produced them.
Usage: sh scripts/build/package-variant-artifacts.sh <variant-label> [package-root]
<variant-label> e.g. B0, B1, B2, B3, B4 - becomes the package
directory name (with a date suffix, since more than
one build of the same variant label may happen
over time).
[package-root] where packages are written (default:
build-work/deploy-packages, gitignored - these are
large binary artifacts, never committed).
Produce a v2 authenticated attestation for a completed build workspace.
WHY THIS IS A SEPARATE SCRIPT
The v1 attestation (.nebulaos-build-verified) is written by tools/run-nebulaos-build.sh, which is a CONTROL-LAYER file: the workspace identity gate compares the installed copy against the canonical one, and any drift blocks every build and every hardware operation until a human runs tools/sync-workspace-control.sh. Folding the v2 signer into that launcher therefore makes attestation and the build system land together, and makes an ordinary attestation change require a privileged re-sync.
So the SIGNER lives here, outside the control layer, and can be run against any completed build workspace. The launcher calling it automatically is a separate, deliberate control-layer change.
WHAT IT ADDS OVER v1
v1 is a plain text file in a world-writable /var/tmp tree: anything that can write the path can claim any build passed. That was fine while a human was the only reader and is not fine now that a Hardware Agent decides whether to flash a printer on the strength of it.
v2 is the same facts plus a HMAC-SHA256 over a domain-separated canonical serialisation, so a reader can tell whether the fields were produced by a holder of the attestation key. It also records what v1 never did: which repository and published tip the source came from, the build launcher's own blob hash, the build profile, whether ccache was on, and digests of the build log and the build manifest.
THE KEY IS NOT OURS TO CREATE
~/.config/nebulaos-attest/attest.key is created by a human, out of band, and is denied to agents. If it is absent this script says so and exits non-zero WITHOUT writing anything - a build with no attestation is a build a Hardware Agent will refuse to install, which is the correct outcome. It never invents a key and never downgrades to an unauthenticated record.
NebulaOS build attestation v2 - HMAC-SHA256 over a canonical field set.
WHAT THIS IS FOR
A v1 attestation (.nebulaos-build-verified, written by run-nebulaos-build.sh) is an UNAUTHENTICATED assertion. It is a plain text file in a world-writable /var/tmp tree, so anything that can write the file can claim any build passed. That was adequate while the only consumer was a human reading it, and is not adequate now that a Hardware Agent decides whether to flash a printer on the strength of it.
v2 adds one property and only one: the reader can tell whether the fields were produced by a holder of the attestation key. It is a MAC, not a signature - there is one key, held by the human who runs this project, and both signing and verification need it. That is the right shape here: the verifier and the signer are the same person's machine, and a public-key scheme would add key distribution for no gain.
WHAT IT DELIBERATELY DOES NOT DO
It does not upgrade v1 files. A v1 attestation that happens to sit next to a v2 one stays v1
and stays unauthenticated - verify refuses anything whose ATTESTATION_VERSION is not exactly
2. Silently treating an old unauthenticated record as authenticated would defeat the entire
point of adding the MAC.
It does not decide policy. It answers "were these exact bytes MACed by this key" and nothing else. Whether a given BUILD_PROFILE is good enough to flash a printer is the caller's decision, made on fields this tool has proven authentic.
THE KEY
~/.config/nebulaos-attest/attest.key (override: NEBULAOS_ATTEST_KEY)
Human-created, never generated by an agent. Enforced on every use:
- the path must be a REGULAR FILE, checked with lstat, so a symlink is
refused rather than followed. A symlink would let anything that can create
one in that directory redirect reads to a file it controls.
- mode 0600 exactly, and the containing directory 0700 exactly. Not "at most"
- an exact match, because a key that is group-readable is not a key.
- owned by the invoking user.
- non-empty, and at least 32 bytes of material.
The key content is NEVER placed in argv, never passed through the environment, and never printed. Only KEY_ID - a one-way tag derived from it - appears in output, so two attestations can be told apart without revealing anything.
CANONICALISATION
The MAC covers a canonical serialisation, not the file's literal bytes, so reordering or reformatting fields cannot change what was authenticated:
- every field except MAC
- each as KEY=VALUE, KEY uppercase [A-Z0-9_]
- sorted by KEY, joined with \n, one trailing \n
- prefixed by a domain-separation string that names the version
Domain separation matters: without it a MAC computed over some other NebulaOS structure with the same key could be replayed as an attestation.
Values may not contain newlines or NUL - both would make the line format ambiguous - and duplicate keys are refused rather than last-one-wins, since "which one did the MAC cover" has no good answer.
USAGE
nebulaos-attest.py sign [--in FILE] [--out FILE] nebulaos-attest.py verify [--in FILE] [--require KEY=VALUE]... nebulaos-attest.py keyid
sign reads KEY=VALUE lines (stdin by default), validates the required v2 field set, appends
MAC=, and writes the result. verify reads a signed attestation, recomputes the MAC in
constant time, and additionally enforces any --require KEY=VALUE constraints, so a caller can
say "and it must be a release profile" in the same breath as "and it was really signed".
Exit codes: 0 ok, 2 refused (bad input, bad key, policy), 3 verification failed. A failed verification is deliberately a DIFFERENT code from a refusal, so a caller cannot conflate "could not check" with "checked and it was forged".
A library module - imported by the tools above it, not run directly.
The Ender-3 V3 KE eMMC layout, with per-fact provenance.
WHERE THESE NUMBERS COME FROM
The vendor's own Ingenic Cloner profile, inside the official recovery package:
configs/x2000/x2000e_mmc0_lpddr2_linux.cfg in Ender-3_V3_KE_1.1.0.12.ingenic (pinned by content in manifests/dependencies.conf)
Its [policy0..policy9] sections name every partition and its absolute byte offset outright:
uboot 0x0 ota 0x100000 sn_mac 0x200000
rtos 0x300000 rtos2 0x700000 kernel 0xb00000
kernel2 0x1300000 rootfs 0x1b00000 rootfs2 0x20f00000
That is a stronger source than anything this project had before, and it corroborates an independent one. The two slot capacities derived from those offsets equal, exactly, the constants scripts/flash-spare-slot.sh has enforced against real hardware since long before this module existed:
0x1300000 - 0xb00000 = 8388608 == KERNEL_PART_BYTES 0x20f00000 - 0x1b00000 = 524288000 == ROOTFS_PART_BYTES
Two unrelated sources agreeing to the byte is what makes these VERIFIED rather than merely plausible. assert_agrees_with_flash_script() below re-derives that agreement from the shell script's own text, so the two copies cannot drift apart silently.
WHAT IS STILL NOT KNOWN
The eMMC's total capacity, and therefore the size of userdata; the exact size of rootfs_data (the docs say "300 MB", which is prose); every partition type GUID; and which of the ten recorded PARTUUIDs belongs to which partition. None of that is needed to write slot 2, which is the only thing this project writes.
sn_mac IS THE ONE THAT CANNOT BE UNDONE
/dev/mmcblk0p2 holds the per-unit factory MAC and serial number. A bounded read on the reference unit returned
26090000000001;FCEE11000001;F005;NEBULA V1.0.0.1;;;;;
and stock's wlan0 uses exactly fc:ee:11:00:00:01. The value is programmed per unit, exists nowhere else on the device, and cannot be regenerated. The vendor's own erase policy deliberately skips it, and erase_list_preserves_sn_mac() exists so that no package this project builds can close that gap.
SCOPE OF THIS MODULE
It is a fact table and two small helpers. An earlier revision also carried a GPT parser and a GPT builder, written for a raw-disk-image approach that was abandoned once the real .img format turned out to be the Creality OTA package. Both were left behind, referenced by nothing, and one of them documented behaviour that no longer existed. They have been removed rather than kept "in case": dead code that describes a design the project rejected is worse than no code at all.
Enrol a printer. A HUMAN runs this; no agent may.
WHY ENROLMENT IS NOT AUTOMATED
Enrolment is the moment someone asserts "this physical printer, the one in front of me, is the one called ". Everything downstream - strict host-key checking, the identity comparison before any destructive write, the refusal to flash a machine that moved onto a remembered DHCP lease - rests on that assertion being made by someone who could see the device.
An installer that can enrol can also enrol the wrong printer. "First contact, record whatever answered" is precisely the trust-on-first-use failure that the rest of this design exists to prevent, and automating enrolment would reintroduce it at the one point where it cannot be detected afterwards.
So this script is NOT in the set of launchers an agent may run, and the profile store it writes is denied to agents by both permission rules and the sandbox.
WHAT IT RECORDS
the per-unit physical identity eMMC CID, SHA-256 of the sn_mac partition
a pinned SSH host key PER OS stock and NebulaOS are different systems
with different keys on the same printer
known addresses per OS a search order for rediscovery, never proof
credentials in separate 0600 files
It reads the identity FROM THE DEVICE, over SSH, and shows it to you before writing anything. Check it against the printer you are standing next to.
The Hardware Agent's operations, and the proofs each one requires.
THE PUBLIC INTERFACE IS SEMANTIC
inspect read-only: what is this printer, what is it running, what is on it
status read-only: is a transaction open, and what state is it in
verify read-only: does this printer currently run this exact build
diagnose read-only: a fixed, bounded report - processes, logs, MCU guard,
update-supervisor state, Moonraker endpoints
restart repair: restart ONE named service with the update supervisor's
semantics (see nebulaos_device.restart_script)
install the developer install. NebulaOS may occupy either or both A/B
slots (2026-10-14); the path follows the device's slot state
(nebulaos_slots): INACTIVE_SLOT writes the slot NebulaOS is not
running from and boots it, PRESERVE_STOCK goes NebulaOS -> Stock
-> flash slot 2 -> NebulaOS when Creality in slot 1 is preserved
reboot software reboot of an idle NebulaOS back into NebulaOS, for
boot timing; refused unless the marker selects a slot that
holds NebulaOS
rtos-logo write the NebulaOS boot logo RTOS image to rtos2 (p4)
only, after a verified host backup (nebulaos_rtos)
rtos-logo-restore write such a backup back to rtos2, named by sha256
There is no ssh, scp, dd, marker, raw reboot, flash, raw usbboot, --host, --password or --command. Not because they are filtered - because no such operation exists here to name. The device is reached only through DeviceSession, whose vocabulary is closed, and the target is chosen only by enrolled device id.
WHAT IS PROVEN BEYOND "ALL REPOS MUST BE CLEAN"
The launcher still runs the full online identity gate, which still requires all five canonical repositories to be clean and published. On top of that blunt rule, the agent proves the specific properties a hardware operation depends on:
control commit C is published, resolvable, and content-bound host control code matches C exactly on-device helper bytes come from C's git objects product commit X is published X has a v2 attestation that verifies the attestation's digests equal the artifact bytes the build profile is release or candidate workspace topology, archive isolation and sentinels are valid
What is NOT relaxed: the full online identity gate still runs. A commit that was never compared against the canonical remote is an unverified source generation, and flashing is the least reversible boundary in the project.
A library module - imported by the tools above it, not run directly.
Read-only capture profiles for the hardware decision points (TOOL-01).
Every profile is a FIXED shell text composed here. The caller chooses only a profile NAME from CAPTURE_PROFILES; nothing the caller types is interpolated into a device command. A profile:
- runs under /tmp/nebulaos-capture.$$ (tmpfs), removed on exit;
- prints CAPTURE_PROFILE=, START_UTC= and END_UTC= so an operator can align
load steps performed outside the profile;
- changes no device state. Profiles that need a write (USB power control,
the SOF-filter switch, a memory load, a camera pause) are EXERCISE
profiles (TOOL-02), not captures, and are listed in EXERCISE_ONLY;
- refuses with PROFILE_REQUIRES_DIAGNOSTIC_IMAGE on a production kernel
when it needs the diagnostic kernel (uname -r ending in -diag).
Probe-based profiles embed a tracked tools/hwcap/*.py file. Those files are pinned in CONTROL_MANIFEST as host files, so the control layer has already compared the running copies with control commit C before any operation runs.
The profile bodies are taken from 09-programmer-execution-plan.md (TOOL-01 and Appendices A/B); where the plan gives two definitions for one name, the Appendix definition wins, as the plan specifies.
A library module - imported by the tools above it, not run directly.
Control commit C, and why the working tree is never the source of privileged code.
C AND X
C the CONTROL commit. Reviewed, published, and the sole source of the code
that touches a printer: the flash helper, the marker helper, the probes.
X the PRODUCT commit being installed. Supplies payload and data ONLY.
The separation exists because installing an OLD build must still use CURRENT reviewed control machinery. If the helper scripts came from X, then installing a six-month-old X would run six- month-old flashing code - including whatever bug was fixed since - against a real printer. Worse, an X chosen by an attacker would supply its own helpers.
So: helper BYTES come from C. Payload bytes come from X. They are different commits and this module keeps them apart.
WHY GIT OBJECTS AND NOT FILES
A working tree is mutable. tools/ in this workspace is explicitly unversioned derived state that the sandbox permits writing, and the repository checkout can be edited by anything with write access - including the agent that is about to ask for a flash. Reading a helper from the working tree means the thing being executed is whatever was there a moment ago.
A git object at a named commit is immutable and content-addressed: git cat-file blob C:path
returns the same bytes for the same C, forever, and a blob whose content changed would be a
different object with a different hash and therefore a different C. So privileged helpers are
read out of the object database, never off disk, and their hashes are checked against a
manifest that is itself at C.
THE PROTECTED MIRROR
~/.local/state/nebulaos-hardware/mirror.git
A bare mirror fetched only from the canonical origin, fsck'd on refresh. It sits outside the
workspace so that an agent editing the workspace cannot alter the objects a flash will read,
and outside git's normal working paths so a stray git checkout cannot touch it.
C MUST BE PUBLISHED. An unpushed commit is one nobody else can review or reproduce, and control code that exists only on one laptop is not reviewed control code. Resolution requires C to be reachable from a branch in the mirror after a fetch from origin.
AN OPEN TRANSACTION FREEZES C
Once a transaction is under way, the control commit it started with is the one it finishes with. Refreshing the mirror mid-transaction could otherwise swap the helper bytes between the flash and the verification.
A library module - imported by the tools above it, not run directly.
The semantic operations an installer may perform on a printer.
WHY THIS IS AN INTERFACE AND NOT A PILE OF SSH CALLS
Two reasons, and the second is the important one.
First: every adversarial case this project needs to test - a wrong printer, a host-key mismatch, a lease that moved, Stock unreachable, a print running, a heater at temperature, a marker write that does not land, a crash while ARMED, a dropped connection mid-flash, a hash mismatch, a second installer - is a property of the DEVICE, not of SSH. Putting the device behind an interface means all of them can be exercised offline against a simulator, which is the only way any of them get tested before a real printer is at risk.
Second, and structurally: this class defines a CLOSED VOCABULARY. There is no run(command)
here, and no read(device, offset, length). A caller can ask "what is the marker", "is the
printer idle", "write this plan" - it cannot ask for an arbitrary command, an arbitrary block
device or an arbitrary offset, because no method takes one. That is what makes "the agent has
no dd, no raw offsets, no --command" a fact about the code rather than a promise about
behaviour.
Trusted internal code composes the real commands. It lives here, below the interface, where it can be reviewed once.
THE SIMULATOR IS NOT IN THIS PACKAGE
Production code under tools/hardware/ contains no reference to any simulator and no import path that could reach one. The simulator implements this same interface and lives under tests/, so a shipped installer cannot construct it even by accident - there is nothing to construct. Tests inject it.
A library module - imported by the tools above it, not run directly.
The evidence a destructive hardware operation requires before it may proceed.
WHAT A DESTRUCTIVE OPERATION MUST BE ABLE TO PROVE
Not "the files look right" - that is satisfied by any self-consistent set of bytes, including a half-finished build whose manifest happens to match its own partial output. It must prove:
* a v2 attestation exists for this exact product commit, and it VERIFIES
under the attestation key (so the fields were produced by a holder of it);
* the attestation's artifact digests equal the bytes about to be installed;
* the build profile is release or candidate - a dev build, with ccache on and
reused incremental output, never qualifies to reach a printer;
* the product commit is published, so what lands on hardware corresponds to
source someone else can fetch and review.
WHY v1 IS NOT ENOUGH, AND WHY IT IS NOT SILENTLY UPGRADED
The v1 record (.nebulaos-build-verified) is an unauthenticated text file in a world-writable /var/tmp tree. Anything that can write the path can claim any build passed. It is still useful
- it is the build launcher's own statement that build.sh exited zero and the canonical workspace stayed clean - but it is an assertion, not evidence, and this module treats it as such.
A v1 record present without a v2 attestation is reported as exactly that, and the destructive path refuses. It is never promoted, never "accepted because the hashes match", and never treated as a degraded form of v2. An unauthenticated record that is accepted whenever the authenticated one is missing is an unauthenticated system with extra steps.
NO KEY MEANS NO INSTALL. THAT IS THE DESIGN.
The attestation key is created by a human and denied to agents. If it is absent, nothing can be verified, and this refuses. That is not an obstacle to work around - it is the control. An installer that falls back to "well, the hashes look fine" when the key is missing has removed the only thing that made the attestation worth having.
A library module - imported by the tools above it, not run directly.
The developer install: NebulaOS -> Stock -> flash -> NebulaOS, as a state machine.
THE OPERATION, AND WHY IT GOES THROUGH STOCK AT ALL
NebulaOS lives in slot 2. A running system cannot overwrite the partitions it is executing from
- an earlier version of this project tried, landed a write on the live rootfs, and needed a manual power cycle to recover. So installing a new NebulaOS means booting something else first, and the only something else is Creality's slot 1.
That makes stock a dependency of the install, which is why so much of this file is about proving stock will work BEFORE committing to it.
THE DANGEROUS WINDOW
From the moment the marker is set to ota:kernel until the moment a verified NebulaOS is running again, the printer is either about to boot stock or is booting stock. Booting stock is survivable; POWER-CYCLING into stock is what lets its MCU updater reflash the GD32 with Creality firmware, destroying the qualified MCU build. This project can perform a software reboot and cannot perform a power cycle, so the window is survivable by construction - but the journal and the output both say POWER_CYCLE_DANGEROUS=YES throughout it, because a human pulling the plug at the wrong moment is the remaining risk.
ARMED, AND DISARMING
"ARMED_STOCK" means: NebulaOS is still running, and the marker says boot stock. It is a real, recoverable state, and every failure that happens in it must undo it - set the marker back to kernel2, flush, read the bytes back, confirm. A failure that leaves the marker armed turns the next unrelated reboot into an unplanned trip to stock.
A resumed transaction that finds NebulaOS running with the marker on stock disarms FIRST, before considering anything else. It does not continue forward on the assumption that it must have meant to.
ONCE SLOT 2 IS VERIFIED, THE MARKER IS THE NEXT THING
Not log collection, not a status report, not a health check. The device is sitting on stock with a freshly written NebulaOS slot; the single most valuable next byte is the one that points the bootloader back at it. Everything else can happen after.
NEBULAOS IN EITHER OR BOTH SLOTS (2026-10-14)
Since the human decision of 2026-10-14 NebulaOS may occupy slot 1 too, so the install first reads the whole slot state (nebulaos_slots) and picks one of two paths from it:
PRESERVE_STOCK NebulaOS runs from slot 2 and Creality is in slot 1, which
the owner has not released: everything above, unchanged.
INACTIVE_SLOT NebulaOS runs from slot N and the other slot holds NebulaOS,
or the owner set INSTALL_OTHER_SLOT=expendable: the other
slot is written directly from the running NebulaOS (the
flash helper re-resolves the live root right before it
writes and refuses it), verified, the marker is pointed at
it, and the printer reboots into it. Creality is never
booted, so there is no stock window, no MCU exposure and no
POWER_CYCLE_DANGEROUS state; until the marker moves, a power
cycle simply boots the running slot again.
Anything else - an unknown running slot, an unidentifiable other slot without the owner's permission, Creality in slot 2 while NebulaOS runs from slot 1 - is refused before anything is written, naming the profile key a human can set.
REALITY WINS
The journal says what a previous process intended. Every decision here is made from what the device says now: which OS is running, what the active root is, what the boot id is, what the marker bytes are, what the slot hashes are. The journal is consulted to understand an in- progress transaction, never to skip a check.
A library module - imported by the tools above it, not run directly.
The transaction journal and the host lock.
THE JOURNAL IS ADVISORY. REALITY WINS.
This is the single most important thing about it. The journal records what the installer BELIEVED was happening. The printer records what actually happened. When they disagree, the device is right and the journal is stale - it was written by a process that may have been killed between deciding to act and acting, or before it could record that it had.
So nothing here is ever used as a substitute for observation. Before any destructive resume, the caller re-observes device identity, which OS is running, the active root, the boot id, the marker, slot-2 state and hashes, the staged payload hashes and the flash lock - and then reconciles. The journal's job is to say "a transaction was open, here is what it was for", which turns an unexplained ARMED marker into a known one.
WHY IT IS KEYED THE WAY IT IS
device id + control commit C + product commit X + xImage sha + rootfs sha
A transaction is only resumable if all five still hold. A different X, or a rebuilt X whose artifacts differ, is a different transaction that happens to involve the same printer, and resuming into it would install something nobody asked for.
ATOMIC WRITES
Every update is written to a temporary file in the same directory, fsynced, then renamed over the target. A journal truncated by a crash mid-write is worse than no journal: it reads as a valid record of a state that was never reached.
THE HOST LOCK
One installer per device per host. Taken with O_EXCL so the check and the claim are one operation - a "does the lock exist" test followed by a create is a race with exactly the window that matters. The lock records pid and boot id so a lock left by a crashed process can be recognised as stale rather than blocking forever, and staleness is decided by whether that pid is still alive, not by an age heuristic.
The device-side flash lock is separate and lives on the printer (DeviceSession.acquire_flash_lock). Two hosts cannot see each other's host locks, so the device-side one is what actually prevents two machines writing at once. Both exist because they fail differently: the host lock gives a clean local error, the device lock is the real mutual exclusion.
A library module - imported by the tools above it, not run directly.
The OTA marker: exact read, exact set, and an honest INDETERMINATE.
WHAT THE MARKER IS
/dev/mmcblk0p1 holds one of two strings that tell the vendor bootloader which slot to boot next:
ota:kernel -> slot 1 (kernel p5, rootfs p7) - Creality's from the factory
ota:kernel2 -> slot 2 (kernel2 p6, rootfs2 p8) - NebulaOS's original home
Since 2026-10-14 NebulaOS may occupy either or both slots, so a marker names a SLOT, not an OS. What a slot holds is nebulaos_slots' question, answered from the device; this module only owns the bytes.
This module owns the byte format and the parse. It performs no I/O: a caller hands it 512 bytes read from the device and gets back a state, or asks it for the exact bytes to write. That split is deliberate - it makes every rule here testable without a printer, and it means the on-device transport cannot accidentally reinterpret a marker.
THE PREFIX TRAP
"ota:kernel" is a prefix of "ota:kernel2". Any parse built on startswith("ota:kernel")
reports STOCK for a device that is actually set to boot NebulaOS. That single mistake would
make an installer reboot into the slot it just decided not to boot, so the parse below never
matches a bare prefix: it matches the full token AND the terminator that follows it, and it
checks the longer token first.
WHY TWO TERMINATORS ARE VALID, AND WHY THAT IS NOT LOOSENESS
Two different writers produce this marker on a real device, and they disagree by one byte:
Creality's stock ota_utils.sh mmc_write_str: echo $str > $dev -> "ota:kernel\n" NebulaOS's
/etc/ota_marker.sh printf '%s\n\n' -> "ota:kernel\n\n" stock's echo over a NebulaOS
"ota:kernel2\n\n" (no truncation) -> "ota:kernel\n\n\n"
and ballaswag/ingenic-usbboot checks strncmp(ota, "ota:kernel\\n", 11), which accepts either.
A parser that demanded "\n\n" would call a perfectly well-defined stock-written marker
INDETERMINATE, and the installer would then refuse to reboot a device whose state was never
actually in doubt.
So both forms are accepted - as an ENUMERATED SET, not as a prefix match or a strip(). Anything outside that set is INDETERMINATE. Writing always emits the canonical two-newline form, so a marker this project wrote is unambiguous even though it tolerates the other one on read.
INDETERMINATE IS A RESULT, NOT AN ERROR
It means "the next boot target cannot be predicted from these bytes". The only correct response is to refuse to reboot. Guessing, defaulting, or retrying a blind toggle are all ways of turning an unknown state into a coin flip on which OS comes up - and one of those faces auto- flashes the MCU.
CREALITY'S TOGGLE IS NEVER USED AUTONOMOUSLY
From stock, local_set_next_boot_device takes no argument: it flips to whatever the marker
currently is not. That is unusable for automation, because the caller cannot state an intent
and the return value does not say where it landed. This module only ever expresses "set kernel"
or "set kernel2", and the transport that executes it verifies by reading the bytes back.
A library module - imported by the tools above it, not run directly.
Enrolled device profiles: who the printer is, and how to reach it safely.
WHY PROFILES EXIST OUTSIDE GIT
Three kinds of thing live here, and none of them belongs in a repository:
* per-unit physical identity (eMMC CID, SHA-256 of the sn_mac partition)
* per-OS SSH host keys, pinned
* credential references and the credentials themselves
The first is device-specific and would be meaningless to anyone else. The second and third are secrets. So the store is:
~/.config/nebulaos-hardware/devices//
with the same hygiene the attestation key gets: directory 0700, files 0600, regular files only (lstat, so a planted symlink is refused rather than followed), owned by the invoking user.
ENROLMENT IS A HUMAN ACT
This module READS profiles. It has no create() and no update-on-the-fly path, because the moment an installer can enrol a device it can also enrol the wrong one - and "first contact, record whatever answered" is precisely how a machine that moved onto a remembered DHCP lease gets flashed.
A human creates the directory, records the identity from a device they have physically confirmed, and pins the host keys. tools/hardware/enroll-device.sh walks through it. That script is deliberately NOT in the set of launchers an agent may run.
STRICT HOST KEYS, NO TOFU, NO REPINNING
Each profile pins a host key PER OS, because the stock slot and the NebulaOS slot are different systems with different keys on the same physical printer. Addressing the device while it runs stock uses the stock key; addressing it while it runs NebulaOS uses the NebulaOS key. A key that does not match is a refusal - never a prompt, never an automatic re-pin. If a key legitimately changed (a reflash, a regenerated host key), a human re-enrols it.
AN ADDRESS IS NOT AN IDENTITY
The profile records address HISTORY per OS, which is a hint for rediscovery after a reboot or a lease change - a list of places to look, in order. It is never proof. Whatever answers is checked against the pinned physical identity before anything destructive happens.
A library module - imported by the tools above it, not run directly.
rtos-logo and rtos-logo-restore: the NebulaOS boot logo on rtos2, and the way back.
WHAT IS WRITTEN, AND WHAT NEVER IS
SPL loads rtos2 (p4) when the OTA marker is ota:kernel2 and rtos (p3) otherwise. rtos-logo
writes rtos2 with the pinned NebulaOS RTOS image - the stock zero.bin with only its 480x272
boot-logo JPEG replaced (scripts/package/rtos_logo.py). rtos-logo-restore writes a backup
taken by this module back to rtos2. Nothing here can name rtos (p3), a kernel, a rootfs or the
marker: the policy is targets.RTOS2_ONLY and the device-side writer refuses any other target on
its own.
WHY THIS NEEDS ITS OWN SAFETY CASE
The RTOS runs before Linux. A broken rtos2 is a NebulaOS boot that stops in SPL, recoverable only over USB mask-ROM (docs/DEVELOPER_RECOVERY.md, "RTOS logo: restoring rtos2"). So:
* the printer must be running NebulaOS from p8 (slot 2) with the marker on
ota:kernel2 - NebulaOS running from slot 1 (possible since 2026-10-14)
boots rtos (p3), which is out of scope, so it is refused with that
reason; idle with every heater target at zero, with no install
transaction open and both the host and the device lock held;
* the stock bytes the logo is patched into are the printer's OWN rtos2, and
only if its first 432824 bytes are the pinned stock image; anything else
is refused (an already-patched rtos2 is reported and left alone);
* the complete 4 MiB rtos2 is copied to the host and verified against the
device's own hash BEFORE anything is written. The backup is never
overwritten, and a restore must name it by sha256;
* the write is one dd with fsync of the staged, hash-checked file, read back
on the device, then hashed again from the host over the written length and
over the whole partition; rtos (p3) is hashed before and after and must not
change;
* a failed or unverified write says, every time: do not reboot or power
cycle, restore from the named backup.
There is no reboot here. The new logo shows at the next NebulaOS boot.
A library module - imported by the tools above it, not run directly.
The A/B slot state of a printer, and which install path that state permits.
WHY THIS EXISTS (human decision, 2026-10-14)
Until then the model was fixed: slot 1 (kernel p5, rootfs p7, marker "ota:kernel") was Creality's and slot 2 (kernel2 p6, rootfs2 p8, marker "ota:kernel2") was NebulaOS's. NebulaOS may now occupy EITHER or BOTH slots: the on-printer updater installs an image into whichever slot is not running and points the marker at it, so after one on-printer update the printer can run NebulaOS from slot 1 with an older NebulaOS in slot 2 - or hold Creality in either slot if its owner installed a Creality image.
So nothing here may assume "slot 1 is stock". Every decision is made from what the device says NOW:
which slot is running the root= the kernel was given (/proc/cmdline) which slot boots next the marker bytes in p1 (nebulaos_marker) what each slot holds the running slot: the running OS itself; the other slot: its rootfs, inspected read-only ON THE DEVICE (nebulaos_device.slot_facts_script)
WHAT "HOLDS" MEANS, AND WHY IT FAILS CLOSED
The other slot's rootfs is classified by the same structural markers nebulaos_device.which_os uses for the running system - files only one of the two images has, never a version string or a hostname:
NebulaOS /etc/ota_marker.sh and the directory /opt/nebulaos
Creality the directory /etc/ota_bin
and cross-checked against the kernel partition's uImage header, whose name is the kernel release (NebulaOS ships Linux 6.x, Creality's KE firmware 4.4):
nebulaos NebulaOS markers only, and the kernel does not say otherwise
creality Creality markers only, and the kernel does not say otherwise
empty no squashfs superblock at the start of the rootfs partition, or
no uImage header on the kernel partition: nothing bootable
unknown anything else - a mount that failed, both or neither marker,
a kernel that contradicts its rootfs (a half-written slot)
"unknown" is never treated as "probably fine to overwrite" and never as "probably bootable".
THE INSTALL PATHS (plan_install)
PRESERVE_STOCK NebulaOS runs from slot 2, Creality is in slot 1, and the
owner has not said the other slot may be overwritten:
software reboot to Creality, write slot 2, verify, select
slot 2, reboot (the original path, unchanged).
INACTIVE_SLOT NebulaOS runs from slot N; write the OTHER slot directly
from the running NebulaOS, verify, select it, reboot into
it. The running slot is the way back and is never written.
Taken when the other slot holds NebulaOS, or when the owner
has said the other slot is expendable (Creality, empty or
unknown content) via the human-owned profile key
INSTALL_OTHER_SLOT=expendable.
The permission to destroy a Creality system is EXPLICIT: a profile key a human writes, never inferred from the device's state, and absent means preserve.
A library module - imported by the tools above it, not run directly.
Validated partition targets: what the flashing layer CAN write, and what a given operation MAY write.
THE DISTINCTION THIS MODULE EXISTS TO MAKE
Two different questions get confused constantly, and confusing them produces either an unsafe tool or a uselessly narrow one:
CAPABILITY can the flashing layer express a validated, bounds-checked,
read-back-verified write to this partition at all?
POLICY is THIS operation, run by THIS caller, allowed to select it?
The generic layer answers the first question broadly. The device belongs to its owner, and a
recovery tool that cannot rewrite the stock slot is not a recovery tool. So kernel, rootfs,
rtos, rtos2 and the OTA marker are all CAPABLE targets: full-device recovery, single-slot
repair and both-sides reinstall are all expressible here.
The developer install operation answers the second question narrowly. Its policy is one of:
PRESERVE_STOCK kernel2, rootfs2 and the marker - NebulaOS in slot 2,
Creality kept in slot 1 as the way through
INACTIVE_SLOT_1 / _2 the kernel and rootfs of the ONE slot NebulaOS is not
running from, and the marker (since 2026-10-14, when
NebulaOS may occupy both slots; nebulaos_slots decides
which applies from the device's state and the
human-owned profile)
and nothing else. That is a property of that one operation, not a limit of the backend, and the two must never be conflated - which is why they are separate objects here rather than one hardcoded allowlist.
WHAT SAFETY IS MADE OF, SINCE IT IS NOT AN ALLOWLIST
* targets are NAMED, never offsets. There is no API here that accepts a byte
offset or a block device path, so no caller - trusted or not - can aim a
write at an arbitrary location.
* every target's offset and length come from the vendor Cloner geometry in
tools/emmc/nebulaos_layout.py, which is evidence, not a guess.
* every write is planned as (target, payload size, payload sha256) and the
plan is validated against the target's real bounds before anything happens.
* the payload must fit, and a plan that would run past the end of its
partition is refused rather than truncated.
THE ONE UNCONDITIONAL REFUSAL
sn_mac is NEVER writable, under any policy, including full-device recovery. It holds the per-
unit factory MAC and serial number (26090000000001;FCEE11000001;F005;NEBULA V1.0.0.1 on the
reference unit, which is the address stock's wlan0 actually uses). It is programmed per unit,
exists nowhere else on the device, and cannot be regenerated or bought. Every other partition
can, in principle, be restored from an image; this one cannot, so it is excluded at the
capability layer rather than left to policy. Even Creality's own Cloner erase policy skips it.
A library module - imported by the tools above it, not run directly.
The proofs: that Stock is a usable way out (PRESERVE_STOCK), that the running NebulaOS slot is the way back (INACTIVE_SLOT, since NebulaOS may occupy both slots - nebulaos_slots), and that an install really landed in the slot it was meant for.
THE STOCK WAY-OUT PROOF
Setting the marker to ota:kernel while NebulaOS is running is the point of no easy return. After the reboot, the only way to continue - or to undo anything - is through the stock slot. If stock turns out to be unreachable, or its credentials are wrong, or there is nowhere to stage the payload, the printer is sitting on Creality's firmware with no automated path back, and the human has to recover it by hand.
So every fact that the Stock half of the install depends on is checked BEFORE the marker is touched, from NebulaOS, while retreat is still free. A missing proof is a refusal, not a warning.
REAL PART-1 VERIFICATION
Checking that services are up is not verification that a specific build was installed; a printer running last week's image also has services up. Part 1 compares the BYTES on the device against the exact artifacts that were supposed to land, by hashing the same prefix length at the same partitions, and only then looks at health.
And it reports PART1_INSTALL_VERIFIED, never HARDWARE_QUALIFIED. Part 1 proves an image is installed and the system came up. Qualification means motion, heating, calibration and printing, which are Part 2 and are not attempted here.
Regenerate tools/hardware/CONTROL_MANIFEST.
The manifest pins every privileged file by content, at the control commit that contains it. Two kinds, with different guarantees - see nebulaos_control.py:
on-device staged onto the printer; its bytes are read from the git object
at C, so the working tree cannot influence what gets sent.
host control modules the launcher itself executes; Python imports them
from disk, so the running copies are COMPARED against C and any
difference refuses the operation.
Run this after changing any privileged file, then commit the manifest IN THE SAME COMMIT as the change. A manifest that lags its files makes every privileged operation refuse, which is the correct failure but an annoying one to debug at 2am.
HWCAP-ADXL: ADXL345 FIFO overflow count during a resonance test.
Subscribes to Klipper's adxl345/dump_adxl345 endpoint on the API socket (JSON messages terminated by 0x03) and reports the largest 'overflows' delta seen. Read-only: it only listens; the test itself (TEST_RESONANCES) is started separately in an authorised motion session.
Final blockers NG5 (2026-10-07): 10 normal pause -> wake -> stream -> pause cycles through the product path (the installed C2 controller, no marker, a real viewer on the stream). Per cycle: pause reached, runtime-PM state, wake latency, first frames, 1080p30, new USB/UVC kernel messages, re-pause.
Final blockers NG3 (2026-10-07): the guard -> Klippy serial handoff, as at boot. Each cycle: Klippy stopped, the MCU left running (it keeps streaming whatever the last session configured), the guard's own application identify (identify handshake only), then Klippy started at once - odd cycles alone, even cycles with two CPU hogs (boot-like contention). Per cycle: Klippy's own connect timeouts, time to ready, uart1 counters, stream rate while closed. Nothing but the identify handshake and Klippy's normal connect reaches the MCU.
GUI/service readiness acceptance T3/T5 (2026-10-10): select the boot screen mode ($MODE: logo|console|blank) through the same path the GUI uses (nebulaos-osconfig boot-display set- mode). The mode is a persistent user setting that takes effect on the next boot, so these profiles deliberately have no undo: the human session sets blank/console, reboots, looks, and finishes with the logo profile. Also prints what the previous boot's S01splash did.
GUI/service readiness acceptance (2026-10-09): self-undoing backlight brightness exercise through the same path the GUI uses (nebulaos-osconfig display ...) over the standard backlight device. Records the persisted display files and the sysfs brightness/bl_power, sets 50 then 25 (each read back, and again after 3 s to prove the level holds past the controller's 2 s bounded window), then the profile's undo restores the recorded raw values and files. Never switches the backlight off. B0, P0, HAD_*, GS_STARTED are read by the profile's undo (nebulaos_capture.py). shellcheck disable=SC2034
GUI/service readiness acceptance (2026-10-09), READ ONLY: the [nebulaos] Moonraker component, network/hostname/mDNS, SFTP, the sudo adapter's refusals, PLR status, boot display and backlight state. Changes nothing: GET requests only, no helper write verb, no mount, the SFTP probe is one INIT/VERSION exchange with a local sftp-server process over a pipe, the sudo probe only exercises refusals.
GUI/service readiness acceptance T4 (2026-10-10): human-observed backlight exercise through the
same path the GUI uses (nebulaos-osconfig display ...). Steps through 25/50/75/100 % (6 s each,
for a person to compare by eye), then switches the backlight off with display sleep (which
starts the touch-wake watcher) and waits up to 90 s for a person to touch the screen; the
watcher, not this script, must bring the backlight back. The profile's undo wakes the display
if it is still off and restores the recorded raw brightness and display files. B0, P0, HAD_*,
GS_STARTED are read by the profile's undo (nebulaos_capture.py). shellcheck disable=SC2034
Wi-Fi diagnosis (Q3 API, 2026-10-11), READ ONLY: NebulaOS's view of wpa_supplicant (GET wifi/status), the saved networks with every key redacted, the network apply state, and the log lines around wlan0, DHCP and the machine.nebulaos.wifi.* requests. Changes nothing.
HWCAP-KDUTY: per-thread CPU duty of a process (or of threads named --comm).
Samples utime+stime (clock ticks, HZ=100 here) of every matching TID at --hz and reports D1 (max CPU-seconds per second over 1 s windows), D100 (max ticks in a 100 ms window, all threads) and L (longest run of 100 ms windows in which one thread was busy every tick). JSON to /tmp/hwcap/out/kduty.json.
Live fix verification S3 + S4 (2026-10-05). Patched copies in /run/nebulaos-live-verify of the camera idle library (S3: no marker -> C2, only an explicit C0 marker opts out) and of S51 (sources that library; S4: stop() waits for the loop to finish, resumes, then clears the state file). The real marker file is never written; the undo stops the patched controller, resumes ustreamer and restores the previous state file.
Live gap verification G10 (2026-10-05): S03 enable outside maintenance is reverted by
memguard. Live: enable with the real S03, time memguard's defensive swapoff. Policy: the
image's memguard functions sourced through their test seam (NEBULAOS_MEMGUARD_NO_AUTORUN=1)
against a lock directory, fake /proc/swaps and fake swapon/swapoff in tmpfs - the maintenance-
lock path is exercised without creating any lock in /usr/data/nebulaos/updates/locks.
Live gap verification G5 (2026-10-05): S97's display gate reads the backlight driver's debugfs status, and debugfs is not mounted on the image. Mounts debugfs temporarily (the undo unmounts it again if this run mounted it), reads both display drivers' status, re-evaluates all four S97 gates and runs the image's S97 with a synthetic qualification_complete=1 config from tmpfs and ndq_apply_all stubbed (nothing is ever applied to the display).
Live gap verification G6 (2026-10-05): udhcpc's first DHCP try at boot. Read-only evidence (udhcpc arguments, interfaces, boot-wait result, link state), then query-only DHCP exchanges on the associated link: a separate udhcpc with -q -n and a logging script that configures nothing (no -R, so no release). The running udhcpc, the address and the routes are untouched.
Live gap verification G7 (2026-10-05): Klippy start -> ready time alone vs in the boot burst. Klippy is restarted (service restart only) warm, with the page cache dropped, and with the page cache dropped while Moonraker and GuppyScreen restart at the same moment (as at boot). Ready is read from Klippy's own API socket (a new pid answering state=ready), so a Moonraker restart does not disturb the measurement. Seeders are not re-run.
Live gap verification G8 (2026-10-05): why the mainsail-macros seeder logs "failed" on every boot. Shows the boot log and marker, then runs the image's seeder run() against the live Moonraker with every POST and DELETE replaced by a stub that refuses and records the would-be write (reads are real), and a marker path in tmpfs. No Moonraker database entry is written or deleted.
Live gap verification G9 (2026-10-05): /usr/data (ext4) mounted "unchecked" on every boot. Read-only: the mount and its kernel messages, the ext4 superblock fields (read from the block device, nothing written), the ext4 error counters, the shutdown path (inittab, rcK) and which processes hold files on /usr/data open - what a clean unmount at shutdown would have to stop.
Live fix verification M1 (2026-10-05): the MCU guard's identify budget.
snap STEP uart1 counters (fe oe rx) with uptime
holders pids holding /dev/ttyS1 (empty = free)
setbaud BAUD leave BAUD as the port's saved rate (as at boot: 9600)
identify LABEL BAUD PORT [MODDIR] time get_application_identity(port, baud) exactly as
mcu_lifecycle.decide() calls it; MODDIR (a patched
copy) is imported ahead of /etc/nebulaos
Only the identify handshake reaches the MCU (no bootloader path, no other command).
A library module - imported by the tools above it, not run directly.
Live gap verification NG1 (2026-10-05): Tornado error responses.
Buildroot's python-tornado 6.4.2 carries 0002-web-Harden-against-invalid-HTTP- reason- phrases.patch (CVE-2025-67724), backported from Tornado 6.5: web.py now validates a reason phrase against httputil._ABNF, which only Tornado 6.5's httputil defines. Every error with a reason phrase then raises in send_error and the client never gets a response.
Runs a throwaway Tornado app on 127.0.0.1:7199 with the image's own Tornado
(the interpreter given, Moonraker's venv) and requests a handler that raises
HTTPError(404, reason=...), as Moonraker's database handler does:
image - Tornado as shipped
fixed - the same, with the missing httputil._ABNF.reason_phrase defined
(RFC 9110 reason-phrase = *( HTAB / SP / VCHAR / obs-text ))
Moonraker itself is not touched.
Live fix verification S1 (2026-10-05): S03nebulaos-diskswap sizes the
swapfile from metadata (ls -ln) instead of reading it (wc -c). The image's
BusyBox has no `stat -c` (first run: empty size, see the report). Runs the image's
S03 and a patched copy in /run/nebulaos-live-verify side by side:
- sandbox: every validation edge case in a fake root under $T with fake
swapon/swapoff/mkswap; both copies must reach the same outcome
- real file: cold-cache `start` timing and data-partition reads, the
normal-boot result (swapfile left OFF), explicit enable/disable
Nothing in the image is modified; the real swapfile is only validated.
Live fix verification S2 (2026-10-05): S97 exits at once when there is no qualified display configuration. Runs the image's S97 and a patched copy from /run/nebulaos-live-verify with a temporary MARKER and synthetic configs in tmpfs. The NDQ library is the image's, wrapped so ndq_apply_all only reports that it would have been called: nothing is ever applied, and the real marker and /usr/data/nebulaos/display-qualified.conf are not touched.
Pre-final acceptance S3 + S4 (2026-10-07): the INSTALLED camera idle policy, using the image's own S51 and library - nothing patched, no marker written. S3: default boot (no marker) -> C2 controller running, paused after the grace, low paused CPU, DWC2 IRQ rate, runtime suspend, viewer resume at 1080p30, re-pause, repeated cycles S4: stop while paused -> resumed + state cleared; restart -> fresh active, grace honoured, paused again; no orphan loop The undo leaves the product default running (S51 start).
HWCAP-PTYRTT: round trip through a pty pair, the klippy <-> klipper_mcu path.
A forked child echoes on the raw slave; the parent writes --bytes every --period-ms on the master and timestamps the echo: two tty flip hops, the same flush_to_ldisc path as the host MCU link. JSON to /tmp/hwcap/out/ptyrtt.json.
uart1 / MCU link probe for the uart-fe-probe and mcu-link-bench exercises.
Runs on the printer while klippy is stopped (the exercise stops and restarts it). Subcommands:
snap STEP print the uart1 counters (tx rx fe oe brk pe) with uptime
holders print the pids holding /dev/ttyS1 (empty = free)
listen BAUD SECONDS open /dev/ttyS1 at BAUD, read only (nothing is written),
report what arrived; then close
setbaud BAUD open, set BAUD, close - leaves BAUD as the port's saved
setting, as a first open after boot finds 9600
bench connect with Klipper's own serialhdl (the MCU guard's
run_connected helper) and time read-only commands
The MCU is only ever sent commands in ALLOWED: identify (inside serialhdl's connect), get_uptime, get_clock, debug_nop, debug_ping. None of them touches a pin, a stepper, a heater or the MCU configuration.
HWCAP-SCHEDSNAP: scheduling policy of every printer-relevant thread.
Reads /proc only (BusyBox has no ps -L). For each /proc//task/: tid, policy, rt_priority, nice, comm. Also the tty_flip workqueue nice, kernel.sched_rt_runtime_us and the NebulaOS priority config. Two samples, --interval seconds apart. JSON to /tmp/hwcap/out/schedsnap.json.
HWCAP-TIMERLAT: emulate klipper_mcu's timer path and measure its latency.
klipper_mcu sleeps in ppoll() with SIGALRM blocked and is woken by a POSIX CLOCK_MONOTONIC timer (src/linux/timer.c, console.c). This does the same at the same priority: SCHED_FIFO --prio, mlockall, timer_create(SIGALRM), absolute timer_settime, ppoll with SIGALRM unblocked; latency = wake time - programmed expiry. stdlib + ctypes only. JSON to /tmp/hwcap/out/timerlat.json.
tools/maintenance/build-cache-gc.sh --dry-run what WOULD be removedNebulaOS build-cache garbage collection.
tools/maintenance/build-cache-gc.sh --dry-run what WOULD be removed
tools/maintenance/build-cache-gc.sh actually remove it
WHY THIS EXISTS
/var/tmp/nebulaos-build reached 275 GB. Each build run is a full clone plus a fetched vendor tree plus Buildroot output - 14-29 GB apiece - and the build launcher's own retention only prunes runs older than 14 days. Nothing else ever removed anything, so the floor rose with every qualification cycle.
WHAT THIS IS NOT
It is not <engine> system prune -a. That would delete images and cache belonging to every
other workload on this machine, including the pinned builder image this project needs and
anything unrelated the user has running. Unrestricted pruning is refused here by design, not by
omission.
THE RULE: NOTHING IS REMOVED THAT WAS NOT POSITIVELY IDENTIFIED
Every candidate must match a known NebulaOS shape AND survive an explicit keep-list. Anything this script does not recognise is left alone and counted as SKIPPED, loudly. A garbage collector that guesses is a data-loss tool.
NEVER removed, mechanically:
- the active build workspace (a live .nebulaos-build.lock, or in use)
- any run carrying a build attestation (.nebulaos-build-verified) - that is
a release candidate's evidence, not scratch space
- the newest run per source SHA, regardless of age
- the canonical source repositories (this script never touches $ROOT)
- the current builder image named by manifests/dependencies.conf
- evidence/, manifests/, and anything under the workspace root
- Buildroot source downloads (kept on purpose - see below)
- .img / .ingenic artifacts, should they ever appear
- anything unrecognised
WHY DOWNLOADS ARE KEPT
The shared download cache is the one thing here that is expensive to lose and cheap to keep: every archive in it is pin-resolved and hash-verified before use, so it is never stale in a way that matters, and discarding it just means re-downloading gigabytes on the next build. It is reported, never collected.
Which firmware files are PRODUCT inputs, and does a change need a rebuild?
PRODUCT_HEAD is the commit whose build produced an image. CONTROL_HEAD is the host-side tooling used to operate on it. Moving CONTROL_HEAD - the Hardware Agent, tests, docs, packaging, workspace control - must not invalidate an already-built product. Only a change to an actual build input does.
The classification is DENY-LIST, on purpose: every path is a product input unless it is listed below as host-side. An unknown new directory therefore errs towards "rebuild", never towards silently reusing a stale image.
Usage: product-inputs.py classify ... # PRODUCT / HOST per path product-inputs.py changed
[] # did product inputs change? product-inputs.py current-build [] # newest build whose product # inputs equal 'snebulaos-qualify.sh - NebulaOS runtime qualification snapshot.
Runs ON THE PRINTER. Read-only: it reads /proc, /sys and loopback HTTP and writes nothing outside its own output stream. It never operates the machine - no motion, no heaters, no probe, no flashing, no configuration change.
Purpose: produce one stable, diff-friendly snapshot so the SAME command run on the current
known-good firmware and on a candidate can be compared with a plain diff. Mission section
21/22.
./nebulaos-qualify.sh > old.txt # on current firmware ./nebulaos-qualify.sh > new.txt # on candidate diff -u old.txt new.txt
OUTPUT CONTRACT - the four states are deliberately distinct, because conflating them is how a regression hides:
<value> a real reading (including a genuine 0)
ABSENT the thing does not exist on this system (no such file/proc/
feature). Expected for optional components.
UNAVAILABLE it exists but could not be read here (permission, busy,
unsupported by this kernel build)
FAILED it exists, was reachable, and returned an error/bad status
Nothing is ever invented. A reading that could not be taken is never printed as 0. Every line is KEY=VALUE, keys are stable and ordered, and no timestamp, pid, uptime-varying value or hostname appears outside the explicitly marked VOLATILE section - so a diff shows behaviour changes, not clock drift.
BusyBox ash compatible. Uses only: cat, grep, sed, awk, cut, tr, sort, head, wc, ls, test. Optional tools are probed, never assumed (the device has no pgrep - see evidence/frozen- candidate-fd4a365/PART1_RESULT.md).
Phase 1 final hardware qualification - SESSION 1 (NO MOTION).
Prepared by the Phase 1 overnight closure mission. NOT executed tonight - the printer stayed off for the entire mission per that mission's own hard rule. Review this script once before running it tomorrow; it is written to be safe to run as-is, but it was never exercised against the real device in this session.
What this does: collects every piece of no-motion, no-heat evidence needed to call Session 1 complete - identity, host MCU health, at24 EEPROM presence/geometry, a full EEPROM backup with a real write/readback/restore test on a chosen safe byte range, and ADXL345 connectivity - all read-only or explicitly reversible, entirely over SSH/Moonraker's HTTP API. NEVER issues G28, any G-code motion command, or any heater command.
Usage: sh tools/qualification/session1-no-motion.sh [output-dir]
You will be prompted for the SSH password interactively (root/openke on custom NebulaOS, per docs/HOW_TO_SWITCH_STOCK_AND_CUSTOM.md) - this script never stores or hardcodes it.
Exits 0 and prints "SAFE TO POWER OFF" only if every check passed. Any single FAIL stops the EEPROM write-test step specifically (never attempts a write against a device that hasn't already proven a clean backup) but still finishes evidence collection for everything else, so the owner has a complete picture either way.
R1 (G82 stage 4, Run 19): rename the NebulaScreen repository, once, by the owner.
GitHub coreflake1/NebulaOS-guppyscreen -> coreflake1/NebulaOS-nebulascreen
local <workspace>/NebulaOS-guppyscreen -> <workspace>/NebulaOS-nebulascreen
Idempotent: every step checks first and skips what is already done, so it can be run again after an interruption. Never recreates the old name on GitHub (old URLs keep working through GitHub's redirect, so old builds stay reproducible). Ends with RENAME_RESULT=PASS or RENAME_RESULT=FAIL.
sh ~/workspace/NebulaOS/NebulaOS-firmware/tools/r1-rename-screen-repo.sh
nebulaos-usb - install NebulaOS and switch boot slots over USB recovery.
The backbone of the future end-user flasher (Windows/macOS/Linux): stdlib Python around the pinned, patched ballaswag/ingenic-usbboot (manifests/dependencies.conf INGENIC_USBBOOT_*), which talks to the X2000E boot ROM over libusb. Unlike the Ingenic Cloner's .ingenic, which erases the whole eMMC except sn_mac, this touches exactly three things:
the kernel partition of ONE slot (A: kernel 0xb00000, B: kernel2 0x1300000) the rootfs partition of that slot (A: rootfs 0x1b00000, B: rootfs2 0x20f00000) the OTA boot marker (ota 0x100000)
Never uboot, sn_mac, rtos/rtos2 or the data partitions. Offsets and capacities come from tools/emmc/nebulaos_layout.py (VERIFIED against the vendor Cloner profile and real hardware) and are cross-checked here at start-up.
sudo python3 nebulaos-usb.py marker sudo python3 nebulaos-usb.py set-slot A|B sudo python3 nebulaos-usb.py flash A|B --build /var/tmp/nebulaos-build//run- [--no-switch] sudo python3 nebulaos-usb.py flash A|B --ximage F --rootfs F --ximage-sha256 H --rootfs-sha256 H
flash checks before writing anything: the payload hashes against the build's attestation (.nebulaos-build-verified) or the given hashes; a MIPS U-Boot kernel image and a squashfs; both fit their partitions. It writes with usbboot's per-chunk read-back verification, then reads both partitions back whole and compares SHA-256 with the payload, and only then points the marker at the slot (verified by reading the marker's bytes).
The Pad must be in USB recovery mode (MicroUSB to the PC; power off; hold both buttons next to
the port 3 s, release reset first, power on; lsusb shows a108:eaef). u-boot is loaded once
per power cycle; a second upload fails until the next power cycle, so a failed load after a
successful one is not an error. Raw USB access needs root (sudo) on Linux.
exit 0 done and verified | 1 failed or not verified (do not boot) | 64 usage
usb-set-boot-slot.sh A|B - point the OTA marker at boot slot A ("ota:kernel", kernel p5 / rootfs p7) or B ("ota:kernel2", kernel2 p6 / rootfs2 p8) over USB recovery, when no system on the printer can be reached. The procedure of docs/DEVELOPER_RECOVERY.md section 3, made idempotent and verified:
- reads the real marker bytes first and swaps only when they point at the
other slot (usbboot --swap-ota toggles; it cannot pick a side);
- verifies the bytes afterwards - --swap-ota's own printout has been seen
to be wrong, so it is never trusted;
- touches only the marker (offset 0x100000, the "ota" partition): no
kernel, rootfs, RTOS, bootloader or partition table.
Before running: Pad in USB recovery mode (USB cable into the Pad's MicroUSB port; power off;
hold both buttons next to the port 3 s, release reset first, power on; lsusb shows
a108:eaef). Needs sudo (raw USB access) and ballaswag/ingenic-usbboot built (USBBOOT=...,
default ~/ingenic-usbboot).
2026-10-14: the first version re-ran usbboot --uboot after the swap and stopped there -
u-boot was still loaded, so a second upload fails although the swap had worked. u-boot is now
loaded once and reused.
exit 0 marker verified on the requested slot | 1 not verified (do not boot) | 64 usage
Write the NebulaOS release record for system updates over the internet (G79).
2026-10-29 (Run 23): no signing key. The record is a release asset next to the image; the printer fetches both over HTTPS from github.com only and keeps the image when its size and sha256 match the record:
nebulaos-release.json {product, channel, version, img {name, size, sha256},
source_head, [min_version, max_version, notes_url]}
make-release-record.py --img NebulaOS-Ender3V3KE-.img --version <x.y.z> --source-head <40-hex> --out
The printer side is nebulaos_osconfig/ota.py.
tools/sdk/export-sdk.sh <build run dir> <output dir>G50 (2026-10-17): export the cross-compilation SDK of a finished, attested firmware build for the NebulaOS package index (NebulaOS-KE/nebulaos-packages).
tools/sdk/export-sdk.sh
Host tooling only - the firmware build is not changed. Rules from the
architecture review (2026-10-17):
- the run is only READ: Buildroot's output/host is copied out and every
change (relocation marker, metadata) happens on the copy
- the Python ABI fingerprint is COPIED from the image the build produced
(target/usr/share/nebulaos/python-abi), never derived again
- the metadata records where it came from: firmware commit, run, attested
xImage/rootfs sha256, build image digest, Buildroot pin
Output: /nebulaos-sdk-.tar.xz and .sha256. After extracting, run ./relocate-sdk.sh once (Buildroot's own script) - the tools carry the build container's paths until then.
tools/sdk/publish-sdk.sh <build run dir>G71 (2026-10-17): make sure the NebulaOS package index can build for a firmware build's Python ABI - run after every attested build.
tools/sdk/publish-sdk.sh
A separate host step, run once the build launcher printed BUILD_ATTESTATION=WRITTEN - never part of the build, so it cannot change a build's result or attestation, and GitHub credentials never reach the launcher or the build container (architecture review 2026-10-17).
- The build's ABI id (the fingerprint the image carries). 2. Release sdk- in the index repository with BOTH assets (.tar.xz and .sha256) = done. That release, not the Pages index, is the idempotency key: the index only appears after a green CI run. 3. Only SDKs built from published source: the build's SOURCE_HEAD must be on origin/main of the firmware repository. Releases are first-wins per ABI id (every wheel for that id is built against that SDK). 4. Otherwise export-sdk.sh on the run (it only reads the run), create the release or complete a partial one, and start the index workflow, with the PC's own GitHub login (gh).
Output, always exit 0:
SDK_PUBLISH=PRESENT|PUBLISHED|REPAIRED|TOOLS_ADDED|SKIPPED_UNPUBLISHED_SOURCE|FAILED (reason)
SDK_INDEX_READY=YES|NO (the index already serves this ABI)
PreToolUse hook - STRICTNESS AT BOUNDARIES, NOT STRICTNESS EVERYWHERE.
The workspace is in DEV mode unless a human explicitly starts release work (WORKSPACE_RULES section 0). Ordinary edits and sandboxed commands are only checked for being in the right WORKSPACE (identity gate --dev: no network, no clean-tree, branch, worktree or drift requirement). What stays strict here is the privilege boundary: who may leave the sandbox, with which file.
FAILS CLOSED on the boundary: if the privilege guard cannot evaluate a request that asks for privilege, names a launcher or names an engine, it denies.
SessionStart hook - inject a SMALL, current identity summary.
Purpose: architectural identity must be present in context from the first token, and must survive compaction. This deliberately injects facts only - no project history, no narrative. Pages of history are what caused agents to reason from stale prose in the first place.
session-start.sh startup | resume | clear
session-start.sh --compact after compaction
Both use the DEV gate: fast and offline. The online canonical-remote check is a RELEASE concern (tools/verify-workspace-identity.sh --release).
Never blocks a session: a hook that hard-fails on startup would make the workspace unusable offline. It reports status; PreToolUse is what fails closed.
apply-workspace-control.sh - the ONE human command that installs the workspace-control layer and proves it took effect.
Installing .claude/ (hooks, settings, agents) is deliberately a human action: the sandbox keeps it read-only to agents, so an agent cannot install its own authority layer. This wrapper does every human-owned step in one go:
- refuses if the canonical control source has uncommitted changes - the hook binds the privileged launchers to their COMMITTED bytes, so installing uncommitted ones would only produce refusals later 2. installs canonical -> workspace root (sync --apply) 3. verifies: no drift, DEV identity valid 4. runs the fast control-layer test suites against the installed copies
Idempotent (re-running changes nothing once applied), fails closed, prints what it changes, and exits non-zero on any partial failure.
Run 23 (2026-10-29): the repositories moved from github.com/coreflake1 to the NebulaOS-KE organisation. Run ONCE by the owner (git config and .gitmodules are write-protected for the agent on purpose):
- every workspace repository's origin -> https://github.com/NebulaOS-KE/ 2. NebulaOS- nebulascreen/.gitmodules: lv_drivers -> NebulaOS-KE (+ submodule sync) 3. install the workspace control (apply-workspace-control.sh), which verifies the identity against the new remotes
Idempotent: a remote or URL that already points at NebulaOS-KE is left alone.
tools/run-nebulaos-build.sh [--plan] [--dev|--candidate|--qualified] <expected-firmware-sha>NebulaOS approved build launcher - the ONLY command the Build Agent is permitted to run outside Claude's sandbox.
WHY THIS EXISTS
The supported build path is NebulaOS-firmware/build.sh, which runs the whole pipeline inside the digest-pinned container. That needs the container socket, which Claude's sandbox denies by stripping supplementary groups. Leaving the sandbox restores the ENTIRE host group set - measured: an unsandboxed shell regains gid 972 and every other group the user holds. So the escape cannot be handed out as "the Build Agent may reach the container engine"; that is indistinguishable from "the Build Agent may do anything on this host".
The escape is bound to this file by REAL PATH and by CONTENT in .claude/hooks/pre-tool-use- identity.sh - the installed copy's bytes must equal the tracked canonical blob at firmware HEAD. Path identity alone was not enough: this directory is unversioned derived state that the sandbox permits writing, so naming an allowed path could otherwise have run arbitrary code with host privilege.
This file delegates to build.sh and reimplements no build stage. It is a gate, not a second build system.
BUILD ISOLATION
A qualification build NEVER runs in the canonical checkout. Stages 04 and 05 copy their output
over tracked paths under artifacts/, so a build in the canonical tree dirties the very
repository this launcher requires to be clean - one build would block the next until a human
ran git checkout, one slip away from a git add -A that commits build output.
Instead the source is cloned fresh, from the canonical REMOTE, at the exact requested commit, into a disposable workspace OUTSIDE the NebulaOS workspace root (putting it inside would register as an unexpected top-level repository and the identity gate would rightly object). Cloning from the remote also means only a PUBLISHED commit can be built: an unpushed SHA simply will not resolve, which is the property a release build wants anyway.
The canonical workspace is an immutable input. The build workspace is disposable and may be modified freely.
BUILD MODES
Semantic names, not pass-through options. DEV is the default; the two release-grade modes are explicit opt-in only (WORKSPACE_RULES section 0).
--dev (default) a development build of any LOCAL commit, pushed or
not. Cloned from the local firmware repository's objects, so
unrelated dirty or unpushed work elsewhere does not matter -
only the commit being built does. build.sh dev mode (cache
reuse). Writes a build record with BUILD_MODE=dev, which
DEV_INSTALL accepts and a release install refuses.
--qualified RELEASE: reproduce the already hardware-qualified baseline
byte-exactly. Any difference from QUALIFIED_BASELINE_TAG is
a failure, by design.
--candidate RELEASE: the same resolved-artifact assertions, but the baseline
comparison is informational. This is the correct mode for
source that legitimately contains not-yet-hardware-qualified
changes - see assert-baseline-config.sh's own header, which
says in terms not to evaluate such a candidate in the
reproduction mode.
--candidate maps to exactly one thing: NEBULAOS_CANDIDATE_BUILD=1. No other environment is set, no option is forwarded to build.sh, and there is no general VAR=value facility. The Build Agent gains one semantic mode, not an environment.
ON REFUSING THE PRESERVED HISTORICAL WORKSPACE This launcher deliberately carries no literal path match for it. Three layers already cover that and all three sit above this script: the OS sandbox's denyRead, the PreToolUse hook's input scan, and the identity gate's own topology checks. This launcher REQUIRES that gate to pass before it runs anything, so it inherits the refusal instead of duplicating it - and duplicating it here would mean writing the very string the hook refuses to accept in tool input.
Usage: tools/run-nebulaos-build.sh [--plan] [--dev|--candidate|--qualified] <expected-firmware-sha>
--plan prints the mode and source the launcher WOULD use, after its checks, and exits before cloning. For tests; the PreToolUse grammar does not offer it.
The SHA is mandatory and must be the full 40 characters. A build whose source identity was not stated up front is not a qualification build.
NebulaOS Hardware Agent launcher - the ONLY route from an agent to a printer, and the only command an agent may run outside Claude's sandbox to get there.
WHY THIS EXISTS
The PreToolUse hook refuses ssh/scp/sftp/ping/nc/socat/... to the hardware agent outright, with no target-bound exception, and that refusal STAYS. The main agent has no network inside the sandbox and no access to the credential store. Both reach hardware only by invoking this file. All device contact is encapsulated below, where it can be constrained and reviewed, rather than spelled out ad hoc in agent-authored shell.
WHO MAY RUN IT (enforced by the PreToolUse hook, not by this file)
nebulaos-hardware every operation
main agent every operation
anyone else nothing
Like the build launcher, the escape is bound to this file by REAL PATH and by CONTENT: the installed copy's bytes must equal the tracked canonical blob at firmware HEAD. Path identity alone is not enough - tools/ is unversioned derived state the sandbox permits writing, so naming an allowed path could otherwise run arbitrary code with host privilege.
THE INTERFACE IS SEMANTIC
run-nebulaos-hardware.sh --device --control inspect run-nebulaos-hardware.sh --device --control status run-nebulaos-hardware.sh --device --control diagnose run- nebulaos-hardware.sh --device --control capture run-nebulaos-hardware.sh --device --control exercise [EXERCISE_CONFIRM=] (without the confirmation word, confirm on stdin) run-nebulaos-hardware.sh --device --control restart run-nebulaos-hardware.sh --device --control reboot REBOOT_CONFIRM=NEBULAOS run-nebulaos-hardware.sh --device --control stage-stock-update STOCK_UPDATE_CONFIRM=SLOT1 run-nebulaos-hardware.sh --device --control rtos-logo RTOS_LOGO_CONFIRM=RTOS2 run-nebulaos-hardware.sh --device --control rtos- logo-restore RTOS_RESTORE_CONFIRM=RTOS2 run-nebulaos-hardware.sh --device --control purge-dev-leftovers PURGE_CONFIRM=DEV-LEFTOVERS run-nebulaos-hardware.sh --device --control datafs-repair DATAFS_REPAIR_CONFIRM=DATA-PARTITION run-nebulaos-hardware.sh --device --control verify run-nebulaos-hardware.sh --device --control install
There is no --host, --password, --command, ssh, scp, dd, marker, flash or raw usbboot - not
filtered, absent. reboot only returns an idle NebulaOS to NebulaOS; it writes nothing. rtos- logo writes the pinned NebulaOS RTOS image to rtos2 (p4) only, after a verified host backup;
rtos-logo-restore writes such a backup back. Neither can name rtos (p3) or any other
partition. The target is an ENROLLED DEVICE ID, not an address: an address is not an identity,
DHCP moves leases, and the machine answering at a remembered IP may be a different one. Which
addresses may be spoken to at all is a property of the human-created device profile.
C AND X
C the control commit: the reviewed, published source of every privileged
helper. Stated explicitly so that installing an OLD product still uses
CURRENT control machinery.
X the product commit being installed. Payload and data only.
THIS LAUNCHER REIMPLEMENTS NOTHING
Every partition write is performed by scripts/flash-spare-slot.sh, ON the device, whose bytes are read from C's git objects. That script owns the slot model, the live-target collision refusal, the capacity checks and the post-write read-back, and it has its own offline test suite. This file is a gate and a courier.
REPAIRS
restart is the only repair: one service from a closed list (klipper, moonraker,
nebulascreen/guppyscreen, webcam, nginx), with the on-device update supervisor's
stop/wait/start and print-idle semantics, refused while that supervisor holds a lock or is
validating. A RELEASE-mode device additionally requires a published control commit. There is
deliberately no package install: the Moonraker venv lives in persistent /usr/data, so a repair
there would outlive the next install and hide a broken image. Product defects are fixed in the
product and reinstalled.
PART 1 SCOPE ONLY
Nothing here homes an axis, moves a motor, heats a nozzle or bed, extrudes, calibrates, starts a print, or flashes the MCU. Those are Part 2 and are deliberately absent, not merely discouraged.
tools/sync-workspace-control.sh # DRY RUN - show what would changeInstall the NebulaOS workspace control layer from its version-controlled canonical source into the (deliberately non-git) workspace root.
tools/sync-workspace-control.sh # DRY RUN - show what would change
tools/sync-workspace-control.sh --apply # actually install
This is the ONE explicit command that is allowed to overwrite root authority and .claude/ state. Ordinary verification never repairs drift: silently healing a modified root would destroy the evidence that something changed it.
It also installs the machine-local launch sentinels into each active repo and git-excludes them, so they never dirty a repository and are never committed.
tools/verify-architecture.sh # fullNebulaOS architecture invariant verification.
This is NOT the identity gate. They answer different questions:
identity -> am I looking at the right source generation?
architecture -> does this source still satisfy the frozen NebulaOS architecture?
Every invariant below is derived MECHANICALLY from current source. Where an invariant cannot be checked honestly, it is reported DOCUMENTED_ONLY rather than given a test that would pass for the wrong reason. A green test that proves nothing is worse than an honest gap.
Usage:
tools/verify-architecture.sh # full
tools/verify-architecture.sh --quick # skip the slower tree scans
Exit: 0 = ARCHITECTURE_INVARIANTS_VALID=YES, 1 = NO.
tools/verify-workspace-identity.sh # DEV (default): fast, offlineNebulaOS workspace identity gate.
Purpose: prove, mechanically, that this workspace is the CURRENT canonical generation of NebulaOS source before any agent or human draws architectural conclusions from it.
This exists because a previous audit inspected an obsolete firmware generation, never noticed, and still emitted a confident production verdict. Any disagreement between local source identity and canonical remote identity must invalidate the audit source BEFORE broad investigation begins.
Usage:
tools/verify-workspace-identity.sh # DEV (default): fast, offline
tools/verify-workspace-identity.sh --dev # explicit DEV (--hook is an alias)
tools/verify-workspace-identity.sh --local # strict audit checks, offline
tools/verify-workspace-identity.sh --release # strict + canonical remotes (--full is an alias)
The workspace is in DEV mode unless a human explicitly starts release work.
--dev fails only on what makes this the wrong WORKSPACE: a missing
canonical repo, an archive or legacy authority tree in the active
root, the root itself being a git repo. Branches, dirty trees,
unpushed commits, extra worktrees, _worktrees/ and _scratch/,
control-layer drift and sentinels are reported as WARN, never
FAIL. Each operation validates its OWN inputs (a build its source
commit and pins, an install its artifact and device).
--local the strict checks without the network: canonical branches, one
worktree, clean trees, no drift, sentinels.
--release everything --local checks, plus every HEAD equal to the canonical
remote. Release preparation, freeze and qualification use this.
Exit: 0 = valid, 1 = NO.
How it works
- Architecture: slots and boot
- Image install and the trial
- Recovery
- The black box
- Printer configuration
Reference (generated)
Build and install
Project