Commit Graph
668 Commits
Author SHA1 Message Date
ScottW514 308a033146 laser.verdict-cut judges the hold by the gated output; laser.disarm-in-hold presses at the arm
Both tests failed for the operator on the bench reference on
2026-09-24, image 20260923232513, from the harness and not the machine.

laser.verdict-cut (00:10:52) needed the kernel's sampled LASER_ON count
to read 0 within 2.5 s of the hold. That count latches once a second
over the second before, so it reads 0 only once a whole window has
closed inside the hold, up to 2 s in, and this hold lasted 1.37 s. Every
sample of the hold read bit 0 of interlock_circuit as 1: the gated
LASER_ON, active low, dark.

The dark judge now reads the gated output itself, cnc/laser_on (1 = on),
about 3300 times a second on the bench reference, yielding the CPU
between reads (the stream threads run SCHED_FIFO). Its witness is the
same reads over the second before the pause, which must see the cut lit
(10 reads or more, none failed). The hold is dark when every read that
falls wholly inside it, from 0.3 s after the first Hold:0 to the last
Hold row, reads off, none failed, and there are at least 500. The 0.3 s
is the pause tier's first deceleration, lit on purpose, still playing
out of the driver's 200 ms queue and its 10 ms lead when the controller
reports Hold:0. The daemon's pause is 3.0 s, as the code already had
and the description now says. dark_span() is pure, kept inside the
test's function, and tests/test_laser_verdict.py runs it through the
function's code object over synthetic trails (8 cases: a dark hold, the
lit deceleration inside the drain, emission after it caught, a burst the
resume ends, failed reads counted, the drain counted from the first
Hold:0, no Hold:0, and a hold too short for the drain).

laser.disarm-in-hold (00:06:23, 180 s) pressed the button through
ctx.act at once, before the job reached its arm wait, so the press was
lost and the move never started. It now uses ctx.arm_press(), which
presses when the button lights, and waits for Run.

Proof: forgetest's unit tests pass on the host (474 OK, 4 skipped). Only the
fingerprints of laser.verdict-cut and laser.disarm-in-hold move.

Acceptance: the change is the two catalog tests themselves; both are
attended (laser emission, the operator present) and a campaign runs
both again.
2026-09-24 14:20:06 -04:00
ScottW514 dc9bede034 forgetest: the first /state parses each suite module once
The first GET /state after forgetest starts hashes every test's
implementation, and it took from 81 s to more than 12 minutes on the
bench reference. Two causes:

- sibling_imports() read and parsed a test's module and every sibling it
  imports, transitively, once per test: 280 ast.parse calls for 26 suite
  modules. module_parts() kept its own parse, sibling_imports() kept
  none.
- The page gives up on a poll after 20 s and polls again; the server
  thread goes on computing. Each new poll started the same cold work
  beside the first, all of it on the one CPU, so the longer the first
  took the more copies ran. That is the spread between 81 s and 12
  minutes.

Each module's text and tree are now read and parsed once and kept, as
are its direct sibling imports, and the implementation hash is filled
by one thread at a time: a poll that arrives while it is computed waits
for it instead of repeating it. catalog.forget(path) drops what is kept
about a file, for the unit tests that edit their modules.

The hashes do not move: every test's implementation hash and domain
fingerprint on the image manifest of 20260923220034 is byte-identical
before and after, so no result is invalidated. On the host the cold
computation went from 7.00 s to 0.29 s (280 parses to 25). On the bench
reference (the file bind-mounted on image 20260923220034, the page
open) the first /state answered in 8 to 12 s after a restart, where the
same restart earlier in the evening had not answered after 7 minutes.

tests/test_responsiveness.py pins both: each suite module parsed at most
once while every test's implementation hash is computed, and three
threads reading one test's hash compute it once. With the old behavior
put back by a patch both fail (280 parses for 26 modules; the hash
computed 3 times). forgetest's unit tests 454 OK. forgetest is the
dev-only harness, outside the catalog's coverage; its catalog
consequence is none, since no fingerprint moves.
2026-09-23 19:22:10 -04:00
ScottW514 56af6eaab2 setup.check-envelope: the head goes back to the home, or the run fails
The test's cleanup jogged the head back to the camera home through the
panel's relative jog, the increment taken from the port's position. The
core refused it every time (error:15, "the move would leave the work
envelope"), eight tries each run, and the test logged "the head is not
back at the home" and passed. The takeover that puts the setup record
back restarts the controller, which zeroes the kernel counters where the
head stands, so the baseline read (0, 0, 0) and called the machine
clean. On the bench reference the head was left 10 mm from the home in X
and in Y by every run of 2026-09-23 that got that far (three PASSes and
one FAIL, images 20260922225653, 20260923084705, 20260923220034).

Why the jog was refused: the port reports the step counters, while the
core adds a relative jog to its own parser position. The bed check's
last jogs, sized from the counters, leave the two apart by up to half a
step. The arithmetic reproduces every logged value: the parser held X
60.1 after the answers, the port read 60.099, the check jogged -50.099
toward X 10, so the parser took 10.001, which the step grid (213.33
steps/mm) turned into 2134 steps, read back as 10.003. The test's
return of -10.003 then targeted X -0.002, and a camera home's envelope
begins at exactly 0, so the core refused the whole jog. No product path returns the head by a relative
jog sized from the counters; the panel's Jog card sends fixed steps.

Now the head goes back in one jog from a Grbl client to the home in
machine coordinates ($J=G90 G53, 0.5 um inside the envelope's start,
far under half a step), and the run fails unless the port reads the head
on the home's step. That reading is taken before the restart, because
nothing after it can see a head left out.

Bench reference, image 20260923220034 with the suite file bind-mounted:
a negative control that aims the return 1 mm off the home FAILs with
"the head is not back at the home (0.0, 0.0, 3.08): [0.998, 0.998,
3.08]" while the baseline still says clean; the fix PASSes with
"$J=G90 G53 X0.0005 Y0.0005 F1200 -> ok" and the head at [0.0, 0.0,
3.08]. forgetest's unit tests 454 OK; the coverage lint passes with
--enforce on the image's manifest.
2026-09-23 19:22:10 -04:00
ScottW514 c95b5aa771 update.job-locks: ask the releases API when nothing was checked
The test took the release to download from GET /update/release, the
kept answer of the daily check. That answer does not outlive a restart
of forgectrl, and a campaign restarts it many times after
update.release-check has run, so on the bench reference (image
20260923084705) the test found "not checked yet" and failed with "no
release is published to download". When the kept answer was never
checked, the test now asks POST /update/check itself, the way
update.release-check does.

PASS on the bench in the unattended queue (the check asked, 200; the
download of v0.0.6 held the lease and ended; no archive left behind);
forgetest's unit tests 452 OK.
2026-09-23 17:50:48 -04:00
ScottW514 d45520c218 exthost.motion-jog: the test needs GRBL mode
The package's jog goes through the controller port, which only the GRBL
controller serves. The test declared no mode, so it ran in whatever mode
it found. Alone it always found GRBL mode; in the unattended queue it
runs after the cloud tests, which leave the machine in cloud mode, and on
the bench reference (image 20260923084705) the jog came back 409 "the
GRBL controller is not running". It now declares mode="grbl", so the
runner switches before it starts.

The other tests without a mode were checked for the same need: none of
them moves the machine or reaches the port (update.job-locks posts a
job, and the job route refuses on the lease before anything else, in
either mode).

exthost.motion-jog PASS on the bench in the queue after the cloud tests;
forgetest's unit tests 452 OK.
2026-09-23 17:32:33 -04:00
ScottW514 2870daa2aa logs.routing: forgeext is one of the loggers
The extension host has had its own logger (log_forgeext_disk and
log_forgeext_remote, a block in the rendered rules, and
/data/log/forgefirm/forgeext/) since it joined the image, and GET /logs
reports it. The test's list of loggers never gained it, so the first run
of logs.routing on an image that carries forgeext failed on the bench
reference (image 20260923084705): "loggers reported: [... 'forgeext'
...]". The list now names forgeext, so the test also holds its rules
block, its effective levels, and its directory, and a stray program's
line must not land in forgeext.log either.

logs.routing PASS on the bench with this file mounted; forgetest's unit
tests 452 OK.
2026-09-23 16:59:11 -04:00
ScottW514 82c354d70e setup suite: the port wait, and the first run's accounts on a failure
Two harness faults found on the bench reference in the campaign on image
20260923084705.

setup.gate-blocks-controllers probed the Grbl port once, at the moment
GET /mode first read the controller running. The supervisor reports
running from the fork and the controller binds TCP 23 a few tens of
milliseconds later, so the probe failed whenever the 0.25 s poll landed
inside that window ("the Grbl port is closed with the controller
running"). Timed on the board with the controller restarted through
forgectrl: /mode read running at 0.066 s, the port accepted at 0.087 s.
The test now waits up to 10 s for the port (50 ms polls) and records
how long it took (port_open_after_s). PASS on the bench (0.001 s).

first_run() replayed the system accounts (forgefirm-users reload) and
removed the temporary home only after its block succeeded. When the
block failed, the records came back but /etc/passwd kept the temporary
account in place of the operator's, and image.health failed after it
(the record's account missing from /etc/passwd). The replay and
the home removal now run in a finally. Proof on the bench: the failing
case run on purpose (setup.first-run-flow against the image's own
forgectrl, which refuses a home by hand at the cloud step) leaves the
operator's account the one account from uid 1000 up and no temporary
home; the same failure before the fix left the temporary account in
/etc/passwd and its home in /data/forgefirm/home.

forgetest's unit tests: 452 OK. setup.first-run-flow and
setup.gate-blocks-controllers PASS on the bench with this file mounted.
2026-09-23 16:29:55 -04:00
ScottW514 853f862f03 Update release 2026-09-23 07:33:33 -04:00
ScottW514 ac2456261e The measured envelope's harness, and setup.check-envelope
scripts/bench/envelope_test.py drives the null-sink controller with the
scripted sender and port client of ctlport_test.py, homing manually:
unset keys are the axis travel; envelope_x_mm and envelope_y_mm are the
far edges at a home and are held to 50 mm up to the travel plus 30 mm;
the port's envelope open is refused before a home and under an open
armed window, and lets the port's jogs reach the travel plus 30 mm;
envelope apply puts the keys' edges back without a home; a status poll
and an empty line leave an open envelope open, while the sender's first
line closes it before the core reads it (a jog into the margin draws
error:15), a running port jog canceled first; a soft reset and the port
client going away each close it. It is registered in the bench
registry as a CI harness, not a bench-page tool.

setup.check-envelope (suite/bedsize.py, its own module): with cloud mode
on, the machine is homed with the camera, and the Bed size check runs
with its jog prompts answered as the page would, out 60.1 mm in X and
61.1 mm in Y from the home. The ends it reports are the home plus
those, the far edges 1 mm short of them are written as envelope_x_mm
and envelope_y_mm, and the controller holds them at once: a panel jog
0.5 mm short of each edge runs, and one 0.5 mm past it is refused with
nothing moved. A second run is opened, and while its first prompt waits
a Grbl client sends one line, which draws the controller's word that
the envelope closed and then ok; the port then says the envelope is
closed, the next answer ends the check in words, and the keys are as
the first run left them. The head is jogged back to the home, and the
keys, the homing mode, and the setup record are put back as found, the
record under a restart.

Proof: envelope_test.py ALL PASS on the host build of the controller of
this change. On the bench reference, with that controller and forgectrl
of the same change bind-mounted: the first two campaigns found flaws in
the test, not the check (jogs of 20 mm made an edge below the 50 mm
forgectrl refuses; the second run started while the machine was still
busy; the head was not returned to the home), then setup.check-envelope
PASS, with forgectrl.auth, kernel.latch-locked-idle, motion.pacing,
motion.jog-roundtrip, and motion.port-jog PASS in the same campaign, and
cloud.mode-switch, motion.soft-limits, and homing.cloud-offsets PASS
after it. forgetest's unit tests pass (452), and the coverage lint
passes with --enforce.
2026-09-23 04:39:06 -04:00
ScottW514 8fc07c47ee forgetest: update.job-locks and homing.cloud-offsets
update.job-locks (suite/updlock.py, its own module): with a release
published on the releases API, POST /update/download starts the
download job (202), which fetches and checks the release's archive and
applies nothing. While it runs, /status names update:download, of kind
system, as the lease's holder, and a settings write and a posted job are
each refused in its name (409). When it ends, the lease is free and the
same settings write is taken. The downloaded archive is removed when it
was not there before.

homing.cloud-offsets (suite/homeoff.py, its own module): with cloud mode
on, homing_mode = gfcloud, and the camera-home offsets at (4.5, -3.25),
$H runs the web-service homing session, and when it ends the controller
declares (4.5, -3.25) on the step grid. The work envelope reaches back
to the home on the negative axis: a jog 2 mm out and 2 mm back in Y and
then in X is accepted and ends at the home. The homing mode and the
offsets are put back as found, each retried until the daemon takes it,
since a settings write is refused for a moment after the suite's Grbl
client closes.

Proof: on the bench reference, update.job-locks PASS after
update.release-check, and homing.cloud-offsets PASS in a campaign with
forgectrl.auth, kernel.latch-locked-idle, and motion.pacing, which also
PASS. forgetest's unit tests pass (452), and the coverage lint passes
with --enforce.
2026-09-23 03:44:22 -04:00
ScottW514 69b56ec1ff forgetest: exthost.wizard, a package's own check on the Setup page
exthost.wizard (suite/extwizard.py, its own module): a package with the
reference id that asks for wizard, whose service runs a check on its
call socket and keeps whether it passed. GET /wiz lists it among the
extensions as pkg:<id> with the package's title, not done. POST
/wiz/pkg:<id>/start runs it on the wizard runner: GET /wiz/dark shows
the package's log line, phase, progress, and confirm prompt, and the
machine lease stays free while it waits. The operator's Yes reaches the
service as yes, the run ends complete with the package's summary, and
GET /wiz then lists the check as done from the package's own answer. The
machine's setup record is the same before and after, with no pkg: wizard
in it and the gate as it was. A second run is aborted from its prompt,
and the service is told. A check of a package that is not installed is
404.

Proof: on the bench reference, with forgectrl and forgeext of the wizard
change bind-mounted, exthost.wizard PASS, beside setup.check-sensors,
exthost.service, and exthost.package-routes PASS on the same daemons.
forgetest's unit tests pass (452), and the coverage lint passes with
--enforce.
2026-09-23 03:44:22 -04:00
ScottW514 99d9830991 The M-code barrier's harness, and exthost.mcode
scripts/bench/mcode_test.py drives the null-sink controller with the
scripted sender and port client of ctlport_test.py: the port's mcodes
table and its refusals, error:20 for a number nothing answers, an
answered M-code's wait (the head still, the port's state naming it with
its words, a port jog busy:mcode, stale and out-of-form answers refused,
the answer's words on the console), a refusal and a timeout each holding
the job, a soft reset ending the wait, and a wait under an open armed
window with M3 S500 shipping no FIRE tick. It is registered in the bench
registry as a CI harness, not a bench-page tool.

exthost.mcode (suite/extmcode.py, its own module): a package with the
reference id that asks for mcode:160 and job_time.run, the one granted,
and whose service answers POST /mcode on its call socket. The host's
status names it as the one that answers M160. A dark job (out 5 mm, M160
P1, back) through POST /job waits at the M-code with the port's state
naming it and the head still over 1 s (the kernel's counters and the
port's position), the service answering after 2 s; the job then ends
done, every line acknowledged, the head back, the service asked once with
the code and its words, no discharge. A job naming M161 fails at that
line with error:20 and nothing moved. A job at M160 P2, which the service
refuses, is held, and is aborted from there. A job left running by a
failed check is aborted before the put-back.

Proof: on the bench reference, with the controller, forgectrl, and
forgeext of this change bind-mounted, exthost.mcode first FAILED and
found the controller announcing the M-code before the kernel had played
the last move (the driver now waits for the kernel to go idle), then
PASS; exthost.service, motion.job, exthost.page-call, exthost.motion-jog,
motion.port-jog, and exthost.package-routes PASS on the same daemons.
mcode_test.py ALL PASS on the host build. forgetest's unit tests pass,
and the coverage lint passes with --enforce.
2026-09-23 02:50:34 -04:00
ScottW514 4e47b7fbf1 forgetest: exthost.catalog, and the coverage lint no longer allows an advisory away
exthost.catalog (suite/extcat.py, its own module): GET /ext/catalog
answers the index the host keeps and the one address it is fetched from.
On a scratch root under /tmp, with a throwaway key standing in for the
OpenGlow extension key, the machine's own forgeext keeps an index signed
with it, and the author key it names for one id makes a package of that
id read as community and endorsed, where before it was unverified; the
same key on another id counts for nothing. On the machine's own root that
index is refused in words, a package handed over as an index is refused
by the product gate, and the index kept is left as it was. The relay
refuses an id with no such form (400) and one the kept index does not
list (404, or 409 with none kept) before anything is fetched, and a
refresh from the fixed address keeps OpenGlow's index when one is
published there and is 502 in curl's words when none is, the kept index
left as it was. Nothing is left in the staging directory.

The coverage lint had a gap: coverage_report() let the allowlist's
docs/** and **/*.md take out a path the BEHAVIORAL list keeps in every
fingerprint, so the four first-run advisory documents were covered by no
test and the lint passed. A change to the privacy advisory would have
invalidated nothing. A behavioral path is now never allowed away, and
setup.advisories-rehash, which accepts every first-run document at its
current hash, covers the four.

Proof: on the bench reference, with forgectrl 848ccc1 and forgeext
a64b933 bind-mounted and the privacy document accepted again at its new
hash with the fixture's press, exthost.catalog PASS (the refresh was 502:
GitHub answered 404, nothing is published at the address yet), and
setup.advisories-rehash PASS with the rest of the campaign. The lint's
new unit test reports the uncovered advisory, and the old reading (the
override ignored) reports nothing for it, which is the gap. forgetest's
unit tests pass (452), and the coverage lint passes with --enforce.
2026-09-23 01:56:57 -04:00
ScottW514 675fc11cb1 forgetest: events.button-telemetry and exthost.lifecycle
Two new tests, each in its own module so that no other test's
fingerprint moves.

events.button-telemetry (suite/evmore.py): a stream opened at
forgectrl's read-only listener, from a loopback source of its own, gets
a telemetry.tick within 14 s with exactly its fields (phase, verdict,
fire_ok, down_c, up_c, state, lid). With the machine idle and nothing
waiting for the button, one press (the fixture's, or the operator's)
arrives as pressed true and then false, in that order. That a press the
machine is waiting for is no event, and the update and setup edges, are
events_test's.

exthost.lifecycle (suite/extlife.py): a package that follows its feed is
running when extensions are turned off. It reads ext.shutdown with the
host's reason and writes it to its data directory before its process
ends, the host stops it a second later, and nothing is frozen in that
second. Its covers name forgeext's run, machine (the armed question
asked during the grace), evfeed, and api.

Proof: on the bench reference, with forgectrl a9c45fc and forgeext
dbb00e5 bind-mounted, both PASS. exthost.lifecycle FAILed on the host
before its grace fix (the service frozen for want of an armed reading)
and PASSed after it. forgetest's unit tests pass (451), and the coverage
lint passes with --enforce.
2026-09-23 00:39:13 -04:00
ScottW514 71b44598eb test_cloud_suite: the quiet wait's deadline is no longer the test
The cloud suite's host tests replay a print's log from threads, and the
suite's quiet wait gave up after 3 s (QUIET_TIMEOUT_S in setUp). On a
loaded host the last replayed lines landed after that, and a test failed
with "still running service moves after 3 s" instead of its own finding:
about one run of the module in five, a different test each time, alone
as well as under the full suite. The deadline is now 15 s. It is a
deadline and not a wait: a quiet machine is seen at once, so a passing
run is no slower. The one test that waits the deadline out on purpose (a
motion that never goes idle) sets 3 s for itself; tearDown puts the
module's value back.

Proof: before, test_cloud_suite failed 2 of 10 runs by itself, every
failure the quiet deadline (test_lid_during_button_wait_on_the_bench_excerpt,
test_pause_resume_fails_without_the_retraced_restart,
test_mode_switch_fails_when_gfhome_never_saw_the_head_move,
test_pause_resume_fails_when_the_kernel_refuses_the_resume). After, 10 of
10. forgetest's whole unit suite: 451 tests OK, 4 skipped, in 492 s
against 491 s before. A host-test change: no catalog consequence.
2026-09-22 23:54:12 -04:00
ScottW514 fab8920cd9 exthost.operator-destinations: the operator names where a package may connect
A new catalog test in a module of its own (suite/extdest.py). It installs
the reference package's id and key asking for net.outbound.operator, with
a service that reports at every start what GET /v0/self says it may
reach and what one dial to the machine's DNS resolver on TCP port 53 does,
and one to the port above it. The resolver is an address every network
gives the machine, that is not the machine, and that answers; named by
address, it gives the service no resolver of its own. It holds: before
anything is named, no destination and the dial refused inside the
sandbox (EACCES), and no rule for it in the package's chain; the machine's
LAN address and loopback refused in the host's words, a destination out
of form 400, an unnamed removal refused; named through POST /ext/dest,
the service started again with the destination, connected, still refused
one port up, and the chain carrying the rule, with no crash; taken away,
started again and refused again, the rule gone. A resolver that takes no
TCP connection even from root fails the test in words rather than passing
it on nothing. covers: forgeext src/install.*, state.*, run.*, super.*,
api.*, caps.*, manifest.*, netrules.*, main.c; forgectrl src/extpkg.*,
main.c.

Proof: on the bench reference (image 20260922225653, with forgeext
28f829e and forgectrl e2d0d05 cross-built and bind-mounted, and this
suite bind-mounted over the installed one), exthost.operator-destinations
PASS in 22 s, with exthost.service, exthost.page-call,
exthost.ui-delivery, exthost.package-routes, exthost.events, and
exthost.panel-install PASS in the same campaign. The first run of it
failed in its own reading of /etc/resolv.conf (bytes against text); the
fix is in the test's module alone, and the PASS is on the fixed module.
2026-09-22 23:15:37 -04:00
ScottW514 861f5597e6 exthost.page-call: a package's page asks its own service
A new catalog test, in a module of its own (suite/extcall.py) so that no
other test's fingerprint moves. It installs the reference package's id
and key with a page and a service of its own, whose service reports what
it was handed and answers its page's calls on the listening end the
host gave it. It holds: FFX_CALL_FD=4 with descriptor 4 a listening
socket and 3 and 5 closed; the socket root's 0600 in root's 0700
directory; POST /ext/call relaying a POST and a GET, the service's own
refusal passed through as its status and words, and a service asking the
API while it answers; five calls out of form refused, 400 by the relay
or 409 in the host's words; forgeext call answering alike; and a disabled
package refused in words, its service stopped and its socket's name
gone, then answering again once enabled. The package, the key, the
setting, and the setup record are put back as found, and no socket name
is left. covers: forgeext src/call.*, src/run.*, src/sandbox.*, src/main.c;
forgectrl src/extpkg.*, src/main.c.

Proof: on the bench reference (image 20260922225653, with forgeext
5c13f1c and forgectrl 2cf6b12 cross-built and bind-mounted, and this
suite bind-mounted over the installed one), exthost.page-call PASS in
16 s, and exthost.service, exthost.ui-delivery, and
exthost.package-routes PASS in the same campaign. forgetest's unit tests:
451, one failure in test_cloud_suite, whose replayed prints flake on
their timing alone as well (1 of 3 runs of that module by itself, in a
different test each time) and touch nothing of this change.
2026-09-22 22:43:51 -04:00
ScottW514 b7e7c0fb2c Manifest: the advisories are behavior, and forgeext's kit and packages are not
forgectrl embeds its advisory documents in the binary, serves them, and
records the operator's consent to one by its hash, so an edited advisory
is a changed consent. They are Markdown under docs/, which the
non-behavioral list takes out of every fingerprint, and that left
setup.extensions-consent's covers entry for docs/advisories/extensions.md
selecting nothing: the enforced coverage lint fails on it (exit 1 on the
dev image's manifest of 20260922225653), and an edited advisory moved no
fingerprint at all. A BEHAVIORAL list now keeps forgectrl's
docs/advisories/** in, ahead of the non-behavioral one.

forgeext's recipe installs the binary and its init script and nothing
else, so packages/ (the official packages, which carry their own
acceptance artifact), sdk/ (the author's kit), template/ and tools/ are
non-behavioral for the image: without that, an edit to the alignment
page or the kit would make every exthost test stale on the next image.

Proof: test_manifest passes its 25 cases, the new ones among them; the
enforced coverage lint on the dev image's manifest of 20260922225653
exits 0 with no empty entry and nothing uncovered, where it exited 1
before. The whole forgetest suite ran its 451 tests; one,
test_cloud_suite's test_pause_resume_passes_on_the_machines_lines,
errored under the suite's load and passes 16 runs of 16 alone, at HEAD
and on this tree alike: it replays a print against timed hooks.
2026-09-22 21:41:30 -04:00
ScottW514 2f51e38178 exthost.service: a package's frame with the lamp named
The reference service takes two more lid-camera frames through its API
socket, with the camera's lamp at 0 and at 1023, and one with a lamp
past its range. The test holds both named frames to being JPEGs, the lit
one the larger (the lamp reached the camera and changed the picture),
and the out-of-range one to a 400 that names the lamp. The service's
text is shared by every exthost test, and every one of them already
covers forgeext's api.c and run.c, which this change moves, so no test
is made stale that was not stale already.

Proof: on the bench reference, image 20260922225653 with forgeext
cd8b471b and forgectrl 9c9ee557 bind-mounted, exthost.platform and
exthost.service PASS: 51,561 bytes at lamp 0, 109,850 at lamp 1023,
and lamp 5000 refused.
2026-09-22 20:21:31 -04:00
ScottW514 d9cc61f198 forgectrl.panel-serves: the page lets no frame navigate
The panel page now carries Content-Security-Policy: frame-src 'none'
(forgectrl 95970cf), the one thing that keeps a package's page from
navigating its own frame to another address with what it was shown in
the URL. What the header does in each browser is the frame-isolation
harness's to prove (forgectrl tools/frame_isolation.py); this test holds
the machine's own served page to carrying it, read with the headers from
the loopback listener. The edit is inside the test's own function, so no
other test's fingerprint moves.

Proof: on the bench reference, image 20260922225653, PASS with forgectrl
95970cf bind-mounted ("GET / Content-Security-Policy: frame-src 'none'"),
and FAIL against the image's own daemon: "the panel page lets a frame
navigate: 200 None".
2026-09-22 20:00:38 -04:00
ScottW514 1bdc11f6fe exthost.ui-delivery: the refusals in the host's words, and the settings
The test held a missing package's page to any status of 400 or more, and
the relay's 502 passed it: the host's refusal lost on the way to the
panel looked the same as a refusal. It is 404 in the host's words now.
The same relay carries a package's settings, which no test asked for
through forgectrl: the test reads them, sends a value over its bound,
and holds the answer to 400 in the host's words with the stored value
unmoved.

The edits are inside the test's own function, so no other test's
fingerprint moves.

Proof: on the bench reference, image 20260922152358, the image's own
forgectrl fails the test (a disabled package's page 502, "the extension
host did not answer"); with forgectrl 8055000 and this file bind-mounted
it passes: 404 "that package is not installed", 404 "this package is
disabled: its interface is not served", and 400 '"threshold" is at most
100' with the threshold still 40.
2026-09-22 18:50:21 -04:00
ScottW514 625d0acec7 exthost.core-range: the evidence is a dict
The test wrote its evidence through ev.note(), which the run's evidence
does not have: it is a plain dict, as every other test uses it. On the
bench the test stopped at its first record with an AttributeError, an
ERROR before anything was asked of the host. The records are keys now,
and the three ranges are one list. The description said a package asking
for a range was installed; the test only inspects, and it says accepted.

No host test runs a suite function, which is how this reached the image.

Proof: on the bench reference, image 20260922152358 with this file
bind-mounted over the installed one, exthost.core-range PASS: the image
stamps "20260922152358", which is no version; inspect answers
core_checked false with no range, with a minimum of 99.0.0, a maximum of
0.0.1, and both ends, and installs nothing.
2026-09-22 18:50:21 -04:00
ScottW514 d410c6f696 manifest-from-tree: forgeext is a component
forgeext ships from its own repository with its own recipe and pin, and
62 covers entries across the catalog name it - but the tree manifest the
coverage lint runs on did not know the component existed, so every one of
those entries selected nothing. The lint exits 1 on an empty entry, so
the merge would have failed on it; worse, until then forgeext's sources
were not coverage-checked at all.

A pin nothing has been pushed to yet (SRCREV all zeros, which is how a
work branch carries this component until the merge) names the component
with no files instead of failing the whole manifest. The lint then
reports its entries as empty and exits 1, which is what an unbumped pin
should do, and the script stays usable in the meantime.

Proof: the script names forgeext and says its pin is not set; with the
component's file list stood in, the 31 empty (forgeext, ...) entries
become zero, and the 32 that remain are forgectrl and grblHAL paths that
do not exist at their pinned commits yet - the pre-push state the work
branches are in.
2026-09-22 10:15:07 -04:00
ScottW514 a552541acd logs.tree-tail-export: the bundle's extension listing
The bundle carries system/extensions.json now. The case reads it out of
the tar, holds it to being the status document, and holds every listed
package to naming its id, version, tier, whether it is enabled, and what
the operator granted it - which is what a report from a machine running
extensions has to be read against. covers gains forgectrl's src/extpkg.*,
where the document is built.
2026-09-22 10:12:25 -04:00
ScottW514 5915408147 exthost: the effective capability list, and a disabled package's page
exthost.package-routes reads the new `effective` field beside the
manifest's own list: what the package may use is never wider than what it
asked for, holds everything the operator granted, and the panel's list
and the host's own command line say the same thing. That is the list the
panel's bridge decides on, so it is the one the case has to hold.

exthost.ui-delivery gains the disabled package: its page is refused in
words, and served again once the operator enables it. Disabling a package
is the way out of everything it does, and its interface was the one door
that stayed open.

Both are changes inside the tests' own functions, so no other case's
fingerprint moves.
2026-09-22 09:57:20 -04:00
ScottW514 59887ac742 exthost.armed-freeze: the camera is shut for the window too
The case proved the freeze and stopped there. A package with the
operator's job_time.run grant is not frozen, and a capture costs
kernel-side work beside the step stream, so the window has a second half:
no program takes a picture during a cut.

From inside the same real armed window the case already opens, the
sampler now takes one background capture and keeps what the machine said.
It must be refused (409, in words that name the window), and a second one
after the window closes must come back a JPEG. The refusal is decided
before any frame is taken, so the probe costs the print nothing - and if
it were served instead, the capture that happened is the one the rule
forbids, which is what the case is here to catch.

covers gains forgectrl's src/cam.* and src/main.c, where the refusal is.
2026-09-22 09:32:13 -04:00
ScottW514 4388d69816 exthost.core-range: the host reads the image's own version file
A package's core range is judged against the version the host reads
from /etc/forgefirm-version, and forgeext read no version at all out of
the form a release image writes it in, so a release image judged no
range. That fault is fixed and proven in forgeext 4fefdb6.

What a campaign can add to that is narrower than it first looks. The
release image does not ship this suite, so a campaign only ever runs on
a dev image, and a dev image stamps ${DATETIME} (dev), which is no
version. The branch where a range is actually judged is therefore
unreachable from here on every machine this suite can run on, and it is
not this test's to claim: version_test in forgeext holds that branch,
over the file as each image build writes it.

So this test proves the half a machine can, which is the half that ties
the daemon to the image it is on: the host reads the image's own file,
finds a build stamp, and judges nothing rather than guessing a version
out of it. A package asking for a minimum far above or a maximum far
below installs all the same, and inspect answers core_checked false. A
dev image that began stamping something version-shaped would fail here,
and that is the regression this test is for; it says so in its own
words rather than leaving a reader to wonder why it only ever takes one
branch.

It is a module of its own, and that is deliberate. A test's fingerprint
holds the shared text of the module it lives in, and the shared text is
every line outside a @test function, the blank lines between them
included - so a test cannot be added to exthost.py without moving the
fingerprint of every test there. Adding it there moved all ten,
exthost.platform (which covers linux-fslc alone) and the operator drill
exthost.panel-install among them. In suite/extcore.py it moves none:
measured across the change, 113 tests before and 114 after, no
fingerprint moved. The helpers come from exthost by import, which puts
that module's shared text in this test's fingerprint and not the other
way round.

Proven on the host: the catalog loads and registers 114 tests, the
coverage lint passes with 0 uncovered paths, pyflakes reports nothing,
and the unit tests run 451 and pass, as they do on the revision before
this one.
2026-09-22 08:28:48 -04:00
ScottW514 77e4c03369 exthost.motion-job: a package runs a program, dark and witnessed
The reference package is installed with motion.job granted, writes a
program into its own data directory, and asks the host to run it.

What it proves on the machine: a program named with a path and one that
is not there are refused by the host, with the machine never asked; the
real one is taken by the machine as the package, every line sent and
acknowledged; the head moves and the head accelerometer sees it; and the
laser latch stays locked with every emission witness at zero.

The program commands no laser, so the runner ends the job as one that
ended without a discharge. That is what a dark program is, and the test
asserts it rather than glossing it: the whole program ran and nothing
fired. A job that does fire is cloud.dark-print's and
laser.emission-witness's.

Three things the first runs taught. A button press needs an "until" - what
the press is expected to do - and a dark program never opens an armed
window, so there is nothing for a press to arm and the test does not
press. The job's lease owner is prefixed "job:". And the job record's
emission.samples counts samples taken, not samples that saw light; the
witnesses are laser_on_samples, lit_s and thermopile_delta.

Proven. The unit suite: 451 tests; install_test: 225 checks. On the
bench reference, image 20260922014545 with the cross-built daemons
mounted over the image's: exthost.motion-job PASS, with exthost.service,
exthost.motion-jog and motion.job PASS beside it. Against the image's
own daemons the route is 404.
2026-09-21 23:02:28 -04:00
ScottW514 0ba0fb112b exthost.ui-delivery: a package's page, served and refused
A package that asks for ui must ship ui/index.html and one that ships it
must ask: the test packs an archive each way and checks the install
refuses both in words. With a proper one it checks GET /ext/ui hands the
page back byte for byte, and that a package which is not installed is
refused.

Each control archive is packed in a directory of its own, because the
packer makes a signing key there and fwup will not remake one over an
existing file - the first version shared a directory and errored.

Proven. The unit suite: 451 tests. On the bench reference, image
20260921225820 with the cross-built daemons mounted over the image's:
exthost.ui-delivery PASS, with exthost.service and exthost.package-routes
PASS beside it. Against the image's own daemons it FAILS: the older host
installs a package that asks for ui and ships no file.
2026-09-21 21:42:13 -04:00
ScottW514 f74382984c exthost.motion-jog: a package moves the machine, watched
A new catalog test for the one capability that moves the head. The
reference package is installed with motion.jog and jogs on the test's
word, through a file the service watches, so the motion happens at a
moment the test chose and can be witnessed while it does.

What it proves on the machine: the host's credential is 0600 and root's
and holds motion.jog and not a camera; a jog past the bounds is refused
by the host with the machine never asked; the package's jog moves the
head and the head accelerometer sees it; and across the whole of it the
laser latch stays locked and the emission witness stays at zero. The
head is jogged back to where it started.

The laser check reads the field names the machine actually publishes
(laser_locked, laser.emission_samples). The first version of it asked
for names that are not in /status, so it compared None to None and could
not have failed.

Proven. The unit suite: 451 tests. On the bench reference, image
20260921220023 with the cross-built daemons mounted over the image's:
exthost.motion-jog PASS, and exthost.service and motion.port-jog PASS
beside it. Against the image's own daemons it FAILS: the route is 404.
2026-09-21 18:57:33 -04:00
ScottW514 75ef8bbc95 exthost.service: a package's camera, and how it yields
The reference package asks for the lid camera and not the head's. From
inside its sandbox the test checks that the granted camera comes back as
a JPEG with the image type to match, that the camera it does not hold is
403, and that a camera there is none of is 400.

It then holds a stream open and asks the service for a capture through a
file the service watches, so the capture happens at a moment of the
test's choosing: it is refused in the machine's own words while somebody
is watching, and served once the viewer stops.

Proven. The unit suite: 451 tests. On the bench reference, image
20260921204711 with the cross-built forgectrl and extension host mounted
over the image's: exthost.service PASS, and exthost.events,
exthost.hold-pause-tier and camera.snapshot PASS beside it.

Acceptance. exthost.service already covers forgeext's **; a package's
camera is part of what a confined service can reach.
2026-09-21 17:59:36 -04:00
ScottW514 2216e5d3f2 camera.snapshot: a background capture yields to a viewer
The test opens a stream, holds it, and checks that a capture marked
background=1 is refused in words while somebody is watching, that the
operator's own unmarked capture is still served, and that the background
capture is served again once the viewer stops. A background value that
is neither 0 nor 1 is refused.

Proven. On the bench reference, image 20260921204711 with the
cross-built forgectrl mounted over the image's: camera.snapshot PASS.
Against the image's own daemon it FAILS at the first of the new checks,
answering a parameter it does not know with a JPEG.

Acceptance. camera.snapshot already covers the camera sources and
src/main.c.
2026-09-21 17:20:37 -04:00
ScottW514 bb28c7855a exthost.service: the storage quota, on the machine
The reference package declares storage:1. The test writes 2 MiB into its
data directory and watches the host measure it, stop it, and show the
quota's reason, then clears the data, enables it again, and sees the
service come back. That is the operator's way out, so the test walks it.

Proven. The unit suite: 451 tests. On the bench reference, image
20260921190848 with the cross-built extension host mounted over the
image's: exthost.service PASS, and exthost.events and
exthost.hold-pause-tier PASS beside it. The first run of this check
caught a real bug: the reason arrived empty, because the host built it
in the buffer the supervisor overwrites when it stops a service.

Acceptance. exthost.service already covers forgeext's **; the quota is
part of what the host does to a confined service, so it is exercised
there.
2026-09-21 16:46:24 -04:00
ScottW514 447f90205d exthost.service: the reference package has settings of its own
The package now declares three settings and, from inside its sandbox,
reads them, sets two, and is refused an undeclared key and a value out of
bounds. The store on disk is checked for its mode and its contents, so
the test proves the host keeps them and not only that it answered.

Proven. The unit suite: 451 tests. On the bench reference, image
20260921190848 with the cross-built forgectrl and extension host mounted
over the image's: exthost.service PASS. Against the image's own daemons
it FAILS where it must, the older parser refusing the manifest with
unknown key "settings".

Acceptance. exthost.service already covers forgeext's ** and forgectrl's
src/main.c; the settings are part of what a confined service can reach,
so they are exercised there rather than in a test of their own.
2026-09-21 16:12:19 -04:00
ScottW514 952e7dc32d exthost.events: the machine's events reach a package
A new catalog test for the one subscription the extension host holds and
the poll a package reads it with. The reference service learns from
GET /v0/self whether it was granted events and polls POST /v0/events only
then, so every other test keeps the half-second heartbeat those tests
watch; what it polled and what it saw go in its data directory.

What it proves on the machine: the host holds the stream while one package
wants it and lets it go when none does; a poll that names its place and
has nothing to be told comes back at its own deadline and not before,
while the first poll, which only asks where the present is, comes back at
once; the lid opened and closed on the fixture reaches the package as lid
events, in order and numbered, which also proves a waiting poll is woken
rather than left to time out; and with the three ordinary streams taken
the host's subscription is still there while a LAN client that asks for
the host's slot by name is refused, because only a loopback peer may claim
it.

The three ordinary streams each bind a source address of their own. The
cap counts peer addresses, and the kernel sends every loopback connection
from 127.0.0.1 whatever address it was aimed at, so three streams without
that would replace each other and leave two slots free. The first run of
this test said a LAN client had been admitted; it had been, correctly, to
a slot the test had failed to fill.

Proven. The unit suite: 451 tests, and the coverage lint passes. On the
bench reference, image 20260921171224 with the cross-built forgectrl and
extension host mounted over the image's: exthost.events PASS, and beside
it exthost.service, exthost.armed-freeze, exthost.hold-pause-tier and
events.stream PASS. Against the image's own daemons exthost.events FAILS
at its first new check, as it should. The bench was left as found, GRBL
mode, no mounts.

Acceptance. exthost.events covers forgeext's src/evfeed.*, src/api.* and
src/run.*, and forgectrl's src/events.* and src/main.c.
2026-09-21 15:07:56 -04:00
ScottW514 eb0a1e7838 exthost.panel-install: the key goes in through the panel, and the box holds the button
The test added the owner's key by copying the file. It now adds it the way
an operator does: POST /ext/key with the machine's button held. Without the
button it is 409 and no file lands; a name with a space, a name that is a
path, and no key at all are 400; a key that is no key is 409 from the host;
with the button held it is added and listed with its id. Removed again, the
same archive reads unverified, and removing a key that is not there is 409.

The bench actuator's press was 200 ms, the firmware's default, and a
request that must reach the machine while the button is down often missed
it: the unverified install took three presses on one run and all ten on
another. ctx.act() now takes `ms`, the fixture client passes it to the box
(the firmware clamps it into 20 to 500), and the two button-held steps ask
for 500. Both landed on the first or second press afterward.

Proven. The unit suite: 451 tests, 0 undefined names (both stub fixtures
take the new argument, and the fixture test pins that the box is asked for
the longest press). On the bench reference, image 20260921161446 with the
cross-built forgectrl and extension host mounted over the image's:
exthost.panel-install PASS, exthost.package-routes PASS,
exthost.hold-pause-tier PASS. Against the image's own daemons the install
test FAILS, as it should.

Acceptance. exthost.panel-install covers forgectrl's src/extpkg.*,
src/main.c, src/auth.* and forgeext's src/main.c, src/install.*,
src/pkg.*; the runner and the fixture client are harness, outside the
suite and outside every fingerprint.
2026-09-21 13:11:29 -04:00
ScottW514 963bded9b3 exthost.package-routes: the panel page carries the card, and its script is in order
The page the image serves must hold the card (extpkgs, extswitch, extfile,
extstaged, extinstallphrase, extAct, loadExt) and must define loadExt
before the call that runs while panel.js parses. That second check is the
bug the browser found: ext.js loaded after panel.js is an undefined
loadExt, and it takes the rest of the System tab's loads down with it.

Proven. The unit suite: 451 tests, 0 undefined names. On the bench
reference, image 20260921154037 with the cross-built forgectrl mounted over
the image's: PASS. Against a forgectrl built with the scripts in the old
order the order check FAILS, as it should.

Acceptance. exthost.package-routes gains forgectrl's src/ui/ext.js,
src/ui/index.html, and src/ui/embed.cmake in its covers map.
2026-09-21 12:13:39 -04:00
ScottW514 9c36df9ee9 exthost.panel-install: a package installed through the panel at each tier's consent
Kind operator with the button as its action, GRBL mode, nothing moves and
nothing fires; extensions stay as found and the packages never run. The
reference package signed with a key nobody trusts uploads as unverified
with consent button: refused without the button held (the phrase is no
substitute, the staged file stays), installed while it is held, listed
unverified with the hold the request granted. A person holds the button; the
bench actuator's press is a half-second pulse, so the test presses again
until a request has landed inside one. With the same key added as the
owner's the upload reads community with consent typed, and goes through
no phrase, the phrase in another case, no grant (the host's words), a grant
with a shell's words, a grant that is an option of the host's, and then
installs. An upload without the login, bytes that are no archive, an install
with nothing staged, and a discarded upload are refused as they should be.
Both packages are removed through the route and the extension root is as
found, the staged file included.

Proven. The unit suite: 451 tests, 0 undefined names. On the bench
reference, image 20260921150233 with the cross-built forgectrl mounted
over the image's: PASS (the third press landed); against the image's own
forgectrl FAIL at its first request.

Acceptance. The test is new. It covers forgectrl's src/extpkg.*,
src/main.c, src/auth.* and forgeext's src/main.c, src/install.*, src/pkg.*,
and requires exthost.package-routes.
2026-09-21 11:39:25 -04:00
ScottW514 121d7d3f81 exthost.package-routes: the panel's package routes against the host's own state
The reference package is installed with the hold grant through the host's
command line and never runs (extensions stay as found, so the test needs
no takeover). GET /ext/status is refused without the login and, with it,
lists the package as the host does beside enabled, safe_mode, and the
host's own status with running true and the living host's pid. POST
/ext/package: hold-required and hold-advisory name and un-name the package
under required-holds; disable and enable change the host's state file; an
action outside the closed list, an action with a shell's words in it, an id
that has not the form of one, and an id with a path in it are 400 and leave
the state file byte for byte; a package that is not installed is 409 in the
host's words; without the login 403 with the package still there; remove
takes the package, its data, and its name away. The key and the work
directory are removed and the extension root is as found.

Proven. The unit suite: 451 tests, 0 undefined names. On the bench
reference, image 20260921140920 with the cross-built forgectrl and
extension host mounted over the image's: PASS; against the image's own
forgectrl, FAIL at its first request.

Acceptance. The test is new: auto. It covers forgectrl's src/extpkg.* and
src/main.c and forgeext's src/main.c, src/install.*, and src/state.*, and
requires exthost.service and forgectrl.auth.
2026-09-21 11:01:40 -04:00
ScottW514 68fd35b8b7 exthost: the reference package uses its API socket, and raises its own hold
The reference service talks to the host over the socket named in FFX_API
and takes machine.read. exthost.service adds, from the inside: GET /v0/self
names the package and what it may use; GET /v0/machine/mode is forgectrl's
answer; a hold it was not granted is 403, a path the API does not have
404, a path with .. 400; FFX_API is in the fixed environment. From the
outside: the socket is root's and the account's at 0660, and another pool
account that connects to it gets EACCES (a new probe mode).

exthost.hold-pause-tier adds the package's own word. The test leaves what
to say in the service's data directory and the service passes it on as
POST /v0/hold: raised, the verdict is EXT with the package's words and fire
withheld; words the form does not take are refused and the hold reads as
before; cleared, the verdict is OK. Then the rest as before: the host's own
raise for a package that ends at every start, the operator's exits, the
stale file under a suspended host.

Proven. The unit suite: 451 tests, 0 undefined names. On the bench
reference, image 20260921130558 with the cross-built host mounted over
the image's: exthost.service PASS, exthost.hold-pause-tier PASS (the
package's hold stood 1.5 s after it raised it; held 2.0 s after the
service was killed; safe mode released it in 0.8 s, extensions off in
1.1 s), exthost.armed-freeze PASS with the changed reference service.
Against the image's own host, which gives a service no socket,
exthost.service FAILS, as it should.

Acceptance. Both tests already cover forgeext whole (exthost.service) and
the hold's files by name (exthost.hold-pause-tier); neither covers map
changes.
2026-09-21 10:08:03 -04:00
ScottW514 ee89b31c07 exthost.hold-pause-tier: a required hold from the grant to the verdict and out
The reference package takes the hold capability (its service ends at once
when the test leaves a file in its data directory: a package that cannot
speak for itself). The install is refused without the operator's grant; a
granted hold is advisory until `forgeext hold <id> required` names the
package under required-holds. With extensions on, the required hold stands
in the host's words until the service has run healthy, then the host keeps
its file fresh and clear and GET /cool/status reads OK. The test then makes
the service end at every start: verdict EXT, fire_ok false, hold true, the
reason naming the package, and never clear in 24 looks over the crash loop.
Safe mode ends it within four seconds and leaving safe mode brings it
back; marked advisory it is dropped; marked required again and the host
suspended, the reason becomes that the host is not answering, and resumed
it is the package's again; ext_enabled=0 ends it. The verdict is read at
idle: nothing moves and nothing fires. Everything is put back as
exthost.service puts it back, the required holds are empty, and the
verdict is OK at the end.

Proven. The unit suite: 451 tests, 0 undefined names. On the bench
reference, image 20260921121235 with the cross-built forgectrl and
extension host mounted over the image's: exthost.hold-pause-tier PASS
(held 1.0 s after the service was killed; safe mode released it in 0.8 s,
extensions off in 0.8 s); against the image's own forgectrl, whose engine
knows no holds, it FAILS with the verdict OK, as it should.

Acceptance. The test is new: auto, takeover. It covers forgeext's
src/holdkeep.*, src/run.*, src/install.*, src/state.*, src/main.c, and
forgectrl's src/holds.* and src/cool.*, and requires exthost.service.
2026-09-21 09:05:02 -04:00
ScottW514 9ee277f977 forgefirm-users: an operator account reaches its home
On the bench reference /data/forgefirm is 0700, and the operator account
could not enter its own home under it (su scott -c "cd ~": permission
denied). forgefirm-users.init makes the directory of homes 0755 on purpose
("a login traverses it to reach its home") and said nothing about the data
directory above it. No script in the tree makes that directory 0700:
settings.c makes it 0755, forgefirm-logging makes it under rcS's umask, and
this script's own mkdir -p under umask 077 never makes it, because the
record it waits for lives inside it (forgectrl 35684ca's message names
that mkdir as a possible author, and it cannot be). The bench reference's
directory dates from 2026-09-11. The render does not depend on who made it.

replay() now puts the search bit for group and others on the data directory
at every render (boot, and every reload forgectrl asks for), whoever made
it and under whatever umask. Nothing is taken away and nothing in it
becomes listable; what is private there is closed file by file. The same
bit is what lets an extension package's account walk to its files.

setup.account-login sets the data directory to 0700 before it makes its
temporary account, as a strict umask leaves it, and then tries the account
from the inside (a child that becomes it): the render that made the account
left the directory at 0711, the home can be entered and written, the data
directory cannot be listed, and the account record cannot be read. Its
put-back (the found mode, the replay, the temporary home) now runs on every
exit path; before, a failure inside the account's lifetime left the
temporary home and its passwd line behind. image.health asks for the search
bit on a machine with an account record.

Proven. On the bench reference, image 20260921022220, the suite files
mounted over the image's: with the image's own init script
setup.account-login FAILS (cd EACCES, write EACCES, the directory still
0700), as it should; with this script mounted over /etc/init.d it PASSES
(0700 to 0711 at the account's render, cd ok, write ok, the listing and the
record EACCES), image.health PASSES reading mode 711, and the operator
account enters /data/forgefirm/home/scott and is refused the listing of
/data/forgefirm. The mounts were taken away and /tmp cleared; the
directory stays at 0711, which is the repair. The unit suite passes (451
tests, 0 undefined names).

Also on image 20260921022220 as flashed, before any of this was mounted:
image.health (its extension host section on a machine for the first time),
exthost.platform, setup.extensions-consent, exthost.service (a killed
host's service gone in 0.05 s, the host back after 5.7 s),
update.product-gate, and exthost.armed-freeze (the window open 38.6 s,
frozen 0.21 s after it opened and 6.02 s before the latch unlocked for the
run, in all 177 samples; thawed 0.41 s after the close) all PASS, and the
host logs under its own logger after a plain boot.

Acceptance. setup.account-login is the regression test, and image.health
proves the boot-time render on every campaign's first test. The init
script is layer content, in the platform identity of every fingerprint.
2026-09-21 08:11:43 -04:00
ScottW514 800d6890a4 exthost.armed-freeze: a package's service is frozen for a real armed window
The freeze is the rule that keeps a package off the core while a job can
fire, and it had a host test against a stand-in forgectrl and one drill by
hand. This is its test on the machine, against the real engine.

The reference package runs, its heartbeat advancing twice a second.
cloud.dark-print's own job and body (a 30 s square at S0, the fixture's
press, the latch locked at the button, unlocked for the run, locked after)
open a real armed window over it. A sampler reads five times a second: the
engine's armed flag (GET /cool/status), the group's frozen state as the
kernel reports it (cgroup.events), the heartbeat, the service's pid, and
the latch. The test asks for a window of 10 s or more; frozen in every
sample from 2 s in to the close, with the heartbeat still; the first frozen
sample no later than the first unlocked-latch sample, so the freeze is in
place before the run; thawed, with the heartbeat moving again, 3 s after
the close; and one process throughout.

exthost.service's put-back and its as-found checks became _put_back() and
_as_found(), shared by both tests. exthost's top-level imports pulled the
setup suite (and now the cloud suite) in ahead of it and reordered the
catalog, so exthost is imported last: image, kernel, forgectrl, setup,
logs, motion, cooling, laser, camera, update, cloud, exthost; 106 tests.

Proven. The unit suite: 451 tests, 0 undefined names. On the bench
reference, image 20260920211625 with the cross-built forgectrl and host
from /tmp, the coolant taken from 27.2 to 25.4 C with an M8 session first
(the cloud client starts no print above 27): exthost.armed-freeze PASS, the
window open 37.5 s, the group frozen 0.42 s after it opened and 4.97 s
before the latch unlocked for the run, frozen in all 172 samples from 2 s
in to the close with the heartbeat still, thawed 0.62 s after the close.
With the host suspended (SIGSTOP) across the window, so that nobody
freezes anything, it FAILS: not frozen in 177 of 177 samples. exthost.service
PASS again after the refactor (a killed host's service gone in 0.05 s, the
host back and the service running after 5.6 s). The bench was left as
found.

Acceptance. exthost.armed-freeze is new: kind operator (the fixture
presses), takeover; it covers forgeext's src/super.*, src/run.*,
src/machine.*, src/cgroup.*, and forgectrl's src/cool.*, and requires
exthost.service and cloud.dark-print.
2026-09-20 22:21:35 -04:00
ScottW514 1a306d5d8d forgetest: a campaign's machine is extension-free
An extension package is software the image does not carry, and a result
taken beside one is not a result about the image. Two places hold the line.

The baseline (_ext_side, in every pre and post pass): a package under the
tests' own prefix (org.forgetest.) and an owner key named forgetest-*.pub
are what a test made and left behind; they are removed (forgeext remove,
the key's file), recorded as restored, and the host stops the service on
its next turn. A process that still runs under a pool account after that
belongs to the operator's own packages: it is recorded as unrestorable and
never touched. An installed package that does not run is nobody's
leftover. hw.pool_pids() reads each process's Uid line; hw.ext_packages()
lists the package directory.

image.health (5b): the extension host is one process (/usr/bin/forgeext
run), its start link sorts after forgectrl's and its kill link before it,
no package is installed, and nothing runs under a pool account.

Proven. test_baseline.py, ExtensionFreeTests, over a stand-in forgeext and
a fake package tree: an installed package that does not run leaves
nothing; a test's package and key are removed and the operator's package
and key stay; a removal that fails says so in the host's own words;
running extensions are reported and left alone. The unit suite passes (451
tests, 0 undefined names). The link order image.health asks for is the one
the built root filesystems of image 20260921014201 have: S90forgectrl
before S91forgeext, K09forgeext before K90forgectrl.

Acceptance. image.health gains forgeext's init/** in its covers map; it
runs first in every campaign and is the on-image proof of 5b. The
baseline is harness, outside the suite and outside every fingerprint.
2026-09-20 21:56:54 -04:00
ScottW514 1e98b37e45 forgeext on the image, and the tests of the host and of the consent
The image installs forgeext beside forgefirm-sandbox. The recipe installs
the init script from forgeext's own tree (start 91, after forgectrl at 90,
whose read-only routes the host takes the machine's state from; stop 9, down
before it). The host runs nothing while ext_enabled is 0, the default.

setup.extensions-consent (takeover): the Extensions advisory is served and
is no first-run document; ext_enabled=1 is refused without the advisory's
hash, with a stale one, without the phrase, with the phrase in another
case, and whole beside a write the daemon refuses, each leaving the setting
and the record on disk untouched; with the hash and the phrase it is
accepted, recorded under on_demand.extensions, and the data directory gains
the search bit and nothing else. The setting, the directory's mode, and the
record are put back, the record under a forgectrl restart.

exthost.service (takeover): a reference package built on the board, signed
with a key made there and added as an owner key. It is unverified before
the key is the owner's and community after; the install is refused without
the consent. Turned on over the advisory, the service is looked at from
outside (account, no_new_privs, seccomp, group, limits, chain) and from
inside (what it can read, write, dial, and open), its first line is looked
for in the forgeext logger's file, safe mode stops it, a host killed with
the service in its quiet loop takes it along within 2 s and comes back, and
ext_enabled=0 leaves no group and no chain. The package, the key, the
setting, the mode, and the record are put back, and the extension root is
compared with how it was found. The reference package exists only while the
test runs: no image carries it, and no image trusts its key.

Proven. forgetest's unit suite: 422 tests pass, 0 undefined names. On the
bench reference, image 20260920211625 with the cross-built forgectrl and
host from /tmp: setup.extensions-consent PASS and, against the image's own
daemon, FAIL at its first request; exthost.service PASS and, with the init
wrapper's kill taken out, FAIL at the killed-host check. The first runs of
exthost.service found what the host tests could not: /data/forgefirm is
0700 on the bench reference, and the service ended with EACCES on its own
entry point until forgectrl opened the directory for search with the
consent.

Acceptance. Both tests are new. exthost.service covers forgeext whole and
forgectrl's src/main.c and src/logs.*; setup.extensions-consent covers
forgectrl's consent path. The recipe, the image line, and the init
script's install are layer content, in the platform identity of every
fingerprint. forgeext joins scripts/manifest-from-tree.py with its first
pin.
2026-09-20 21:41:21 -04:00
ScottW514 2894269115 The deny rules: the machine itself is never a destination
The way through the extension sandbox's deny rules is an allowlist, and an
allowlist names addresses. The machine's own LAN address is not a fact
anybody can pin: a new DHCP lease can turn a peer's address on some
package's list into the machine's, and with it open the Grbl port or
forgectrl's listeners to that package.

ffx.nft now refuses it structurally. Everything a host sends to one of its
own addresses, the LAN one included, leaves through lo, so chain pool
refuses `oifname "lo"` before it looks at the allow map; the two refusals
(a reset for TCP, a drop for the rest, both counted) move into chain
refuse, which pool jumps to from both places. No kernel option is new:
oifname is in the nf_tables core.

scripts/sandbox-rules-test.py gets a destination that is not the machine:
a second network namespace joined by a veth pair, with listeners of its
own. A pool uid is refused on loopback, IPv6 loopback, its own LAN address,
and the peer; an allow chain opens one port of the peer to one uid and
nothing else; with loopback, IPv6 loopback, and the machine's own address
added to that list the uid is still refused at all three while the peer
still answers; a reload closes it. It needs ip and nsenter now.

exthost.platform reads its counters from chain refuse, holds the rule's
place ahead of the map, and adds the case on the machine: an allow chain
for the last pool uid that names forgectrl on loopback and on the LAN
address opens neither, and the chain is removed.

Proven. The rules test passes with nft 1.0.9, and four controls each fail
it: the range one uid short, the TCP reject turned to accept, the
delete-table line removed, and the lo rule removed (the uid then reaches
all three of the machine's addresses). On the bench reference, image
20260920211625, this rule file loaded from /tmp with nft -f and this suite
file mounted: exthost.platform PASS, uid 831 refused at 127.0.0.1:443 and
172.16.1.97:443 with both on its allowlist, the counters [0, 0] to
[12, 4]. Against the image's own rules the same test fails on the rule's
absence, which is the control. The image's rules were reloaded after. The
unit suite passes (422).

Acceptance. exthost.platform gates the rule on the machine; sandbox-ci
gates the file. The rule file is layer content, in the platform identity
of every fingerprint.
2026-09-20 19:53:05 -04:00
ScottW514 5ad7ee6faa forgeext: the recipe, and the extension-signing key in the keyring
forgeext is the extension host, a component of its own: recipe
recipes-forgefirm/forgeext (cmake, pkgconfig, forgefirm-manifest, so it is
a manifest component with its own pin file). It links jansson, libarchive,
and libsodium, and runs with fwup, the keyring, and forgefirm-sandbox.
libarchive and jansson are on the image already; libsodium comes with it.
The pin is all zeros because the repository has no pushed commit to name:
the recipe builds from a working tree through externalsrc, and a build
from pins cannot fetch it until the first push sets the pin. It is not in
the image's install list.

forgefirm-keys installs a third trust anchor,
/etc/forgefirm/keys/ext/forgefirm-ext.pub: the OpenGlow extension-signing
public key, the official tier of extension packages. It is a different key
from the release key on purpose: it signs more often, and its loss must
not sign firmware. forgeext refuses an extension archive whose only valid
signature is the release key's or a factory key's.

Proven. The recipe cross-builds forgeext from the working tree, and that
binary ran the verify-and-install cases on the bench reference (image
20260920211625, from /tmp, with the board's own fwup 1.16.0 and the
image's keyring) with the results of the host test. forgefirm-keys builds
and packages the key 0644 under ext/ (0755), byte-identical to the file
here; it is 32 key bytes and is not the release key.

Acceptance. No catalog test reads either yet: forgeext's tests are the
exthost suite that comes with its daemon, and the key is layer content, in
the platform identity of every fingerprint.
2026-09-20 19:34:45 -04:00
ScottW514 9128f0e98e update.product-gate: extension packages at the firmware upload
forgectrl's firmware paths now read an archive's product and take firmware
only (fwproduct.c). The upload route was not exercised by any catalog test;
this one is its door.

Archives made on the spot with the machine's own fwup go to
POST /update/upload as a multipart form: an extension package unsigned,
one signed with a throwaway key, and one that carries a task are each
refused with 400 "this archive is an extension package, not firmware" and
nothing is staged; an archive of another product is refused as not
firmware; one whose product is ForgeFIRM firmware is taken as the unsigned
upload it is, and the staged file is removed. Nothing is applied: the
apply calls the same gate before fwup -a, and its proof is the daemon's
host test, because a catalog test that could fail there would write a slot.

Proven. On the bench reference, image 20260920211625 with the gated daemon
and this file mounted over the image's: update.product-gate PASS, and
update.slots-and-signature PASS beside it (the signature refusal still
comes first). Against the image's own daemon, which has no gate, the test
fails on its first upload (200), and its cleanup leaves nothing staged.
The unit suite passes (422) with no undefined name.

Acceptance. update.product-gate covers forgectrl's src/fwproduct.* with
the update sources.
2026-09-20 19:34:45 -04:00
ScottW514 619414ed2b cloud suite: a print turned away before the button fails at once, with the reason
The cloud client turns a print away before the button wait for four
reasons of its own (machine._safe_to_move): the lid or the interlock, a
machine that is not idle, the coolant above its start ceiling
(THERMAL.max_start_temp), a coolant sensor that reads nothing. It logs the
reason and finishes the print ':cancelled' within a millisecond. The four
tests that wait for the button looked only for the wait, so on the bench
reference, with the loop at 27.4 C against the ceiling of 27,
cloud.dark-print sat out its 120 s and said "the print never reached the
button wait".

wait_button_wait replaces the four waits: it ends on the print's finish
line as well, and fails with the client's own lines, for example "the
client turned the print away before the button wait: INFO
machine:_safe_to_move machine temp is too high, temp: 27.4 (... finished
with event ":cancelled")". The ceiling is the client's and stays where it
is: the remedy for a warm loop is airflow, a run session for a minute or
two.

Proven. test_a_print_turned_away_before_the_button_fails_at_once_with_the_reason
replays the bench reference's lines and fails in under 30 s with that
reason; with the early exit disabled it fails after the full 120 s with the
old words. Each reason is a line the pinned cloud library can log, so the
phrase check passes. The unit suite passes (422) with no undefined name. On
the bench reference, image 20260920211625 with this cloud.py over the
image's, cloud.dark-print passes through the new wait with the loop at
25.8 C.

Acceptance. cloud.dark-print, cloud.verdict-refuse,
cloud.lid-during-button-wait, and every test that starts an offline print
go through the new wait. The helper is module text, so every cloud.py
test's fingerprint moves; no product behavior changes.
2026-09-20 18:02:31 -04:00
ScottW514 474bf9db09 motion.job: measure the job's reach from where the test began
_job_wait read its own starting X when it was called, which is after the
lease wait and three refused requests beside the running job. On image
20260920204744 those took long enough that the head was already 2.55 mm
out, and the test failed with "the kernel saw 17.450 mm of the 20" on a
job whose record said done, 7 of 7 acknowledged, no emission sample, and
the counters back at 0.0. It now takes the caller's X, read before the job
was posted; laser.recorder-dark, the helper's other caller, reads no
distance and passes none.

Proven. On the bench reference with the fixed file over image
20260920204744's: motion.job PASS three times running, laser.recorder-dark
PASS. The file is in image 20260920211625. The unit suite passes (422).

Acceptance. motion.job is the test. The helper is module text, so every
motion.py test's fingerprint moves; no product behavior changes.
2026-09-20 18:02:31 -04:00