Skip to content

fork fleet: desk setup pack kit (D8) - #225

Draft
scuttletoby wants to merge 5 commits into
mainfrom
fork/desk-d8-setup-pack
Draft

scuttletoby wants to merge 5 commits into
mainfrom
fork/desk-d8-setup-pack

Conversation

@scuttletoby

@scuttletoby scuttletoby commented Nov 15, 2026 •

Copy link
Copy Markdown
Owner

Row D8 of the desktop pool plan: a setup pack for a desktop that runs fork fleet jobs over the desk link (D1-D7).

Why

Today a desktop joins a desk pool through about twenty hand steps in the runbook (launcher, walls, desk agent, gate
hook, runner users, settings files, key, units). That is slow and easy to get subtly wrong (a missing wall, a
foreign launcher, a lingering user). This kit does all of it from one release commit, checks every part with one
PASS/FAIL list, and removes it again.

  • fork-kit/desk/bootstrap.sh: same pattern as the other kits. It checks the sha is on the protected ci-release
    branch, downloads that commit and runs the installer from it.
  • fork-kit/desk/sbin/desk-install:
    • install: reads the desk section of the machine file (pool, always-on host and its host key, slots,
      reserve). It makes fork-runner-N users with no login, no linger, and no cron or at. It stages the release's
      fleet files, tests them, and moves them into /opt/fork-fleet. Then it writes the settings files and makes the
      desk key once. Last it writes the units, starts the refresh timer (the first template is built in the
      background) and starts the agent.
    • check: one PASS/FAIL line per part, including every wall of this OS (fork-fleet-walls.py describe). It
      only reads.
    • uninstall: stops every runner (launcher, then systemctl stop); while one still runs it stops there and keeps
      the nft table, users and record. Then it removes every fork-fleet-* namespace and ffh-* link, the nft
      table, and only what the kit made: its units, the files of its manifest, template, settings, key, its users and
      the deny files it created. Anything else in its folders is kept and named; the record goes last.
  • The desk section in the example machine file, a row in the kit table, a pointer in the runbook.

Not here: the Windows starter (D9), the "free" script (D10), seeds (D11), the fork's own pool switch (D12).

What happens when this runs again, stops half-way, or meets existing, old or empty state?

  • Runs again: files, settings and units that are the same are not rewritten, no user is made, the key is
    kept, and the agent is neither stopped nor restarted (a test checks no systemctl restart is run). After a change
    to a file, setting, key or unit the agent is restarted. A test compares the whole fake root before and after.
    The agent reads its settings only when it starts, so the record gets a restart-pending mark before the first
    change and loses it after the restart: a run stopped in between (or refused by the agent's settings check)
    restarts the agent on the next run, and check fails until then.
  • Stops half-way: a test stops the install after each of its six steps on a fresh root and then runs it again.
    The result equals a full install every time.
    • A new release is staged in /opt/fork-fleet/.kit-stage-* and tested first: the walls know this OS, the gate
      loads with the fleet config, the pool is in it, bash -n, and the agent's --help. A release that fails is
      never moved in. The next run removes a leftover stage folder.
    • Changed files are renamed in while the agent and the refresh timer are stopped. MANIFEST.sha256 and then SOURCE are written last,
      so check shows a half-done swap.
    • Users are written to the record before useradd, so uninstall still removes a user from a run that stopped
      right there.
    • The key is made under a temporary name and renamed (public half first). A private key without its public half
      is refused with a fix.
    • The fleet config choice (and the project's copy, /etc/fork-fleet/fleet-config.json) is recorded before the
      swap, so a run stopped right after the swap keeps what was asked for.
    • A stopped uninstall keeps its record until the very end (removed after everything else), so running it again
      finishes; a runner that will not stop keeps the nft table, users and record in place.
  • Existing / old state: a launcher, launch.conf, desk.conf, desk key, unit or systemd drop-in for one of
    the kit's units that the kit did not write (an older hand install) is refused before anything changes; check
    fails on a drop-in that appears later, and on an installed unit that is not the release's (header plus
    /opt/fork-fleet/units/UNIT). The kit never adopts or overwrites it. Type-wide service.d//timer.d/ drop-ins
    are not checked: they are the distribution's defaults for every unit and only root can write them. Users that
    already exist are used but not recorded, so uninstall leaves them (their namespaces and links are still removed);
    the cron/at deny lines the kit added for them are recorded and removed. A user that a smaller slots no longer
    uses stays (no login, unused) and check names it; uninstall removes it.
    A record of another shape is refused, not trusted. An upgrade removes files the old release had
    and the new one has not.
  • Empty state: check with nothing installed lists FAILs and writes nothing. uninstall with no record does
    nothing and exits 0. An OS with no walls file (walls exit 3) is refused before anything is written.

General vs project

General. The kit names no project, person or machine. Pool, host, host key and slots come from the private
machine file on that desktop. The pools come from the release's fleet config or a project's --fleet-config,
which is remembered for later upgrades (--release-fleet-config goes back to the release's own). A
--fleet-config in a folder that others or a runner user may write is refused. Example values are made up (fleet-host.example).

Upstream fit

This touches only fork-only folders (fork-kit/, .github/fork-*). Nothing in upstream T3 changes.

Size

After fix round 1 the PR has about 765 added lines that are not tests (installer, bootstrap, README, example),
over the 400 guideline; round 1 added about 180 installer lines (uninstall that removes only its own, drop-ins,
restart only on change, settings check). Splitting off check or
uninstall would leave a PR that cannot be tested on its own state rules.

Layout differs from the other kits on purpose (README, "Why one flat folder"). The desk agent refuses a launcher or
walls path with a link in it, so there is no "versions + one link". Files are renamed into /opt/fork-fleet and
recorded in a manifest instead.

Tests

fork-kit/desk/tests/install.test.py (listed in .github/fork-ci-tests.txt) has 33 tests. They run on a fake
root in a temporary folder, with fake useradd, systemctl, ssh-keygen, ip, nft and so on, and stub fleet
files. Nothing touches the real system, and no root is needed. Beyond the first 19 (fresh install, re-run, a stop
after each step, upgrade, refusals, bad releases, keys, existing users, check, uninstall, bootstrap arguments),
round 1 added: a changed setting restarts the agent (and a plain re-run does not), drop-ins refused at install and
failed by check, settings that the machine file no longer gives, uninstall keeps what the kit did not make, removes
the deny files it made, keeps the table while a runner still runs, removes every runner namespace, removes its
record last, refuses a record of another shape, --release-fleet-config, and bootstrap flags after --fleet-config.

Run with python3 -I -B -W error::ResourceWarning fork-kit/desk/tests/install.test.py.

Round 2 added: a change written by a killed run still restarts the agent (and check fails until then), a refused
agent check keeps the restart, the fleet config choice outlives a run killed right after the swap (both ways),
check fails on a unit that is not the release's, check names a user left by fewer slots, uninstall removes the
deny lines it added for users that were there, a fleet config others may write is refused, and the bootstrap test
now gives a good --sha/--repo before --fleet-config.

Local run (Python 3.12): 33 tests, OK; fork-kit/common/tests/machine_file.test.py 17 OK.

Red-checks

Each rule below was broken on purpose in a copy of the installer, one at a time, and the named test failed (all red;
the full run is OK again after the restore):

  • the user is recorded before useradd -> test_a_user_made_by_a_run_that_died_is_removed_by_uninstall
  • unchanged files are left alone -> test_runs_again_and_changes_nothing
  • a foreign install is refused -> test_refusals_change_nothing
  • a bad release is never moved in -> test_bad_release_is_not_installed
  • uninstall removes only its own users -> test_existing_user_is_used_not_recorded
  • a half key is refused -> test_key_is_made_once_and_a_half_key_is_refused
  • the OS check comes before any write -> test_refusals_change_nothing
  • an agent that refuses its settings starts nothing -> test_agent_refusing_its_settings_starts_nothing

Round 1: the new test file run against the round-0 installer: 15 tests fail, among them every round-1 MUST test
(drop-ins, re-run without restart, uninstall keeps what it did not make, deny files, table kept while a runner runs,
every namespace, record last, record shape, bootstrap flags). The record-last test lists folders by name, so a
whole-folder removal fails it whatever order the file system gives.

Round 2: the new test file run against the round-1 installer: 8 tests fail (restart after a killed run, restart
after a refused agent check, unit check, fewer slots, fleet config others may write, the fleet config choice both
ways, deny lines for users that were there); the bootstrap test fails on the round-0 bootstrap.

🤖 Generated with Claude Code

fork-kit/desk: bootstrap, install, PASS/FAIL check and uninstall for a
desktop's side of a desk pool (runner users, launcher, walls, desk agent,
gate hook, settings, key, units), from one release commit. Safe to run
again and to stop at any step; refuses an install it did not make;
uninstall removes only what it made.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@scuttletoby

Copy link
Copy Markdown
Owner Author

Review round 1 at 04a398d: two independent lenses (security, correctness), both SAFE WITH NITS. Fix round to follow on this branch.

Security (lens225s): no way out for a hostile job as fork-runner-N; bootstrap trust, root-owned paths, nologin users, machine-file patterns and the stop-half-way cases all hold. Findings:

  1. desk-install:302,414,435-437: check misses systemd drop-ins and uninstall leaves them. An old fork-fleet-desk-agent.service.d/override.conf with its own ExecStart= gives ALL PASS while systemd runs the override. Refuse at install, or compare systemctl cat with the kit's unit text. (proven)
  2. :441-442,449: uninstall deletes all of /etc/fork-fleet and /var/lib/fork-fleet, including files the kit did not make (e.g. a token). The PR body says it removes only its own. (proven)
  3. :242-247,446-448: at.deny is created and never removed. With no at.deny and no at.allow, only root may use at; creating at.deny opens at to every other local user, and that stays after uninstall. Remember which deny files the kit created and delete them. (proven)
  4. :428-434: uninstall ignores active/stop failures and deletes the fork_fleet nft table anyway; a runner that did not stop keeps routed network with no filter (ip_forward stays 1). Stop fork-fleet-* units via systemctl as a fallback; keep the table until none is active.
  5. :212: the refresh timer keeps running during the file swap (only the agent is stopped).
  6. :181-184: a malformed pools value (dict) hits the suppressed error types.
  7. :305: no way back to the release's own fleet config once a project config was used.

Correctness (lens225c): install/check/uninstall agree; 19 tests pass and CI lists them. Findings:

  1. :338: a plain re-run always runs systemctl restart, though README and body say the agent is not stopped. With KillMode=control-group this kills an in-flight start (host gets 75). Restart only when files, settings or units changed. (proven)
  2. :432-433: uninstall removes namespaces only for createdUsers; a hand-made fork-runner-1 keeps fork-fleet-fork-runner-1 and its ffh-* link. (proven)
  3. :449: rmtree(ETC) can delete desk-kit.json before launch.conf; killed there, the next install refuses launch.conf as foreign and uninstall says "nothing installed". Remove the rest, then the record, then rmdir.
  4. Same as security 2 (README vs whole-folder removal).
  5. bootstrap.sh:163,197: args after --fleet-config pass through unchecked, so a later --sha wins and skips the ci-release check; --fleet-config before --repo gives a misleading error. Parse it like the other flags.
  6. machine.example.json/README use pool desktop, which the release config does not have; use pc or say the pool must exist.
  7. :305,432: a record of the wrong shape crashes with KeyError instead of a Refused.
  8. :374-377: check does not notice the machine file and the written settings disagree.

Both lenses ran the state question (runs again, stopped at each step, old/foreign/empty state); everything not listed above is handled.

scuttletoby and others added 3 commits November 15, 2026 20:45
- install refuses a systemd drop-in it did not write for its units; check fails on one
- re-run restarts the agent only when a file, setting or key changed
- check compares the settings files with the machine file
- uninstall stops every runner, keeps the nft table, users and record while one
  still runs, removes every fork-fleet-* namespace and ffh- link, removes only
  what the kit made (deny files it created, its users' deny lines), names what
  it leaves, and removes its record last
- the record is read only when it has this kit's shape
- --release-fleet-config goes back to the release's fleet config
- bootstrap parses --fleet-config like the other flags

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e ip (D8)

Uninstall now deletes the namespaces ip netns list shows, not one per
recorded user; the older test listed none.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A whole-folder removal took the record first only for some directory
orders, so the test passed on the old code by luck. Listing by name puts
the record first, which the old order fails every time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@scuttletoby

Copy link
Copy Markdown
Owner Author

Fix round 1 (commits 5f64eb9, 7e73543, 99bf235; head 99bf235). Tests: install.test.py 27 OK, machine_file.test.py 17 OK.

Security (lens225s):

  1. Drop-ins: fixed (5f64eb9). Install refuses a drop-in it did not write for any of its units (UNIT.d/*.conf, also the dash-prefix forms); check fails on one that appears later. Tests: the refusal cases in test_refusals_change_nothing, test_check_fails_on_a_drop_in_and_on_settings_the_machine_file_no_longer_gives.
  2. Uninstall deletes whole folders: fixed (5f64eb9). It removes the files of its manifest, template, runner copies, settings and key only; anything else (a token, a drop-in) is kept and named. Test: test_uninstall_keeps_what_the_kit_did_not_make.
  3. at.deny left behind: fixed (5f64eb9). The record names the deny files the kit created; uninstall deletes those, and only removes its users' lines from ones that were there. Test: test_uninstall_removes_the_deny_files_it_made.
  4. nft table removed while a runner runs: fixed (5f64eb9). Uninstall stops through the launcher, then systemctl stop for any fork-fleet-* unit left; while one is still active it stops there and keeps the table, users and record. Test: test_uninstall_keeps_the_table_while_a_runner_still_runs.
  5. Refresh timer during the swap: fixed (5f64eb9): the timer and refresh service are stopped with the agent.
  6. Malformed pools: fixed (5f64eb9): a pools that is not a list of objects is a clear refusal.
  7. No way back to the release's config: fixed (5f64eb9): --release-fleet-config (tested in test_a_changed_setting_restarts_the_agent).

Correctness (lens225c):

  1. Re-run always restarts: fixed (5f64eb9). Restart only after a file, setting or key changed. Tests: test_runs_again_and_changes_nothing (no systemctl restart), test_a_changed_setting_restarts_the_agent.
  2. Namespaces only for created users: fixed (5f64eb9). Every fork-fleet-* namespace and ffh-* link is removed. Test: test_uninstall_removes_every_runner_namespace.
  3. Record removed before the rest: fixed (5f64eb9): record last, then the empty folders. Test: test_uninstall_removes_its_record_last.
  4. Same as security 2: fixed; README says what uninstall removes and keeps.
  5. bootstrap --fleet-config: fixed (5f64eb9): parsed like the other flags; a later --sha is checked. Tests in the bootstrap argument test.
  6. Pool desktop in the example: fixed (5f64eb9): pc, and README says the pool must be in the installed config.
  7. Record of another shape: fixed (5f64eb9): refused, not trusted. Test: test_uninstall_refuses_a_record_of_another_shape.
  8. check vs machine file: fixed (5f64eb9): a check row compares the written settings with the machine file (same test as 1).

Fails before the fix: the new test file against the round-0 installer: 15 tests fail (every MUST test above). Two follow-up test commits: 7e73543 (the uninstall test gives its namespace through the fake ip), 99bf235 (the record-last test lists folders by name, so it fails on the old order every time).

PR body updated (uninstall, re-run, drop-ins, size, test count). Still draft.

@scuttletoby

Copy link
Copy Markdown
Owner Author

Re-check lens 225c r1 (correctness) at 99bf235: SAFE WITH NITS

Earlier findings: S1, S2/C4, S4, S5, S6, S7, C1, C2, C3, C5, C6, C7, C8 FIXED (new tests fail on round-0: 14 fail + 3 errors); S3 at.deny PARTLY (finding 2). 27 tests pass at head; the new test file is in .github/fork-ci-tests.txt:45.

New:

  1. SHOULD FIX, round-1 regression: a stopped run loses the restart (desk-install:373-392). changed lives only in memory; the agent reads desk.conf only at start (fork-fleet-desk-agent.py:441). The machine file's host changes, install writes desk.conf, then stops before :392 (Ctrl-C, failing sysctl -p at :375, agent refusing settings at :383, failed daemon-reload). The re-run sees the settings unchanged and only runs systemctl start (a no-op): the agent keeps dialing the old host and check still says ALL PASS. Fix: save a pending-restart flag in the record before writing, clear it after the restart.
self.assertEqual(self.install()[0], 0)
self.machine({'version': 1, 'desk': {**MACHINE['desk'], 'host': 'other-host.example'}})
self.install(stop_after='settings')
self.clear_calls(); self.assertEqual(self.install()[0], 0)
self.assertIn('systemctl restart fork-fleet-desk-agent.service', self.calls())   # fails
  1. Nit (security-shaped): at.deny stays after uninstall when a runner user already existed (:292-294 vs :562-565): install creates /etc/at.deny with both runner users, uninstall strips only the user it made, the file stays, so at is open to every other local user. Remove the lines the kit added for all runner users.
  2. Nit: the fleet-config choice is saved after the swap (:369-371); killed after --release-fleet-config swaps but before save_state, the record still says project and the next plain install keeps the stale release config forever. Save fleetConfig before the swap.
  3. Nit: the bootstrap test case at install.test.py:613 passes on round-0 too (sha stays empty there); give a valid --sha before --fleet-config and a bad one after.

State question: re-run (:140 skip), half-way (:189, :276, :313-321, record last :571, stuck runner :533; gap = finding 1), old/foreign state (:347-355, :131), empty (:466, :522). Fakes can't show a real systemctl show DropInPaths on WSL, nor that a running agent keeps its start-time settings (what hides finding 1).

@scuttletoby

Copy link
Copy Markdown
Owner Author

Re-check lens 225s r1 (security) at 99bf235: SAFE WITH NITS

No way out of fork-runner-N for a hostile job and no way in from the network. S1-S7 and C1-C8 FIXED (round-1 tests on the round-0 installer: 14 fail + 3 errors; head: install 27 OK, machine_file 17 OK). The part of S1 asking check to compare unit text is NOT FIXED (finding 2).

New:

  1. A killed install loses the restart (same as lens 225c r1 finding 1): :369-392 sets changed only from this run's writes; the agent reads desk.conf once at start. Fix: a restart-pending flag in the record, or restart when the agent's ActiveEnterTimestamp is older than settings, key or units.
  2. check passes with a stale or edited unit (:482 only tests the kit header). An upgrade killed after settings leaves release B in /opt and A's units in /etc/systemd/system; after a reboot check says ALL PASS for release B while systemd runs A's unit. Fix: compare each unit with HEADER + /opt/fork-fleet/units/<unit> (optionally systemctl show -p FragmentPath).
  3. Nit: drop_ins skips the type-wide service.d/ and timer.d/ (:183); root-only paths.
  4. Nit: when slots shrinks, the extra fork-runner-N stays and check stops looking at it (:438); uninstall still removes it.
  5. Nit: --fleet-config ownership is not checked (bootstrap.sh:37, :356); only matters if the owner picks a job-writable path.

Probe tests (fail at head):

def test_a_change_written_by_a_killed_run_still_restarts_the_agent(self):
    self.assertEqual(self.install()[0], 0)
    self.machine({'version': 1, 'desk': {**MACHINE['desk'], 'host': 'new-host.example'}})
    self.assertIn('stopped after', str(self.install(stop_after='settings')[0]))
    self.clear_calls(); self.assertEqual(self.install()[0], 0)
    self.assertIn('systemctl restart fork-fleet-desk-agent.service', self.calls())
def test_check_fails_when_a_unit_is_not_the_release_unit(self):
    self.assertEqual(self.install()[0], 0); self.template()
    new = self.release('release2', {'.github/scripts/fork-fleet-desk-agent.service': '# v2\n[Service]\n'})
    self.install(sha=SHA2, source=new, stop_after='settings')
    self.assertEqual(self.run_main('check')[0], 1)

Checked and holding: bootstrap runs only curl + stdlib before the sha check (merge base, protected branch, no redirects, python3 -I); /etc/fork-fleet 0700, key 0600; runner users with no shell, no linger, cron/at denied, and the launcher refuses uid 0/extra groups/linger; symlink-safe rmtree after runners stop.

…et config) (D8)

- A restart-pending mark in the record, saved before the first change the
  agent reads (files, settings, key, units) and cleared after the restart: a
  run stopped in between restarts the agent on the next run; check names it.
- check compares each installed unit with the header plus the release's
  /opt/fork-fleet/units/UNIT.
- The record lists every deny line the kit added (also for runner users that
  were there before); uninstall removes them all.
- The fleet config choice (and the project's copy in
  /etc/fork-fleet/fleet-config.json) is saved before the swap.
- --fleet-config is refused in a folder others or a runner user may write.
- check names a kit-made runner user that a smaller slots no longer uses.
- The bootstrap test gives a good --sha/--repo before --fleet-config, so it
  fails on the round-0 bootstrap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@scuttletoby

Copy link
Copy Markdown
Owner Author

Fix round 2 at 4a13be5 (one commit on top of 99bf235)

Lens 225c r1 (correctness) + 225s r1 (security):

  • 225c 1 / 225s 1, a killed install loses the restart -> fixed (4a13be5). The record gets restartPending: true before the first change the agent reads (swapped files, settings, key, units) and loses it only after systemctl restart worked. check fails while it is set. Tests: test_a_change_written_by_a_killed_run_still_restarts_the_agent (the lens probe, plus the check line and "not again"), test_a_refused_agent_check_keeps_the_restart_for_the_next_run.
  • 225s 2, check passes with a stale or edited unit -> fixed (4a13be5): each unit must equal the header plus /opt/fork-fleet/units/UNIT. Test: test_check_fails_when_a_unit_is_not_the_release_unit (the lens probe, then a full install passes again).
  • 225c 2, at.deny stays for a runner user that already existed -> fixed (4a13be5): the record lists every [deny file, user] line the kit added, saved before the write; uninstall removes them all (older records: the users it made, as before). Test: test_uninstall_removes_the_deny_lines_it_added_for_users_that_were_there.
  • 225c 3, fleet-config choice saved after the swap -> fixed (4a13be5): the choice is saved before the swap, and the project's config is kept in /etc/fork-fleet/fleet-config.json (written from the staged copy, so a refused config changes nothing), so a run killed right after the swap keeps the choice both ways. Test: test_the_fleet_config_choice_outlives_a_run_killed_after_the_swap (two subtests).
  • 225c 4, the bootstrap test passes on round 0 -> fixed (4a13be5): a good --sha/--repo before --fleet-config and a bad one after; fails on the round-0 bootstrap ("run this as root" instead of "--sha must be").
  • 225s 3 nit, type-wide service.d//timer.d/ -> documented (README, PR body): they hold the distribution's defaults for every unit (refusing them would refuse a stock Fedora), and only root can write them.
  • 225s 4 nit, slots shrinking -> fixed in part (4a13be5): check names a kit-made user that is no longer a slot ("no longer a slot ... uninstall removes it"). Install does not remove it, because a runner may still be running as it; documented. Test: test_check_names_a_runner_user_left_by_fewer_slots.
  • 225s 5 nit, --fleet-config ownership -> fixed (4a13be5): refused when the file or a folder above it is writable by others (sticky folders like /tmp are fine) or by a runner user's group, or owned by a runner user. Test: refusal case "a fleet config others may write".

State question: runs again (a cleared mark gives start, not restart; test), stops half-way (mark saved before the first write; choice saved before the swap), old state (a round-1 record without addedDenyLines falls back to the users it made; a round-1 project record without the copy reads /opt/fork-fleet/fork-fleet.json), empty (fresh record has the new keys).

Red-check: the new test file against the round-1 installer: 8 fail (both restart tests, unit check, fewer slots, open fleet config, fleet config both ways, deny lines). Head: install.test.py 33 OK.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant