@bongos/core 1.19.217 → 1.19.219

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.217",
6
- "core_contract": "1.19.217",
7
- "source_commit": "2a474dc7f283910befbca2879f13d342c4d5f0b4",
5
+ "core_version": "1.19.219",
6
+ "core_contract": "1.19.219",
7
+ "source_commit": "c74ac31eedb3ec3676f8517b51302df625298931",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-08-18T00:33:32.467Z",
9
+ "built_at": "2026-08-18T03:06:20.562Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
- "docs_redacted": 310,
12
+ "docs_redacted": 311,
13
13
  "agent_docs_stubbed": 21,
14
14
  "functional_verbatim": 1302,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 1633,
20
- "tree_sha256": "a3212b990024b1a28961deb9e9d93d52eeca950d4be79f84b3bddd2fe928583f",
19
+ "file_count": 1634,
20
+ "tree_sha256": "4da1158ccf79b2a7a5253dad319cc74192493be9bda2e6a8874df8c60f581cc5",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/blocker-review/SKILL.md",
@@ -1359,10 +1359,15 @@
1359
1359
  "mode": "0000644",
1360
1360
  "sha256": "aaff11b955bcbacaebf2475d01034dce1c47c70c22534195aebbf90767b025f6"
1361
1361
  },
1362
+ {
1363
+ "path": "docs/adr/0176-private-repo-deploy-keys.md",
1364
+ "mode": "0000644",
1365
+ "sha256": "eb560b832629f708ad458f64c6a282bf55ae86c24626431d48d258e9ca0ac6d6"
1366
+ },
1362
1367
  {
1363
1368
  "path": "docs/adr/README.md",
1364
1369
  "mode": "0000644",
1365
- "sha256": "6748701da2559d6019116eb00e9aeeaad947e08d17f9c9cf77e8f45c19e2c69f"
1370
+ "sha256": "23c4e663f175ea1d6ee4ccbe041905c29814c50612af921c8b836f187e8d8355"
1366
1371
  },
1367
1372
  {
1368
1373
  "path": "docs/api-reference.md",
@@ -1487,7 +1492,7 @@
1487
1492
  {
1488
1493
  "path": "docs/design/projects-surfaces-redesign-direction.md",
1489
1494
  "mode": "0000644",
1490
- "sha256": "750f39818862c66964788bb0dbc7dba6725ed6ad861f09536195fb3509342abf"
1495
+ "sha256": "c2b0a31e12f4ba4402f2fb0eb774ced49998ad5f751aac9289d49ec1d8ce473a"
1491
1496
  },
1492
1497
  {
1493
1498
  "path": "docs/design/vanilla-hall-ui-redesign-scope.md",
@@ -2692,7 +2697,7 @@
2692
2697
  {
2693
2698
  "path": "modules/dev-box/box-access.js",
2694
2699
  "mode": "0000644",
2695
- "sha256": "9357b423836c4e8d2cd3c9144d294c3cd3b498ac30755a9e6f62b5bbaa2add5e"
2700
+ "sha256": "870976966de7c48be4172390dc8e4f7d2eb6ac53e20dfb3fd5d40b08cfcd7850"
2696
2701
  },
2697
2702
  {
2698
2703
  "path": "modules/dev-box/box-credential.js",
@@ -3962,7 +3967,7 @@
3962
3967
  {
3963
3968
  "path": "modules/provisioning/onboard-plan.js",
3964
3969
  "mode": "0000644",
3965
- "sha256": "4aae27c3c56de480e9cc4f19405572a96c6bf0246fdd859adceae629c7a71842"
3970
+ "sha256": "9124712dbaa724d03177f1b5779ca3aad639151c7b1ef9721dd65f5374de4afe"
3966
3971
  },
3967
3972
  {
3968
3973
  "path": "modules/provisioning/pollers/liveness-sweep.js",
@@ -4282,12 +4287,12 @@
4282
4287
  {
4283
4288
  "path": "package-lock.json",
4284
4289
  "mode": "0000644",
4285
- "sha256": "09501ec03bde658dfcbc651c0603d0c45f5d38914aafb73ff685ccec7c76d69e"
4290
+ "sha256": "ac23681c5493a57f1faca551e4896e4a7a4e2dabb6246bf2a84c39ae6e571257"
4286
4291
  },
4287
4292
  {
4288
4293
  "path": "package.json",
4289
4294
  "mode": "0000644",
4290
- "sha256": "c6882c7a5bf88c3cdd811dd9b957aeffe46b5ffbdc9c42e45a49c5e5caff1edf"
4295
+ "sha256": "7122e5cca352a5abc317d25267eba6c0bab955febf2179bf362a896be424d0bc"
4291
4296
  },
4292
4297
  {
4293
4298
  "path": "public-docs/index.html",
@@ -4547,7 +4552,7 @@
4547
4552
  {
4548
4553
  "path": "public-landing/projects.html",
4549
4554
  "mode": "0000644",
4550
- "sha256": "603ec55ce4de6477cf5d0602e5963a096799a404b2324f12c62104023e9e0e4b"
4555
+ "sha256": "88f416526efee1a722fdc082a1f92f26de1d4f8850db4054be79f46d50a1582a"
4551
4556
  },
4552
4557
  {
4553
4558
  "path": "public-landing/world/assets/clouds/cloud-island-large.png",
@@ -5202,7 +5207,7 @@
5202
5207
  {
5203
5208
  "path": "scripts/gds/provision.js",
5204
5209
  "mode": "0000644",
5205
- "sha256": "242f51c3e2ae991dc8a02d7285c829d3cd7b2a4022c516b218d259cac14b855f"
5210
+ "sha256": "0a1cdb35e9357e07307d58de8099b2fc3c46377cedafb4d0f5784e2a9a51a639"
5206
5211
  },
5207
5212
  {
5208
5213
  "path": "scripts/gds/publish-credential-check.js",
@@ -5682,7 +5687,7 @@
5682
5687
  {
5683
5688
  "path": "src/bongos/github-repos.js",
5684
5689
  "mode": "0000644",
5685
- "sha256": "f34518361f15a20c4dc60a936878e72b4c81d99e41e00180270919dcbbd83008"
5690
+ "sha256": "e69373026518a4e67ceca38e596354fa471fe11b7b00a718af62a1d3aabc0c4a"
5686
5691
  },
5687
5692
  {
5688
5693
  "path": "src/bongos/goal-scope-check.js",
@@ -5902,7 +5907,7 @@
5902
5907
  {
5903
5908
  "path": "src/module-api.js",
5904
5909
  "mode": "0000644",
5905
- "sha256": "6123bf63d65e4e5327c05a413117015e2198293f2a1a2191f10cb1ecbb397fbf"
5910
+ "sha256": "d509a7e4f16027ccef999e8aadf1a2cef5368bce5cd0c3bda3b7eb61cbceaa5f"
5906
5911
  },
5907
5912
  {
5908
5913
  "path": "src/module-loader/catalog.js",
@@ -6197,7 +6202,7 @@
6197
6202
  {
6198
6203
  "path": "tests/box_access.mjs",
6199
6204
  "mode": "0000644",
6200
- "sha256": "694153e377a78d4325aa4ff9b947f691d71362f843da25815b9740821903802c"
6205
+ "sha256": "1e4db680c5925e473fff9ad6247e797bd9089fdf3696c3ffb161903bda41dcda"
6201
6206
  },
6202
6207
  {
6203
6208
  "path": "tests/box_auth_check.mjs",
@@ -6747,7 +6752,7 @@
6747
6752
  {
6748
6753
  "path": "tests/github_repos_helpers.mjs",
6749
6754
  "mode": "0000644",
6750
- "sha256": "a450e5248b3b97ddaa11b1b9c79d768779df24b11cd1177174390ae80f5fe624"
6755
+ "sha256": "0bde5e75593b43a307c3bab748f02b1f77afc2e3a98decf88f2bff419c843b7d"
6751
6756
  },
6752
6757
  {
6753
6758
  "path": "tests/github_token_store.mjs",
@@ -7397,7 +7402,7 @@
7397
7402
  {
7398
7403
  "path": "tests/onboard.mjs",
7399
7404
  "mode": "0000644",
7400
- "sha256": "71eeb42b47ad2ba11b8379cce263ceac2da42b00b9fe55aab64546027b193364"
7405
+ "sha256": "92dd484634703c5204bc88eeccfdfdf310a2b0984488fb8d065d52a246b9f998"
7401
7406
  },
7402
7407
  {
7403
7408
  "path": "tests/onboarding_gate.mjs",
@@ -7532,12 +7537,12 @@
7532
7537
  {
7533
7538
  "path": "tests/projects_hub.mjs",
7534
7539
  "mode": "0000644",
7535
- "sha256": "0471ced4ef3d16b5df6a373c04b13c1e83f2c9b2a9590734e4f02f95e195c45a"
7540
+ "sha256": "a5ddb284f4b5cb46e19b90d07dbb06ee42de9e4911d76b882db23a6dab63f8b5"
7536
7541
  },
7537
7542
  {
7538
7543
  "path": "tests/provision.mjs",
7539
7544
  "mode": "0000644",
7540
- "sha256": "30b55efaa97e695603875ec057901d5333977d41705fc862fc831a424c4e2674"
7545
+ "sha256": "a0e47992e3e5b3158a8949b695fd6306aa01f39a51776fbfc2c5a0d47b881951"
7541
7546
  },
7542
7547
  {
7543
7548
  "path": "tests/provisioning_default_domain.mjs",
@@ -7582,7 +7587,7 @@
7582
7587
  {
7583
7588
  "path": "tests/public_landing_projects.mjs",
7584
7589
  "mode": "0000644",
7585
- "sha256": "c0e2e85c84f75503438c1951b72a94cf1c74a7cd0c27b774c2dc4d46d99d6cf8"
7590
+ "sha256": "eaadefd338bd50a8310c699cf0acc4bceb34ef601e8ed901cb301bfcdf6bd4c3"
7586
7591
  },
7587
7592
  {
7588
7593
  "path": "tests/public_mirror_export.mjs",
@@ -0,0 +1,58 @@
1
+ # 0176. Private-repo standup: a per-repo read-only deploy key, minted with the owner's token
2
+
3
+ Date: 2026-08-17 · Status: accepted · Task: 1002735 (walk finding N4, idea 1000708)
4
+
5
+ ## Context
6
+
7
+ A standalone instance's repo is cloned/pushed **once** with the owner's `repo`-scoped OAuth
8
+ token (ADR 0155), but its durable `origin` is the shared SSH deploy alias
9
+ (`instanceRepoRemote`, ADR 0125) because the recurring deploy — `git pull --ff-only origin
10
+ main` at every provision, and the pull-deploy reset between them — needs auth that never
11
+ expires. The shared key is not registered on repos the platform didn't create, so a PRIVATE
12
+ repo fails every recurring pull with GitHub's no-leak `Repository not found` (reproduced 3×
13
+ in the goal-1000032 walk, **including with a valid token stored** — finding N4). The create
14
+ wizard nevertheless advertised "create the repo yourself and adopt it" as the private-code
15
+ path: a promise the runner could not keep. Seen live on prod (`mercury`, 2026-08-17).
16
+
17
+ ## Decision
18
+
19
+ When a standalone instance's `target_ref` repo answers **private** to the owner's stored
20
+ token, provisioning mints the instance its own GitHub **deploy key** and wires the repo
21
+ checkout to use it:
22
+
23
+ - an ed25519 keypair generated beside the checkout (`<root>/.git/cloudbongos-deploy-key` —
24
+ untracked by definition, survives `git reset --hard`, dies with the checkout);
25
+ - registered on the repo via the owner's token as **`read_only: true`**, titled
26
+ `cloudbongos-deploy-<slug>` (idempotent: a stale same-title key is replaced);
27
+ - `origin` re-pointed at plain `git@github.com:<owner>/<repo>.git` and
28
+ `core.sshCommand` set to `ssh -F /dev/null -i <key> -o IdentitiesOnly=yes` **in the repo's
29
+ own config**, so every future git op in that checkout authenticates with this key no matter
30
+ which script runs it, and the shared alias's `~/.ssh/config` identity can never leak in.
31
+
32
+ Public repos take the unchanged shared-alias path — zero behavior change. Teardown
33
+ best-effort revokes the key by title. Pushes keep preferring the owner-token HTTPS URL
34
+ (task 1002317), which is why read-only suffices.
35
+
36
+ ## Alternatives rejected
37
+
38
+ - **GitHub App installation token** (ADR 0133 gives instances an App): tokens expire hourly,
39
+ so every unattended pull would need a minting sidecar or a platform round-trip — the wrong
40
+ amount of machinery for a cron `git pull`, and self-host venues would depend on the hub.
41
+ - **Registering the shared platform key on the repo**: grants the whole platform durable
42
+ access to the owner's private code with one shared secret; per-repo keys keep the blast
43
+ radius to one repo, are revocable one at a time, and are visible to the owner in their own
44
+ repo settings.
45
+
46
+ ## Consequences
47
+
48
+ - Private repos work end-to-end for adopt, manual, and fresh standups whenever the owner's
49
+ `repo`-scoped token is stored; without one, private standups still fail at git (the
50
+ request-time refusal is task 1002909's preflight).
51
+ - The flip-to-private refusal on the manage page (task 1003045) stays until existing
52
+ already-provisioned instances get the same wiring on flip — a follow-up task, since their
53
+ origin still rides the shared alias.
54
+ - Builder **dev boxes** of a private-repo project are out of scope here: their
55
+ `box-source-fetch.sh` clone uses box-side auth and needs the same treatment (follow-up).
56
+ - `bongos upgrade --commit-pin` on a private instance pushes its pin via `origin` — now the
57
+ read-only key, so GitHub refuses it and the pull-deploy reset can revert the bump.
58
+ `upgrade.js` needs the owner-token push path (follow-up).
@@ -249,5 +249,6 @@ This keeps the decision history honest and traceable.
249
249
  | 0170 | [**The featured projection tells time honestly: `created_at` in, `updated_at` out, owner from the project's own archon** ([task 1002778](https://cloudbongos.com/builders#/task/1002778), goal 1000061). The hub card's metadata pass added `created_at` to the public `/projects/featured` projection, labelled **Listed** — it is the moment the hub catalogued the row, never when the project began, and the C6 card test bans the word "Created". Two recorded deviations: (1) the task brief said `updated_at` was ready to add, but the column is bumped by every client re-registration AND the Archon's feature toggle, so as a public "last updated" it would report curation noise as project activity — it may sort, never project, and the test pins the SELECT list itself (`SELECT *` banned: the route is CORS-open rank:public and a star would leak `owner_github_id`/`status`). (2) The owner fact reads the project's OWN leaderboard archon row (login + avatar, type-checked and capped) from the payload project-stats already fetches — zero extra requests, the 2-fetch pin intact, the advisory `owner_github_id` column still unread per the design contract. Multi-archon: top-credits archon is named; a fallen-off-the-page archon renders as no owner shown, never a wrong one. Rejected: projecting `updated_at` as asked (it lies), reading `owner_github_id` (advisory, contract-banned), a third per-origin fetch for activity recency (moves the test pin for a fact nobody asked for yet).](<redacted>.md) | platform identity / public surface |
250
250
  | 0174 | [**Governance is renamed government, its positions are ranks, and "role" is reserved for the three crafts** ([task 1003002](https://cloudbongos.com/builders#/task/1003002), R01 of goal 1000068). Owner directive, 2026-08-16. The word **role** named two unrelated things: the permission system's ladder tiers (`governance_roles`, the "rank-roles") and the three crafts (`tasks.discipline` = engineer/artist/ideator) — and goal 1000066 was actively claiming it for the crafts (`three-roles-only`, `users-hold-roles`), putting the two on a collision course. Resolved at full depth rather than as a label pass, because a labels-only fix leaves the UI and the code disagreeing, which is the condition that produced the collision. `modules/governance/` → `modules/government/`; the kernel module key, the config files, `/api/bongos/governance/*` → `/government/*`, and the permission keys `governance.manage` → `government.manage` (~50 sites) + `page.view.governance` → `page.view.government`. **The entity IS the rank — no second noun**: `governance_roles` → `government_ranks`, `role_key` → `rank_key`, `rank-roles.js` → `ranks.js`; "position" stays prose-only, since introducing it as a code term would recreate the qualifier problem "rank-role" already demonstrates. The cross-module kernel port `governance.roleSync` → `government.rankSync` must land atomically across its provider and both consumers (`modules/lifecycle/db.js`, `src/bongos/db.js`) because it is a transaction participant on the rank-write path. Hand-composed bundles stay in the one table as **custom ranks** — the owner's call over splitting them into a separate "appointments" concept, with the objection recorded (a custom bundle is not a ladder position, and ADR 0172 §4 already treats the two kinds as different). Enforced by a `fitness.js` rule, allowlist shipped with reasons. **Discord's server roles keep the word** — that noun belongs to Discord's API. Amends ADR 0151 + ADR 0172 (the editable-rank-roles one) in **vocabulary only**; ladder slugs, `builders.rank`, `requireRank`, and every floor/grant/assignment semantic are untouched. Rejected: renaming the ladder slugs (turns a bounded rename into a trust-boundary migration), a permanent compat shim (both vocabularies stay correct forever).](<redacted>.md) | government / permissions |
251
251
  | 0175 | [**The Board Room: Full Idea ratification is the first upward-pointing gate** ([task 1003014](https://cloudbongos.com/builders#/task/1003014), R01 of goal 1000069). Every existing gate points DOWN the rank ladder; the owner asked for one that could slow THEM down — *"otherwise I can just ship and build as I want, finding loopholes."* The board's subjects are exactly **two**, both rare by construction: a **Full Idea** and a **change to the constitution itself** (`subject_type` from day one). Because the vote gates no build path, the **ADR 0162 tension dissolves rather than being compromised** — nothing self-executes because there is nothing downstream to execute; ratification confers legitimacy and does NOT auto-create the goal. A vote is a **timed sitting, not a quorum**: absent members are *nulled* (dropped from the denominator, never counted as a no), so the clock is the deadlock exit and no shotgun/fork-with-treasury clause is needed at this scale — default 90 minutes, closing early on full turnout, and yes, a short window means whoever is awake governs (the owner's explicit intent). **Consent, not majority**: silence is consent, one reasoned objection returns the idea, and a zero-vote close PASSES. A rejection must carry free text **plus one of the five Full Idea sections** — which is what **forces the hall to be the voting surface**, since a Discord reaction could only ever be feedback-free; Discord is therefore an outbound notification mirror that casts nothing, and its own mobile app is the mobile push (no mobile surface is built). The completeness grade is the **floor** (a window opens automatically on pass), the vote is the **merit**; concurrent windows are normal. Who governs is **configuration** — day one is monarchy expressed as config on the real machinery, never a separate code path — and **amendments are decided under whatever rule is currently in force**, so flipping out of monarchy is a legal monarchy act and no bootstrap exception is needed. Open ballot; an author may never vote on their own item. Franchise derives from **rank, not role**, and that is forced: `PATCH /me/disciplines` is deliberately ungated, so a craft-scoped chamber would be one-request privilege escalation. Unblocks goal 1000067 / IC5, which consumes the pass event and *"invents no parallel vote"*. Rejected: quadratic vote weighting and the attribution-economy knobs (γ, τ, the 30% stack cap — v1's franchise is not credit-weighted at all), eternity clauses, and any legal-entity integration.](<redacted>.md) | government / ideas / governance |
252
+ | 0176 | [**Private-repo standup: a per-repo read-only deploy key, minted with the owner's token** ([task 1002735](https://cloudbongos.com/builders#/task/1002735), goal 1000032; walk finding N4, seen live on `mercury` 2026-08-17). A standalone instance's repo is cloned/pushed once with the owner's `repo`-scoped token (ADR 0155), but the durable `origin` rides the shared SSH deploy alias — which GitHub answers "Repository not found" for a private repo it isn't registered on, so every private standup failed at the recurring `git pull --ff-only origin main`, **token or not**. Fix: when the target repo answers *private* to the owner's stored token, provisioning mints the instance its OWN ed25519 deploy key beside the checkout (`.git/cloudbongos-deploy-key` — untracked, survives `git reset --hard`, dies with the checkout), registers it on the repo **read-only** as `cloudbongos-deploy-<slug>` (idempotent by title; a stale same-title key is replaced, never duplicated), then wires the CHECKOUT: `origin` re-pointed at plain `git@github.com` and `core.sshCommand` pinned to the key with `-F /dev/null` so no `~/.ssh/config` identity can shadow it. Public repos take the unchanged shared-alias path; pushes keep preferring the owner-token HTTPS URL (task 1002317), which is why read-only suffices; teardown best-effort revokes by title. Rejected: **GitHub App installation tokens** (hourly expiry means every unattended pull needs a minting sidecar or a hub round-trip) and **registering the shared platform key on the repo** (one shared secret granting the whole platform durable access to private code; per-repo keys keep the blast radius to one repo and are visible to the owner in their own repo settings). Follow-ups: the manage page's flip-to-private stays refused until already-provisioned instances get the same wiring, and builder dev boxes of a private-repo project need their own leg.](<redacted>.md) | provisioning / security |
252
253
 
253
254
  > ⚠️ **Numbering collision (known):** two ADRs share `0008`. The numbers were assigned in parallel sessions before the lock could be enforced. The files are kept under their existing filenames to preserve git history, but the table disambiguates them as `0008-a` / `0008-b`. New ADRs should resume at `0040` to avoid further collisions. (`0024` is also a real on-disk duplicate: the cloneable-repo/local-first-memory ADR and the multi-agent-system-architecture ADR were both numbered `0024` in parallel; both keep their filenames, disambiguated as `0024-a` / `0024-b` like `0008`. Likewise `0025` ended up with two on-disk files — the offsite-backup ADR and the structured-criterion↔task-link ADR — both kept under their existing filenames and disambiguated as `0025-a` / `0025-b`; separately, the BFG ADR had also claimed `0025` on 2026-05-30 and yielded to `0026`. A third happened 2026-06-06: `0036` was claimed in parallel by the builders-subdomain ADR ([#726](https://example.com/builders#/task/726)) and this Discord-channels ADR ([#727](https://example.com/builders#/task/727)); the Discord-channels ADR yielded to `0037`. A fourth landed 2026-06-10: `0044` was claimed in parallel by the palette-replacement ADR and the per-box game-preview ADR ([#897](https://example.com/builders#/task/897)); both keep their filenames, disambiguated as `0044-a` / `0044-b` like `0008`. A fifth was caught pre-land 2026-06-13: `0050` was claimed in parallel by the device-flow-admission ADR ([#1000](https://example.com/builders#/task/1000)) and the sandbox-for-everyone ADR ([#1001](https://example.com/builders#/task/1001)); the latter yielded to `0052` before merging, so there is no `0050` duplicate. A sixth was caught pre-land 2026-06-21: the tool-agnostic-design-layer ADR ([#1375](https://example.com/builders#/task/1375)) first collided at `0079` with the value-gated-autonomous-engagement ADR, then at `0080` with the llm-cache-api-transport ADR (both part of the 0075–0080 LLM-cost-reduction series, landed on `main` in parallel); it yielded to `0081` before merging, so there is no `0079`/`0080` design-layer duplicate. A seventh landed 2026-06-23: `0084` was claimed in parallel by the auto-derived-per-task-rank-floor ADR and the remove-default-spend-cap ADR (task 1499); the rank-floor ADR landed on `main` first, so the spend-cap ADR yielded to `0085`, leaving no `0084` duplicate. An eighth landed 2026-06-27: `0095` was claimed in parallel by the borrowed-memory-and-retrieval-concepts ADR (PR #459, landed first) and the cross-agent-context-management ADR ([#1651](https://example.com/builders#/task/1651), PR #460); both keep their filenames, disambiguated as `0095-a` / `0095-b` like `0008`. These two are companions — `0095-a` adopts the memory/retrieval concepts and explicitly defers the grep-vs-semantic question to `0095-b`.)
@@ -78,7 +78,7 @@ Verification is an **overlay on lifecycle, not a new lifecycle state**. The runn
78
78
  2. **No wall-clock promises.** Kill "usually under a minute" and friends. Describe what's happening and what happens next; never how long.
79
79
  3. **Retry honesty (F19).** Intents retire after 3 attempts and nothing auto-retries. The snagged card says so — "Stopped after 3 tries" — and gains an owner-facing **Try again** action that re-enqueues (the enqueue API already accepts re-provisioning; wire it). "The platform keeps retrying" copy dies.
80
80
  4. **Teardown truth (F17) — decided: the database is not dropped.** (Owner, 2026-08-12: no database drop unless we find otherwise — retention is policy, not an accident.) The confirm and the torn-down card say so plainly: the server goes away, the repository stays the owner's, the database is kept and recoverable by the operator. Copy always states exactly what the runner does.
81
- 5. **Repo visibility is stated, always (task 1002721).** Every path that creates or adopts a repo says whether it will be public or private, before the owner commits — GitHub's own UI must never be the first place visibility appears.
81
+ 5. **Repo visibility is stated, always (task 1002721).** Every path that creates or adopts a repo says whether it will be public or private, before the owner commits — GitHub's own UI must never be the first place visibility appears. Private repos deploy via a per-project read-only deploy key the platform runner wires at standup (ADR 0176, task 1002735) — a **control-plane feature**: on the self-host venue there is no platform runner, so the `repo-auth` plan step is absent by design and repository access is the owner's own doing (they clone with their own credentials on their own box). A self-host surface must say that plainly rather than imply the platform will wire it.
82
82
  6. **Newborn welcome (idea 1000701).** A freshly-provisioned instance greets its founding owner with a welcome moment, and account nags (billing, art-key coverage) are suppressed until first-run completes.
83
83
 
84
84
  ## 6. Surface · Create wizard
@@ -171,24 +171,63 @@ const BASE_SPARSE_PATHS = [
171
171
  // globs come in two shapes: directory globs (trailing '/', e.g. 'modules/dev-box/')
172
172
  // and file-list globs (exact paths, e.g. the kernel key's 'src/bongos/db.js'). We
173
173
  // KEEP the directory globs (normalized to no trailing slash, matching the BASE
174
- // style) and DROP the file globs — every file glob in the map sits under a BASE
175
- // directory (src/bongos, scripts) or is a top-level/ancestor file cone mode pulls
176
- // automatically, so base already materializes it. (A future non-cone sparse move
177
- // could pull individual files; out of scope — cone mode stays.) Deterministic:
178
- // BASE in declared order, then the module dirs sorted + deduped.
174
+ // style). A FILE glob cannot be emitted as-is, so it contributes its PARENT
175
+ // DIRECTORY instead — unless BASE or another emitted dir already covers it.
176
+ //
177
+ // It used to be dropped outright, on the reasoning that "every file glob in the
178
+ // map sits under a BASE directory (src/bongos, scripts) or is a top-level file
179
+ // cone mode pulls automatically". That was true when written and **silently
180
+ // stopped being true**: ADR 0174 gave the `government` key the file globs
181
+ // `config/government.json` + `config/government.neutral.json`, and `config` is
182
+ // not in BASE. The result was a goal walled to `government` whose box could never
183
+ // see two files inside its own scope wall — which released task 1003018 (R05 of
184
+ // goal 1000069), whose entire deliverable was one of them. Rather than restore an
185
+ // invariant that expires again the next time a module claims a config file, the
186
+ // parent is emitted and the class is covered by construction.
187
+ //
188
+ // TRADE-OFF, stated rather than implied: the parent directory is a SUPERSET of
189
+ // the glob — asking for `config/government.json` materializes all of `config/`.
190
+ // The box's view is already a superset of the wall (BASE hands out `scripts`,
191
+ // `docs` and `tests` wholesale regardless of scope), so this is consistent with
192
+ // the existing posture, not a new concession. The WALL is unchanged: what a
193
+ // builder may author is still decided by goal-scope-check against the globs, not
194
+ // by what happens to be on disk.
195
+ //
196
+ // Top-level file globs (no '/') contribute nothing — cone mode includes root
197
+ // files automatically. Deterministic: BASE in declared order, then the module
198
+ // dirs sorted + deduped.
179
199
  function sparsePathsForScopeKeys(scopeKeys, moduleScopeMap) {
180
200
  const base = [...BASE_SPARSE_PATHS];
181
201
  const seen = new Set(base);
182
202
  const extra = [];
203
+ // Cone mode materializes a directory and everything under it, so a path is
204
+ // already covered when IT or any ancestor has been emitted. This is what keeps
205
+ // `scripts/gds/box.js` from adding `scripts/gds` — BASE's `scripts` covers it.
206
+ const covered = (dir) => {
207
+ let p = dir;
208
+ for (;;) {
209
+ if (seen.has(p)) return true;
210
+ const cut = p.lastIndexOf('/');
211
+ if (cut < 0) return false;
212
+ p = p.slice(0, cut);
213
+ }
214
+ };
215
+ const add = (dir) => {
216
+ if (!dir || covered(dir)) return;
217
+ seen.add(dir);
218
+ extra.push(dir);
219
+ };
183
220
  for (const key of Array.isArray(scopeKeys) ? scopeKeys : []) {
184
221
  const entry = moduleScopeMap && typeof moduleScopeMap.scopeForModule === 'function'
185
222
  ? moduleScopeMap.scopeForModule(key)
186
223
  : null;
187
224
  if (!entry || !Array.isArray(entry.globs)) continue;
188
225
  for (const glob of entry.globs) {
189
- if (typeof glob !== 'string' || !glob.endsWith('/')) continue; // dirs only (cone mode)
190
- const dir = glob.replace(/\/+$/, '');
191
- if (!seen.has(dir)) { seen.add(dir); extra.push(dir); }
226
+ if (typeof glob !== 'string' || !glob) continue;
227
+ if (glob.endsWith('/')) { add(glob.replace(/\/+$/, '')); continue; }
228
+ const cut = glob.lastIndexOf('/');
229
+ if (cut < 0) continue; // a root file — cone mode already includes it
230
+ add(glob.slice(0, cut));
192
231
  }
193
232
  }
194
233
  extra.sort();
@@ -40,6 +40,7 @@ function onboardPlan(mode = 'greenfield', opts = {}) {
40
40
  { key: 'dns-preflight', kind: 'auto', shapes: CONTROL_PLANE_SHAPES, label: 'DNS token preflight — before the address step (task 1980)' },
41
41
  { key: 'init', kind: 'auto', label: adopt ? 'bongos init --adopt — LAYER onto the existing repo (pre-flight + accept)' : 'bongos init — scaffold the instance config' },
42
42
  { key: 'scaffold', kind: 'auto', label: adopt ? 'Layer config + pinned @bongos/core onto the existing repo (no clobber)' : 'Scaffold the standalone repo (own repo + pinned @bongos/core)' },
43
+ { key: 'repo-auth', kind: 'auto', shapes: CONTROL_PLANE_SHAPES, label: 'Wire repository access — a private repo gets its own read-only deploy key automatically (ADR 0176); you act only if setup asks you to re-grant access or raise it to repository admin' },
43
44
  { key: 'provision', kind: 'auto', shapes: CONTROL_PLANE_SHAPES, label: 'Provision core + DB + address + service (standalone, task 1978)' },
44
45
  { key: 'seed-version', kind: 'auto', label: 'Seed the first version (V1) so the instance can take tasks immediately (task 2119)' },
45
46
  { key: 'oauth-app', kind: 'human', label: 'Wire GitHub sign-in — one-click GitHub App (task 2080), or a manual OAuth app' },
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.217",
3
+ "version": "1.19.219",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.217",
9
+ "version": "1.19.219",
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.217",
3
+ "version": "1.19.219",
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",
@@ -632,7 +632,7 @@ h1[tabindex="-1"]:focus{outline:none;}
632
632
  explanation, not another platform's task tracker. -->
633
633
  <details class="finePrint" id="mRepoVisWhy">
634
634
  <summary>Why can’t it be private yet?</summary>
635
- <p>The platform deploys a project by reading its code from GitHub with a shared read-only credential — and private repositories don’t admit it. A private repository’s next deploy would simply fail, so the switch stays off until each project gets its own deploy credential. <!--apex-only:start-->That work is tracked as <a href="{{buildersOrigin}}#/task/1002735" target="_blank" rel="noopener">task 1002735</a> in the builders’ hall.<!--apex-only:end--></p>
635
+ <p>The platform used to deploy every project by reading its code from GitHub with a shared read-only credential — and private repositories don’t admit it. New standups fix this: a private repository now gets the project’s own read-only deploy key, wired automatically<!--apex-only:start--> — shipped as <a href="{{buildersOrigin}}#/task/1002735" target="_blank" rel="noopener">task 1002735</a> in the builders’ hall<!--apex-only:end-->. A project set up before that still reads with the shared credential, so flipping it private would break its next deploy — the switch stays off until existing projects are re-wired.</p>
636
636
  </details>
637
637
  </div>
638
638
 
@@ -781,7 +781,7 @@ h1[tabindex="-1"]:focus{outline:none;}
781
781
  <!-- visibility is stated on EVERY repo path (task 1002721) — this baked
782
782
  line is the floor; updateFreshPreview() replaces it with the same
783
783
  claim plus the resolved repo URL once a name is typed -->
784
- <div class="hint" id="ghFreshHint">We’ll create an empty <strong>public</strong> repo — projects here are open by design. Want private code? Create the repo yourself on GitHub and “adopt” it instead.</div>
784
+ <div class="hint" id="ghFreshHint">We’ll create an empty <strong>public</strong> repo — projects here are open by design. Want private code? Create the repo yourself on GitHub and “adopt” it instead — a private repository gets its own read-only deploy key automatically.</div>
785
785
  </div>
786
786
 
787
787
  <div class="row2" id="manualHome">
@@ -2517,7 +2517,7 @@ h1[tabindex="-1"]:focus{outline:none;}
2517
2517
  /* Visibility stated up front (task 1002694 / idea 1000684): repos WE create are
2518
2518
  public by design (github-repos.js — open-source, capture-resistant); the
2519
2519
  private path is adopting a private repo the owner makes themselves. */
2520
- hint.className = 'hint'; hint.innerHTML = 'We’ll create an empty <strong>public</strong> repo at <code style="font-family:var(--font-mono)">github.com/' + esc(myLogin()) + '/' + esc(state.repo || 'name') + '</code> — projects here are open by design. Want private code? Create the repo yourself on GitHub and “adopt” it instead.';
2520
+ hint.className = 'hint'; hint.innerHTML = 'We’ll create an empty <strong>public</strong> repo at <code style="font-family:var(--font-mono)">github.com/' + esc(myLogin()) + '/' + esc(state.repo || 'name') + '</code> — projects here are open by design. Want private code? Create the repo yourself on GitHub and “adopt” it instead — a private repository gets its own read-only deploy key automatically.';
2521
2521
  }
2522
2522
  }
2523
2523
  $('ghNewName').addEventListener('input', function () { state.repo = this.value.trim(); updateFreshPreview(); saveDraft(); });
@@ -3623,7 +3623,9 @@ h1[tabindex="-1"]:focus{outline:none;}
3623
3623
 
3624
3624
  function errCategory(note) {
3625
3625
  var n = String(note || '');
3626
- if (/repository not found/i.test(n)) return 'We couldn’t push the starter code to the repository — it wasn’t found on GitHub.';
3626
+ if (/^repository not found:/i.test(n)) return 'We couldn’t find the project’s repository on GitHub even with the access you granted — it may have been renamed or deleted, or the access was lost.';
3627
+ if (/^repository access is not admin:/i.test(n)) return 'Your GitHub access can read the repository but can’t manage its deploy keys — a repository admin needs to raise your access (or make the repository public).';
3628
+ if (/repository not found/i.test(n)) return 'We couldn’t push the starter code to the repository — it wasn’t found on GitHub. A private repository looks “not found” too — if yours is private, sign in again to re-grant repository access, then retry.';
3627
3629
  if (/mkdir|no such file or directory|read-only file system/i.test(n)) return 'The server couldn’t prepare a workspace for the project.';
3628
3630
  if (/(push|clone|github).*permission|permission.*(push|clone|repo)/i.test(n)) return 'We couldn’t reach the repository — the account that set it up doesn’t have permission.';
3629
3631
  if (/permission denied/i.test(n)) return 'The server hit a permission wall while setting up.';
@@ -3996,14 +3998,14 @@ h1[tabindex="-1"]:focus{outline:none;}
3996
3998
  if (repo.private) {
3997
3999
  repoVisMsg(
3998
4000
  '<div class="addr">This repository is <b>private</b> — only accounts it’s shared with can read its code.</div>' +
3999
- '<div class="projMsg err">The platform can’t deploy private repositories yet, so deploys for this project will fail while it stays private.</div>' +
4001
+ '<div class="projMsg err">A private repository deploys through the project’s own read-only deploy key, wired at standup. A project set up before that fix can’t read it yet — retry the standup from your projects page, or make the repository public.</div>' +
4000
4002
  (repo.admin
4001
4003
  ? '<div class="actions"><button type="button" class="btn small" data-repo-public>Make it public</button></div>'
4002
4004
  : '<p class="finePrint">Your GitHub account can’t change this repository’s settings.</p>'));
4003
4005
  } else {
4004
4006
  repoVisMsg(
4005
4007
  '<div class="addr">This repository is <b>public</b> — anyone can read its code.</div>' +
4006
- '<p class="finePrint">Making it private isn’t possible from here yet: the platform can’t deploy private repositories. The switch lands with that fix.</p>');
4008
+ '<p class="finePrint">Making it private isn’t possible from here yet: this project’s deploys still read the repository with the shared credential, so flipping now would break the next deploy. The switch lands with the re-wiring fix.</p>');
4007
4009
  }
4008
4010
  }
4009
4011
 
@@ -575,17 +575,8 @@ async function scaffoldStandaloneRepo(inst, deps) {
575
575
  // token on both paths. It rides a chmod-600 git credential-store file, never the logged
576
576
  // command or makeExec's env (makeExec surfaces both), and is removed in a `finally` so a
577
577
  // failed git op can't leave it on disk. No token (org-owned repo, or secret-box off) →
578
- // fall back to the SSH `origin` remote. `deps.getGithubToken` is injectable for tests;
579
- // production resolves it lazily against the core db slice with the runner's pool.
580
- let ownerToken = null;
581
- if (inst.owner_builder_id) {
582
- try {
583
- const fetchTok = deps.getGithubToken
584
- || ((bid) => require(path.join(REPO_ROOT, 'src/bongos/db')).getGithubToken({ builderId: bid, p: deps.db }));
585
- const r = await fetchTok(inst.owner_builder_id);
586
- ownerToken = r && r.token ? r.token : null;
587
- } catch (e) { log(` [scaffold] owner OAuth token lookup failed (${e && e.message}) — using the SSH remote`); }
588
- }
578
+ // fall back to the SSH `origin` remote.
579
+ const ownerToken = await resolveOwnerGithubToken(inst, deps, { tag: '[scaffold]' });
589
580
  // The credential-store file + the credential-LESS URL git resolves against it. The URL
590
581
  // carries only the `x-access-token` username (no secret), so it is safe in a logged argv.
591
582
  const credFile = ownerToken ? path.join(os.tmpdir(), `bongos-gitcred-${inst.slug}-${process.pid}`) : null;
@@ -742,6 +733,158 @@ async function scaffoldStandaloneRepo(inst, deps) {
742
733
  }
743
734
  }
744
735
 
736
+ // ── private-repo deploy key (ADR 0176, task 1002735; walk finding N4) ────────────
737
+
738
+ // The instance owner's stored `repo`-scoped OAuth token (ADR 0155), or null (none
739
+ // stored, org-owned repo, secret-box off). Shared by the scaffold push, the
740
+ // private-repo deploy-key wiring, and the teardown revoke. `deps.getGithubToken` is
741
+ // injectable for tests; production resolves it lazily against the core db slice
742
+ // with the runner's pool.
743
+ async function resolveOwnerGithubToken(inst, deps, { tag = '[repo-auth]' } = {}) {
744
+ if (!inst.owner_builder_id) return null;
745
+ try {
746
+ const fetchTok = deps.getGithubToken
747
+ || ((bid) => require(path.join(REPO_ROOT, 'src/bongos/db')).getGithubToken({ builderId: bid, p: deps.db }));
748
+ const r = await fetchTok(inst.owner_builder_id);
749
+ return r && r.token ? r.token : null;
750
+ } catch (e) {
751
+ (deps.log || (() => {}))(` ${tag} owner OAuth token lookup failed (${e && e.message})`);
752
+ return null;
753
+ }
754
+ }
755
+
756
+ function deployKeyTitle(inst) { return `cloudbongos-deploy-${inst.slug}`; }
757
+ // Beside the checkout's own metadata: untracked by definition, survives the deploy's
758
+ // `git reset --hard`, and dies with the checkout rather than accreting in ~/.ssh.
759
+ function deployKeyPath(inst) { return path.posix.join(standaloneRoot(inst), '.git', 'cloudbongos-deploy-key'); }
760
+
761
+ // Make a PRIVATE target_ref durably readable by the recurring deploy pull. The
762
+ // owner's token covers only the ONE-SHOT clone/push (its credential file is deleted
763
+ // after the scaffold), while `origin` rides the shared SSH alias — which GitHub
764
+ // answers "Repository not found" for a private repo it isn't registered on. That
765
+ // mismatch is walk finding N4: every private standup failed at `git pull --ff-only
766
+ // origin main`, token or not (seen live: mercury, 2026-08-17).
767
+ //
768
+ // The fix (ADR 0176): mint the instance its OWN ed25519 deploy key, register it
769
+ // READ-ONLY on the repo with the owner's token (idempotent by title; a stale
770
+ // same-title key is replaced), then wire the CHECKOUT — origin re-pointed at plain
771
+ // github.com and core.sshCommand pinned to the key with -F /dev/null so no
772
+ // ~/.ssh/config identity can shadow it. Public repos return untouched; no token →
773
+ // skip (the request-time refusal is task 1002909's preflight). Dry-run plans only.
774
+ async function ensurePrivateRepoAccess(inst, deps) {
775
+ const { log, apply } = deps;
776
+ const exec = deps.controlExec || deps.exec;
777
+ if (inst.hosting_shape !== 'standalone') return { skipped: 'shape' };
778
+ const repo = parseTargetRef(inst.target_ref);
779
+ if (!repo) return { skipped: 'no_repo' };
780
+ // Dry-run stays OFFLINE like every other plan path here (the checkDnsTokenScope
781
+ // preflight is apply-gated for the same reason): no token read, no GitHub call —
782
+ // a plan run must stay a safe inspection tool for a snagged instance.
783
+ if (!apply) {
784
+ log(` [repo-auth] would check ${repo.owner}/${repo.name} visibility with the owner's credential and, if PRIVATE, mint + register a read-only deploy key and wire origin + core.sshCommand to it (ADR 0176)`);
785
+ return { planned: true };
786
+ }
787
+ const ownerToken = await resolveOwnerGithubToken(inst, deps);
788
+ if (!ownerToken) {
789
+ log(' [repo-auth] no usable owner GitHub credential (none stored, or it could not be read) — a PRIVATE repo would fail at the deploy pull (public repos are unaffected)');
790
+ return { skipped: 'no_token' };
791
+ }
792
+ const ghRepos = deps.githubRepos || require(path.join(REPO_ROOT, 'src/bongos/github-repos'));
793
+ let info;
794
+ try {
795
+ info = await ghRepos.getUserRepo(ownerToken, { owner: repo.owner, name: repo.name, fetchImpl: deps.fetchImpl });
796
+ } catch (e) {
797
+ if (e && e.code === 'repo_not_found') {
798
+ // Plain sentence: this rides error_note onto the owner's project card.
799
+ throw new Error(`repository not found: GitHub answered 404 for ${repo.owner}/${repo.name} even with your granted credential — the repository may have been renamed or deleted, or the credential lost access. Check it on GitHub, then try again.`);
800
+ }
801
+ log(` [repo-auth] could not read ${repo.owner}/${repo.name} (${e && e.message}) — continuing; a PRIVATE repo would fail at the deploy pull`);
802
+ return { skipped: 'github_unreachable' };
803
+ }
804
+
805
+ const root = standaloneRoot(inst);
806
+ const keyPath = deployKeyPath(inst);
807
+ const title = deployKeyTitle(inst);
808
+ const fsImpl = deps.fsImpl || fs;
809
+
810
+ if (!info.private) {
811
+ // Flipped private → public since we wired it: un-wire, or the checkout stays
812
+ // pinned (IdentitiesOnly + -F /dev/null admit no other identity) to a key the
813
+ // owner is now free to delete from their repo settings. Keyed on our key file
814
+ // so the common public path stays zero-mutation.
815
+ if (fsImpl.existsSync(keyPath)) {
816
+ log(` [repo-auth] ${repo.owner}/${repo.name} is public again — un-wiring its deploy key`);
817
+ try {
818
+ const keys = await ghRepos.listDeployKeys(ownerToken, { owner: repo.owner, name: repo.name, fetchImpl: deps.fetchImpl });
819
+ const ours = keys.find((k) => k.title === title);
820
+ if (ours) await ghRepos.deleteDeployKey(ownerToken, { owner: repo.owner, name: repo.name, keyId: ours.id, fetchImpl: deps.fetchImpl });
821
+ } catch (e) { log(` [repo-auth] (remote key revoke skipped: ${e && e.message})`); }
822
+ exec(`git -C ${root} remote set-url origin ${instanceRepoRemote(repo)}`);
823
+ exec(`git -C ${root} config --unset core.sshCommand`, { allowFail: true });
824
+ exec(`rm -f ${keyPath} ${keyPath}.pub`, { allowFail: true });
825
+ return { private: false, unwired: true };
826
+ }
827
+ return { private: false }; // the shared-alias path reads public repos as-is
828
+ }
829
+
830
+ // Registering a deploy key needs repository ADMIN; getUserRepo already told us.
831
+ // Refuse up front with the fix, instead of a raw 403 mid-standup.
832
+ if (info.admin === false) {
833
+ throw new Error(`repository access is not admin: your granted credential can read ${repo.owner}/${repo.name}, but registering its deploy key needs repository ADMIN. Ask a repository admin to raise your access (or make the repository public), then try again.`);
834
+ }
835
+
836
+ log(` [repo-auth] ${repo.owner}/${repo.name} is PRIVATE — wiring its own read-only deploy key (ADR 0176)`);
837
+ if (!fsImpl.existsSync(keyPath) || !fsImpl.existsSync(`${keyPath}.pub`)) {
838
+ exec(`rm -f ${keyPath} ${keyPath}.pub`, { allowFail: true });
839
+ exec(`ssh-keygen -q -t ed25519 -N '' -C ${title} -f ${keyPath}`);
840
+ exec(`chmod 600 ${keyPath}`); // ssh-keygen's 0600 default, made an explicit invariant
841
+ }
842
+ const pubKey = String(fsImpl.readFileSync(`${keyPath}.pub`, 'utf8')).trim();
843
+ // Compare algorithm+key only — GitHub stores the key without the local comment.
844
+ const keyBody = (k) => String(k || '').trim().split(/\s+/).slice(0, 2).join(' ');
845
+ let minted = false;
846
+ try {
847
+ const keys = await ghRepos.listDeployKeys(ownerToken, { owner: repo.owner, name: repo.name, fetchImpl: deps.fetchImpl });
848
+ const ours = keys.find((k) => k.title === title);
849
+ const current = !!(ours && keyBody(ours.key) === keyBody(pubKey));
850
+ if (ours && !current) {
851
+ // GitHub 422s a duplicate title/key, and a stale registration is a dead credential —
852
+ // replace, never accumulate.
853
+ await ghRepos.deleteDeployKey(ownerToken, { owner: repo.owner, name: repo.name, keyId: ours.id, fetchImpl: deps.fetchImpl });
854
+ log(` [repo-auth] removed the stale "${title}" key on ${repo.owner}/${repo.name} (local key changed)`);
855
+ }
856
+ if (!current) {
857
+ try {
858
+ await ghRepos.addDeployKey(ownerToken, { owner: repo.owner, name: repo.name, title, key: pubKey, readOnly: true, fetchImpl: deps.fetchImpl });
859
+ } catch (e) {
860
+ if (!e || e.code !== 'key_in_use') throw e;
861
+ // The keypair is slug-keyed but the repo can change underneath it (renamed
862
+ // then recreated, target_ref re-pointed): the key is registered SOMEWHERE
863
+ // else, so retrying with this pair can never succeed. Mint fresh, try once.
864
+ log(' [repo-auth] GitHub holds this key elsewhere (422) — minting a fresh keypair and retrying once');
865
+ exec(`rm -f ${keyPath} ${keyPath}.pub`, { allowFail: true });
866
+ exec(`ssh-keygen -q -t ed25519 -N '' -C ${title} -f ${keyPath}`);
867
+ exec(`chmod 600 ${keyPath}`);
868
+ const freshPub = String(fsImpl.readFileSync(`${keyPath}.pub`, 'utf8')).trim();
869
+ await ghRepos.addDeployKey(ownerToken, { owner: repo.owner, name: repo.name, title, key: freshPub, readOnly: true, fetchImpl: deps.fetchImpl });
870
+ }
871
+ minted = true;
872
+ log(` [repo-auth] registered read-only deploy key "${title}" on ${repo.owner}/${repo.name}`);
873
+ } else {
874
+ log(` [repo-auth] deploy key "${title}" already registered — nothing to mint`);
875
+ }
876
+ } catch (e) {
877
+ if (e && e.code === 'not_repo_admin') {
878
+ throw new Error(`repository access is not admin: your granted credential can read ${repo.owner}/${repo.name}, but registering its deploy key needs repository ADMIN. Ask a repository admin to raise your access (or make the repository public), then try again.`);
879
+ }
880
+ // Plain sentence for the owner card — a transient GitHub hiccup must not read as jargon.
881
+ throw new Error(`deploy-key setup failed for ${repo.owner}/${repo.name}: ${e && e.message}. Nothing is wrong on your side — try again, and if it keeps failing make the repository public and retry.`);
882
+ }
883
+ exec(`git -C ${root} remote set-url origin git@github.com:${repo.owner}/${repo.name}.git`);
884
+ exec(`git -C ${root} config core.sshCommand "ssh -F /dev/null -i ${keyPath} -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new"`);
885
+ return { private: true, minted };
886
+ }
887
+
745
888
  // ── re-provision core-pin refresh (gap-list W3, task 1003050) ────────────────────
746
889
  //
747
890
  // The hazard: a standalone instance's RUNNING core can move ahead of its repo's
@@ -848,15 +991,7 @@ async function refreshStandaloneCorePin(inst, deps) {
848
991
  // Commit + push the refresh to the instance repo — the shared deploy key cannot
849
992
  // write a consumer's repo (task 1002317), so prefer the owner's OAuth token,
850
993
  // exactly like the scaffold's push.
851
- let ownerToken = null;
852
- if (inst.owner_builder_id) {
853
- try {
854
- const fetchTok = deps.getGithubToken
855
- || ((bid) => require(path.join(REPO_ROOT, 'src/bongos/db')).getGithubToken({ builderId: bid, p: deps.db }));
856
- const r = await fetchTok(inst.owner_builder_id);
857
- ownerToken = r && r.token ? r.token : null;
858
- } catch (e) { log(` [core-pin] owner OAuth token lookup failed (${e && e.message}) — pushing via the SSH origin`); }
859
- }
994
+ const ownerToken = await resolveOwnerGithubToken(inst, deps, { tag: '[core-pin]' });
860
995
  const repo = parseTargetRef(inst.target_ref);
861
996
  const credFile = ownerToken && repo ? path.join(os.tmpdir(), `bongos-gitcred-pin-${inst.slug}-${process.pid}`) : null;
862
997
  const cmd = buildCorePinRefreshCommit({ root, fromVersion: plan.pinned, toVersion: targetVersion, repo, ownerToken, credFile });
@@ -1571,6 +1706,10 @@ async function provisionInstance(inst, deps) {
1571
1706
  // throw: the greenfield flow now has no manual clone step. Idempotent + dry-run-safe.
1572
1707
  if (inst.hosting_shape === 'standalone') {
1573
1708
  await scaffoldStandaloneRepo(inst, deps);
1709
+ // A PRIVATE target_ref gets its own read-only deploy key BEFORE step 2's
1710
+ // `git pull --ff-only origin main` — the pull that failed unauthenticated
1711
+ // on every private standup (walk finding N4; ADR 0176).
1712
+ await ensurePrivateRepoAccess(inst, deps);
1574
1713
  }
1575
1714
  }
1576
1715
  if (!inst.db_name) { patch.db_name = inst.slug; inst = { ...inst, db_name: inst.slug }; }
@@ -1917,6 +2056,33 @@ async function teardownInstance(inst, deps) {
1917
2056
  } else if (apply && deps.doApi && inst.droplet_id) {
1918
2057
  await deps.doApi.deleteDroplet(inst.droplet_id);
1919
2058
  }
2059
+ // Revoke the instance's deploy key (ADR 0176) and remove its local halves. The
2060
+ // checkout itself is deliberately left in place (data is precious), so the key
2061
+ // files must NOT ride along — and a revoke skip must be LOUD: a torn-down project
2062
+ // silently keeping durable read access to the owner's private repo is the exact
2063
+ // shape a teardown exists to end. A missing token / API hiccup never blocks it.
2064
+ if (inst.hosting_shape === 'standalone' && inst.target_ref) {
2065
+ const repo = parseTargetRef(inst.target_ref);
2066
+ if (repo) {
2067
+ try {
2068
+ const token = await resolveOwnerGithubToken(inst, deps, { tag: '[teardown]' });
2069
+ if (token) {
2070
+ const ghRepos = deps.githubRepos || require(path.join(REPO_ROOT, 'src/bongos/github-repos'));
2071
+ if (apply) {
2072
+ const keys = await ghRepos.listDeployKeys(token, { owner: repo.owner, name: repo.name, fetchImpl: deps.fetchImpl });
2073
+ const ours = keys.find((k) => k.title === deployKeyTitle(inst));
2074
+ if (ours) {
2075
+ await ghRepos.deleteDeployKey(token, { owner: repo.owner, name: repo.name, keyId: ours.id, fetchImpl: deps.fetchImpl });
2076
+ log(` revoked the "${deployKeyTitle(inst)}" deploy key on ${repo.owner}/${repo.name}`);
2077
+ }
2078
+ } else log(` would revoke the "${deployKeyTitle(inst)}" deploy key on ${repo.owner}/${repo.name} if present`);
2079
+ } else {
2080
+ log(` (deploy-key revoke skipped: no usable owner credential — if ${repo.owner}/${repo.name} carries a "${deployKeyTitle(inst)}" deploy key, remove it in the repo's settings)`);
2081
+ }
2082
+ } catch (e) { log(` (deploy-key revoke skipped: ${e && e.message})`); }
2083
+ boxExec(`rm -f ${deployKeyPath(inst)} ${deployKeyPath(inst)}.pub`, { allowFail: true });
2084
+ }
2085
+ }
1920
2086
  if (apply) {
1921
2087
  await provisioning.setInstanceStatus(db, inst.id, 'torn_down', { torn_down_at: new Date().toISOString() });
1922
2088
  await provisioning.recordEvent(db, { instanceId: inst.id, ownerBuilderId: inst.owner_builder_id, event: 'torn_down', detail: 'torn down by runner', actor: 'runner' });
@@ -2318,6 +2484,8 @@ module.exports = {
2318
2484
  resolveVendorableCoreTarball, planCorePinRefresh, safeVersionLabel, buildCorePinRefreshCommit, refreshStandaloneCorePin,
2319
2485
  // adopt vs greenfield onboarding (ADR 0121, task 1002562)
2320
2486
  onboardMode, alreadyScaffolded,
2487
+ // private-repo deploy key (ADR 0176, task 1002735)
2488
+ resolveOwnerGithubToken, deployKeyTitle, deployKeyPath, ensurePrivateRepoAccess,
2321
2489
  zoneForDomain, dnsUpsertEnv, classifyZoneScope, checkDnsTokenScope,
2322
2490
  caddyBlock, caddySnippetPath, healthzCmd, dedicatedUserData, nextFreePort,
2323
2491
  // executors + orchestration (injectable deps for tests)
@@ -162,4 +162,82 @@ async function makeRepoPublic(token, { owner, name, fetchImpl = fetch } = {}) {
162
162
  };
163
163
  }
164
164
 
165
- module.exports = { listUserRepos, createUserRepo, getUserRepo, makeRepoPublic, GithubApiError, MAX_PAGES };
165
+ // ---- deploy keys (task 1002735 / ADR 0176) ----
166
+ // The per-repo credential a PRIVATE repo's recurring deploy pull authenticates
167
+ // with: provisioning mints an ed25519 keypair, registers its public half here with
168
+ // the owner's token, and teardown revokes it by title. read_only defaults TRUE and
169
+ // widening it must stay an explicit caller choice — the deploy only ever pulls, and
170
+ // a default-writable key would hand a leaked instance box write access to the
171
+ // owner's repo history.
172
+
173
+ async function listDeployKeys(token, { owner, name, fetchImpl = fetch } = {}) {
174
+ const res = await fetchImpl(`${GH_API}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/keys?per_page=${PER_PAGE}`, {
175
+ headers: ghHeaders(token),
176
+ });
177
+ if (res.status === 401) {
178
+ throw new GithubApiError('GitHub rejected the stored token', { status: 401, code: 'github_unauthorized' });
179
+ }
180
+ if (res.status === 403) {
181
+ throw new GithubApiError('GitHub refused — deploy keys need repository admin', { status: 403, code: 'not_repo_admin' });
182
+ }
183
+ if (res.status === 404) {
184
+ throw new GithubApiError('GitHub answered 404 for that repository', { status: 404, code: 'repo_not_found' });
185
+ }
186
+ if (!res.ok) {
187
+ throw new GithubApiError(`GitHub deploy-key list failed (${res.status})`, { status: res.status, code: 'github_error' });
188
+ }
189
+ const rows = await res.json();
190
+ return (Array.isArray(rows) ? rows : []).map((k) => ({
191
+ id: k.id, title: k.title, key: k.key, read_only: !!k.read_only,
192
+ }));
193
+ }
194
+
195
+ async function addDeployKey(token, { owner, name, title, key, readOnly = true, fetchImpl = fetch } = {}) {
196
+ const res = await fetchImpl(`${GH_API}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/keys`, {
197
+ method: 'POST',
198
+ headers: ghHeaders(token, { 'Content-Type': 'application/json' }),
199
+ body: JSON.stringify({ title, key, read_only: readOnly }),
200
+ });
201
+ if (res.status === 201) {
202
+ const k = await res.json();
203
+ return { id: k.id, title: k.title, read_only: !!k.read_only };
204
+ }
205
+ if (res.status === 401) {
206
+ throw new GithubApiError('GitHub rejected the stored token', { status: 401, code: 'github_unauthorized' });
207
+ }
208
+ if (res.status === 403) {
209
+ throw new GithubApiError('GitHub refused — deploy keys need repository admin', { status: 403, code: 'not_repo_admin' });
210
+ }
211
+ if (res.status === 404) {
212
+ throw new GithubApiError('GitHub answered 404 for that repository', { status: 404, code: 'repo_not_found' });
213
+ }
214
+ if (res.status === 422) {
215
+ // GitHub refuses a public key already registered ANYWHERE (deploy key or user key).
216
+ throw new GithubApiError('GitHub refused the key — it is already registered somewhere', { status: 422, code: 'key_in_use' });
217
+ }
218
+ throw new GithubApiError(`GitHub deploy-key add failed (${res.status})`, { status: res.status, code: 'github_error' });
219
+ }
220
+
221
+ async function deleteDeployKey(token, { owner, name, keyId, fetchImpl = fetch } = {}) {
222
+ const res = await fetchImpl(`${GH_API}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/keys/${encodeURIComponent(keyId)}`, {
223
+ method: 'DELETE',
224
+ headers: ghHeaders(token),
225
+ });
226
+ if (res.status === 204) return true;
227
+ if (res.status === 401) {
228
+ throw new GithubApiError('GitHub rejected the stored token', { status: 401, code: 'github_unauthorized' });
229
+ }
230
+ if (res.status === 403) {
231
+ throw new GithubApiError('GitHub refused — deploy keys need repository admin', { status: 403, code: 'not_repo_admin' });
232
+ }
233
+ if (res.status === 404) {
234
+ throw new GithubApiError('GitHub answered 404 for that deploy key', { status: 404, code: 'key_not_found' });
235
+ }
236
+ throw new GithubApiError(`GitHub deploy-key delete failed (${res.status})`, { status: res.status, code: 'github_error' });
237
+ }
238
+
239
+ module.exports = {
240
+ listUserRepos, createUserRepo, getUserRepo, makeRepoPublic,
241
+ listDeployKeys, addDeployKey, deleteDeployKey,
242
+ GithubApiError, MAX_PAGES,
243
+ };
package/src/module-api.js CHANGED
@@ -861,8 +861,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
861
861
  // landed since 1.19.215 with no explicit bump. run 32084584347. (task 1002620)
862
862
  // 1.19.217 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
863
863
  // landed since 1.19.216 with no explicit bump. run 32084927511. (task 1002620)
864
+ // 1.19.218 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
865
+ // landed since 1.19.217 with no explicit bump. run 32086598601. (task 1002620)
866
+ // 1.19.219 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
867
+ // landed since 1.19.218 with no explicit bump. run 32094215476. (task 1002620)
864
868
  // ---------------------------------------------------------------------------
865
- const CORE_VERSION = '1.19.217'; // CI auto-patch carrier (ADR 0161); see changelog above
869
+ const CORE_VERSION = '1.19.219'; // CI auto-patch carrier (ADR 0161); see changelog above
866
870
 
867
871
  // A namespaced logger so a module's log lines are attributable + consistent.
868
872
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -221,14 +221,69 @@ t('cone-filter: dev-box keeps its DIRS, DROPS its file globs (scripts/gds/box.js
221
221
  assert.ok(out.includes('devbox-app'), 'the app dir is kept');
222
222
  assert.ok(!out.some((p) => p.endsWith('.js') || p.endsWith('.sh')), 'no file globs survive the cone filter');
223
223
  assert.ok(!out.includes('scripts/gds/box.js'), 'the box CLI file is dropped (BASE scripts covers it)');
224
+ // …and its PARENT is not added either: BASE's `scripts` already covers it, so
225
+ // the ancestor check must stop `scripts/gds` from appearing. This is the half
226
+ // that keeps the file-glob rescue below from over-sharing.
227
+ assert.ok(!out.includes('scripts/gds'), 'a covered parent is not re-emitted');
228
+ });
229
+
230
+ // A file glob whose parent is NOT already covered must contribute that parent.
231
+ // Regression for the live failure that released task 1003018 (R05 of goal
232
+ // 1000069): the `government` key's config/government*.json globs were dropped on
233
+ // the reasoning that every file glob sits under a BASE directory — true when
234
+ // written, false once ADR 0174 added them, since `config` is not in BASE. The box
235
+ // could not see two files inside its own scope wall.
236
+ t('file-glob rescue: government contributes `config` for its config/*.json globs', () => {
237
+ const out = access.sparsePathsForScopeKeys(['government'], scopeMap);
238
+ assert.ok(out.includes('modules/government'), 'the module dir is kept');
239
+ assert.ok(out.includes('config'), 'config/government.neutral.json must reach the box via its parent');
240
+ assert.ok(!out.some((p) => p.endsWith('.json')), 'still no file paths — cone mode would reject one');
241
+ });
242
+
243
+ t('file-glob rescue holds for EVERY key in the map — no module can silently lose one', () => {
244
+ // Enumerated, never sampled: a module that gains a file glob under a new parent
245
+ // is covered the day it lands, which is exactly how this bug escaped before.
246
+ for (const key of scopeMap.moduleKeys()) {
247
+ const out = access.sparsePathsForScopeKeys([key], scopeMap);
248
+ for (const glob of scopeMap.scopeForModule(key).globs) {
249
+ if (glob.endsWith('/')) continue;
250
+ const cut = glob.lastIndexOf('/');
251
+ if (cut < 0) continue; // a root file — cone mode includes it automatically
252
+ const parent = glob.slice(0, cut);
253
+ const reachable = out.some((p) => parent === p || parent.startsWith(`${p}/`));
254
+ assert.ok(reachable, `${key}: no cone path materializes ${glob} (parent ${parent})`);
255
+ }
256
+ }
257
+ });
258
+
259
+ t('the emitted set stays directories only, for every key', () => {
260
+ // A leading dot is a DIRECTORY here ('.devcontainer', '.claude'), so the
261
+ // extension test must require a non-dot character before the dot — otherwise
262
+ // every BASE dotfile-dir reads as a file and this assertion fires on healthy
263
+ // input. (It did, on first write.)
264
+ const looksLikeFile = (seg) => /[^.]\.[a-z0-9]+$/i.test(seg);
265
+ for (const key of scopeMap.moduleKeys()) {
266
+ for (const p of access.sparsePathsForScopeKeys([key], scopeMap)) {
267
+ assert.ok(!looksLikeFile(p.split('/').pop()),
268
+ `${key}: ${p} looks like a file — git sparse-checkout set would abort on it`);
269
+ }
270
+ }
224
271
  });
225
272
 
226
- t('cone-filter: the kernel key contributes only its real DIR globs (config, src/module-loader)', () => {
273
+ t('cone-filter: the kernel key materializes its real DIR globs (config, src/module-loader)', () => {
227
274
  const out = access.sparsePathsForScopeKeys(['kernel'], scopeMap);
228
275
  // kernel is mostly individual src/bongos/*.js FILES (dropped — BASE src/bongos
229
276
  // covers them via cone ancestor-inclusion) + a few genuine DIR globs.
230
277
  assert.ok(out.includes('config'), 'config/ is a real dir the task needs, not in BASE');
231
- assert.ok(out.includes('src/module-loader'), 'src/module-loader/ is a real dir not in BASE');
278
+ // src/module-loader/ is a real dir not in BASE, but it is NOT emitted verbatim:
279
+ // kernel also carries the file globs src/instance-config.js, src/branding.js,
280
+ // src/modules.js and src/module-api.js, whose rescued parent is `src` — and once
281
+ // `src` is emitted the ancestor check absorbs every path beneath it, this one
282
+ // included. Asserting literal membership would be asserting emission ORDER, not
283
+ // reachability. What the box must actually get is the DIRECTORY's contents, so
284
+ // assert that, using the same idiom as the enumerated rescue test above.
285
+ const reaches = (dir) => out.some((p) => dir === p || dir.startsWith(`${p}/`));
286
+ assert.ok(reaches('src/module-loader'), 'src/module-loader must be materialized (here via the rescued `src`)');
232
287
  assert.ok(!out.includes('src/bongos/db.js'), 'the kernel FILE globs are dropped');
233
288
  assert.ok(!out.includes('server.js'), 'top-level files never enter the sparse set (cone auto-includes them)');
234
289
  });
@@ -163,3 +163,77 @@ test('makeRepoPublic: 403 → typed not_repo_admin', async () => {
163
163
  (e) => e.code === 'not_repo_admin' && e.status === 403,
164
164
  );
165
165
  });
166
+
167
+ // ---- deploy-key helpers (task 1002735 / ADR 0176) ----
168
+
169
+ test('listDeployKeys: GETs /repos/{owner}/{name}/keys, maps id/title/key/read_only', async () => {
170
+ let calledUrl = '';
171
+ const fetchImpl = async (url) => {
172
+ calledUrl = url;
173
+ return res(200, [
174
+ { id: 11, title: 'cloudbongos-deploy-demo', key: 'ssh-ed25519 AAAA1', read_only: true, extra: 'dropped' },
175
+ { id: 12, title: 'laptop', key: 'ssh-ed25519 AAAA2', read_only: false },
176
+ ]);
177
+ };
178
+ const keys = await gh.listDeployKeys('tok', { owner: 'me', name: 'demo', fetchImpl });
179
+ assert.match(calledUrl, /\/repos\/me\/demo\/keys\?/);
180
+ assert.deepEqual(keys, [
181
+ { id: 11, title: 'cloudbongos-deploy-demo', key: 'ssh-ed25519 AAAA1', read_only: true },
182
+ { id: 12, title: 'laptop', key: 'ssh-ed25519 AAAA2', read_only: false },
183
+ ]);
184
+ });
185
+
186
+ test('listDeployKeys: 404 → typed repo_not_found', async () => {
187
+ const fetchImpl = async () => res(404, { message: 'Not Found' });
188
+ await assert.rejects(
189
+ () => gh.listDeployKeys('tok', { owner: 'me', name: 'gone', fetchImpl }),
190
+ (e) => e.code === 'repo_not_found' && e.status === 404,
191
+ );
192
+ });
193
+
194
+ test('addDeployKey: POSTs title+key with read_only:true BY DEFAULT — a write key must be an explicit choice', async () => {
195
+ // The deploy pull only ever reads; a default-writable key would silently widen the
196
+ // blast radius of a leaked instance box to the owner's repo history.
197
+ let sentBody = null;
198
+ const fetchImpl = async (url, init) => {
199
+ sentBody = JSON.parse(init.body);
200
+ return res(201, { id: 99, title: 'cloudbongos-deploy-demo', read_only: true });
201
+ };
202
+ const out = await gh.addDeployKey('tok', { owner: 'me', name: 'demo', title: 'cloudbongos-deploy-demo', key: 'ssh-ed25519 AAAA1 c', fetchImpl });
203
+ assert.deepEqual(sentBody, { title: 'cloudbongos-deploy-demo', key: 'ssh-ed25519 AAAA1 c', read_only: true });
204
+ assert.deepEqual(out, { id: 99, title: 'cloudbongos-deploy-demo', read_only: true });
205
+ });
206
+
207
+ test('addDeployKey: 422 → typed key_in_use (GitHub refuses a key already registered anywhere)', async () => {
208
+ const fetchImpl = async () => res(422, { message: 'key is already in use' });
209
+ await assert.rejects(
210
+ () => gh.addDeployKey('tok', { owner: 'me', name: 'demo', title: 't', key: 'k', fetchImpl }),
211
+ (e) => e.code === 'key_in_use' && e.status === 422,
212
+ );
213
+ });
214
+
215
+ test('deploy-key helpers: 403 → typed not_repo_admin on all three (deploy keys need repo ADMIN, and the runner maps this to a plain refusal)', async () => {
216
+ const fetchImpl = async () => res(403, { message: 'Must have admin rights' });
217
+ for (const call of [
218
+ () => gh.listDeployKeys('tok', { owner: 'me', name: 'demo', fetchImpl }),
219
+ () => gh.addDeployKey('tok', { owner: 'me', name: 'demo', title: 't', key: 'k', fetchImpl }),
220
+ () => gh.deleteDeployKey('tok', { owner: 'me', name: 'demo', keyId: 1, fetchImpl }),
221
+ ]) {
222
+ await assert.rejects(call, (e) => e.code === 'not_repo_admin' && e.status === 403);
223
+ }
224
+ });
225
+
226
+ test('deleteDeployKey: DELETEs /keys/{id}, true on 204; 404 → typed key_not_found', async () => {
227
+ let called = null;
228
+ const ok = await gh.deleteDeployKey('tok', {
229
+ owner: 'me', name: 'demo', keyId: 11,
230
+ fetchImpl: async (url, init) => { called = { url, method: init.method }; return res(204, null); },
231
+ });
232
+ assert.equal(ok, true);
233
+ assert.equal(called.method, 'DELETE');
234
+ assert.match(called.url, /\/repos\/me\/demo\/keys\/11$/);
235
+ await assert.rejects(
236
+ () => gh.deleteDeployKey('tok', { owner: 'me', name: 'demo', keyId: 12, fetchImpl: async () => res(404, {}) }),
237
+ (e) => e.code === 'key_not_found' && e.status === 404,
238
+ );
239
+ });
package/tests/onboard.mjs CHANGED
@@ -46,6 +46,24 @@ test('onboardPlan(adopt): same sequence + human steps, but the scaffold-leg labe
46
46
  assert.doesNotMatch(label(green, 'init'), /--adopt/);
47
47
  });
48
48
 
49
+ test('onboardPlan lists repo-auth as an AUTO, control-plane step between scaffold and provision (ADR 0176, task 1002735)', () => {
50
+ // The private-repo deploy-key wiring runs inside provisionInstance right after the
51
+ // scaffold; naming it in the ONE canonical sequence is what surfaces the only
52
+ // owner-side condition (re-grant access / raise to admin) in every done panel.
53
+ const plan = ob.onboardPlan();
54
+ const keys = plan.map((s) => s.key);
55
+ const step = plan.find((s) => s.key === 'repo-auth');
56
+ assert.ok(step, 'the plan carries a repo-auth step');
57
+ assert.equal(step.kind, 'auto', 'the runner mints + registers the key — no routine owner action');
58
+ assert.deepEqual(step.shapes, ['standalone', 'co-tenant', 'dedicated'],
59
+ 'self-host has no platform runner, so its plan must not promise this leg');
60
+ assert.ok(keys.indexOf('scaffold') < keys.indexOf('repo-auth'), 'the checkout exists before it can be wired');
61
+ assert.ok(keys.indexOf('repo-auth') < keys.indexOf('provision'), 'wired before the provision leg whose pull needs it');
62
+ assert.match(step.label, /read-only deploy key/, 'the label says what the key can and cannot do');
63
+ // present in BOTH modes — adopt and greenfield private repos take the same wiring
64
+ assert.ok(ob.onboardPlan('adopt').find((s) => s.key === 'repo-auth'));
65
+ });
66
+
49
67
  test('onboardPlan lists seed-first-version as an explicit AUTO step right after provision (task 2119 anti-drift)', () => {
50
68
  // The first-version seed used to be an unlisted afterthought bolted to the local
51
69
  // `bongos init` leg (run --no-seed in every hosted flow, so it never fired). Making
@@ -995,9 +995,10 @@ test('flip-to-private cannot travel from this page — only the public direction
995
995
  assert.match(SCRIPT, /visibility: 'public'/, 'the one POST body pins the public direction');
996
996
  assert.doesNotMatch(SCRIPT, /visibility:\s*'private'/,
997
997
  'no code path may send the private direction');
998
- // the refusal is SAID, with its reason, not silently missing
999
- assert.match(SCRIPT, /can’t deploy private repositories/,
1000
- 'the private-repo limitation is stated in plain words');
998
+ // the refusal is SAID, with its reason, not silently missing — post-ADR-0176 the
999
+ // reason is THIS project's shared-credential wiring, not a platform-wide inability
1000
+ assert.match(SCRIPT, /would break the next deploy/,
1001
+ 'the flip-to-private limitation is stated in plain words');
1001
1002
  });
1002
1003
 
1003
1004
  test('the visibility shown is GitHub\'s answer, never an assumption', () => {
@@ -1990,5 +1990,186 @@ await ta('UNUSABLE creds from a successful conversion still report codeSpent —
1990
1990
  assert.equal(r.codeSpent, true, 'the conversion RESOLVED — GitHub burned the code even though the creds are junk');
1991
1991
  });
1992
1992
 
1993
+ console.log('\nprivate-repo deploy key (ADR 0176, task 1002735):');
1994
+
1995
+ // A recorder wired for ensurePrivateRepoAccess: fake GitHub API + fake fs so no
1996
+ // network, no real keys. `keys` = what listDeployKeys answers; `existing` = whether
1997
+ // the local keypair files are already on disk.
1998
+ function repoAuthDeps({ apply = true, priv = true, token = 'gho_TOK', keys = [], pub = 'ssh-ed25519 AAAAlocal cloudbongos-deploy-demo', existing = true } = {}) {
1999
+ const { deps, cmds } = recorder({ apply });
2000
+ const apiCalls = [];
2001
+ if (token) deps.getGithubToken = async () => ({ token, scope: 'read:user,repo' });
2002
+ deps.githubRepos = {
2003
+ getUserRepo: async (_t, a) => { apiCalls.push(['get', `${a.owner}/${a.name}`]); return { full_name: `${a.owner}/${a.name}`, private: priv, admin: true }; },
2004
+ listDeployKeys: async (_t, a) => { apiCalls.push(['list', `${a.owner}/${a.name}`]); return keys; },
2005
+ addDeployKey: async (_t, a) => { apiCalls.push(['add', a.title, a.key, a.readOnly]); return { id: 99, title: a.title, read_only: true }; },
2006
+ deleteDeployKey: async (_t, a) => { apiCalls.push(['del', a.keyId]); return true; },
2007
+ };
2008
+ deps.fsImpl = { existsSync: () => existing, readFileSync: () => `${pub}\n` };
2009
+ return { deps, cmds, apiCalls };
2010
+ }
2011
+ const privInst = { id: 9, slug: 'demo', hosting_shape: 'standalone', status: 'requested', owner_builder_id: 7, target_ref: 'lars589/demo' };
2012
+
2013
+ await ta('ensurePrivateRepoAccess(private): registers a READ-ONLY key and wires origin + core.sshCommand into the checkout', async () => {
2014
+ const { deps, cmds, apiCalls } = repoAuthDeps({ existing: false });
2015
+ const r = await P.ensurePrivateRepoAccess(privInst, deps);
2016
+ assert.equal(r.private, true);
2017
+ const joined = cmds.join('\n');
2018
+ // a fresh ed25519 keypair, passphrase-less, generated where the checkout lives,
2019
+ // its 0600 mode made explicit rather than inherited from ssh-keygen's default
2020
+ assert.match(joined, /ssh-keygen -q -t ed25519 -N '' -C cloudbongos-deploy-demo -f \S+\/demo\/\.git\/cloudbongos-deploy-key/);
2021
+ assert.match(joined, /chmod 600 \S+\/\.git\/cloudbongos-deploy-key/);
2022
+ // registered read_only:true — the deploy only ever pulls
2023
+ const add = apiCalls.find((c) => c[0] === 'add');
2024
+ assert.ok(add, 'registered the key via the GitHub API');
2025
+ assert.equal(add[1], 'cloudbongos-deploy-demo', 'titled per-instance so teardown can revoke by title');
2026
+ assert.equal(add[3], true, 'read_only: a leaked box key must never be able to write the owner\'s repo');
2027
+ // origin re-pointed at PLAIN github.com (the shared alias would resolve the wrong key)…
2028
+ assert.match(joined, /git -C \S+\/demo remote set-url origin git@github\.com:lars589\/demo\.git/);
2029
+ // …and core.sshCommand pinned to the key IN THE REPO CONFIG: survives `git reset --hard`,
2030
+ // applies to every future git op here, and -F \/dev\/null keeps ~/.ssh/config identities out.
2031
+ assert.match(joined, /git -C \S+\/demo config core\.sshCommand "ssh -F \/dev\/null -i \S+\/\.git\/cloudbongos-deploy-key -o IdentitiesOnly=yes/);
2032
+ });
2033
+
2034
+ await ta('ensurePrivateRepoAccess(public): ZERO mutation — the shared-alias path is byte-identical to today', async () => {
2035
+ const { deps, cmds, apiCalls } = repoAuthDeps({ priv: false, existing: false });
2036
+ const r = await P.ensurePrivateRepoAccess(privInst, deps);
2037
+ assert.equal(r.private, false);
2038
+ assert.equal(cmds.length, 0, 'no exec at all for a public repo');
2039
+ assert.deepEqual(apiCalls.map((c) => c[0]), ['get'], 'one visibility read, nothing else');
2040
+ });
2041
+
2042
+ await ta('ensurePrivateRepoAccess(flipped public): UN-wires — origin back to the shared alias, sshCommand unset, key halves removed + revoked', async () => {
2043
+ // A repo made public after being wired stays pinned (IdentitiesOnly + -F /dev/null
2044
+ // admit no other identity) to a key the owner may now delete — a stuck state the
2045
+ // old public path could never reach.
2046
+ const { deps, cmds, apiCalls } = repoAuthDeps({
2047
+ priv: false, existing: true,
2048
+ keys: [{ id: 11, title: 'cloudbongos-deploy-demo', key: 'ssh-ed25519 AAAAlocal', read_only: true }],
2049
+ });
2050
+ const r = await P.ensurePrivateRepoAccess(privInst, deps);
2051
+ assert.equal(r.unwired, true);
2052
+ assert.deepEqual(apiCalls.find((c) => c[0] === 'del'), ['del', 11], 'revokes the now-orphaned key');
2053
+ const joined = cmds.join('\n');
2054
+ assert.match(joined, /remote set-url origin git@github-deploy:lars589\/demo\.git/, 'origin returns to the shared alias');
2055
+ assert.match(joined, /config --unset core\.sshCommand/);
2056
+ assert.match(joined, /rm -f \S+\/\.git\/cloudbongos-deploy-key \S+\/\.git\/cloudbongos-deploy-key\.pub/);
2057
+ });
2058
+
2059
+ await ta('ensurePrivateRepoAccess(private, non-admin token): refuses UP FRONT with the fix, not a raw 403 mid-standup', async () => {
2060
+ const { deps, cmds, apiCalls } = repoAuthDeps({ existing: false });
2061
+ deps.githubRepos.getUserRepo = async () => ({ full_name: 'lars589/demo', private: true, admin: false });
2062
+ await assert.rejects(() => P.ensurePrivateRepoAccess(privInst, deps), /repository access is not admin: .*ADMIN/);
2063
+ assert.ok(!cmds.some((c) => /ssh-keygen/.test(c)), 'no key minted for a credential that cannot register it');
2064
+ assert.ok(!apiCalls.some((c) => c[0] === 'add'));
2065
+ });
2066
+
2067
+ await ta('ensurePrivateRepoAccess: 422 key_in_use (repo changed under a slug-keyed pair) self-heals — fresh keypair, one retry', async () => {
2068
+ const { deps, cmds, apiCalls } = repoAuthDeps({ existing: true });
2069
+ let addCalls = 0;
2070
+ deps.githubRepos.addDeployKey = async (_t, a) => {
2071
+ apiCalls.push(['add', a.title, a.key, a.readOnly]);
2072
+ addCalls++;
2073
+ if (addCalls === 1) { const e = new Error('key already in use'); e.code = 'key_in_use'; throw e; }
2074
+ return { id: 99 };
2075
+ };
2076
+ const r = await P.ensurePrivateRepoAccess(privInst, deps);
2077
+ assert.equal(r.minted, true);
2078
+ assert.equal(addCalls, 2, 'exactly one retry');
2079
+ assert.ok(cmds.some((c) => /ssh-keygen/.test(c)), 'the retry rides a FRESH keypair — the old one can never succeed');
2080
+ });
2081
+
2082
+ await ta('ensurePrivateRepoAccess: a transient GitHub failure surfaces as a plain sentence, never raw jargon on the owner card', async () => {
2083
+ const { deps } = repoAuthDeps({ existing: true });
2084
+ deps.githubRepos.listDeployKeys = async () => { const e = new Error('GitHub deploy-key list failed (502)'); e.code = 'github_error'; throw e; };
2085
+ await assert.rejects(() => P.ensurePrivateRepoAccess(privInst, deps), /deploy-key setup failed for lars589\/demo: .*try again/);
2086
+ });
2087
+
2088
+ await ta('ensurePrivateRepoAccess(no token): skips without touching git or GitHub (task 1002909 owns the request-time refusal)', async () => {
2089
+ const { deps, cmds, apiCalls } = repoAuthDeps({ token: null });
2090
+ const r = await P.ensurePrivateRepoAccess(privInst, deps);
2091
+ assert.equal(r.skipped, 'no_token');
2092
+ assert.equal(cmds.length, 0);
2093
+ assert.equal(apiCalls.length, 0);
2094
+ });
2095
+
2096
+ await ta('ensurePrivateRepoAccess: an already-registered CURRENT key re-asserts the git wiring but mints nothing', async () => {
2097
+ const { deps, cmds, apiCalls } = repoAuthDeps({
2098
+ keys: [{ id: 11, title: 'cloudbongos-deploy-demo', key: 'ssh-ed25519 AAAAlocal', read_only: true }],
2099
+ });
2100
+ const r = await P.ensurePrivateRepoAccess(privInst, deps);
2101
+ assert.equal(r.minted, false);
2102
+ assert.ok(!apiCalls.some((c) => c[0] === 'add'), 'no re-registration');
2103
+ assert.ok(!apiCalls.some((c) => c[0] === 'del'), 'no deletion');
2104
+ assert.ok(!cmds.some((c) => /ssh-keygen/.test(c)), 'no new keypair');
2105
+ // the wiring is still re-asserted (a re-provision heals a hand-edited remote)
2106
+ assert.ok(cmds.some((c) => /remote set-url origin git@github\.com:lars589\/demo\.git/.test(c)));
2107
+ assert.ok(cmds.some((c) => /config core\.sshCommand/.test(c)));
2108
+ });
2109
+
2110
+ await ta('ensurePrivateRepoAccess: a STALE same-title key (local key lost/rotated) is replaced, not duplicated', async () => {
2111
+ const { deps, apiCalls } = repoAuthDeps({
2112
+ keys: [{ id: 11, title: 'cloudbongos-deploy-demo', key: 'ssh-ed25519 AAAAother', read_only: true }],
2113
+ });
2114
+ const r = await P.ensurePrivateRepoAccess(privInst, deps);
2115
+ assert.equal(r.minted, true);
2116
+ const order = apiCalls.map((c) => c[0]);
2117
+ assert.ok(order.indexOf('del') >= 0 && order.indexOf('del') < order.indexOf('add'), 'deletes the stale key BEFORE adding (GitHub 422s a duplicate)');
2118
+ assert.equal(apiCalls.find((c) => c[0] === 'del')[1], 11);
2119
+ });
2120
+
2121
+ await ta('ensurePrivateRepoAccess: repo_not_found with a token → a PLAIN error naming the repo (feeds the owner card, not raw git stderr)', async () => {
2122
+ const { deps } = repoAuthDeps();
2123
+ deps.githubRepos.getUserRepo = async () => { const e = new Error('GitHub answered 404 for that repository'); e.code = 'repo_not_found'; throw e; };
2124
+ await assert.rejects(
2125
+ () => P.ensurePrivateRepoAccess(privInst, deps),
2126
+ /repository not found: .*lars589\/demo.*credential/i,
2127
+ );
2128
+ });
2129
+
2130
+ await ta('ensurePrivateRepoAccess(dry-run): fully OFFLINE — no token read, no GitHub call, plan line only', async () => {
2131
+ const { deps, cmds, apiCalls } = repoAuthDeps({ apply: false, existing: false });
2132
+ let tokenReads = 0;
2133
+ deps.getGithubToken = async () => { tokenReads++; return { token: 'gho_TOK' }; };
2134
+ const r = await P.ensurePrivateRepoAccess(privInst, deps);
2135
+ assert.equal(r.planned, true);
2136
+ assert.equal(cmds.length, 0, 'no mutation in dry-run');
2137
+ assert.deepEqual(apiCalls, [], 'a plan run must stay a safe inspection tool — zero cloud calls');
2138
+ assert.equal(tokenReads, 0, 'not even the credential read');
2139
+ });
2140
+
2141
+ await ta('provisionInstance(standalone): the private-repo wiring lands BETWEEN the scaffold and the step-2 pull', async () => {
2142
+ const { deps, cmds } = repoAuthDeps({ existing: false });
2143
+ const r = await P.provisionInstance({ ...coTenant, hosting_shape: 'standalone', target_ref: 'lars589/mercury' }, deps);
2144
+ assert.equal(r.ok, true);
2145
+ const idx = (re) => cmds.findIndex((c) => re.test(c));
2146
+ assert.ok(idx(/remote set-url origin git@github\.com:lars589\/mercury\.git/) >= 0, 'wired the private origin');
2147
+ assert.ok(idx(/remote set-url origin git@github\.com:/) < idx(/git pull --ff-only origin main/),
2148
+ 'the pull that failed on prod (mercury, 2026-08-17) now runs AFTER the key wiring');
2149
+ });
2150
+
2151
+ await ta('teardownInstance(standalone): best-effort revokes the instance\'s deploy key by title', async () => {
2152
+ const { deps, apiCalls } = repoAuthDeps({
2153
+ keys: [{ id: 11, title: 'cloudbongos-deploy-demo', key: 'ssh-ed25519 AAAAlocal', read_only: true }],
2154
+ });
2155
+ deps.apply = true;
2156
+ await P.teardownInstance({ ...privInst, status: 'active' }, deps);
2157
+ assert.deepEqual(apiCalls.find((c) => c[0] === 'del'), ['del', 11], 'the torn-down project keeps no live credential on the owner\'s repo');
2158
+ });
2159
+
2160
+ await ta('teardownInstance(standalone): no registered key / no token → teardown proceeds untouched', async () => {
2161
+ const a = repoAuthDeps({ keys: [] });
2162
+ a.deps.apply = true;
2163
+ await P.teardownInstance({ ...privInst, status: 'active' }, a.deps);
2164
+ assert.ok(!a.apiCalls.some((c) => c[0] === 'del'), 'nothing to revoke');
2165
+ const b = repoAuthDeps({ token: null });
2166
+ b.deps.apply = true;
2167
+ const r = await P.teardownInstance({ ...privInst, status: 'active' }, b.deps);
2168
+ assert.equal(r.ok, true, 'a missing credential never blocks a teardown');
2169
+ // The checkout is deliberately left behind, so the key halves must NOT ride along —
2170
+ // revoked or not, the local private key never outlives the teardown.
2171
+ assert.ok(b.cmds.some((c) => /rm -f \S+\/\.git\/cloudbongos-deploy-key \S+\.pub/.test(c)), 'local key halves removed even with no credential');
2172
+ });
2173
+
1993
2174
  console.log(`\nprovision: ${passed} passed, ${failed} failed`);
1994
2175
  process.exit(failed ? 1 : 0);
@@ -124,8 +124,8 @@ test('the manage view is served: identity block + honest not-yet sections (task
124
124
  test('the repo-visibility section is served live, with the private direction refused (task 1003045)', async () => {
125
125
  const { body } = await request('/projects', 'game.test.local');
126
126
  assert.match(body, /id="mRepoVis"/, 'the live section body is served');
127
- assert.match(body, /can’t deploy private repositories/,
128
- 'the private-repo limitation is stated in plain words');
127
+ assert.match(body, /would break the next deploy/,
128
+ 'the flip-to-private limitation is stated in plain words');
129
129
  assert.doesNotMatch(body, /visibility:\s*'private'/,
130
130
  'no served code path may send the private direction');
131
131
  assert.match(body, /repos=1/, 'the reconnect door asks for repository access');