carlos / platform Public

Merge remote-tracking branch 'origin/main' into plans-2-box-limits

claude agent, operated by paul@keymail.dev 6621044a9193b87e4da9cab39fdafc59866b0a68 parent 53394caparent 4a1e4b2
7 files changed, +1007 −1
  • docs/site/changelog-links.json +60 −0
  • docs/site/changelog.json +601 −0
  • internal/edge/purge_listen_linux.go +92 −0
  • internal/edge/purge_listen_linux_test.go +230 −0
  • internal/edge/purge_listen_unix.go +1 −1
  • internal/provision/plan14_root_reads_test.go +17 −0
  • internal/provision/testdata/plan14-root-reads-securelisten-337.tsv +6 −0
diff --git a/docs/site/changelog-links.json b/docs/site/changelog-links.json
index 3082e8c6a..80ec9b2e2 100644
--- a/docs/site/changelog-links.json
+++ b/docs/site/changelog-links.json
@@ -1,5 +1,65 @@
{
"//": "For the changelog: each release-note bullet's bold lead, mapped to the branches behind it. Kept by hand, one release at a time, as step 10 of the release recipe in the README. go run ./cmd/changelog fails on a lead no bullet has, or a branch that did not land in that release.",
+ "v0.28.7": {
+ "`carlos logs` now shows each run.": [
+ "run-lifecycle-logs-325"
+ ],
+ "An app can ask for one uploaded object to be scanned.": [
+ "scan-demand-revival"
+ ],
+ "The console's account switcher has a search box.": [
+ "console-switcher-filter"
+ ],
+ "An offline move can copy most of its data before the instance goes offline.": [
+ "moves-offline-precopy"
+ ],
+ "Every box in both regions must run this release before `stage` works.": [
+ "moves-offline-precopy"
+ ],
+ "Moves and replica copies now run eight object copies at once.": [
+ "moves-offline-precopy"
+ ],
+ "On-demand scanning needs `CARLOS_SCAN_WEBHOOK_KEY` only for callbacks.": [
+ "scan-demand-revival"
+ ]
+ },
+ "v0.28.6": {
+ "A guard-armed box's protected console can now be replaced.": [
+ "install-console-upgrade"
+ ],
+ "A nested instance's `config.env` is written as soon as its directory appears.": [
+ "moves-offline-fixes"
+ ],
+ "Each offline move step now sees a current box report.": [
+ "moves-offline-fixes"
+ ],
+ "A source box's scheduler leaves a moved instance alone.": [
+ "moves-offline-fixes"
+ ]
+ },
+ "v0.28.5": {
+ "`carlos move offline` moves one instance to another region, with planned downtime.": [
+ "moves-offline"
+ ],
+ "Roll every box in both regions before you start an offline move.": [
+ "moves-offline"
+ ],
+ "A box whose guard is armed on v0.28.4 needs one update by hand.": [
+ "armed-box-updates"
+ ],
+ "The guarded updater reads only what a candidate writes to standard output.": [
+ "armed-box-updates"
+ ],
+ "`carlos system guard-arm` makes its directories readable whatever your umask is.": [
+ "claim-guard-umask"
+ ],
+ "Routes with their own unit now restart on boxes whose agent doesn't run as root.": [
+ "adopt-unit-root-pass-315"
+ ],
+ "`carlos system status` warns when a new box would boot an older build.": [
+ "bootstrap-carlos-follows-316"
+ ]
+ },
"v0.28.4": {
"Console invites now arrive.": [
"design-console-invite-delivery"
diff --git a/docs/site/changelog.json b/docs/site/changelog.json
index a15fa98c9..8b790967a 100644
--- a/docs/site/changelog.json
+++ b/docs/site/changelog.json
@@ -2,6 +2,607 @@
"//": "Generated by go run ./cmd/changelog from git, the published release notes and Amadan. Do not edit; regenerate.",
"repo": "https://amadan.net/carlos/platform",
"releases": [
+ {
+ "tag": "v0.28.7",
+ "date": "2026-10-21",
+ "sha": "771ca01b6be00cec299a8e265c34136daa1fe0d1",
+ "published": true,
+ "notes_url": "https://github.com/carlosframework/releases/releases/tag/v0.28.7",
+ "notes": {
+ "form": "bullets",
+ "sections": [
+ {
+ "title": "",
+ "bullets": [
+ {
+ "md": "**`carlos logs` now shows each run.** It shows when each `carlos run` started, who asked for it and how it ended. The command's output is not kept there.",
+ "branches": [
+ "run-lifecycle-logs-325"
+ ],
+ "docs": [
+ "/docs/cli#carlos-logs",
+ "/docs/cli#carlos-run"
+ ]
+ },
+ {
+ "md": "**An app can ask for one uploaded object to be scanned.** Where scanning is enabled for the deployment and an operator has granted the store, the app requests a scan with an app-scoped `service` credential and polls for the result, or receives a signed callback. See [Receiving scan results](https://carlosframework.com/docs/scan-callbacks).",
+ "branches": [
+ "scan-demand-revival"
+ ]
+ },
+ {
+ "md": "**The console's account switcher has a search box.** It helps when you belong to many accounts.",
+ "branches": [
+ "console-switcher-filter"
+ ]
+ }
+ ]
+ },
+ {
+ "title": "For operators",
+ "bullets": [
+ {
+ "md": "**Roll the boxes first, then the console.** Coming from v0.28.4, read the [v0.28.5 operator notes](https://github.com/carlosframework/releases/releases/tag/v0.28.5) first. Coming from v0.28.5, upgrade the console with `carlos system claims install-console --upgrade` as the [v0.28.6 notes](https://github.com/carlosframework/releases/releases/tag/v0.28.6) describe.",
+ "docs": [
+ "/docs/cli#carlos-system-claims"
+ ]
+ },
+ {
+ "md": "**An offline move can copy most of its data before the instance goes offline.** Run `carlos move offline stage --host <host> --to <region>` while the instance keeps serving. It copies the instance's replica to the target region, and running it again copies only what is new. `start` then takes the instance offline as before, and the seal copies only what changed since the last stage. Until `start`, `abort` removes the staged copy and the instance is never offline. While a move is staged, the instance refuses `carlos run` and deletion, and no other instance can move offline.",
+ "branches": [
+ "moves-offline-precopy"
+ ],
+ "docs": [
+ "/docs/cli#carlos-move",
+ "/docs/cli#carlos-run"
+ ]
+ },
+ {
+ "md": "**Every box in both regions must run this release before `stage` works.** Each box now publishes `offline-stage-1`, and `stage` refuses while any box in either region lacks it.",
+ "branches": [
+ "moves-offline-precopy"
+ ]
+ },
+ {
+ "md": "**Moves and replica copies now run eight object copies at once.** Before, they copied one object at a time, so a large replica took minutes to copy while the instance was offline.",
+ "branches": [
+ "moves-offline-precopy"
+ ]
+ },
+ {
+ "md": "**On-demand scanning needs `CARLOS_SCAN_WEBHOOK_KEY` only for callbacks.** Polling works without it. To rotate it, keep the old key in `CARLOS_SCAN_WEBHOOK_KEY_PREVIOUS` and run `carlos system antivirus rotate-webhook-key`. Apps whose delivered secret changes restart.",
+ "branches": [
+ "scan-demand-revival"
+ ],
+ "docs": [
+ "/docs/cli#carlos-system"
+ ]
+ },
+ {
+ "md": "**Every dormant switch stays off.** Leave `CARLOS_MOVES_PHASE1`, `CARLOS_HOST_CLAIM`, `CARLOS_HOST_CLAIM_ORIGINS`, `CARLOS_HOST_CLAIM_RETIRED_LOCATIONS`, `CARLOS_CONSOLE_ID`, `CARLOS_REGION_HA`, `CONSOLE_APP_REGIONS`, `CARLOS_TEMPORARY_APPS`, `CARLOS_REQUIRE_POINTER_DIGEST` and `CARLOS_PLAN_CATALOG` unset, don't put `CARLOS_BOX_REFERENCE=group` in any host.env, and don't run `carlos channels digests on`. Keep `CARLOS_SWAP` as it is: a box armed for zero-downtime deploys stays armed.",
+ "docs": [
+ "/docs/cli#carlos-channels-digests"
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ "changes": [
+ {
+ "sha": "771ca01b6be00cec299a8e265c34136daa1fe0d1",
+ "date": "2026-10-21",
+ "subject": "moves-offline-precopy: carlos move offline stage (pre-copy while serving) and parallel mirror copies",
+ "branch": "moves-offline-precopy",
+ "branch_url": "https://amadan.net/carlos/platform/-/branch/moves-offline-precopy",
+ "commits": 10,
+ "docs": [
+ "/docs/cli#carlos-move"
+ ],
+ "credit": [
+ {
+ "person": "Paul Campbell",
+ "agents": [
+ "claude"
+ ]
+ }
+ ]
+ },
+ {
+ "sha": "f550afa21b8098460cbcf0bac2cac6748e52b802",
+ "date": "2026-10-21",
+ "subject": "spec: plans, caps and packs for the first paid launch (draft)",
+ "branch": "design-plans-and-caps",
+ "branch_url": "https://amadan.net/carlos/platform/-/branch/design-plans-and-caps",
+ "commits": 15,
+ "discussions": [
+ {
+ "id": 280,
+ "title": "Root trusts service-writable hostguard and health records under /data (claim root paths)",
+ "state": "open"
+ }
+ ],
+ "docs": [
+ "/docs/cli#carlos-accounts-plan",
+ "/docs/plans#turning-plans-on",
+ "/docs/plans#the-catalog",
+ "/docs/plans#assigning-a-plan",
+ "/docs/plans#sponsored-accounts"
+ ],
+ "credit": [
+ {
+ "person": "Paul Campbell",
+ "agents": [
+ "claude"
+ ]
+ }
+ ]
+ },
+ {
+ "sha": "aaa6a9498092d0e24910991127b45a9b21cdf7b4",
+ "date": "2026-10-21",
+ "subject": "root tests: the carlos binary is built once per test run, not once per test",
+ "branch": "test-suite-speed",
+ "branch_url": "https://amadan.net/carlos/platform/-/branch/test-suite-speed",
+ "commits": 3,
+ "discussions": [
+ {
+ "id": 291,
+ "title": "Test suite speed: where the CI gate's 375 seconds go, and a ranked plan",
+ "state": "open"
+ }
+ ],
+ "credit": [
+ {
+ "person": "Paul Campbell",
+ "agents": [
+ "claude"
+ ]
+ }
+ ]
+ },
+ {
+ "sha": "512baa591dc275b5b3a01a353d6993fb19509a0b",
+ "date": "2026-10-21",
+ "subject": "Prompts:",
+ "branch": "test-suite-debt",
+ "branch_url": "https://amadan.net/carlos/platform/-/branch/test-suite-debt",
+ "commits": 4,
+ "discussions": [
+ {
+ "id": 226,
+ "title": "Root package tests read the operator's real ~/.carlos/config (TestShipVerbAPIMode aborts the package)",
+ "state": "open"
+ },
+ {
+ "id": 258,
+ "title": "Root CLI tests read the operator's real ~/.carlos/config when TMPDIR is under HOME",
+ "state": "open"
+ },
+ {
+ "id": 263,
+ "title": "Hibernating instances: keep 7 days of snapshot history like awake ones",
+ "state": "closed"
+ },
+ {
+ "id": 266,
+ "title": "Flaky: sim/litestreamfacts TestGate_AnchoredLineageThroughStore under load",
+ "state": "open"
+ },
+ {
+ "id": 274,
+ "title": "Flaky CI test: stopevidence TestFDIdentityFindsAHardLinkHeldByAChild",
+ "state": "open"
+ }
+ ],
+ "credit": [
+ {
+ "person": "Paul Campbell",
+ "agents": [
+ "claude"
+ ]
+ }
+ ]
+ },
+ {
+ "sha": "f54aa2fc50b8481b9ba4b6bfe09a21bc7c7172e2",
+ "date": "2026-10-21",
+ "subject": "carlos run: a run's start and end reach carlos logs (#325)",
+ "branch": "run-lifecycle-logs-325",
+ "branch_url": "https://amadan.net/carlos/platform/-/branch/run-lifecycle-logs-325",
+ "commits": 5,
+ "discussions": [
+ {
+ "id": 325,
+ "title": "Run a one-off command with one instance's delivered config (carlos exec)",
+ "state": "closed"
+ }
+ ],
+ "docs": [
+ "/docs/cli#carlos-run"
+ ],
+ "credit": [
+ {
+ "person": "Paul Campbell",
+ "agents": [
+ "claude"
+ ]
+ }
+ ]
+ },
+ {
+ "sha": "3d444a1b63c6e853527292cd8845f1347fdbb544",
+ "date": "2026-10-21",
+ "subject": "Merge reviewed on-demand object scanning revival",
+ "branch": "scan-demand-revival",
+ "branch_url": "https://amadan.net/carlos/platform/-/branch/scan-demand-revival",
+ "commits": 1,
+ "docs": [
+ "/docs/cli#carlos-store-scan",
+ "/docs/cli#deployment-wide-object-scanning",
+ "/docs/scan-callbacks#asking-for-a-scan",
+ "/docs/scan-callbacks#the-states",
+ "/docs/scan-callbacks#callbacks-take-a-path"
+ ],
+ "credit": [
+ {
+ "person": "Paul Campbell",
+ "agents": [
+ "claude"
+ ]
+ }
+ ]
+ },
+ {
+ "sha": "ce8c425ae5930e1c2807018bde1880b93b0947cd",
+ "date": "2026-10-20",
+ "subject": "console: searchable, scrolling account switcher",
+ "branch": "console-switcher-filter",
+ "branch_url": "https://amadan.net/carlos/platform/-/branch/console-switcher-filter",
+ "commits": 2,
+ "discussions": [
+ {
+ "id": 280,
+ "title": "Root trusts service-writable hostguard and health records under /data (claim root paths)",
+ "state": "open"
+ }
+ ],
+ "credit": [
+ {
+ "person": "Paul Campbell",
+ "agents": [
+ "claude"
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "v0.28.6",
+ "date": "2026-10-20",
+ "sha": "e0986eabc5bc2a7ae5e17e58b19c9a6b92beecfe",
+ "published": true,
+ "notes_url": "https://github.com/carlosframework/releases/releases/tag/v0.28.6",
+ "notes": {
+ "form": "bullets",
+ "sections": [
+ {
+ "title": "",
+ "bullets": [
+ {
+ "md": "**Nothing changes for people deploying apps with the CLI.**"
+ }
+ ]
+ },
+ {
+ "title": "For operators",
+ "bullets": [
+ {
+ "md": "**Roll the boxes first, then the console.**"
+ },
+ {
+ "md": "**Coming from v0.28.4, read the [v0.28.5 operator notes](https://github.com/carlosframework/releases/releases/tag/v0.28.5) first.** They all still apply, including the one update by hand for a box whose guard is armed on v0.28.4."
+ },
+ {
+ "md": "**A guard-armed box's protected console can now be replaced.** Once the box's router is on this release, run `carlos system claims install-console --upgrade --from <sha256>` as root, where the sha256 is the console that is running now. It refuses unless that build is what the box holds, then installs the new console and keeps the old one as `console.prev`. No hand recovery is needed. A box upgraded this way can't be armed again with `carlos system guard-arm` until `install-console` has been run without `--upgrade`, because `guard-arm` expects both copies to be the same build.",
+ "branches": [
+ "install-console-upgrade"
+ ],
+ "docs": [
+ "/docs/cli#carlos-system-claims",
+ "/docs/cli#carlos-system"
+ ]
+ },
+ {
+ "md": "**A nested instance's `config.env` is written as soon as its directory appears.** Before, a move could leave the instance without it for up to an hour.",
+ "branches": [
+ "moves-offline-fixes"
+ ]
+ },
+ {
+ "md": "**Each offline move step now sees a current box report.** While a move is running, the source boxes and the sealing box report again every 15 seconds. A move takes under a minute of downtime when its steps run back to back. A step that meets a stale report now says the report is not current and to try again. Before, `flip` could answer that a process was still running.",
+ "branches": [
+ "moves-offline-fixes"
+ ]
+ },
+ {
+ "md": "**A source box's scheduler leaves a moved instance alone.** After `open`, it no longer writes a stray token file into the instance it gave up.",
+ "branches": [
+ "moves-offline-fixes"
+ ]
+ },
+ {
+ "md": "**Every dormant switch stays off.** Leave `CARLOS_MOVES_PHASE1`, `CARLOS_HOST_CLAIM`, `CARLOS_HOST_CLAIM_ORIGINS`, `CARLOS_HOST_CLAIM_RETIRED_LOCATIONS`, `CARLOS_CONSOLE_ID`, `CARLOS_REGION_HA`, `CONSOLE_APP_REGIONS`, `CARLOS_TEMPORARY_APPS` and `CARLOS_REQUIRE_POINTER_DIGEST` unset, don't put `CARLOS_BOX_REFERENCE=group` in any host.env, and don't run `carlos channels digests on`. Keep `CARLOS_SWAP` as it is: a box armed for zero-downtime deploys stays armed.",
+ "docs": [
+ "/docs/cli#carlos-channels-digests"
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ "changes": [
+ {
+ "sha": "e0986eabc5bc2a7ae5e17e58b19c9a6b92beecfe",
+ "date": "2026-10-20",
+ "subject": "moves-offline-fixes: harness Run 9 defects. Config sync writes a nested row's config.env as soon as its instance directory appears, keeping it pending until delivered (6); each offline door step sees a current report: source boxes and the sealer refresh reports every 15 s while a move is active without renewing the tombstone, and a stale report answers MV-O178 (7); a source box's scheduler leaves a held or moved host alone, excluding it before the batch limit (8). Astra reviewed (two rounds); census-ruling-moves-offline-fixes (platform-1c); MV-O178 approved by Paul.",
+ "branch": "moves-offline-fixes",
+ "branch_url": "https://amadan.net/carlos/platform/-/branch/moves-offline-fixes",
+ "commits": 9,
+ "credit": [
+ {
+ "person": "Paul Campbell",
+ "agents": [
+ "claude"
+ ]
+ }
+ ]
+ },
+ {
+ "sha": "2185035698a1111c487651bddb1bf2500cd92ccc",
+ "date": "2026-10-20",
+ "subject": "install-console --upgrade --from <sha256>: replace a guard-armed box's protected console",
+ "branch": "install-console-upgrade",
+ "branch_url": "https://amadan.net/carlos/platform/-/branch/install-console-upgrade",
+ "commits": 4,
+ "docs": [
+ "/docs/cli#carlos-system-claims"
+ ],
+ "credit": [
+ {
+ "person": "Paul Campbell",
+ "agents": [
+ "claude"
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "v0.28.5",
+ "date": "2026-10-20",
+ "sha": "54ae7fb93820f37f123bbdcbcb57f4f2efb25dc9",
+ "published": true,
+ "notes_url": "https://github.com/carlosframework/releases/releases/tag/v0.28.5",
+ "notes": {
+ "form": "bullets",
+ "sections": [
+ {
+ "title": "",
+ "bullets": [
+ {
+ "md": "**Nothing changes for people deploying apps with the CLI.**"
+ }
+ ]
+ },
+ {
+ "title": "For operators",
+ "bullets": [
+ {
+ "md": "**Roll the boxes first, then the console.**"
+ },
+ {
+ "md": "**Coming from v0.28.3, read the [v0.28.4 operator notes](https://github.com/carlosframework/releases/releases/tag/v0.28.4) first.** They all still apply."
+ },
+ {
+ "md": "**`carlos move offline` moves one instance to another region, with planned downtime.** It is for operators, works only while host claims and Phase 1 moves are off, and takes one step per command: `start`, `seal`, `flip`, `open`, then `finish`. The instance is offline from `start` until `open`. Until `open`, `abort` puts it back where it was with nothing lost.",
+ "branches": [
+ "moves-offline"
+ ],
+ "docs": [
+ "/docs/cli#carlos-move"
+ ]
+ },
+ {
+ "md": "**Roll every box in both regions before you start an offline move.** `seal` waits until every box in the instance's current region has stopped it and said so, and a box on an earlier release never says so.",
+ "branches": [
+ "moves-offline"
+ ]
+ },
+ {
+ "md": "**A box whose guard is armed on v0.28.4 needs one update by hand.** Its updater reads a candidate's progress messages as part of the answer, so `carlos system update` refuses every candidate on that box. Stop `carlos-system-update.path`, put the v0.28.5 binary in a root-only file after checking it against `SHA256SUMS.txt`, run it as root as `system apply` with the box's `host.env`, check the box, then start `carlos-system-update.path` again. Do one box at a time. From then on the box updates as usual.",
+ "branches": [
+ "armed-box-updates"
+ ],
+ "docs": [
+ "/docs/cli#carlos-system"
+ ]
+ },
+ {
+ "md": "**The guarded updater reads only what a candidate writes to standard output.** Progress and warnings no longer break its checks, so this is the last update a guard-armed box needs by hand.",
+ "branches": [
+ "armed-box-updates"
+ ]
+ },
+ {
+ "md": "**`carlos system guard-arm` makes its directories readable whatever your umask is.** Before, a root shell with a strict umask left them unreadable to the box's updater.",
+ "branches": [
+ "claim-guard-umask"
+ ],
+ "docs": [
+ "/docs/cli#carlos-system"
+ ]
+ },
+ {
+ "md": "**Routes with their own unit now restart on boxes whose agent doesn't run as root.** The agent now hands those restarts to root. Before, the restart was refused, the agent read that as a broken build and rolled back.",
+ "branches": [
+ "adopt-unit-root-pass-315"
+ ]
+ },
+ {
+ "md": "**`carlos system status` warns when a new box would boot an older build.** It compares `bootstrap/carlos` with stable's build and says which file to copy over it.",
+ "branches": [
+ "bootstrap-carlos-follows-316"
+ ],
+ "docs": [
+ "/docs/cli#carlos-system"
+ ]
+ },
+ {
+ "md": "**Every dormant switch stays off.** Leave `CARLOS_MOVES_PHASE1`, `CARLOS_HOST_CLAIM`, `CARLOS_HOST_CLAIM_ORIGINS`, `CARLOS_HOST_CLAIM_RETIRED_LOCATIONS`, `CARLOS_CONSOLE_ID`, `CARLOS_REGION_HA`, `CONSOLE_APP_REGIONS`, `CARLOS_TEMPORARY_APPS` and `CARLOS_REQUIRE_POINTER_DIGEST` unset, don't put `CARLOS_BOX_REFERENCE=group` in any host.env, and don't run `carlos channels digests on`. Keep `CARLOS_SWAP` as it is: a box armed for zero-downtime deploys stays armed.",
+ "docs": [
+ "/docs/cli#carlos-channels-digests"
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ "changes": [
+ {
+ "sha": "54ae7fb93820f37f123bbdcbcb57f4f2efb25dc9",
+ "date": "2026-10-20",
+ "subject": "armed-box-updates: guard-armed boxes can take a release by the ruled re-arm. Cherry-picks 8376a468 (rootaws: an inherited disabled sentinel is not an override) and 8a313548 (hostfloor: a guarded run returns stdout only) from claim-empty-cohort-done, Astra-reviewed there; census-ruling-claim-rootaws-sentinel, confirmed for main by platform-1c. For v0.28.5.",
+ "branch": "armed-box-updates",
+ "branch_url": "https://amadan.net/carlos/platform/-/branch/armed-box-updates",
+ "commits": 3,
+ "credit": [
+ {
+ "person": "Paul Campbell",
+ "agents": [
+ "claude"
+ ]
+ }
+ ]
+ },
+ {
+ "sha": "7af881553052fb0b1a26308f49e446df5bcf71be",
+ "date": "2026-10-20",
+ "subject": "moves-offline: operator-run offline region move (claims-off, planned downtime): holds and fences every source box, seals the source with a create-only tombstone after a final checkpoint, lands every dependency on the target proven against an immutable seal.json, opens, then retires the source by rename. Reviewed task by task and by Fable and Astra; census-ruling-moves-offline; copy approved by Paul 2026-10-20. Dormant until an operator runs carlos move offline.",
+ "branch": "moves-offline",
+ "branch_url": "https://amadan.net/carlos/platform/-/branch/moves-offline",
+ "commits": 40,
+ "docs": [
+ "/docs/cli#carlos-move"
+ ],
+ "credit": [
+ {
+ "person": "Paul Campbell",
+ "agents": [
+ "claude"
+ ]
+ }
+ ]
+ },
+ {
+ "sha": "57569bfffa089b9e0ac9c61b9d6f1a03ccf4fc97",
+ "date": "2026-10-20",
+ "subject": "changelog-v0.28.4: release step 10 for v0.28.1 to v0.28.4",
+ "branch": "changelog-v0.28.4",
+ "branch_url": "https://amadan.net/carlos/platform/-/branch/changelog-v0.28.4",
+ "commits": 1,
+ "credit": [
+ {
+ "person": "Paul Campbell",
+ "agents": [
+ "claude"
+ ]
+ }
+ ]
+ },
+ {
+ "sha": "cd2dd6e1a3f208b31932d28721bfa0eedb7e7a76",
+ "date": "2026-10-20",
+ "subject": "system status: say when bootstrap/carlos is not stable's build (#316)",
+ "branch": "bootstrap-carlos-follows-316",
+ "branch_url": "https://amadan.net/carlos/platform/-/branch/bootstrap-carlos-follows-316",
+ "commits": 2,
+ "discussions": [
+ {
+ "id": 280,
+ "title": "Root trusts service-writable hostguard and health records under /data (claim root paths)",
+ "state": "open"
+ },
+ {
+ "id": 316,
+ "title": "Keep bootstrap/carlos in step with the fleet: system update mirrors releases but new boxes still boot a stale binary",
+ "state": "open"
+ }
+ ],
+ "credit": [
+ {
+ "person": "Paul Campbell",
+ "agents": [
+ "claude"
+ ]
+ }
+ ]
+ },
+ {
+ "sha": "d7a5b8eb640535f54dd7e0380eda59e9bcc6c2c6",
+ "date": "2026-10-20",
+ "subject": "adopt: an agent that is not root hands named-unit routes to root's pass (#315)",
+ "branch": "adopt-unit-root-pass-315",
+ "branch_url": "https://amadan.net/carlos/platform/-/branch/adopt-unit-root-pass-315",
+ "commits": 3,
+ "discussions": [
+ {
+ "id": 280,
+ "title": "Root trusts service-writable hostguard and health records under /data (claim root paths)",
+ "state": "open"
+ },
+ {
+ "id": 315,
+ "title": "Agent's in-process adopt rolls back a console update when it can't restart console.service itself (\"Interactive authentication required\")",
+ "state": "closed"
+ }
+ ],
+ "credit": [
+ {
+ "person": "Paul Campbell",
+ "agents": [
+ "claude"
+ ]
+ }
+ ]
+ },
+ {
+ "sha": "96db0efe4a0dc94f26cc8e01d5ed8c5c07e3296a",
+ "date": "2026-10-19",
+ "subject": "claim-guard-umask: guard-arm makes its guard, system and lock directories 0755 whatever the caller's umask (#317)",
+ "branch": "claim-guard-umask",
+ "branch_url": "https://amadan.net/carlos/platform/-/branch/claim-guard-umask",
+ "commits": 2,
+ "discussions": [
+ {
+ "id": 317,
+ "title": "guard-arm's directories inherit the caller's umask: umask 077 broke the guarded console (exit 203)",
+ "state": "open"
+ }
+ ],
+ "credit": [
+ {
+ "person": "Paul Campbell",
+ "agents": [
+ "claude"
+ ]
+ }
+ ]
+ }
+ ]
+ },
{
"tag": "v0.28.4",
"date": "2026-10-18",
diff --git a/internal/edge/purge_listen_linux.go b/internal/edge/purge_listen_linux.go
new file mode 100644
index 000000000..e6ac55bee
--- /dev/null
+++ b/internal/edge/purge_listen_linux.go
@@ -0,0 +1,92 @@
+//go:build linux
+
+package edge
+
+import (
+ "errors"
+ "fmt"
+ "net"
+ "os"
+ "syscall"
+)
+
+// SecureUnixListen binds socketPath as a unix socket reachable only by its
+// owner, removing any stale file left behind by a crashed previous instance.
+//
+// The owner-only guarantee is the whole point of the function and the reason
+// it is split out per-OS rather than shimmed at the syscall: a purge socket
+// that anyone on the box can connect to hands out exactly the wake-storm
+// lever this feature exists to remove. Making that guarantee unrepresentable
+// on an OS without umask is safer than making it a no-op there.
+//
+// On Linux the socket is made 0600 before it is bound: bind creates the
+// file with the socket's own mode less the umask, so it appears owner-only
+// at socketPath with no window in between. The file is bound where it will
+// be served because unixdial checks the path a listener was bound under
+// against the path it dialled, so binding elsewhere and renaming is out.
+// This replaces setting the process umask to 0177 around the bind: the
+// umask belongs to the whole process, so every file and directory any
+// other goroutine made in that window lost its group and other bits, and a
+// directory lost its execute bit with them (amadan #337). Nothing here
+// touches the umask.
+func SecureUnixListen(socketPath string) (net.Listener, error) {
+ // An abstract address (a leading @) has no file and so no permissions:
+ // any uid could connect to it. Refuse it rather than serve it.
+ if socketPath == "" || socketPath[0] == '@' {
+ return nil, fmt.Errorf("unix socket %q: need a filesystem path, which is what makes it owner-only", socketPath)
+ }
+ if err := os.Remove(socketPath); err != nil && !errors.Is(err, os.ErrNotExist) {
+ return nil, err
+ }
+ fd, err := syscall.Socket(syscall.AF_UNIX, syscall.SOCK_STREAM|syscall.SOCK_CLOEXEC, 0)
+ if err != nil {
+ return nil, os.NewSyscallError("socket", err)
+ }
+ f := os.NewFile(uintptr(fd), socketPath)
+ defer f.Close() // net.FileListener holds its own duplicate
+ if err := syscall.Fchmod(fd, 0600); err != nil {
+ return nil, os.NewSyscallError("fchmod", err)
+ }
+ if err := syscall.Bind(fd, &syscall.SockaddrUnix{Name: socketPath}); err != nil {
+ return nil, &net.OpError{Op: "listen", Net: "unix", Addr: &net.UnixAddr{Name: socketPath, Net: "unix"}, Err: os.NewSyscallError("bind", err)}
+ }
+ bound := true
+ defer func() {
+ if bound {
+ os.Remove(socketPath)
+ }
+ }()
+ // The kernel caps the backlog at net.core.somaxconn.
+ if err := syscall.Listen(fd, 4096); err != nil {
+ return nil, os.NewSyscallError("listen", err)
+ }
+ // Fail closed if the file came out reachable by group or other: that
+ // would mean the kernel ignored the socket's mode, and the window this
+ // function exists to close was open.
+ fi, err := os.Lstat(socketPath)
+ if err != nil {
+ return nil, err
+ }
+ if fi.Mode().Type() != os.ModeSocket || fi.Mode().Perm()&0o077 != 0 {
+ return nil, fmt.Errorf("unix socket %s was created %v, not owner-only", socketPath, fi.Mode())
+ }
+ // The umask can only have taken bits away; give the owner back read
+ // and write so it can connect.
+ if err := os.Chmod(socketPath, 0600); err != nil {
+ return nil, err
+ }
+ fl, err := net.FileListener(f)
+ if err != nil {
+ return nil, err
+ }
+ ln, ok := fl.(*net.UnixListener)
+ if !ok {
+ fl.Close()
+ return nil, fmt.Errorf("unix socket %s: listener is %T, not a unix listener", socketPath, fl)
+ }
+ // A listener made from a file does not remove its path on Close; one
+ // from net.Listen("unix") does, and callers rely on it.
+ ln.SetUnlinkOnClose(true)
+ bound = false
+ return ln, nil
+}
diff --git a/internal/edge/purge_listen_linux_test.go b/internal/edge/purge_listen_linux_test.go
new file mode 100644
index 000000000..70b086839
--- /dev/null
+++ b/internal/edge/purge_listen_linux_test.go
@@ -0,0 +1,230 @@
+//go:build linux
+
+package edge
+
+import (
+ "net"
+ "os"
+ "path/filepath"
+ "runtime"
+ "strconv"
+ "strings"
+ "sync"
+ "syscall"
+ "testing"
+)
+
+// amadan #337: binding must not change the process umask, so directories
+// another goroutine makes while sockets are being bound keep their mode.
+// Under the old umask-0177 bind, a 0755 MkdirAll racing it came out 0600.
+// Not parallel: it sets the process umask to a known value for its run.
+func TestSecureUnixListenLeavesTheUmaskAlone(t *testing.T) {
+ old := syscall.Umask(0o022)
+ t.Cleanup(func() { syscall.Umask(old) })
+ root := t.TempDir()
+ if err := os.MkdirAll(filepath.Join(root, "l"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+
+ // The directory maker runs for as long as the binder does, starting
+ // together, so the two always overlap.
+ const rounds = 300
+ start := make(chan struct{})
+ binding := make(chan struct{})
+ var wg sync.WaitGroup
+ wg.Add(2)
+ go func() {
+ defer wg.Done()
+ defer close(binding)
+ <-start
+ for i := range rounds {
+ ln, err := SecureUnixListen(filepath.Join(root, "l", "s"+strconv.Itoa(i)+".sock"))
+ if err != nil {
+ t.Error(err)
+ return
+ }
+ ln.Close()
+ }
+ }()
+ var dirs []string
+ go func() {
+ defer wg.Done()
+ <-start
+ for i := 0; ; i++ {
+ select {
+ case <-binding:
+ return
+ default:
+ }
+ d := filepath.Join(root, "d", strconv.Itoa(i))
+ if err := os.MkdirAll(d, 0o755); err != nil {
+ t.Error(err)
+ return
+ }
+ dirs = append(dirs, d)
+ }
+ }()
+ close(start)
+ wg.Wait()
+ if len(dirs) == 0 {
+ t.Fatal("no directory was made while the binds ran")
+ }
+ for _, d := range dirs {
+ fi, err := os.Stat(d)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got := fi.Mode().Perm(); got != 0o755 {
+ t.Fatalf("%s is %o, want 0755: a bind changed the process umask", d, got)
+ }
+ }
+ if got := syscall.Umask(0o022); got != 0o022 {
+ t.Fatalf("umask after the binds = %o, want 022", got)
+ }
+}
+
+// The listener is reached at socketPath, reports it, removes it on Close,
+// and leaves no private directory behind.
+func TestSecureUnixListenServesAtItsPathAndLeavesNothingBehind(t *testing.T) {
+ t.Parallel()
+ dir := t.TempDir()
+ sock := filepath.Join(dir, "c.sock")
+ ln, err := SecureUnixListen(sock)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got := ln.Addr().String(); got != sock {
+ t.Errorf("Addr = %s, want %s", got, sock)
+ }
+ accepted := make(chan error, 1)
+ go func() {
+ c, err := ln.Accept()
+ if err == nil {
+ c.Close()
+ }
+ accepted <- err
+ }()
+ c, err := net.Dial("unix", sock)
+ if err != nil {
+ t.Fatalf("dial %s: %v", sock, err)
+ }
+ c.Close()
+ if err := <-accepted; err != nil {
+ t.Fatal(err)
+ }
+ if entries := names(t, dir); len(entries) != 1 || entries[0] != "c.sock" {
+ t.Fatalf("directory holds %v, want only c.sock", entries)
+ }
+ if err := ln.Close(); err != nil {
+ t.Fatal(err)
+ }
+ if entries := names(t, dir); len(entries) != 0 {
+ t.Fatalf("after Close the directory holds %v, want nothing", entries)
+ }
+}
+
+// A path that cannot be cleared (here: a directory with something in it)
+// fails before anything is bound, and is left as it was.
+func TestSecureUnixListenRefusesAPathItCannotClear(t *testing.T) {
+ t.Parallel()
+ dir := t.TempDir()
+ sock := filepath.Join(dir, "c.sock")
+ if err := os.MkdirAll(filepath.Join(sock, "occupied"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if ln, err := SecureUnixListen(sock); err == nil {
+ ln.Close()
+ t.Fatal("bound over a non-empty directory")
+ }
+ if entries := names(t, dir); len(entries) != 1 || entries[0] != "c.sock" {
+ t.Fatalf("directory holds %v after a failed bind, want only the original c.sock", entries)
+ }
+}
+
+// An abstract address has no file, so no permissions: refused, not bound.
+func TestSecureUnixListenRefusesAnAbstractAddress(t *testing.T) {
+ t.Parallel()
+ for _, p := range []string{"@carlos-test-abstract", ""} {
+ if ln, err := SecureUnixListen(p); err == nil {
+ ln.Close()
+ t.Errorf("SecureUnixListen(%q) bound; want a refusal", p)
+ }
+ }
+}
+
+// The listener is a *net.UnixListener, as net.Listen("unix") gives, so
+// callers keep SetDeadline, File and SetUnlinkOnClose.
+func TestSecureUnixListenGivesAUnixListener(t *testing.T) {
+ t.Parallel()
+ ln, err := SecureUnixListen(filepath.Join(t.TempDir(), "c.sock"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer ln.Close()
+ if _, ok := ln.(*net.UnixListener); !ok {
+ t.Fatalf("listener is %T, want *net.UnixListener", ln)
+ }
+}
+
+// A second Close must not remove a socket a later listener has since bound
+// at the same path.
+func TestSecureUnixListenClosesItsPathOnce(t *testing.T) {
+ t.Parallel()
+ sock := filepath.Join(t.TempDir(), "c.sock")
+ first, err := SecureUnixListen(sock)
+ if err != nil {
+ t.Fatal(err)
+ }
+ first.Close()
+ second, err := SecureUnixListen(sock)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer second.Close()
+ first.Close()
+ if _, err := os.Stat(sock); err != nil {
+ t.Fatalf("the second listener's socket is gone after the first closed again: %v", err)
+ }
+}
+
+func names(t *testing.T, dir string) []string {
+ t.Helper()
+ entries, err := os.ReadDir(dir)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var out []string
+ for _, e := range entries {
+ out = append(out, e.Name())
+ }
+ return out
+}
+
+// A socket path at the longest length the kernel accepts still binds: the
+// private directory's name is sized so the bind inside it is no longer
+// than socketPath, even for a short socket name.
+func TestSecureUnixListenBindsAtTheLengthLimit(t *testing.T) {
+ t.Parallel()
+ limit := 107 // sun_path is 108 bytes on Linux, including the NUL
+ if runtime.GOOS != "linux" {
+ limit = 103 // 104 on the BSDs and darwin
+ }
+ dir := t.TempDir()
+ pad := limit - len(dir) - len("/") - len("/c.sock")
+ if pad < 1 {
+ t.Skipf("temp dir %s leaves no room under %d bytes", dir, limit)
+ }
+ long := filepath.Join(dir, strings.Repeat("p", pad))
+ if err := os.Mkdir(long, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ sock := filepath.Join(long, "c.sock")
+ if len(sock) != limit {
+ t.Fatalf("built a %d-byte path, want %d", len(sock), limit)
+ }
+ ln, err := SecureUnixListen(sock)
+ if err != nil {
+ t.Fatalf("a %d-byte socket path did not bind: %v", len(sock), err)
+ }
+ ln.Close()
+}
diff --git a/internal/edge/purge_listen_unix.go b/internal/edge/purge_listen_unix.go
index d4a8cdb3b..857ae5c89 100644
--- a/internal/edge/purge_listen_unix.go
+++ b/internal/edge/purge_listen_unix.go
@@ -1,4 +1,4 @@
-//go:build !windows
+//go:build !windows && !linux
package edge
diff --git a/internal/provision/plan14_root_reads_test.go b/internal/provision/plan14_root_reads_test.go
index 217c65123..495304a7c 100644
--- a/internal/provision/plan14_root_reads_test.go
+++ b/internal/provision/plan14_root_reads_test.go
@@ -1101,6 +1101,23 @@ func TestPlan14RootReadCensus(t *testing.T) {
t.Fatal(err)
}
seal = "3737ebbd0ee87811839624724e552e0af02f63d70baaa0516a024440d848fdae"
+ // Census overlay securelisten-337 (amadan #337: edge.SecureUnixListen
+ // makes its socket owner-only with fchmod before bind instead of setting
+ // the process umask around it), loaded last, after ci-speed-activator,
+ // under census-ruling-securelisten-337 (Paul, as census owner,
+ // 2026-10-21, accepted as proposed). SecureUnixListen in purge_listen_linux.go is new
+ // accepted legacy at J4 root-input (as pendingDirs.appeared and its
+ // caller ListenControl): one Lstat of the socket the agent has just
+ // bound for itself to serve. The internal/edge envelope is re-pinned.
+ // One frozen declaration is added, so the seal moves;
+ // ci-speed-activator's is checked first.
+ if errs := checkRootReadLegacy(inv, seal); len(errs) != 0 {
+ t.Fatal(errs)
+ }
+ if err = loadRootReadInventory(filepath.Join(root, "internal/provision/testdata/plan14-root-reads-securelisten-337.tsv"), &inv, true); err != nil {
+ t.Fatal(err)
+ }
+ seal = "33a8ff46dd9fcf85664c42c42eaf01585af6c92806cb7830fa8f30fd0950c430"
errs := append(compareRootReads(got, inv), checkRootReadLegacy(inv, seal)...)
errs = append(errs, checkRootReadDeclarations(got, inv)...)
if len(errs) > 0 {
diff --git a/internal/provision/testdata/plan14-root-reads-securelisten-337.tsv b/internal/provision/testdata/plan14-root-reads-securelisten-337.tsv
new file mode 100644
index 000000000..a402400b2
--- /dev/null
+++ b/internal/provision/testdata/plan14-root-reads-securelisten-337.tsv
@@ -0,0 +1,6 @@
+# Census overlay securelisten-337 (amadan #337: edge.SecureUnixListen makes its socket owner-only with fchmod before bind instead of setting the process umask to 0177 around the bind), loaded LAST, after ci-speed-activator. RULED as census-ruling-securelisten-337 (Paul, as census owner, 2026-10-21), accepted as proposed.
+# SecureUnixListen in internal/edge/purge_listen_linux.go (new file, //go:build linux) is a new unit with one read site: an Lstat of the socket it has just bound, to fail closed if group or other bits are set. It runs in the agent (/opt/carlos/carlos agent, carlos-edge.service, user carlos) on the sockets the agent itself is about to serve, and makes no root decision (it also refuses an empty or abstract @ path before bind, and the listener it returns removes its path on Close through SetUnlinkOnClose), so it is accepted legacy at J4 root-input, the disposition census-ruling-moves-offline-fixes gave pendingDirs.appeared (the agent's own Lstat of paths it made) and plan14-root-reads.tsv gives ListenControl, its caller. Its callers (listenLogs, listenCmdStream, ListenControl) are unchanged. The old umask implementation stays in purge_listen_unix.go for other unix systems (build tag now !windows && !linux); the census scans linux only and finds no read there.
+# Pinned at branch commit 8fb4ccb1 (Astra review: the listener returned is the *net.UnixListener itself with SetUnlinkOnClose(true); empty and abstract paths refused). One frozen declaration is added, so the seal moves; ci-speed-activator's is checked first. The internal/edge envelope is re-pinned for the new file and the build tag change.
+@legacy 670864719cd404f826f3ba1365d8867d0cbb1c0b2eda867676c34968493fd4d7 internal/edge/purge_listen_linux.go SecureUnixListen legacy accepted-legacy accepted-legacy;rev3=census-ruling-securelisten-337;identity=the agent (/opt/carlos/carlos agent, carlos-edge.service, user carlos);basis=root-input (J4, as pendingDirs.appeared under census-ruling-moves-offline-fixes and as ListenControl, its caller); new on Linux (amadan #337), replacing the umask bind, which set the process umask to 0177 and so stripped group and other bits from files other goroutines made meanwhile: one Lstat, which never follows a link, of the unix socket this call has just bound at socketPath for the agent itself to serve (an instance's purge control socket through ListenControl, a role's log socket through listenLogs, a cmd unit's stdout and stderr sockets through listenCmdStream), so the bind fails closed unless the file is a socket with no group or other bits (an empty or abstract @ path is refused before bind, since it has no file and so no permissions); it reads no file content, decides only whether this bind fails, and makes no root decision. startIsolated (claim mode, rolelaunch_root_linux.go) also calls listenLogs, as the agent and outside its l.Work.With step, before it asks root to run the reservation; root never opens or reads the socket file:Lstat@1490 2b037b125506ef5e89c9c2ffedca1474c23ecd30113eac3e7586fc41af6af6b6
+internal/edge/purge_listen_linux.go SecureUnixListen legacy accepted-legacy accepted-legacy;rev3=census-ruling-securelisten-337;identity=the agent (/opt/carlos/carlos agent, carlos-edge.service, user carlos);basis=root-input (J4, as pendingDirs.appeared under census-ruling-moves-offline-fixes and as ListenControl, its caller); new on Linux (amadan #337), replacing the umask bind, which set the process umask to 0177 and so stripped group and other bits from files other goroutines made meanwhile: one Lstat, which never follows a link, of the unix socket this call has just bound at socketPath for the agent itself to serve (an instance's purge control socket through ListenControl, a role's log socket through listenLogs, a cmd unit's stdout and stderr sockets through listenCmdStream), so the bind fails closed unless the file is a socket with no group or other bits (an empty or abstract @ path is refused before bind, since it has no file and so no permissions); it reads no file content, decides only whether this bind fails, and makes no root decision. startIsolated (claim mode, rolelaunch_root_linux.go) also calls listenLogs, as the agent and outside its l.Work.With step, before it asks root to run the reservation; root never opens or reads the socket file:Lstat@1490 2b037b125506ef5e89c9c2ffedca1474c23ecd30113eac3e7586fc41af6af6b6
+@coverage internal/edge e8f21100bb0132adc26fb49e12143780e9fbcfecd33ea8e91b6ad7db89198021