@bongos/core 1.19.937 → 1.19.939

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.bongos-core.json CHANGED
@@ -2,22 +2,22 @@
2
2
  "artifact": "bongos-core",
3
3
  "manifest_schema": 1,
4
4
  "generator": "scripts/gds/package-core.js",
5
- "core_version": "1.19.937",
6
- "core_contract": "1.19.937",
7
- "source_commit": "2aee2eb9e23c44abf42a24b09f9603555ba33673",
5
+ "core_version": "1.19.939",
6
+ "core_contract": "1.19.939",
7
+ "source_commit": "5a36a537ba12fc2d9dc3ce29ec6e84fb102238e8",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-21T05:57:31.197Z",
9
+ "built_at": "2026-09-21T18:56:49.358Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 511,
13
13
  "agent_docs_stubbed": 26,
14
- "functional_verbatim": 2334,
14
+ "functional_verbatim": 2335,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 2871,
20
- "tree_sha256": "026277292d3a56e019a060c711325a482164c64ae62daa9c92ee820321bd0a81",
19
+ "file_count": 2872,
20
+ "tree_sha256": "718dc449d9f22dd45347cce2f17dc24d6ca3d8e0cce9c9b1e7aad67a8683df47",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/ask-for-help/SKILL.md",
@@ -1897,7 +1897,7 @@
1897
1897
  {
1898
1898
  "path": "docs/adr/0281-an-instance-identity-is-its-own-unix-account-and-pg-role.md",
1899
1899
  "mode": "0000644",
1900
- "sha256": "2697c7cdca6d518d7ca7d84cf354ca92f264bf641e0b178bfa038f83878d5963"
1900
+ "sha256": "d36d2333c07107c09d7431b4f49ceaafe51231dd4646dbb13bb081d0b2b63a33"
1901
1901
  },
1902
1902
  {
1903
1903
  "path": "docs/adr/0282-a-module-that-spends-for-its-callers-declares-a-payer-and-the-core-refuses-it-without-one.md",
@@ -2677,7 +2677,7 @@
2677
2677
  {
2678
2678
  "path": "docs/module-api-changelog.md",
2679
2679
  "mode": "0000644",
2680
- "sha256": "3fddd5b37bb0a00a12bf88b81992eb2510e0115b8cc7f67c8fd67c51b65ae678"
2680
+ "sha256": "4e9e33798c5671990f7d9d45e0695f43b8de1621cdaa316dd30d4e014f93df90"
2681
2681
  },
2682
2682
  {
2683
2683
  "path": "docs/modules-contract.md",
@@ -2897,7 +2897,7 @@
2897
2897
  {
2898
2898
  "path": "docs/recipes/self-host.md",
2899
2899
  "mode": "0000644",
2900
- "sha256": "a5c98b8ee01461669a5f4ee5fa361e85e11915eb8326e060c4cf20841c35c07e"
2900
+ "sha256": "48dbce2966702987cbb9de6639ceb1e63ab3c7166e759bf93df06bd9f5875a88"
2901
2901
  },
2902
2902
  {
2903
2903
  "path": "docs/recipes/server-bring-up.md",
@@ -4807,7 +4807,7 @@
4807
4807
  {
4808
4808
  "path": "modules/hall-ui/public/deploy.js",
4809
4809
  "mode": "0000644",
4810
- "sha256": "e5a689447524c8c3460a2a649bea3673c958c6ec8607ad41144c575d29d142d0"
4810
+ "sha256": "de280baf7432be56c67a65681a6e5d5af7146464271dec547d5bd6d45be2f37a"
4811
4811
  },
4812
4812
  {
4813
4813
  "path": "modules/hall-ui/public/diagram-viewer.css",
@@ -6497,7 +6497,7 @@
6497
6497
  {
6498
6498
  "path": "modules/provisioning/core-upgrade.js",
6499
6499
  "mode": "0000644",
6500
- "sha256": "291a3765197f9429c70dd240fe5cdfa72a245ad3698c822032e71bb88999d533"
6500
+ "sha256": "f5556a75b34977d0c46d4c9cf95383e4f57088c137e1c29c5b87211a5f159451"
6501
6501
  },
6502
6502
  {
6503
6503
  "path": "modules/provisioning/credential-preflight.js",
@@ -6667,7 +6667,7 @@
6667
6667
  {
6668
6668
  "path": "modules/provisioning/routes/core-upgrade.js",
6669
6669
  "mode": "0000644",
6670
- "sha256": "7447a5eb37dc120e14fa6b5fb81c574ec951b9a46ed126d5c954c63b03bc6b6d"
6670
+ "sha256": "8ceb0707b3bfe16e308cdf3034b034bcea8b5e266bcfa1d0e47bd53a61392990"
6671
6671
  },
6672
6672
  {
6673
6673
  "path": "modules/provisioning/routes/disconnect.js",
@@ -8002,12 +8002,12 @@
8002
8002
  {
8003
8003
  "path": "package-lock.json",
8004
8004
  "mode": "0000644",
8005
- "sha256": "f1999bdf0489eddf32aabd9a5d2aec6b82bd9765a598e0eff299c31a367b6ff6"
8005
+ "sha256": "f9412625c08d05fd8718e0590544e5b4ac8d61ce85168c01c98973b0b0afbafd"
8006
8006
  },
8007
8007
  {
8008
8008
  "path": "package.json",
8009
8009
  "mode": "0000644",
8010
- "sha256": "0be6855a6ff73d2592bff19899727f708abd44265f2ea4538cc48491982a5aac"
8010
+ "sha256": "fcdd31f9e93e415ebe9384cfa896520ea80733044e833513f914c0f873180774"
8011
8011
  },
8012
8012
  {
8013
8013
  "path": "public-docs/index.html",
@@ -8067,7 +8067,7 @@
8067
8067
  {
8068
8068
  "path": "scripts/gds/address-park-sweep.sh",
8069
8069
  "mode": "0000644",
8070
- "sha256": "9cf1964d675f385ff73bbbd7683f9b0fd2b9776c832548c2e662536af31b2d15"
8070
+ "sha256": "b08a77da7a834dbe30fa9bd041ce05289f8abf48b3c44764ecbe2238cbc88e9c"
8071
8071
  },
8072
8072
  {
8073
8073
  "path": "scripts/gds/adopt-detect.js",
@@ -8137,7 +8137,7 @@
8137
8137
  {
8138
8138
  "path": "scripts/gds/audit-authorship.sh",
8139
8139
  "mode": "0000644",
8140
- "sha256": "27b436b82d129f956ddb956efdca9226f31602a8f22b4099b9e6b0ffeb49313e"
8140
+ "sha256": "95607e7f1383148ecb21d448196872f752988d5c70169f5470a12eafc7e2643e"
8141
8141
  },
8142
8142
  {
8143
8143
  "path": "scripts/gds/audit-deps.js",
@@ -8427,12 +8427,12 @@
8427
8427
  {
8428
8428
  "path": "scripts/gds/db-backup-nightly.sh",
8429
8429
  "mode": "0000644",
8430
- "sha256": "5e8b7ebfe0b82a426e229a256daf231cb597d8e1df2b908c528a0ed0a67066a5"
8430
+ "sha256": "6123be86860d43d95d5d44966eb3e49caf9c5dc05a2a9ec02c67f53f527919dd"
8431
8431
  },
8432
8432
  {
8433
8433
  "path": "scripts/gds/db-backup-offsite.sh",
8434
8434
  "mode": "0000644",
8435
- "sha256": "97f52f6aff95e1c81e069c1d4228eecb514af6068c810441465b5f5b7ee0f2ce"
8435
+ "sha256": "f530f46117bb052f8294e29c95970f6325e1e39f7ee27c96b92c2d672537b37d"
8436
8436
  },
8437
8437
  {
8438
8438
  "path": "scripts/gds/demote.js",
@@ -8742,7 +8742,7 @@
8742
8742
  {
8743
8743
  "path": "scripts/gds/instance-apex.js",
8744
8744
  "mode": "0000644",
8745
- "sha256": "fcb59a4eda007d98179dfe54956fcce4ff07c11ca941ed6dea41b38c0daca421"
8745
+ "sha256": "7b2402543c3b1eadbafc14907e6dc6689b434ac65fb9c709115dd81a92eb13eb"
8746
8746
  },
8747
8747
  {
8748
8748
  "path": "scripts/gds/key-card.js",
@@ -8947,12 +8947,12 @@
8947
8947
  {
8948
8948
  "path": "scripts/gds/provision-config.js",
8949
8949
  "mode": "0000644",
8950
- "sha256": "68882e5e600881c0e3a96d4b84a9ad38dd77c3216a09a7886a263cfe40044f1e"
8950
+ "sha256": "6905238b164d1fbd47abbc1f495e6f1bf2de58ed26545853f8aa92f199dc78e5"
8951
8951
  },
8952
8952
  {
8953
8953
  "path": "scripts/gds/provision-core-upgrade.js",
8954
8954
  "mode": "0000644",
8955
- "sha256": "8ad61c85e0cf3f515fbd970fedcd6c33871d70e4dd42c36cb275ae907e31b655"
8955
+ "sha256": "4a84f48518a6427e95bf23e273865b6b2753489b40737b9256191fd2a65562e1"
8956
8956
  },
8957
8957
  {
8958
8958
  "path": "scripts/gds/provision-disconnect.js",
@@ -8967,7 +8967,7 @@
8967
8967
  {
8968
8968
  "path": "scripts/gds/provision-process.js",
8969
8969
  "mode": "0000644",
8970
- "sha256": "210d5135b157363499dc34bb8ac34eb7f661a61d8ba3d23e6a64953e5893559e"
8970
+ "sha256": "34f24f674b7be4607f598d6f968e3e3c1f15cb9175361956b0808a1df1ac723c"
8971
8971
  },
8972
8972
  {
8973
8973
  "path": "scripts/gds/provision-repo-auth.js",
@@ -8977,7 +8977,7 @@
8977
8977
  {
8978
8978
  "path": "scripts/gds/provision-repo.js",
8979
8979
  "mode": "0000644",
8980
- "sha256": "ef448bfb12437b2a3b83d7d726daff6a38b2a5a2db52096cc0150171d73547ce"
8980
+ "sha256": "ab6b240d93920bb81bf096aff9553c478a2c3e8c0c4926b81c468e99aee89d67"
8981
8981
  },
8982
8982
  {
8983
8983
  "path": "scripts/gds/provision-teardown.js",
@@ -8987,12 +8987,12 @@
8987
8987
  {
8988
8988
  "path": "scripts/gds/provision-units.js",
8989
8989
  "mode": "0000644",
8990
- "sha256": "4228fa562e8832f545558c15caf94fdc3e5d96d7928e6ecb8a6bf23dd6511f04"
8990
+ "sha256": "f8c642838cbae5dc096f35870bda10f3da342e1cd93b0713e4528bfefe1819a8"
8991
8991
  },
8992
8992
  {
8993
8993
  "path": "scripts/gds/provision.js",
8994
8994
  "mode": "0000644",
8995
- "sha256": "e384ffc0ecfcdb05d275d1245a6bc1214f2737c0fa67ae21eb8228ce1a398abf"
8995
+ "sha256": "e879686d5fcadfd32eb1dfc4e1640fc229cc4f0afc78615681964484e8457c1f"
8996
8996
  },
8997
8997
  {
8998
8998
  "path": "scripts/gds/publish-credential-check.js",
@@ -9512,7 +9512,7 @@
9512
9512
  {
9513
9513
  "path": "scripts/gds/stray-unit-sweep.sh",
9514
9514
  "mode": "0000644",
9515
- "sha256": "7bc5a6193130085d7faebfb6a71a16b89aa09747f30fdfd612d7cf4c7a1778ff"
9515
+ "sha256": "f90a8ae14f3715f1454431d49be858b605bb3030a2ff3359f5ba2d41ebfd7a3f"
9516
9516
  },
9517
9517
  {
9518
9518
  "path": "scripts/gds/surface.js",
@@ -10007,7 +10007,7 @@
10007
10007
  {
10008
10008
  "path": "src/module-api.js",
10009
10009
  "mode": "0000644",
10010
- "sha256": "546941331565d646b5b815a2a66c4833993abd3affcd2ba309ddad87191d766a"
10010
+ "sha256": "7e4584b3285ed7261fa69727d9100dc10856000a7d8f75c8e9f1f47c26eb2f93"
10011
10011
  },
10012
10012
  {
10013
10013
  "path": "src/module-loader/catalog.js",
@@ -10922,7 +10922,7 @@
10922
10922
  {
10923
10923
  "path": "tests/core_upgrade_runner.mjs",
10924
10924
  "mode": "0000644",
10925
- "sha256": "6077bfd9eca120db9dcfebec30fa355d0ae281df6cf9fbd6489203640051fcc1"
10925
+ "sha256": "f8bc5b5329c4de27b41a9172e6b5023c4854d864f532aac3ed062e0d35633f00"
10926
10926
  },
10927
10927
  {
10928
10928
  "path": "tests/cosmos_token_contrast.mjs",
@@ -13037,12 +13037,12 @@
13037
13037
  {
13038
13038
  "path": "tests/provision.mjs",
13039
13039
  "mode": "0000644",
13040
- "sha256": "4e1d463693cb7d5e363964360b72085d2307578bd430f2fee1129698d907a567"
13040
+ "sha256": "b6558cb094a2d38c837d3e5028573c14e5c55c832555b382c1d1135a55b5c6b5"
13041
13041
  },
13042
13042
  {
13043
13043
  "path": "tests/provision_account_identity.mjs",
13044
13044
  "mode": "0000644",
13045
- "sha256": "d56cf2553c0b5efa6a17df3d578b7b5766ff62504af6c226688f73ac417df3c6"
13045
+ "sha256": "9f62eea1b43a478334a83df52d0e81a3b3b67f4c4eea54680d2d8db3b25ee513"
13046
13046
  },
13047
13047
  {
13048
13048
  "path": "tests/provision_caddy_wiring.mjs",
@@ -14009,6 +14009,11 @@
14009
14009
  "mode": "0000644",
14010
14010
  "sha256": "24257ca4ad1a7bcefb735ee1b02609469035730e0723645f71e10eb71525bb72"
14011
14011
  },
14012
+ {
14013
+ "path": "tests/sweep_app_account.mjs",
14014
+ "mode": "0000644",
14015
+ "sha256": "46fef77aa82d7f5295ed119b2d5ca77aeb29f5f267a3b82235260a94ee5bd41f"
14016
+ },
14012
14017
  {
14013
14018
  "path": "tests/task_classifier_vocab.mjs",
14014
14019
  "mode": "0000644",
@@ -46,6 +46,8 @@ ADR 0128 anticipated the DB half in its own alternatives: *"Do createdb/migrate
46
46
  - **`provision.js` sits two lines under the 1,500-line ratchet.** The generators and their full rationale live in `provision-repo.js`; the runner carries the wiring and a pointer. The next change there will have to move something out.
47
47
  - **The non-privileged path is unchanged in kind.** The DDL escalates through the same `asPostgres()` helper ADR 0128 introduced, so a runner without `PROVISION_SUDO` renders the same commands without `sudo` — as broken, and no more broken, than it already was for `createdb`.
48
48
 
49
+ > **Amended by task 1002719 — the app user is resolved, not named.** The account whose group every instance joins (and which owns the backup dir and runs the backup timers) was the founder's own account as a literal, so a standup on any other host died `invalid user` at the backup step. `appUser()` in `provision-repo.js` now answers it: `PROVISION_APP_USER`, else the account the runner runs as — which on a plane *is* the app user, so the founder box resolves exactly what the literal said. **root is refused, not resolved**: instance accounts join this account's group, and group root is not a read grant to hand a tenant. The name is validated before it reaches a shell command or a unit file, and the refusal is a preflight: it fires before the status flip, never midway through a standup. Its **group** is the account's primary group looked up on the box (`$(id -gn …)`; the backup unit carries no `Group=`), never the account name spelled twice — an admin-made service account need not have a group of its own name. `audit-authorship.sh` now flags the founder's unix account in account position (`-o`/`-u`, `User=`, `*user = '…'`) in runtime code, because the GitHub-login grep never could.
50
+
49
51
  ## Alternatives considered
50
52
 
51
53
  - **A `DATABASE_URL` in `web.env` instead of discrete `PG*` vars.** `pool.js` already prefers it, so it would have needed no pool change at all. Rejected: the socket path has to ride as `?host=…` or a percent-encoded authority, and the exact parse is `pg-connection-string`'s business rather than something the test could pin — trading a stated, assertable identity for a URL whose behavior we would be trusting.
@@ -2348,5 +2348,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
2348
2348
  landed since 1.19.935 with no explicit bump. run 35561285958. (task 1002620)
2349
2349
  1.19.937 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2350
2350
  landed since 1.19.936 with no explicit bump. run 35566446478. (task 1002620)
2351
+ 1.19.938 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2352
+ landed since 1.19.937 with no explicit bump. run 35638291152. (task 1002620)
2353
+ 1.19.939 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2354
+ landed since 1.19.938 with no explicit bump. run 35641614084. (task 1002620)
2351
2355
  ---------------------------------------------------------------------------
2352
2356
  ```
@@ -39,7 +39,7 @@ Author `config/branding.json` from the neutral starter — see [the branding con
39
39
 
40
40
  **Or scaffold it with `bongos init`** ([task #1204](https://example.com/builders#/task/1204)): a short interview (or `bongos init --from spec.json` for a hands-free / CI run) that writes both config files, **seeds your first version + its done-when goal**, and **files the human-only kickoff steps** (register a GitHub OAuth app, first owner sign-in, repo binding, admission) as real tracked tasks — so a fresh instance has a claimable queue on day one instead of an empty hall. It never invents your branding (it points you at the fill prompt for that). Preview the whole plan with `bongos init --dry-run`.
41
41
 
42
- **Production hosting, optional (the `provisioning` module — [ADR 0111](../adr/0111-instance-hosting-provisioning-module.md) §3).** If you enable the `provisioning` module and answer the interview's hosting questions (shape — co-tenant `$0` or a dedicated droplet — bring-your-own domain, credential ref, tier), init turns those answers into a real **provisioning request** (a queued `provision` intent the control-plane runner drains) instead of only filing a manual "production topology" task. **A queued request does nothing until a runner drains it:** on the control plane that is a systemd timer running `node scripts/gds/provision.js run-intents --apply`. This Docker image now carries the tools the scaffold needs — git, and a core it can package out of its own `.git`-less checkout (task 1003995) — and supervision is no longer systemd-only: the runner picks a process manager, falling back to supervisord where systemd is not running (task 1003997, [ADR 0306](../adr/0306-instance-supervision-is-a-seam-with-systemd-as-the-default.md)). Three standup legs still assume a systemd box, so on a compose self-host a new project cannot stand up: the container has no reverse proxy for a child instance's front door, cannot create the per-instance unix account that isolates one instance from another, and installs its backup dir as a hardcoded founder user (task 1002719). **Two of the three now refuse instead of failing opaquely, each for its own reason** — so fixing one cannot silently re-open the other. **The ingress leg now refuses instead of failing opaquely** (task 1004017, [ADR 0315](../adr/0315-a-plane-with-no-proxy-is-refused-not-given-a-no-op-ingress.md)): provisioning probes the plane with `caddy version` and returns `Ingress preflight failed: …` naming the missing proxy, before it writes a Caddy snippet or reloads anything. Previously a domained child was told to edit an `/etc/caddy/Caddyfile` the container does not have, and a domainless one crashed on the snippet write. Giving the container a real front door is still unbuilt — the refusal closes the failure mode, not the capability. **The identity leg refuses too** (task 1004018, [ADR 0317](../adr/0317-a-plane-that-cannot-give-an-instance-its-own-account-is-refused.md)): provisioning probes whether the runner can reach root on this plane and returns `Account preflight failed: …` before any mutation, because `useradd` cannot run under `USER node` and every child there would otherwise share one uid — the sibling-secret vector [ADR 0281](../adr/0281-an-instance-identity-is-its-own-unix-account-and-pg-role.md) exists to close. It is a capability question, not a knob: there is no setting that accepts a shared uid. Note supervisord has no timer concept either, so a container-hosted instance gets no automatic DB backup — the standup log prints the `crontab` line to add instead. The two irreducibly-human legs — the GitHub OAuth app and the first owner sign-in — stay guided/manual. If the module isn't enabled on the target instance, init falls back to the manual production-topology task, so nothing is silently skipped.
42
+ **Production hosting, optional (the `provisioning` module — [ADR 0111](../adr/0111-instance-hosting-provisioning-module.md) §3).** If you enable the `provisioning` module and answer the interview's hosting questions (shape — co-tenant `$0` or a dedicated droplet — bring-your-own domain, credential ref, tier), init turns those answers into a real **provisioning request** (a queued `provision` intent the control-plane runner drains) instead of only filing a manual "production topology" task. **A queued request does nothing until a runner drains it:** on the control plane that is a systemd timer running `node scripts/gds/provision.js run-intents --apply`. This Docker image now carries the tools the scaffold needs — git, and a core it can package out of its own `.git`-less checkout (task 1003995) — and supervision is no longer systemd-only: the runner picks a process manager, falling back to supervisord where systemd is not running (task 1003997, [ADR 0306](../adr/0306-instance-supervision-is-a-seam-with-systemd-as-the-default.md)). Three standup legs assumed a systemd box, and on a compose self-host a new project still cannot stand up: the container has no reverse proxy for a child instance's front door, cannot create the per-instance unix account that isolates one instance from another, and installed its backup dir as a hardcoded founder user. **That third leg is fixed** (task 1002719): the backup dir's owner is `PROVISION_APP_USER`, else the account the runner runs as (root is refused), so the standup no longer dies `invalid user` on a host without the founder's account. **The other two now refuse instead of failing opaquely, each for its own reason** — so fixing one cannot silently re-open the other. **The ingress leg now refuses instead of failing opaquely** (task 1004017, [ADR 0315](../adr/0315-a-plane-with-no-proxy-is-refused-not-given-a-no-op-ingress.md)): provisioning probes the plane with `caddy version` and returns `Ingress preflight failed: …` naming the missing proxy, before it writes a Caddy snippet or reloads anything. Previously a domained child was told to edit an `/etc/caddy/Caddyfile` the container does not have, and a domainless one crashed on the snippet write. Giving the container a real front door is still unbuilt — the refusal closes the failure mode, not the capability. **The identity leg refuses too** (task 1004018, [ADR 0317](../adr/0317-a-plane-that-cannot-give-an-instance-its-own-account-is-refused.md)): provisioning probes whether the runner can reach root on this plane and returns `Account preflight failed: …` before any mutation, because `useradd` cannot run under `USER node` and every child there would otherwise share one uid — the sibling-secret vector [ADR 0281](../adr/0281-an-instance-identity-is-its-own-unix-account-and-pg-role.md) exists to close. It is a capability question, not a knob: there is no setting that accepts a shared uid. Note supervisord has no timer concept either, so a container-hosted instance gets no automatic DB backup — the standup log prints the `crontab` line to add instead. The two irreducibly-human legs — the GitHub OAuth app and the first owner sign-in — stay guided/manual. If the module isn't enabled on the target instance, init falls back to the manual production-topology task, so nothing is silently skipped.
43
43
 
44
44
  ## Configuration surface
45
45
 
@@ -6,7 +6,7 @@
6
6
  // pipeline and the ONLY act that makes shipped work visible to users — and it was
7
7
  // reachable from exactly one place, a shell on the control-plane box holding an ssh key.
8
8
  // An agent could not close the loop either: the permission classifier blocks a mutating
9
- // `sudo -u lars` over ssh by design. So the one gate that is the owner's to hold was the
9
+ // `sudo -u <app user>` over ssh by design. So the one gate that is the owner's to hold was the
10
10
  // one gate with no instrument. This page is the instrument.
11
11
  //
12
12
  // WHY THE PROJECTS ARE HERE AND NOT IN THEIR OWN HALLS. The obvious shape — each owner
@@ -7,7 +7,7 @@
7
7
  // that makes shipped work visible to users. It was reachable from exactly one
8
8
  // place — a shell on the control-plane box with an ssh key — so the owner could not
9
9
  // take it, and neither could an agent (the permission classifier blocks a mutating
10
- // `sudo -u lars` over ssh by design).
10
+ // `sudo -u <app user>` over ssh by design).
11
11
  //
12
12
  // THIS FILE ONLY ASKS. It validates and queues an intent; the control-plane runner
13
13
  // drains it and drives the proven `bongos upgrade` path with its health gate and
@@ -8,7 +8,7 @@
8
8
  // reachable from exactly one place — a shell on the control-plane box holding an
9
9
  // ssh key — which is why the owner said "I don't think I can make anything live",
10
10
  // and why an agent could not close the loop for them either: the permission
11
- // classifier blocks a mutating `sudo -u lars` over ssh by design.
11
+ // classifier blocks a mutating `sudo -u <app user>` over ssh by design.
12
12
  //
13
13
  // THE WEB TIER ONLY ASKS. This queues an intent; the control-plane runner drains it
14
14
  // and drives the proven `bongos upgrade` path with its health gate and auto-rollback
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.937",
3
+ "version": "1.19.939",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.937",
9
+ "version": "1.19.939",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.937",
3
+ "version": "1.19.939",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -16,6 +16,7 @@
16
16
  # Run on the control plane as a sudoer:
17
17
  # bash scripts/gds/address-park-sweep.sh # survey only — read-only
18
18
  # bash scripts/gds/address-park-sweep.sh --apply # remove the snippets, reload Caddy, park the hostnames
19
+ # Run from anywhere but an instance's own vendored core, set INSTANCE_DIR to that instance.
19
20
  #
20
21
  # Left in place on purpose, even with --apply: each project's database, dumps and checkout,
21
22
  # and the row's domain + port (ADR 0181 — a revive re-uses that address).
@@ -23,15 +24,25 @@ set -uo pipefail
23
24
 
24
25
  APPLY=0; [ "${1:-}" = "--apply" ] && APPLY=1
25
26
  DB="${PGDATABASE:-cloudbongos}"
26
- INSTANCE_DIR="${INSTANCE_DIR:-/home/lars/cloudbongos-instance}"
27
+ # The instance checkout and the account that owns it (task 1004135) — never a named person.
28
+ # Run from an instance's vendored core, the checkout is three levels up (the rule
29
+ # selfInstanceRoot() in provision-repo.js uses); from anywhere else it must be named. The
30
+ # account is that checkout's owner unless PROVISION_APP_USER pins it; root holds no role here.
31
+ CORE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
32
+ case "$CORE_DIR" in */node_modules/@bongos/core) INSTANCE_DIR="${INSTANCE_DIR:-${CORE_DIR%/node_modules/@bongos/core}}" ;; esac
33
+ [ -n "${INSTANCE_DIR:-}" ] || { echo "set INSTANCE_DIR to the control plane's instance checkout — this sweep is not running from one"; exit 1; }
34
+ APP_USER="${PROVISION_APP_USER:-$(stat -c %U "$INSTANCE_DIR" 2>/dev/null)}"
35
+ if [ "$APP_USER" = root ] || ! [[ "$APP_USER" =~ ^[a-z_][a-z0-9_-]{0,31}$ ]]; then
36
+ echo "cannot use '$APP_USER' as the app account — set PROVISION_APP_USER to the unprivileged account that owns $INSTANCE_DIR"; exit 1
37
+ fi
27
38
  SITES="${PROVISION_CADDY_SNIPPET_DIR:-/etc/caddy/sites}"
28
39
  BOX_ENV="${BOX_ENV:-/etc/cloudbongos/box.env}"
29
40
 
30
- rows=$(sudo -n -u lars psql -d "$DB" -tAF '|' -c "select slug, coalesce(domain, '') from provisioning_instances where status='torn_down' order by slug" 2>/dev/null) \
31
- || { echo "cannot read provisioning_instances from $DB as lars"; exit 1; }
41
+ rows=$(sudo -n -u "$APP_USER" psql -d "$DB" -tAF '|' -c "select slug, coalesce(domain, '') from provisioning_instances where status='torn_down' order by slug" 2>/dev/null) \
42
+ || { echo "cannot read provisioning_instances from $DB as $APP_USER"; exit 1; }
32
43
 
33
44
  # slug|domain|zone|reserved|blackhole per row, plus an apex line — from the installed core.
34
- plan=$(sudo -n -u lars env ROWS="$rows" bash -c 'cd "$1" && exec node -' _ "$INSTANCE_DIR" <<'JS'
45
+ plan=$(sudo -n -u "$APP_USER" env ROWS="$rows" bash -c 'cd "$1" && exec node -' _ "$INSTANCE_DIR" <<'JS'
35
46
  const core = './node_modules/@bongos/core';
36
47
  const { isReservedHostname } = require(`${core}/modules/provisioning/provisioning.js`);
37
48
  const { dnsReleaseEnv } = require(`${core}/scripts/gds/provision-net.js`);
@@ -66,7 +77,7 @@ while IFS='|' read -r kind slug domain zone reserved blackhole; do
66
77
  printf ' %-22s %s resolves to %s — park at %s (zone %s)\n' "$slug" "$domain" "$now" "$blackhole" "$zone"
67
78
  left+=("$slug:dns")
68
79
  [ "$APPLY" = 1 ] || continue
69
- sudo -n -u lars bash -c 'set -a; . "$1"; set +a; BOX_HOSTNAME="$2" BOX_IP="$3" CLOUDFLARE_ZONE="$4" BOX_PROXIED=0 exec "$BOX_DNS_HOOK"' \
80
+ sudo -n -u "$APP_USER" bash -c 'set -a; . "$1"; set +a; BOX_HOSTNAME="$2" BOX_IP="$3" CLOUDFLARE_ZONE="$4" BOX_PROXIED=0 exec "$BOX_DNS_HOOK"' \
70
81
  _ "$BOX_ENV" "$domain" "$blackhole" "$zone" </dev/null || echo " ⚠ could not park $domain — re-run to retry"
71
82
  done <<< "$plan"
72
83
 
@@ -13,6 +13,8 @@
13
13
  # during a builder's session. (Use `req.builder.id` / the active claim.)
14
14
  # * `lars589` / `larskristensen` literals in setup-path code or test data
15
15
  # that would mis-attribute a second builder's work.
16
+ # * The founder's unix account in account position (`-o`/`-u <it>`,
17
+ # `User=<it>`, `APP_USER = '<it>'`) in runtime code — section 6.
16
18
  #
17
19
  # What is INTENTIONAL and allow-listed (NOT a violation):
18
20
  # * Archived shipping docs in `limitations/*-shipped.md` — historical record.
@@ -280,6 +282,31 @@ fi
280
282
  grep -vE "$OPS_ALLOWLIST_PATTERN" "$TMP_HITS" \
281
283
  | grep -vE "$SPEC_ALLOWLIST_PATTERN" > "$TMP_FILTERED" || true
282
284
 
285
+ # ----------------------------------------------------------------------------
286
+ # 6. The founder's UNIX ACCOUNT in account position (task 1002719). Family (c)
287
+ # greps the GitHub login, and a unix name is not a GitHub login — which is
288
+ # how the standup's `install -d -o <founder> …` reached every operator's box
289
+ # unseen. Runtime code only (tests/ pin fixtures by design), and the ops
290
+ # allowlist above does NOT apply: "that is the droplet's unix user" is the
291
+ # very assumption this family refuses in code that runs on other planes.
292
+ # Derive the account instead (provision-repo.js appUser()).
293
+ # ----------------------------------------------------------------------------
294
+ UNIX_USER_PATTERN='((^|[^A-Za-z0-9_.-])(-o|-g|-u)[ =]lars\b)|(\b(User|Group)=lars\b)|(\bchown( -R)? lars\b)|([A-Za-z_]*[Uu][Ss][Ee][Rr][A-Za-z_]*\s*[:=]\s*["'"'"']lars["'"'"'])'
295
+ UNIX_ALLOWLIST=(
296
+ # This script's own pattern.
297
+ 'scripts/gds/audit-authorship.sh'
298
+ )
299
+ UNIX_ALLOWLIST_RE=''
300
+ for e in "${UNIX_ALLOWLIST[@]}"; do
301
+ UNIX_ALLOWLIST_RE+="^${e//./\\.}:|"
302
+ done
303
+ UNIX_ALLOWLIST_RE="${UNIX_ALLOWLIST_RE%|}"
304
+
305
+ grep -RIn -E "${EXCLUDE_DIRS[@]}" --exclude-dir=tests --exclude-dir=test "${EXCLUDE_FILES[@]}" -e "$UNIX_USER_PATTERN" . 2>/dev/null \
306
+ | sed 's|^\./||' \
307
+ | sort -u \
308
+ | grep -vE "$UNIX_ALLOWLIST_RE" >> "$TMP_FILTERED" || true
309
+
283
310
  HIT_COUNT=$(wc -l < "$TMP_FILTERED" | tr -d ' ')
284
311
 
285
312
  if [ "$VERBOSE" = "1" ]; then
@@ -306,6 +333,7 @@ echo "Remediation:"
306
333
  echo " * Paths like /Users/larskristensen/... -> use \$HOME or os.path.expanduser('~')"
307
334
  echo " * builder_id = 1 / shipped_by = 1 -> use req.builder.id / the active claim"
308
335
  echo " * lars589 / larskristensen literals -> use the current builder's github_login"
336
+ echo " * -o/-u lars, User=lars, *user = 'lars' -> derive the account (appUser() in provision-repo.js)"
309
337
  echo ""
310
338
  echo "If a hit IS intentional (historical doc, droplet operational identity,"
311
339
  echo "or a frozen seed/import script), add it to the ALLOWLIST or"
@@ -1,7 +1,7 @@
1
1
  #!/bin/bash
2
2
  # Nightly pg_dump of the GDS Postgres DB.
3
3
  #
4
- # Runs as user `lars` via the gds-db-backup.timer systemd unit (see
4
+ # Runs as the unit's User= (the plane's app account) via the gds-db-backup.timer systemd unit (see
5
5
  # scripts/gds/gds-db-backup.timer + .service in this repo). Output lands
6
6
  # in /var/backups/gds/ as <db>-YYYY-MM-DDTHHMMSSZ.sql.gz (UTC timestamp), and
7
7
  # files older than 30 days are pruned.
@@ -33,7 +33,7 @@ set -euo pipefail
33
33
 
34
34
  # ---- configuration ----------------------------------------------------------
35
35
 
36
- # Destination directory for dumps. Owned by lars:lars, mode 0750.
36
+ # Destination directory for dumps. Owned by that account and its primary group, mode 0750.
37
37
  BACKUP_DIR="${GDS_BACKUP_DIR:-/var/backups/gds}"
38
38
 
39
39
  # Database to dump: explicit GDS_BACKUP_DB wins, else the instance's own branding
@@ -69,7 +69,7 @@ DUMP_GLOB="${DB_NAME}-${D}${D}${D}${D}-${D}${D}-${D}${D}T${D}${D}${D}${D}${D}${D
69
69
  if [ ! -d "$BACKUP_DIR" ]; then
70
70
  echo "ERROR: backup dir $BACKUP_DIR does not exist." >&2
71
71
  echo " Create it with:" >&2
72
- echo " sudo install -d -o lars -g lars -m 0750 $BACKUP_DIR" >&2
72
+ echo " sudo install -d -o $(id -un) -g $(id -gn) -m 0750 $BACKUP_DIR" >&2
73
73
  exit 2
74
74
  fi
75
75
 
@@ -9,7 +9,7 @@
9
9
  #
10
10
  # Vendor decision: DigitalOcean Spaces over Backblaze B2 — see ADR 0025.
11
11
  #
12
- # Runs as user `lars` via the gds-db-offsite.timer systemd unit (see
12
+ # Runs as the unit's User= (the plane's app account) via the gds-db-offsite.timer systemd unit (see
13
13
  # scripts/gds/gds-db-offsite.service + .timer). Weekly cadence.
14
14
  #
15
15
  # Authentication: S3-style credentials for the Space live OUTSIDE the repo
@@ -121,7 +121,7 @@ for v in SPACES_KEY SPACES_SECRET SPACES_BUCKET SPACES_REGION SPACES_ENDPOINT; d
121
121
  done
122
122
  if [ "${#missing[@]}" -gt 0 ]; then
123
123
  echo "ERROR: missing Spaces credentials: ${missing[*]}" >&2
124
- echo " Set them in $ENV_FILE (chmod 600, owned by lars). See ADR 0022 + 0025." >&2
124
+ echo " Set them in $ENV_FILE (chmod 600, owned by $(id -un)). See ADR 0022 + 0025." >&2
125
125
  exit 2
126
126
  fi
127
127
 
@@ -25,4 +25,14 @@ function provisionerBotEmail(env = process.env) {
25
25
  return apex ? `bot@${apex}` : 'bot@cloud-bongos.invalid';
26
26
  }
27
27
 
28
- module.exports = { instanceApex, provisionerBotEmail };
28
+ // The name that identity commits under: the pack's product name, else the platform's
29
+ // (task 1002719). Every caller builds `git config user.name "<name>"`, so a name the
30
+ // shell cannot carry inside double quotes falls back instead of being interpolated.
31
+ function brandProductName() {
32
+ try { return String(require('../../src/branding').branding().identity.productName || '').trim(); } catch { return ''; }
33
+ }
34
+ function provisionerBotName(name = brandProductName()) {
35
+ return name && !/["`$\\\r\n]/.test(name) ? name : 'Cloud Bongos';
36
+ }
37
+
38
+ module.exports = { instanceApex, provisionerBotEmail, provisionerBotName };
@@ -108,7 +108,7 @@ const CONFIG = {
108
108
  standaloneBase: process.env.PROVISION_STANDALONE_BASE || '/srv/cloudbongos',
109
109
  // ── Privilege escalation (task 2066, ADR 0128) ───────────────────────────
110
110
  // The intent runner runs as a NON-root user (provision-intent-runner.service:
111
- // User=lars) that holds passwordless sudo. That user cannot itself write under
111
+ // User= the app user) that holds passwordless sudo. That user cannot itself write under
112
112
  // /etc, reload systemd/Caddy, or peer-auth to Postgres as a superuser — so the
113
113
  // runbook steps below FAIL as bare commands (the goal-35 blocker). With
114
114
  // PROVISION_SUDO=1 (alias PROVISION_PRIVILEGED=1) the privileged steps escalate:
@@ -163,7 +163,7 @@ const CONFIG = {
163
163
 
164
164
  // task 1002757 — the provisioner's git identity resolves from the instance's own
165
165
  // apex (env → bot@<apex> → neutral), never the hub's address hardcoded.
166
- const { provisionerBotEmail } = require('./instance-apex');
166
+ const { provisionerBotEmail, provisionerBotName } = require('./instance-apex');
167
167
 
168
168
  const MAX_INTENT_ATTEMPTS = numEnv('PROVISION_INTENT_MAX_ATTEMPTS', 3);
169
169
 
@@ -229,5 +229,6 @@ module.exports = {
229
229
  numEnv,
230
230
  oauthSecret,
231
231
  provisionerBotEmail,
232
+ provisionerBotName,
232
233
  softFailResult,
233
234
  };
@@ -30,7 +30,7 @@
30
30
 
31
31
  const path = require('path');
32
32
  const { CONFIG, MANIFEST_VERIFY_INTERVAL_MS, MANIFEST_VERIFY_TRIES, REPO_ROOT } = require('./provision-config.js');
33
- const { APP_USER, dbName, instanceUser, selfInstanceRoot, standaloneRoot } = require('./provision-repo.js');
33
+ const { appUser, dbName, instanceUser, selfInstanceRoot, standaloneRoot } = require('./provision-repo.js');
34
34
  const { isValidSlug } = require('../../modules/provisioning/provisioning.js');
35
35
 
36
36
  // The shapes whose box this runner can actually reach. Mirrors UPGRADE_SHAPES in the
@@ -277,19 +277,19 @@ function parsePreflight(out) {
277
277
  * Which unix account should this instance's upgrade run as? Asks the box (task 1004128).
278
278
  *
279
279
  * The fleet is heterogeneous: instances provisioned before task 1003369 run as the
280
- * shared APP_USER, instances provisioned after run as their own `bongos-<slug>`. Neither
280
+ * shared app user, instances provisioned after run as their own `bongos-<slug>`. Neither
281
281
  * is guessable from the row, and getting it wrong is not a soft failure — `sudo` refuses
282
282
  * an unknown user and the upgrade dies before pre-flight, which is exactly how the
283
283
  * tenant leg has been broken.
284
284
  *
285
285
  * Falling back is NOT a privilege escalation: an instance with no account of its own is
286
- * already running as APP_USER (its unit says so), so this runs the upgrade as precisely
286
+ * already running as the app user (its unit says so), so this runs the upgrade as precisely
287
287
  * the account that owns the files and the database it is about to touch. It IS reported,
288
288
  * because a missing per-instance account is a provisioning gap someone should close, and
289
289
  * a silent fallback would hide it forever.
290
290
  */
291
291
  function resolveRunAs(inst, deps, { probe: allowProbe = true } = {}) {
292
- if (inst.hosting_shape === 'control-plane') return { runAs: APP_USER, fellBack: false };
292
+ if (inst.hosting_shape === 'control-plane') return { runAs: appUser(), fellBack: false };
293
293
  // Re-checked HERE rather than left to instanceUser()'s own throw, for the reason
294
294
  // upgradeInvocation states about itself: a call site whose safety depends on a
295
295
  // helper's internals is one refactor away from a shell-interpolation path.
@@ -317,7 +317,7 @@ function resolveRunAs(inst, deps, { probe: allowProbe = true } = {}) {
317
317
  if (!/no such user|unknown user/i.test(said)) {
318
318
  return { runAs: wanted, fellBack: false, probeInconclusive: true };
319
319
  }
320
- return { runAs: APP_USER, fellBack: true, wanted };
320
+ return { runAs: appUser(), fellBack: true, wanted };
321
321
  }
322
322
 
323
323
  // ONE BUILDER FOR BOTH LEGS (task 1004121).
@@ -357,7 +357,7 @@ function upgradeInvocation(inst, to, { privileged = false, dryRun = false, reque
357
357
  // half right. The control-plane leg is correct and verified live. The tenant leg
358
358
  // assumed the per-instance account model of task 1003369 is provisioned everywhere —
359
359
  // and on the live box NO `bongos-*` account exists, no such Postgres role exists, and
360
- // every instance unit still says `User=lars`. So the fix for "Postgres has no role
360
+ // every instance unit still names the shared app user. So the fix for "Postgres has no role
361
361
  // root" swapped one absent account for another, and every tenant upgrade died with
362
362
  // `sudo: unknown user bongos-test4`.
363
363
  //
@@ -368,7 +368,7 @@ function upgradeInvocation(inst, to, { privileged = false, dryRun = false, reque
368
368
  // (resolveRunAs below) instead of either side assuming. Passing it in keeps this
369
369
  // builder pure; the default preserves the old derivation for callers that have not
370
370
  // probed.
371
- const account = runAs || (inst.hosting_shape === 'control-plane' ? APP_USER : instanceUser(inst));
371
+ const account = runAs || (inst.hosting_shape === 'control-plane' ? appUser() : instanceUser(inst));
372
372
  const sudoP = privileged ? `sudo -u ${account} env PGDATABASE=${dbName(inst)} ` : '';
373
373
  const cli = path.posix.join('scripts', 'gds', 'upgrade.js');
374
374
  const shared = [`${sudoP}node ${cli}`, '--registry', `--to ${to}`,
@@ -20,7 +20,7 @@
20
20
  const fs = require('node:fs');
21
21
  const path = require('node:path');
22
22
  const { CONFIG, REPO_ROOT } = require('./provision-config.js');
23
- const { APP_USER, CORE_PKG_DIR, instanceUser, standaloneRoot } = require('./provision-repo.js');
23
+ const { CORE_PKG_DIR, appGroupExpr, appUser, instanceUser, standaloneRoot } = require('./provision-repo.js');
24
24
  const {
25
25
  backupScriptPath, backupService, backupServicePath, backupTimer, backupTimerPath,
26
26
  backupUnitName, serviceUnit, serviceUnitPath,
@@ -39,7 +39,7 @@ const systemd = {
39
39
  // The backup DIR belongs to this step, not to the runbook: it exists only to serve the
40
40
  // schedule, so a manager that installs no schedule must not create it either.
41
41
  installBackup(inst, { exec, writeFile, sudoP }) {
42
- exec(`${sudoP}install -d -o ${APP_USER} -g ${APP_USER} -m 0750 ${CONFIG.backupDir}`);
42
+ exec(`${sudoP}install -d -o ${appUser()} -g ${appGroupExpr()} -m 0750 ${CONFIG.backupDir}`);
43
43
  writeFile(backupServicePath(inst), backupService(inst), { mode: 0o644, sudo: true });
44
44
  writeFile(backupTimerPath(inst), backupTimer(inst), { mode: 0o644, sudo: true });
45
45
  exec(`${sudoP}systemctl daemon-reload`);
@@ -14,7 +14,7 @@ const crypto = require('node:crypto');
14
14
  const fs = require('node:fs');
15
15
  const os = require('node:os');
16
16
  const path = require('node:path');
17
- const { CONFIG, REPO_ROOT, coreVersionSafe, provisionerBotEmail } = require('./provision-config.js');
17
+ const { CONFIG, REPO_ROOT, coreVersionSafe, provisionerBotEmail, provisionerBotName } = require('./provision-config.js');
18
18
  // The repo-identity + owner-credential leaf (task 1003081). Carved OUT of this file so
19
19
  // upgrade.js can reach these three without pulling provision*.js into the merge
20
20
  // resolver's executed closure; re-exported below so every existing caller is unchanged.
@@ -86,10 +86,34 @@ function seedFirstVersionCmd(inst, { privileged = false } = {}) {
86
86
  // It is the ADR 0108 DESTINATION model; these generators make provision.js drive the
87
87
  // scripts/deploy/deploy-instance.sh runbook API-driven instead of by hand.
88
88
  const CORE_PKG_DIR = 'node_modules/@bongos/core';
89
- // The unix account the instance's Node process + its backup timer run as. Mirrors
90
- // serviceUnit's hardcoded `User=lars`/`Group=lars` (the droplet's app user); kept as
91
- // one constant so the backup dir owner + the backup unit's User= can't drift from it.
92
- const APP_USER = 'lars';
89
+ // The plane's app user: the unix account that owns the control-plane checkout, owns the
90
+ // backup dir, runs the backup timers, and whose GROUP every instance account joins to read
91
+ // the checkout. One resolver, so those four can't drift apart.
92
+ //
93
+ // Derived, never named (task 1002719). It used to be the founder's own account as a
94
+ // literal, and a standup on any host that account did not exist on died `invalid user` at
95
+ // the backup step. PROVISION_APP_USER pins it; otherwise it is the account this runner
96
+ // executes as, which on a plane IS the app user (the runner unit runs as it), so the live
97
+ // box resolves exactly what the literal said. A runner driving a different box over SSH
98
+ // (remoteHost) should pin it. root is refused, not resolved: the instance
99
+ // accounts join this account's group, and group root is not a read grant to hand a tenant.
100
+ // Validated because it is interpolated into shell commands and unit files.
101
+ const UNIX_ACCOUNT_RE = /^[a-z_][a-z0-9_-]{0,31}$/;
102
+ function appUser({ env = process.env, userInfo = os.userInfo } = {}) {
103
+ let name = String(env.PROVISION_APP_USER || '').trim();
104
+ if (!name) {
105
+ try { name = userInfo().username; } catch { name = ''; } // a uid with no passwd entry
106
+ }
107
+ if (name === 'root' || !UNIX_ACCOUNT_RE.test(name)) {
108
+ const why = name === 'root' ? 'is root' : 'is not a usable unix account name';
109
+ throw new Error(`provisioning: the app user ${JSON.stringify(name)} ${why} — set PROVISION_APP_USER to the unprivileged account that owns this checkout`);
110
+ }
111
+ return name;
112
+ }
113
+ // The app user's PRIMARY group, looked up by the shell on the box the command runs on. It
114
+ // need not share the account's name (a service account created into `users`), so it is
115
+ // never spelled as the account name.
116
+ function appGroupExpr() { return `$(id -gn ${appUser()})`; }
93
117
  function standaloneRoot(inst, { base = CONFIG.standaloneBase } = {}) { return path.posix.join(base, inst.slug); }
94
118
  // WHERE THE INSTANCE THIS CORE SERVES ACTUALLY LIVES (task 1004123).
95
119
  //
@@ -145,7 +169,7 @@ function standaloneRegenDocsCmd() { return `node ${CORE_PKG_DIR}/scripts/gds/reg
145
169
  // ── Per-instance identity: its OWN unix account + its OWN password-authed PG role ──
146
170
  // (task 1003369, audit ref B3 of the 2026-08-29 security audit.)
147
171
  //
148
- // THE HOLE THIS CLOSES. Every instance service used to run `User=lars/Group=lars`, and
172
+ // THE HOLE THIS CLOSES. Every instance service used to run as the one shared app user, and
149
173
  // co-tenant is the DEFAULT shape — so on a shared box every instance ran as the SAME
150
174
  // unix user. A unix user may read /proc/<pid>/environ of its own processes, which means
151
175
  // any instance could read every SIBLING's environment: GITHUB_APP_PRIVATE_KEY, the
@@ -223,7 +247,7 @@ function ensureInstanceUserCmd(inst, { privileged = false } = {}) {
223
247
  // NOT re-open the two vectors this task closes: /proc/<pid>/environ stays readable only
224
248
  // by the process's own uid, and the DB now demands a password no sibling holds.
225
249
  function grantInstanceRepoReadCmd(inst, { privileged = false } = {}) {
226
- return `${privileged ? 'sudo ' : ''}usermod -aG ${APP_USER} ${instanceUser(inst)}`;
250
+ return `${privileged ? 'sudo ' : ''}usermod -aG ${appGroupExpr()} ${instanceUser(inst)}`;
227
251
  }
228
252
 
229
253
  // Can this plane give a child instance its OWN unix account AT ALL? (task 1004018, ADR 0317)
@@ -758,7 +782,7 @@ async function scaffoldStandaloneRepo(inst, deps) {
758
782
  } else {
759
783
  writeFile(giPath, INSTANCE_GITIGNORE);
760
784
  }
761
- exec(`git -C ${root} config user.name "Cloud Bongos"`);
785
+ exec(`git -C ${root} config user.name "${provisionerBotName()}"`);
762
786
  exec(`git -C ${root} config user.email "${provisionerBotEmail()}"`);
763
787
  exec(`git -C ${root} add -A`);
764
788
  const commitMsg = mode === 'adopt'
@@ -1012,7 +1036,7 @@ function buildCorePinRefreshCommit({ root, fromVersion, toVersion, repo, ownerTo
1012
1036
  : `git -C ${root} push origin main`;
1013
1037
  return {
1014
1038
  config: [
1015
- `git -C ${root} config user.name "Cloud Bongos"`,
1039
+ `git -C ${root} config user.name "${provisionerBotName()}"`,
1016
1040
  `git -C ${root} config user.email "${provisionerBotEmail()}"`,
1017
1041
  ],
1018
1042
  add: `git -C ${root} add -A`,
@@ -1094,7 +1118,8 @@ async function refreshStandaloneCorePin(inst, deps) {
1094
1118
  // cwd, deploy-instance.sh §4); co-tenant/dedicated run this control-plane checkout's src/.
1095
1119
 
1096
1120
  module.exports = {
1097
- APP_USER,
1121
+ appGroupExpr,
1122
+ appUser,
1098
1123
  accountPlaneGap,
1099
1124
  accountPreflight,
1100
1125
  CORE_PKG_DIR,
@@ -11,7 +11,7 @@
11
11
  const fs = require('node:fs');
12
12
  const path = require('node:path');
13
13
  const { CONFIG, REPO_ROOT } = require('./provision-config.js');
14
- const { APP_USER, CORE_PKG_DIR, dbName, instanceDbRole, instanceStateDir, instanceUser, parseTargetRef, standaloneRoot } = require('./provision-repo.js');
14
+ const { CORE_PKG_DIR, appUser, dbName, instanceDbRole, instanceStateDir, instanceUser, parseTargetRef, standaloneRoot } = require('./provision-repo.js');
15
15
 
16
16
  function serviceUnit(inst, { etcBase = CONFIG.etcBase } = {}) {
17
17
  const envFile = path.posix.join(etcBase, inst.slug, 'web.env');
@@ -103,8 +103,8 @@ Requires=postgresql.service
103
103
 
104
104
  [Service]
105
105
  Type=oneshot
106
- User=${APP_USER}
107
- Group=${APP_USER}
106
+ User=${appUser()}
107
+ # No Group=: systemd then uses the app user's primary group, whatever it is named.
108
108
  # The DB to dump + where dumps land — set on the unit (not via the instance web.env)
109
109
  # so the dump targets the right database with no fallback to a stale default (root
110
110
  # cause of the task-2210 breakage). db-backup-nightly.sh reads both as env.
@@ -26,7 +26,7 @@
26
26
  // WOULD run and mutates nothing (no DB writes, no shell, no cloud call). The
27
27
  // LIVE leg is an explicit operator/timer `--apply`.
28
28
  // • PRIVILEGE model (task 2066, ADR 0128). The intent runner runs as a NON-root
29
- // user (User=lars) with passwordless sudo, which cannot itself write under /etc,
29
+ // user (the app user) with passwordless sudo, which cannot itself write under /etc,
30
30
  // reload systemd/Caddy, or peer-auth to Postgres as a superuser. PROVISION_SUDO=1
31
31
  // escalates exactly those steps (sudo install / sudo systemctl / sudo -u postgres);
32
32
  // default OFF = bare commands (root / test / co-tenant-legacy contexts unchanged).
@@ -50,7 +50,7 @@ const cp = require('node:child_process');
50
50
  const crypto = require('node:crypto');
51
51
 
52
52
  const { CONFIG, MANIFEST_UA, softFailResult, MANIFEST_VERIFY_INTERVAL_MS, MANIFEST_VERIFY_TRIES, MAX_INTENT_ATTEMPTS, REPO_ROOT, coreVersionSafe, hasFlag, loadDeps, oauthSecret, provisionerBotEmail } = require('./provision-config.js');
53
- const { APP_USER, accountPreflight, alreadyScaffolded, buildCorePinRefreshCommit, coreCheckoutHasGit, dbCreateCmd, dbName, dbRoleCmd, dbRoleCmdShown, dbRoleGrantsCmd, ensureInstanceUserCmd, generateDbPassword, grantInstanceRepoReadCmd, instanceDbRole, instanceStateDir, instanceUser, deployKeyPath, deployKeyTitle, ensurePrivateRepoAccess, installedCorePackDir, installedCoreTarball, instanceInitSpec, instanceRepoRemote, migrateCmd, onboardMode, ownerLoginOf, parseTargetRef, planCorePinRefresh, refreshStandaloneCorePin, resolveOwnerGithubToken, resolveVendorableCoreTarball, safeVersionLabel, scaffoldStandaloneRepo, seedFirstVersionCmd, standaloneInstallCmd, standaloneMigrateCmd, standalonePullCmd, standaloneRegenDocsCmd, standaloneRoot } = require('./provision-repo.js');
53
+ const { accountPreflight, appGroupExpr, appUser, alreadyScaffolded, buildCorePinRefreshCommit, coreCheckoutHasGit, dbCreateCmd, dbName, dbRoleCmd, dbRoleCmdShown, dbRoleGrantsCmd, ensureInstanceUserCmd, generateDbPassword, grantInstanceRepoReadCmd, instanceDbRole, instanceStateDir, instanceUser, deployKeyPath, deployKeyTitle, ensurePrivateRepoAccess, installedCorePackDir, installedCoreTarball, instanceInitSpec, instanceRepoRemote, migrateCmd, onboardMode, ownerLoginOf, parseTargetRef, planCorePinRefresh, refreshStandaloneCorePin, resolveOwnerGithubToken, resolveVendorableCoreTarball, safeVersionLabel, scaffoldStandaloneRepo, seedFirstVersionCmd, standaloneInstallCmd, standaloneMigrateCmd, standalonePullCmd, standaloneRegenDocsCmd, standaloneRoot } = require('./provision-repo.js');
54
54
  const { backupScriptPath, backupService, backupServicePath, backupTimer, backupTimerPath, backupUnitName, instanceManifestCmd, originEnvVarsFor, serviceUnit, serviceUnitPath, settingsConsumed, settingsEnvVarsFor, upsertEnvVars, webEnvBody, webEnvPath } = require('./provision-units.js');
55
55
  const { caddyBlock, caddySnippetPath, checkCaddyWiring, checkDnsTokenScope, classifyZoneScope, dnsReleaseEnv, dnsUpsertEnv, federateInstance, federationHubOrigin, healthzCmd, identityCmd, identityVerdict, ingressPlaneGap, ingressPreflight, shouldFederate, zoneForDomain } = require('./provision-net.js');
56
56
  const { resolveProcessManager } = require('./provision-process.js');
@@ -282,6 +282,9 @@ async function provisionInstance(inst, deps) {
282
282
  // useradd would crash mid-provision instead of refusing.
283
283
  const accountGap = accountPreflight({ exec: boxExec, apply, log, shape: inst.hosting_shape, privileged, probe: deps.accountPlaneGap });
284
284
  if (accountGap) return { ok: false, error: accountGap };
285
+ // App-user preflight (task 1002719): steps below name the app user, and appUser() refuses
286
+ // root or an unusable name. Refuse HERE, before the status flip, not midway through.
287
+ try { appUser(); } catch (e) { return { ok: false, error: e.message }; }
285
288
  // Caddy preflight (task 1002860, ADR 0303): a box whose base Caddyfile has no tls-check ask
286
289
  // can never certify this domain, so refuse before touching it. Injectable like the DNS check.
287
290
  const caddyCheck = deps.checkCaddyWiring || checkCaddyWiring;
@@ -640,7 +643,7 @@ runcmd:
640
643
  - [ bash, -lc, "${dbCreateCmd(i)} || true" ]
641
644
  - [ bash, -lc, "cd ${REPO_ROOT} && ${migrateCmd(i)}" ]
642
645
  - [ bash, -lc, "${seedFirstVersionCmd(i)}" ]
643
- ${roleLines} - [ bash, -lc, "install -d -o ${APP_USER} -g ${APP_USER} -m 0750 ${CONFIG.backupDir}" ]
646
+ ${roleLines} - [ bash, -lc, "install -d -o ${appUser()} -g ${appGroupExpr()} -m 0750 ${CONFIG.backupDir}" ]
644
647
  - [ bash, -lc, "systemctl daemon-reload && systemctl enable --now ${i.slug} ${backupUnitName(i)}.timer" ]
645
648
  `;
646
649
  }
@@ -13,6 +13,7 @@
13
13
  # Run on the control plane as a sudoer:
14
14
  # bash scripts/gds/stray-unit-sweep.sh # survey only — read-only
15
15
  # bash scripts/gds/stray-unit-sweep.sh --apply # disable --now, remove the unit files, reload, reset-failed
16
+ # Run from anywhere but an instance's own vendored core, set INSTANCE_DIR to that instance.
16
17
  #
17
18
  # Left in place on purpose, even with --apply: the project's database, its dumps under
18
19
  # /var/backups/gds, any checkout, and its Caddy block — data is precious, and the address is
@@ -23,10 +24,22 @@ APPLY=0; [ "${1:-}" = "--apply" ] && APPLY=1
23
24
  DB="${PGDATABASE:-cloudbongos}"
24
25
  ETC=/etc/systemd/system
25
26
 
26
- # The torn-down slugs, from the ledger, read as the instance user (peer auth — the runner's
27
+ # The app account (task 1004135) — never a named person: PROVISION_APP_USER, else the owner
28
+ # of the instance checkout, found the way address-park-sweep.sh finds it (three levels up
29
+ # from an instance's vendored core; from anywhere else INSTANCE_DIR names it). A clone in an
30
+ # operator's own home must not decide it. root is refused: it holds no Postgres role here.
31
+ CORE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
32
+ case "$CORE_DIR" in */node_modules/@bongos/core) INSTANCE_DIR="${INSTANCE_DIR:-${CORE_DIR%/node_modules/@bongos/core}}" ;; esac
33
+ [ -n "${PROVISION_APP_USER:-}${INSTANCE_DIR:-}" ] || { echo "set INSTANCE_DIR to the control plane's instance checkout (or PROVISION_APP_USER) — this sweep is not running from one"; exit 1; }
34
+ APP_USER="${PROVISION_APP_USER:-$(stat -c %U "$INSTANCE_DIR" 2>/dev/null)}"
35
+ if [ "$APP_USER" = root ] || ! [[ "$APP_USER" =~ ^[a-z_][a-z0-9_-]{0,31}$ ]]; then
36
+ echo "cannot use '$APP_USER' as the app account — set PROVISION_APP_USER to the unprivileged account that owns ${INSTANCE_DIR:-the instance checkout}"; exit 1
37
+ fi
38
+
39
+ # The torn-down slugs, from the ledger, read as the app account (peer auth — the runner's
27
40
  # own identity; nothing here needs a superuser).
28
- slugs=$(sudo -n -u lars psql -d "$DB" -tAc "select slug from provisioning_instances where status='torn_down' order by slug" 2>/dev/null) \
29
- || { echo "cannot read provisioning_instances from $DB as lars"; exit 1; }
41
+ slugs=$(sudo -n -u "$APP_USER" psql -d "$DB" -tAc "select slug from provisioning_instances where status='torn_down' order by slug" 2>/dev/null) \
42
+ || { echo "cannot read provisioning_instances from $DB as $APP_USER"; exit 1; }
30
43
 
31
44
  found=(); cleared=()
32
45
  echo "== torn-down rows that still have systemd units =="
package/src/module-api.js CHANGED
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
71
71
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
72
72
  // the entry to that file. Look for a version's history there, not here.
73
73
  // ---------------------------------------------------------------------------
74
- const CORE_VERSION = '1.19.937'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.939'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
75
75
 
76
76
  // A namespaced logger so a module's log lines are attributable + consistent.
77
77
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -38,6 +38,9 @@ const P = require('../modules/provisioning/provisioning.js');
38
38
  const G = require('../modules/provisioning/control-plane-guard.js');
39
39
  const RP = require('../scripts/gds/provision-repo.js');
40
40
 
41
+ // task 1002719: the app user is derived, so pin one that is nobody's real account.
42
+ process.env.PROVISION_APP_USER = 'appsvc';
43
+
41
44
  let passed = 0;
42
45
  let failed = 0;
43
46
  const t = async (name, fn) => {
@@ -433,7 +436,7 @@ await t('the control plane upgrades as the operator account, never as root', asy
433
436
  const d = { ...makeDeps({ served: '1.19.762' }), privileged: true };
434
437
  await R.coreUpgradeInstance(inst({ hosting_shape: 'control-plane', slug: 'cloudbongos', port: 3002 }), d, intent());
435
438
  const cmd = upgradeCall(d);
436
- assert.match(cmd, /sudo -u lars /, 'runs as the operator account');
439
+ assert.match(cmd, /sudo -u appsvc /, 'runs as the operator account');
437
440
  assert.doesNotMatch(cmd, /sudo node /, 'a bare `sudo node` is the bug — it lands on root');
438
441
  });
439
442
 
@@ -442,7 +445,7 @@ await t('a tenant upgrades as its OWN account, which is also its Postgres login
442
445
  await R.coreUpgradeInstance(inst({ hosting_shape: 'standalone', slug: 'test4', port: 3006 }), d, intent());
443
446
  const cmd = upgradeCall(d);
444
447
  assert.match(cmd, /sudo -u bongos-test4 /, 'the instance account, not the operator and not root');
445
- assert.doesNotMatch(cmd, /sudo -u lars /, 'one tenant must never be upgraded under another account');
448
+ assert.doesNotMatch(cmd, /sudo -u appsvc /, 'one tenant must never be upgraded under another account');
446
449
  });
447
450
 
448
451
  await t('the database is NAMED, not discovered — an adopted repo has no core at its root to ask', async () => {
@@ -691,7 +694,7 @@ await t('a tenant WITHOUT one falls back to the app user, and says so', () => {
691
694
  // is reported rather than silently papered over.
692
695
  const d = acctDeps({ accountExists: false });
693
696
  const r = R.resolveRunAs(inst({ hosting_shape: 'standalone', slug: 'test4' }), d);
694
- assert.equal(r.runAs, 'lars');
697
+ assert.equal(r.runAs, 'appsvc');
695
698
  assert.equal(r.fellBack, true);
696
699
  assert.equal(r.wanted, 'bongos-test4', 'and names the account that is missing');
697
700
  });
@@ -699,7 +702,7 @@ await t('a tenant WITHOUT one falls back to the app user, and says so', () => {
699
702
  await t('the control plane is never probed — it has no instance account by design', () => {
700
703
  const d = acctDeps({ accountExists: false });
701
704
  const r = R.resolveRunAs(inst({ hosting_shape: 'control-plane', slug: 'cloudbongos' }), d);
702
- assert.deepEqual({ runAs: r.runAs, fellBack: r.fellBack }, { runAs: 'lars', fellBack: false });
705
+ assert.deepEqual({ runAs: r.runAs, fellBack: r.fellBack }, { runAs: 'appsvc', fellBack: false });
703
706
  assert.equal(d.ranWith.filter((c) => /^id -u /.test(c.cmd)).length, 0, 'no probe was spent');
704
707
  });
705
708
 
@@ -707,7 +710,7 @@ await t('the dry run is invoked as the account that actually exists', async () =
707
710
  const d = acctDeps({ accountExists: false, dryRunStdout: ' ✓ pre-flight: ok\n' });
708
711
  await R.preflightUpgrade(inst({ hosting_shape: 'standalone', slug: 'test4', port: 3006 }), '1.19.762', d);
709
712
  const cmd = (d.ranWith.find((c) => /upgrade\.js/.test(c.cmd)) || {}).cmd || '';
710
- assert.match(cmd, /sudo -u lars /, `must not sudo to an account that does not exist — got: ${cmd}`);
713
+ assert.match(cmd, /sudo -u appsvc /, `must not sudo to an account that does not exist — got: ${cmd}`);
711
714
  assert.doesNotMatch(cmd, /bongos-test4/);
712
715
  });
713
716
 
@@ -715,7 +718,7 @@ await t('the MOVE probes identically, so preview and move cannot diverge on the
715
718
  const d = acctDeps({ accountExists: false });
716
719
  await R.coreUpgradeInstance(inst({ hosting_shape: 'standalone', slug: 'test4', port: 3006 }), d, intent());
717
720
  const cmd = (d.ranWith.find((c) => /upgrade\.js/.test(c.cmd)) || {}).cmd || '';
718
- assert.match(cmd, /sudo -u lars /, 'the move follows the same probe as the preview');
721
+ assert.match(cmd, /sudo -u appsvc /, 'the move follows the same probe as the preview');
719
722
  });
720
723
 
721
724
  await t('an INCONCLUSIVE probe keeps the isolated account rather than quietly widening it', () => {
@@ -789,7 +792,7 @@ await t('a ✖ line still wins over the raw tail', async () => {
789
792
  await t('a fallback is recorded on the verdict the owner reads', async () => {
790
793
  const d = acctDeps({ accountExists: false, dryRunStdout: ' ✓ pre-flight: ok\n' });
791
794
  const v = await R.preflightUpgrade(inst({ hosting_shape: 'standalone', slug: 'test4', port: 3006 }), '1.19.762', d);
792
- assert.equal(v.ran_as, 'lars');
795
+ assert.equal(v.ran_as, 'appsvc');
793
796
  assert.match(v.account_note, /no bongos-test4 account yet/);
794
797
  });
795
798
 
@@ -18,6 +18,8 @@ import { join } from 'node:path';
18
18
 
19
19
  const require = createRequire(import.meta.url);
20
20
  const P = require('../scripts/gds/provision.js');
21
+ const AP = require('../scripts/gds/instance-apex.js');
22
+ const RP = require('../scripts/gds/provision-repo.js');
21
23
 
22
24
  // The env-var prefix a provisioned instance resolves = the envPrefix an instance INHERITS from
23
25
  // the neutral pack (task 1003740: webEnvBody no longer pins a branding pack anywhere — the
@@ -40,6 +42,11 @@ process.env.PROVISION_GIT_SSH_ALIAS = 'github-deploy';
40
42
  // The probe itself is covered directly, with an injected fs.
41
43
  process.env.PROVISION_PROCESS_MANAGER = 'systemd';
42
44
 
45
+ // And the app user (task 1002719): unpinned it is whoever runs the suite. Pinned to a name
46
+ // that is nobody's real account, so an assertion passes only if the value came through the
47
+ // resolver — a leftover literal cannot produce it.
48
+ process.env.PROVISION_APP_USER = 'appsvc';
49
+
43
50
  let passed = 0, failed = 0;
44
51
  function t(name, fn) {
45
52
  try { fn(); passed++; console.log(` PASS ${name}`); }
@@ -167,11 +174,16 @@ t('buildCorePinRefreshCommit (the commit step): git sequence, token-URL push, an
167
174
  // must not attribute its commits to the hub. Assert against the resolver so
168
175
  // this pins the behaviour, not one instance's literal.
169
176
  assert.deepEqual(withTok.config, [
170
- 'git -C /srv/bongos/spike-harbor config user.name "Cloud Bongos"',
177
+ `git -C /srv/bongos/spike-harbor config user.name "${AP.provisionerBotName()}"`,
171
178
  `git -C /srv/bongos/spike-harbor config user.email "${P.provisionerBotEmail()}"`,
172
179
  ]);
173
180
  assert.equal(P.provisionerBotEmail({ PROVISION_BOT_EMAIL: 'ops@acme.test' }), 'ops@acme.test', 'env override wins');
174
181
  assert.doesNotMatch(P.provisionerBotEmail({}), /example/i, 'never a founder-host address');
182
+ // task 1002719: the NAME comes from the pack too, and one the shell cannot quote falls back.
183
+ assert.equal(AP.provisionerBotName('Acme Forge'), 'Acme Forge', 'the pack\'s product name');
184
+ for (const unsafe of ['Acme "Forge"', 'a$(id)', 'a`id`', 'a\\b', 'a\nb', '']) {
185
+ assert.equal(AP.provisionerBotName(unsafe), 'Cloud Bongos', `falls back on ${JSON.stringify(unsafe)}`);
186
+ }
175
187
  assert.equal(withTok.add, 'git -C /srv/bongos/spike-harbor add -A');
176
188
  assert.match(withTok.commit, /@bongos\/core 1\.19\.2 -> 1\.19\.196 \(task 1003050\)/);
177
189
  assert.match(withTok.push, /store --file=\/tmp\/cred.*x-access-token@github\.com\/jaxri\/spike-harbor\.git HEAD:main/);
@@ -199,7 +211,7 @@ t('backup units: per-instance names + the DB set on the unit (fixes the task-221
199
211
  // the DB is pinned on the unit → no fallback to the nonexistent example default
200
212
  assert.match(svc, /Environment=GDS_BACKUP_DB=mercury/);
201
213
  assert.match(svc, /Environment=GDS_BACKUP_DIR=\/var\/backups\/gds/);
202
- assert.match(svc, new RegExp(`User=${reEsc('lars')}`), 'runs as the app user (mirrors serviceUnit)');
214
+ assert.match(svc, new RegExp(`User=${reEsc('appsvc')}`), 'runs as the app user (mirrors serviceUnit)');
203
215
  assert.match(svc, /ReadWritePaths=\/var\/backups\/gds/, 'the only writable path is the backup dir');
204
216
  const timer = P.backupTimer(coTenant);
205
217
  assert.match(timer, /OnCalendar=\*-\*-\* 04:00:00 UTC/);
@@ -466,7 +478,7 @@ await ta('provisionInstance(co-tenant) composes db → migrate → service → h
466
478
  assert.ok(writes.some((w) => /\/mercury\.service$/.test(w)), 'wrote systemd unit');
467
479
  assert.ok(writes.some((w) => /\/mercury\.caddy$/.test(w)), 'wrote per-instance Caddy snippet');
468
480
  // task 2213: EVERY instance is born with backups — dir + per-instance timer/service
469
- assert.match(joined, /install -d -o lars -g lars -m 0750 \/var\/backups\/gds/, 'creates the backup dir');
481
+ assert.match(joined, /install -d -o appsvc -g \$\(id -gn appsvc\) -m 0750 \/var\/backups\/gds/, 'creates the backup dir');
470
482
  assert.match(joined, /systemctl enable --now mercury-db-backup\.timer/, 'enables the per-instance backup timer');
471
483
  assert.ok(writes.some((w) => /\/mercury-db-backup\.service$/.test(w)), 'wrote backup service unit');
472
484
  assert.ok(writes.some((w) => /\/mercury-db-backup\.timer$/.test(w)), 'wrote backup timer unit');
@@ -626,7 +638,8 @@ t('systemd manager: emits byte-identical commands + unit paths to the inline str
626
638
  PM.systemd.installBackup(INST, io2);
627
639
  // The backup DIR moved in here with the schedule it serves: a manager that installs no
628
640
  // schedule must not create it either. Order is unchanged — dir, units, reload, enable.
629
- assert.match(io2.cmds[0], /^sudo install -d -o \S+ -g \S+ -m 0750 /);
641
+ // The group is the app user's PRIMARY group, looked up on the box (task 1002719).
642
+ assert.match(io2.cmds[0], /^sudo install -d -o appsvc -g \$\(id -gn appsvc\) -m 0750 /);
630
643
  assert.deepEqual(io2.cmds.slice(1), ['sudo systemctl daemon-reload', 'sudo systemctl enable --now demo-db-backup.timer']);
631
644
  assert.deepEqual(io2.files.map((f) => f.path),
632
645
  ['/etc/systemd/system/demo-db-backup.service', '/etc/systemd/system/demo-db-backup.timer']);
@@ -1329,7 +1342,7 @@ await ta('dedicatedUserData bakes the nightly backup: unit files (b64) + dir + t
1329
1342
  const b64 = cloudInit.match(/content: ([A-Za-z0-9+/=]+)/)[1];
1330
1343
  assert.match(Buffer.from(b64, 'base64').toString('utf8'), /Environment=GDS_BACKUP_DB=mercury/);
1331
1344
  // and the dir is created + the timer enabled on first boot
1332
- assert.match(cloudInit, /install -d -o lars -g lars -m 0750 \/var\/backups\/gds/);
1345
+ assert.match(cloudInit, /install -d -o appsvc -g \$\(id -gn appsvc\) -m 0750 \/var\/backups\/gds/);
1333
1346
  assert.match(cloudInit, /enable --now mercury mercury-db-backup\.timer/);
1334
1347
  });
1335
1348
 
@@ -2932,7 +2945,31 @@ t('ensureInstanceUserCmd: idempotent SYSTEM account, nologin, state dir re-asser
2932
2945
  });
2933
2946
 
2934
2947
  t('grantInstanceRepoReadCmd: adds the instance account to the app-user group so the unit can exec its WorkingDirectory', () => {
2935
- assert.equal(P.grantInstanceRepoReadCmd(coTenant, { privileged: true }), 'sudo usermod -aG lars bongos-mercury');
2948
+ assert.equal(P.grantInstanceRepoReadCmd(coTenant, { privileged: true }), 'sudo usermod -aG $(id -gn appsvc) bongos-mercury');
2949
+ });
2950
+
2951
+ t('appUser: PROVISION_APP_USER, else the account the runner runs as — never a named founder (task 1002719)', () => {
2952
+ const runner = (username) => () => ({ username });
2953
+ assert.equal(RP.appUser({ env: { PROVISION_APP_USER: 'ops' }, userInfo: runner('deploy') }), 'ops', 'the pin wins');
2954
+ assert.equal(RP.appUser({ env: {}, userInfo: runner('deploy') }), 'deploy', "unpinned: the runner's own account");
2955
+ assert.equal(RP.appUser(), 'appsvc', 'the suite pin is what every generator above rendered');
2956
+ });
2957
+
2958
+ t('appUser: refuses root and any name a shell command or unit file could not carry', () => {
2959
+ const runner = (username) => () => ({ username });
2960
+ assert.throws(() => RP.appUser({ env: {}, userInfo: runner('root') }), /is root — set PROVISION_APP_USER/);
2961
+ assert.throws(() => RP.appUser({ env: {}, userInfo: () => { throw new Error('ENOENT'); } }),
2962
+ /"" is not a usable unix account name/, 'a uid with no passwd entry names the knob');
2963
+ for (const bad of ['a b', 'x;rm -rf /', '$(id)', 'Admin', '-o', 'a'.repeat(33)]) {
2964
+ assert.throws(() => RP.appUser({ env: { PROVISION_APP_USER: bad }, userInfo: runner('deploy') }),
2965
+ /not a usable unix account name/, `refuses ${JSON.stringify(bad)}`);
2966
+ }
2967
+ // …and the generators go through it rather than rendering the bad value.
2968
+ process.env.PROVISION_APP_USER = 'root';
2969
+ try {
2970
+ assert.throws(() => P.backupService(coTenant), /is root/);
2971
+ assert.throws(() => P.grantInstanceRepoReadCmd(coTenant), /is root/);
2972
+ } finally { process.env.PROVISION_APP_USER = 'appsvc'; }
2936
2973
  });
2937
2974
 
2938
2975
  t('dbRoleCmd: LOGIN + password, upserted — CREATE on a fresh box, ALTER on a re-provision (the rotation path)', () => {
@@ -3052,7 +3089,7 @@ t('dedicatedUserData: the droplet runs the SAME account + role steps, in the sam
3052
3089
  const ud = P.dedicatedUserData(coTenant);
3053
3090
  const at = (needle) => ud.indexOf(needle);
3054
3091
  assert.ok(at('useradd --system') > -1, 'the droplet creates the account too');
3055
- assert.ok(at('usermod -aG lars') > -1);
3092
+ assert.ok(at('usermod -aG $(id -gn appsvc)') > -1);
3056
3093
  assert.ok(at('useradd --system') < at('createdb mercury'), 'account before the DB');
3057
3094
  assert.ok(at('migrate.sh') < at('CREATE ROLE %I'), 'the role grants must follow the migrate that created the tables');
3058
3095
  assert.ok(at('CREATE ROLE %I') < at('GRANT ALL ON ALL TABLES'), 'role before its grants');
@@ -12,6 +12,9 @@ const require = createRequire(import.meta.url);
12
12
  const P = require('../scripts/gds/provision.js');
13
13
  const { accountPlaneGap, accountPreflight } = require('../scripts/gds/provision-repo.js');
14
14
 
15
+ // The app user is derived from whoever runs the suite unless pinned (task 1002719).
16
+ process.env.PROVISION_APP_USER = 'appsvc';
17
+
15
18
  const PROBE = `sh -c 'test "$(id -u)" = 0'`;
16
19
  const isRoot = () => ({ ok: true, stdout: '' });
17
20
  // What a shell that RAN reports: exit 1 from `test`, or 127 when sudo is not installed.
@@ -133,3 +136,16 @@ test('provision: a box where the runner reaches root is unchanged — the live s
133
136
  const idx = (re) => h.cmds.findIndex((c) => re.test(c));
134
137
  assert.ok(idx(/id -u.*= 0/) < idx(/useradd --system/), 'the probe precedes the account it guards');
135
138
  });
139
+
140
+ test('provision: an app user that resolves to root is refused before a single write (task 1002719)', async () => {
141
+ const h = harness();
142
+ process.env.PROVISION_APP_USER = 'root'; // what an unpinned runner running as root resolves
143
+ try {
144
+ const r = await P.provisionInstance(coTenant, h.deps);
145
+ assert.equal(r.ok, false);
146
+ assert.match(r.error, /is root — set PROVISION_APP_USER/);
147
+ assert.ok(!h.ran(/useradd/), 'refused before step 1c makes an account the run could not finish');
148
+ assert.equal(h.writes.length, 0);
149
+ assert.deepEqual(h.statuses, [], 'the instance never flips to provisioning');
150
+ } finally { process.env.PROVISION_APP_USER = 'appsvc'; }
151
+ });
@@ -0,0 +1,112 @@
1
+ // tests/sweep_app_account.mjs — the two backfill sweeps act as the plane's app account,
2
+ // never a named person (task 1004135). Both used to `sudo -u <founder>`, so a second
3
+ // operator who ran them got `sudo: unknown user`.
4
+ //
5
+ // Behavioural: each sweep runs under bash with stub `sudo` / `stat` / `systemctl` first on
6
+ // PATH. The sudo stub records its argv and answers nothing (an empty ledger), so the
7
+ // assertion is on the account the script actually asked for. Nothing touches a box.
8
+ import assert from 'node:assert/strict';
9
+ import { test } from 'node:test';
10
+ import { spawnSync } from 'node:child_process';
11
+ import fs from 'node:fs';
12
+ import os from 'node:os';
13
+ import path from 'node:path';
14
+ import { fileURLToPath } from 'node:url';
15
+
16
+ const REPO = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
17
+ const skip = spawnSync('bash', ['-c', 'true']).error ? 'bash not on PATH' : false;
18
+ const slash = (p) => p.replace(/\\/g, '/');
19
+
20
+ function stubs() {
21
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sweep-stubs-'));
22
+ const sudoLog = slash(path.join(dir, 'sudo.log'));
23
+ const statLog = slash(path.join(dir, 'stat.log'));
24
+ const bin = (name, body) => fs.writeFileSync(path.join(dir, name), `#!/usr/bin/env bash\n${body}\n`, { mode: 0o755 });
25
+ bin('sudo', `echo "$*" >> "${sudoLog}"; exit 0`);
26
+ bin('stat', `echo "$*" >> "${statLog}"; echo checkout-owner`);
27
+ bin('systemctl', 'exit 0');
28
+ const lines = (f) => (fs.existsSync(f) ? fs.readFileSync(f, 'utf8').split('\n').filter(Boolean) : []);
29
+ return { dir, sudo: () => lines(sudoLog), stat: () => lines(statLog) };
30
+ }
31
+
32
+ function run(scriptPath, s, env = {}) {
33
+ const base = { ...process.env };
34
+ delete base.PROVISION_APP_USER;
35
+ delete base.INSTANCE_DIR;
36
+ return spawnSync('bash', [slash(scriptPath)], {
37
+ cwd: REPO, encoding: 'utf8',
38
+ env: { ...base, ...env, PATH: `${s.dir}${path.delimiter}${process.env.PATH}` },
39
+ });
40
+ }
41
+
42
+ const STRAY = path.join(REPO, 'scripts', 'gds', 'stray-unit-sweep.sh');
43
+ const PARK = path.join(REPO, 'scripts', 'gds', 'address-park-sweep.sh');
44
+
45
+ test('stray-unit-sweep: PROVISION_APP_USER is the account the ledger is read as', { skip }, () => {
46
+ const s = stubs();
47
+ run(STRAY, s, { PROVISION_APP_USER: 'appsvc' });
48
+ assert.match(s.sudo()[0] || '', /^-n -u appsvc psql /);
49
+ assert.equal(s.stat().length, 0, 'a pin is not second-guessed');
50
+ });
51
+
52
+ test('stray-unit-sweep: unpinned outside a vendored core, it refuses rather than trust whoever owns this clone', { skip }, () => {
53
+ const s = stubs();
54
+ const r = run(STRAY, s);
55
+ assert.equal(r.status, 1);
56
+ assert.match(r.stdout, /set INSTANCE_DIR/);
57
+ assert.deepEqual(s.stat(), [], 'the clone\'s owner was never asked');
58
+ assert.deepEqual(s.sudo(), []);
59
+ });
60
+
61
+ test('stray-unit-sweep: INSTANCE_DIR names the checkout whose owner acts', { skip }, () => {
62
+ const s = stubs();
63
+ const inst = fs.mkdtempSync(path.join(os.tmpdir(), 'sweep-inst-'));
64
+ run(STRAY, s, { INSTANCE_DIR: slash(inst) });
65
+ assert.match(s.stat()[0] || '', new RegExp(`^-c %U .*${path.basename(inst)}$`));
66
+ assert.match(s.sudo()[0] || '', /^-n -u checkout-owner psql /);
67
+ });
68
+
69
+ test('stray-unit-sweep: root is refused before anything runs', { skip }, () => {
70
+ const s = stubs();
71
+ const r = run(STRAY, s, { PROVISION_APP_USER: 'root' });
72
+ assert.equal(r.status, 1);
73
+ assert.match(r.stdout, /cannot use 'root' as the app account — set PROVISION_APP_USER/);
74
+ assert.deepEqual(s.sudo(), [], 'sudo never asked');
75
+ });
76
+
77
+ test('address-park-sweep: outside a vendored core, INSTANCE_DIR must be named — no founder-home default', { skip }, () => {
78
+ const s = stubs();
79
+ const r = run(PARK, s);
80
+ assert.equal(r.status, 1);
81
+ assert.match(r.stdout, /set INSTANCE_DIR/);
82
+ assert.deepEqual(s.sudo(), []);
83
+ });
84
+
85
+ test('address-park-sweep: root is refused before anything runs', { skip }, () => {
86
+ const s = stubs();
87
+ const r = run(PARK, s, { INSTANCE_DIR: slash(os.tmpdir()), PROVISION_APP_USER: 'root' });
88
+ assert.equal(r.status, 1);
89
+ assert.match(r.stdout, /cannot use 'root' as the app account — set PROVISION_APP_USER/);
90
+ assert.deepEqual(s.sudo(), [], 'sudo never asked');
91
+ });
92
+
93
+ test('address-park-sweep: every sudo hop uses the pinned account', { skip }, () => {
94
+ const s = stubs();
95
+ run(PARK, s, { INSTANCE_DIR: slash(os.tmpdir()), PROVISION_APP_USER: 'appsvc' });
96
+ const hops = s.sudo().filter((l) => / -u /.test(l));
97
+ assert.ok(hops.length >= 2, `the ledger read and the core load both ran — got ${JSON.stringify(s.sudo())}`);
98
+ for (const h of hops) assert.match(h, /^-n -u appsvc /);
99
+ });
100
+
101
+ for (const [name, src] of [['address-park-sweep', PARK], ['stray-unit-sweep', STRAY]]) {
102
+ test(`${name}: run from an instance's vendored core, the instance is three levels up and its owner acts`, { skip }, () => {
103
+ const s = stubs();
104
+ const inst = fs.mkdtempSync(path.join(os.tmpdir(), 'sweep-inst-'));
105
+ const gds = path.join(inst, 'node_modules', '@bongos', 'core', 'scripts', 'gds');
106
+ fs.mkdirSync(gds, { recursive: true });
107
+ fs.copyFileSync(src, path.join(gds, `${name}.sh`));
108
+ run(path.join(gds, `${name}.sh`), s);
109
+ assert.match(s.stat()[0] || '', new RegExp(`^-c %U .*${path.basename(inst)}$`), 'the instance root, not the package');
110
+ assert.match(s.sudo()[0] || '', /^-n -u checkout-owner psql /);
111
+ });
112
+ }