@bongos/core 1.21.14 → 1.21.16

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.
Files changed (36) hide show
  1. package/.bongos-core.json +51 -41
  2. package/docs/adr/0281-an-instance-identity-is-its-own-unix-account-and-pg-role.md +3 -0
  3. package/docs/adr/0364-repo-access-is-asked-at-sign-in-and-held-for-the-session.md +30 -0
  4. package/docs/adr/README.md +1 -0
  5. package/docs/copy-inventory.md +255 -255
  6. package/docs/copy-registry.json +288 -288
  7. package/docs/module-api-changelog.md +4 -0
  8. package/docs/page-readings.json +363 -362
  9. package/docs/recipes/upgrading-the-core.md +1 -0
  10. package/modules/hall-ui/public/deploy.js +3 -0
  11. package/modules/public-landing/public/account.html +2 -2
  12. package/modules/public-landing/public/account.probes.json +2 -2
  13. package/modules/public-landing/public/index.html +1 -1
  14. package/modules/public-landing/public/projects.html +22 -13
  15. package/package-lock.json +2 -2
  16. package/package.json +1 -1
  17. package/release-notes.json +12 -0
  18. package/scripts/gds/provision-core-upgrade.js +52 -20
  19. package/scripts/gds/upgrade-migrate.js +101 -4
  20. package/scripts/gds/upgrade-outcome.js +6 -0
  21. package/scripts/gds/upgrade.js +39 -44
  22. package/scripts/gds/wedge-remedy.js +10 -4
  23. package/src/bongos/db.js +7 -6
  24. package/src/bongos/routes/auth.js +17 -4
  25. package/src/module-api.js +1 -1
  26. package/tests/core_upgrade_runner.mjs +34 -10
  27. package/tests/github_token_session_life.mjs +110 -0
  28. package/tests/hub_account_page.mjs +2 -2
  29. package/tests/instance_db_migrated_on_code_move.mjs +20 -2
  30. package/tests/landing_page.mjs +2 -2
  31. package/tests/projects_hub_pre_uat.mjs +2 -1
  32. package/tests/upgrade.mjs +147 -0
  33. package/tests/upgrade_outcome.mjs +3 -0
  34. package/tests/wedge_remedy.mjs +36 -2
  35. package/tests/wizard_draft_resume.mjs +49 -0
  36. package/tests/wizard_intent_resume.mjs +4 -2
@@ -222,6 +222,7 @@ running a go-live.
222
222
  ## Gotchas
223
223
 
224
224
  - **The Deploy door fixes three known snags by itself, once** (task 1004448, `scripts/gds/wedge-remedy.js`). When a core move through `/deploy` fails on a **dirty tree** (it commits the tracked changes inside the project), a **full disk** (it prunes that project's own nightly dumps down to the newest 7) or a **database behind its code** (it migrates that database with its own `PGDATABASE` before the code moves), the runner applies the fix, records a `core-upgrade-remedy` event on the project, and tries the move once more on the next tick. If the retry fails for the same reason, or the fix itself fails, the move is retired as `unknown` (`remedy_did_not_hold` / `remedy_failed`) for a person — never a second fix. The manual fixes below still apply to the unattended nightly sweep, which does not go through the door.
225
+ - **A core move uses TWO unix accounts, and which one does what is not interchangeable** (task 1004512, `resolveRunAs` in `scripts/gds/provision-core-upgrade.js`). The move runs as the account that owns the checkout — the app user, on every hosting shape — because everything it writes lives there: the pin, `node_modules`, `.claude/`, the pin commit, and the `git status` it starts with. Only the **database** steps (identity pre-check, migrate, the ledger insert) run as the project's own `bongos-<slug>` account, handed over with `--migrate-as`; that account owns the database and `PUBLIC` has no `CONNECT`, so nothing else can reach it. The flag is emitted only when the two differ, so a control plane and a project older than ADR 0281 run the command they always did. *The symptom when this is wrong:* every move on a project with its own account fails with `fatal: detected dubious ownership in repository`, which the clean-tree pre-flight used to report as a dirty tree — the remedy below then commits a tree nobody touched and the retry fails identically. A `git status` that could not answer is now its own reason (`git_unreadable`, class `unknown`), so it goes to a person instead of to that fix. **Do not "fix" a recurrence with `safe.directory`** — git would then read the tree and the next write step would fail instead.
225
226
  - **A Deploy-door move nobody can fix files a blocker and posts to Discord — once** (task 1004449, `scripts/gds/move-escalation.js`). A move retired as `unknown` (including `remedy_did_not_hold`, `remedy_failed` and a failed rollback) files one blocker (`source=provisioning`, `source_ref=core-move:<instance id>:<intent id>`) with the reason and what the move reported, and posts one notice to `#ship-news` that names the project only if it is public. The next core move on that project that lands closes the blocker by itself and posts an all-clear. Refusals the owner can act on (`needs_decision`) are shown on the deploy page instead, not filed.
226
227
  - **`--registry` needs no credential at all** (task 1003878). `@bongos/core` is **public** on npm (task 1003872), so `bongos upgrade --registry` resolves it from the default registry like any other dependency — no token, no login, and no instance `.npmrc`. An install failure on this path is therefore a *real* failure (network, registry outage, or a version that was never published), never a missing credential.
227
228
  *History, so the symptom is recognisable on an old instance:* the core used to publish private, and the instance `.npmrc` read `_authToken=<redacted> npm gives that per-project key precedence, so with the env var unset it expanded EMPTY and **masked a perfectly valid literal token in `~/.npmrc`** — npm went anonymous, the private core 404'd, and the error blamed a credential the box actually held. Task 1002754 worked around it by resolving the token in-process; task 1003878 deleted the whole mechanism along with the `.npmrc` that caused it. An instance still carrying a committed `.npmrc` from a pre-1003878 scaffold can safely delete it.
@@ -142,6 +142,9 @@
142
142
  not_published: 'that version is not published yet',
143
143
  npm_install: 'installing the new core stopped part-way',
144
144
  dirty_tree: 'the project had unsaved file changes on its machine',
145
+ // Deliberately not a variant of dirty_tree above: nothing is known about the files here,
146
+ // which is the whole difference (task 1004512).
147
+ git_unreadable: 'the project’s files could not be read on its machine, so the move never started — nothing changed, and someone is looking at it',
145
148
  disk_full: 'the machine ran out of disk space',
146
149
  schema_pending: 'the project’s database was behind its code',
147
150
  outside_channel: 'that version is outside this project’s update rule',
@@ -104,7 +104,7 @@
104
104
  <!-- the identity chip IS the profile trigger; paintBar fills it from GET /me.
105
105
  Signed out it stays hidden and the sign-in door takes its slot. -->
106
106
  <button class="trig who" type="button" id="who" data-menu="profile" aria-expanded="false" aria-controls="menuProfile" hidden><img id="whoImg" alt="" hidden><span class="whoName" id="whoLogin"></span><svg class="cv" viewBox="0 0 20 20" aria-hidden="true"><path d="m5.6 8 4.4 4.4L14.4 8"/></svg></button>
107
- <a class="signinDoor" id="signinTop" hidden href="/api/bongos/auth/web/start?return=/account">Sign in</a>
107
+ <a class="signinDoor" id="signinTop" hidden href="/api/bongos/auth/web/start?return=/account&amp;repos=1">Sign in</a>
108
108
  </div>
109
109
  </header>
110
110
 
@@ -138,7 +138,7 @@
138
138
  <section class="aDoor" id="door" hidden aria-labelledby="door-h">
139
139
  <h2 class="vh" id="door-h">Sign in</h2>
140
140
  <p>Sign in with GitHub to change your handle, your bio and links, and who can find you.</p>
141
- <a class="btn" id="signin" href="/api/bongos/auth/web/start?return=/account">
141
+ <a class="btn" id="signin" href="/api/bongos/auth/web/start?return=/account&amp;repos=1">
142
142
  <!-- keep the spaces inside this path's numbers: same shape, and they are load-bearing (task 1004401) -->
143
143
  <svg class="gh" viewBox="0 0 16 16" aria-hidden="true"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23 .82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2 .82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12 .51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29 .25.54.73 .54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15 .46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8z"/></svg>
144
144
  Sign in with GitHub
@@ -5,7 +5,7 @@
5
5
  "probes": [
6
6
  { "name": "signed out: the door, and sign-in returns to /account", "auth": false, "width": 1440,
7
7
  "steps": [["wait", 300]],
8
- "expect": { "visible": ["#door"], "hidden": ["#acctBody"], "href": [["#signin", "/api/bongos/auth/web/start?return=/account"]] } },
8
+ "expect": { "visible": ["#door"], "hidden": ["#acctBody"], "href": [["#signin", "/api/bongos/auth/web/start?return=/account&repos=1"]] } },
9
9
  { "name": "signed in: nothing changed saves nothing", "auth": true, "width": 1440,
10
10
  "steps": [["wait", 400], ["click", "#profileSave"], ["wait", 150]],
11
11
  "expect": { "text": [["#profileMsg", "Nothing has changed."]] } },
@@ -56,7 +56,7 @@
56
56
  "expect": { "closed": "#menuProjects" } },
57
57
  { "name": "signed out: no chip, the sign-in door in its slot, returning to /account", "auth": false, "width": 1440,
58
58
  "steps": [["wait", 300]],
59
- "expect": { "hidden": ["#who"], "visible": ["#signinTop", "#projTrig"], "href": [["#signinTop", "/api/bongos/auth/web/start?return=/account"]] } },
59
+ "expect": { "hidden": ["#who"], "visible": ["#signinTop", "#projTrig"], "href": [["#signinTop", "/api/bongos/auth/web/start?return=/account&repos=1"]] } },
60
60
  { "name": "signed out the Projects band is Sky / Table / New project (no Your projects)", "continue": true,
61
61
  "steps": [["click", "#projTrig"], ["wait", 120]],
62
62
  "expect": { "open": "#menuProjects", "count": [["#menuProjects .bandCell:not([hidden])", 3]], "hidden": ["#mineCell"] } },
@@ -673,7 +673,7 @@ composition A; landing composition v3, picked by the owner 2026-08-27. No roll,
673
673
  <!-- the identity slot: the sign-in door signed out; the chip (the profile trigger) once
674
674
  the session resolves — swapped client-side, never a wrong-state flash -->
675
675
  <span class="whoSlot" id="who">
676
- <a class="pill signin" href="/api/bongos/auth/web/start?return=/">Sign in</a>
676
+ <a class="pill signin" href="/api/bongos/auth/web/start?return=/&amp;repos=1">Sign in</a>
677
677
  </span>
678
678
  </div>
679
679
  </header>
@@ -705,7 +705,7 @@ summary{min-height:24px;padding:3px 0;}
705
705
  <!-- the identity chip IS the profile trigger; renderWho fills it. Signed
706
706
  out it does not exist and the sign-in door takes its slot. -->
707
707
  <button class="trig who" type="button" id="who" data-menu="profile" aria-expanded="false" aria-controls="menuProfile" hidden></button>
708
- <a class="signinDoor" id="signinTop" hidden href="/api/bongos/auth/web/start?return=/projects">Sign in</a>
708
+ <a class="signinDoor" id="signinTop" hidden href="/api/bongos/auth/web/start?return=/projects&amp;repos=1">Sign in</a>
709
709
  </div>
710
710
  </header>
711
711
 
@@ -897,7 +897,7 @@ summary{min-height:24px;padding:3px 0;}
897
897
  /create, listing your own instances needs no repo scope) -->
898
898
  <section id="gate" hidden>
899
899
  <p class="lede">Every project you own, in one place — its live status, its address, and the controls that matter. Sign in with GitHub to see yours.</p>
900
- <a class="btn" id="signin" href="/api/bongos/auth/web/start?return=/projects%3Fview%3Dmine">
900
+ <a class="btn" id="signin" href="/api/bongos/auth/web/start?return=/projects%3Fview%3Dmine&amp;repos=1">
901
901
  <svg class="gh" viewBox="0 0 16 16" aria-hidden="true"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8z"/></svg>
902
902
  Sign in with GitHub
903
903
  </a>
@@ -999,7 +999,7 @@ summary{min-height:24px;padding:3px 0;}
999
999
  <!-- signed out: the same identity-only door the mine view uses (ADR 0143) -->
1000
1000
  <section id="mGate" class="card" hidden>
1001
1001
  <p class="lede">This is a project’s management page — it answers only to the person who owns it. Sign in with GitHub to open yours.</p>
1002
- <a class="btn" id="mSignin" href="/api/bongos/auth/web/start?return=/projects%3Fview%3Dmine">
1002
+ <a class="btn" id="mSignin" href="/api/bongos/auth/web/start?return=/projects%3Fview%3Dmine&amp;repos=1">
1003
1003
  <svg class="gh" viewBox="0 0 16 16" aria-hidden="true"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8z"/></svg>
1004
1004
  Sign in with GitHub
1005
1005
  </a>
@@ -1395,7 +1395,8 @@ summary{min-height:24px;padding:3px 0;}
1395
1395
  <!-- The wizard's own sign-in opts into repo scope (read:user + repo, ADR 0155) up
1396
1396
  front (&repos=1) — anyone here intends to create a project, which needs repo
1397
1397
  access, so we ask once at sign-in instead of a second "Connect" step later.
1398
- Hall + CLI sign-in stay identity-only (ADR 0143 least-privilege default). -->
1398
+ Every hub sign-in door does the same since ADR 0364; the hall + CLI
1399
+ sign-ins stay identity-only (ADR 0143 least-privilege default). -->
1399
1400
  <a class="btn big" id="wizSignin" href="/api/bongos/auth/web/start?return=/projects%3Fview%3Dnew&amp;repos=1">
1400
1401
  <svg class="gh" viewBox="0 0 16 16" aria-hidden="true"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8z"/></svg>
1401
1402
  Continue with GitHub
@@ -2494,11 +2495,12 @@ summary{min-height:24px;padding:3px 0;}
2494
2495
  else history.replaceState(null, '', '?view=' + v + idPart);
2495
2496
  } catch (e) {}
2496
2497
  if (v !== 'manage') document.title = BASE_TITLE;
2497
- /* the shell's sign-in door returns the visitor to the view they left, and
2498
- carries the repo scope when that view is the wizard (ADR 0143: the
2499
- create path asks once up front instead of a second consent at Home) */
2498
+ /* the shell's sign-in door returns the visitor to the view they left. Every
2499
+ sign-in door on the hub's own pages carries the repo scope (task 1004549,
2500
+ ADR 0364): it is asked once, at sign-in, and held for the whole session, so
2501
+ no flow ever stops halfway to ask for it */
2500
2502
  $('signinTop').href = '/api/bongos/auth/web/start?return=/projects%3Fview%3D' + v +
2501
- idPart.replace('&', '%26').replace('=', '%3D') + (v === 'new' ? '&repos=1' : '');
2503
+ idPart.replace('&', '%26').replace('=', '%3D') + '&repos=1';
2502
2504
  /* the mine view is the only one that polls; it re-reads on entry so a
2503
2505
  card that changed while you were on the map is not stale — including
2504
2506
  the membership + invitations lists (task 1003774), the same re-read
@@ -7190,8 +7192,15 @@ summary{min-height:24px;padding:3px 0;}
7190
7192
  function enter(focus) {
7191
7193
  if (authKnown && !me) { show(0, !!focus); return; }
7192
7194
  var created = authKnown ? createdRecord() : null;
7193
- if (created && created.id) { enterCreated(created, !!focus); return; }
7194
- paintResumeOffer(null);
7195
+ /* A draft part-way through the wizard (steps 1–5) beside a stored create is a
7196
+ SECOND project being typed. A reload or a GitHub round-trip (repo access) is
7197
+ that same visit continuing, so the draft keeps its place and the earlier
7198
+ project stays one click away on the resume line — it used to land on the
7199
+ earlier project's done panel, dropping the founder out of the draft
7200
+ (task 1004549). A deliberate nav still goes through enterCreated. */
7201
+ var draftInFlight = state.step >= 1 && state.step <= 5;
7202
+ if (created && created.id && (focus || !draftInFlight || resumeIntent)) { enterCreated(created, !!focus); return; }
7203
+ paintResumeOffer(created && created.id ? created : null);
7195
7204
  /* before identity settles, never render past step 1 (the fork and its terms
7196
7205
  tick, since task 1004504): deeper steps carry authenticated side effects (the Home-step repo
7197
7206
  probe), and onAuth() re-enters to the real draft step the moment /me answers */
@@ -7345,8 +7354,8 @@ summary{min-height:24px;padding:3px 0;}
7345
7354
  function jsonOrEmpty(res) { return res.json().catch(function () { return {}; }); }
7346
7355
 
7347
7356
  function signInAgain() {
7348
- /* from the wizard, the re-sign-in keeps the repo scope it already granted */
7349
- var back = currentView === 'new' ? '/projects%3Fview%3Dnew&amp;repos=1' : '/projects';
7357
+ /* the re-sign-in keeps the repo scope, like every hub sign-in door (ADR 0364) */
7358
+ var back = (currentView === 'new' ? '/projects%3Fview%3Dnew' : '/projects') + '&amp;repos=1';
7350
7359
  return 'Your session expired — <a href="' + API + '/auth/web/start?return=' + back + '">sign in again</a>.';
7351
7360
  }
7352
7361
 
@@ -7948,7 +7957,7 @@ summary{min-height:24px;padding:3px 0;}
7948
7957
  in hand so "checked Xm ago" keeps telling the truth (C6). ── */
7949
7958
 
7950
7959
  function manageSignin() {
7951
- return API + '/auth/web/start?return=' +
7960
+ return API + '/auth/web/start?repos=1&return=' +
7952
7961
  encodeURIComponent('/projects?view=manage&id=' + manageId);
7953
7962
  }
7954
7963
 
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.21.14",
3
+ "version": "1.21.16",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.21.14",
9
+ "version": "1.21.16",
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.21.14",
3
+ "version": "1.21.16",
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",
@@ -8840,5 +8840,17 @@
8840
8840
  "id": "1004537",
8841
8841
  "text": "Autobongos no longer picks tasks that can only be done by logging into a live server. They are skipped before any claim and listed as \"needs_host_access\", so no more wasted runs and pointless owner blockers."
8842
8842
  }
8843
+ ],
8844
+ "1.21.15": [
8845
+ {
8846
+ "id": "1004512",
8847
+ "text": "Deploying a core update to a project that runs under its own account used to fail every time, blaming unsaved file changes that were never there. The update now writes files as the account that owns them and touches the databa"
8848
+ }
8849
+ ],
8850
+ "1.21.16": [
8851
+ {
8852
+ "id": "1004549",
8853
+ "text": "Signing in on cloudbongos.com now asks GitHub for repo access once and keeps it while you stay signed in, so project setup no longer stops to ask — and a trip to GitHub mid-setup brings you back exactly where you were."
8854
+ }
8843
8855
  ]
8844
8856
  }
@@ -319,8 +319,8 @@ async function coreUpgradeInstance(inst, deps, intent) {
319
319
  // The preview probes; the move must probe identically or the two commands diverge on
320
320
  // the one field task 1004121 exists to keep identical. Skipped when !apply, where the
321
321
  // command is printed and never run — a plan should not spend a subprocess per tick.
322
- const { runAs } = resolveRunAs(inst, deps, { probe: apply });
323
- const { cmd, runDir } = upgradeInvocation(inst, to, { privileged, requestedBy, selfRoot, runAs });
322
+ const { runAs, migrateAs } = resolveRunAs(inst, deps, { probe: apply });
323
+ const { cmd, runDir } = upgradeInvocation(inst, to, { privileged, requestedBy, selfRoot, runAs, migrateAs });
324
324
 
325
325
  if (!apply) {
326
326
  // A --dry-run runner tick used to only PRINT this command. It now runs the CLI's own
@@ -571,7 +571,24 @@ function parsePreflight(out) {
571
571
  }
572
572
 
573
573
  /**
574
- * Which unix account should this instance's upgrade run as? Asks the box (task 1004128).
574
+ * Which unix accounts does this instance's upgrade need? Asks the box (task 1004128).
575
+ *
576
+ * TWO ANSWERS, NOT ONE (task 1004512). Task 1004128 asked a single question — "which
577
+ * account does this instance run as?" — and ran the WHOLE move as it. That is right for
578
+ * the database and wrong for the files, and the two have different owners on every
579
+ * project provisioned since task 1003369:
580
+ *
581
+ * • `runAs` — the account that owns the CHECKOUT, which is the app user on every
582
+ * hosting shape (provision-repo.js scaffolds /srv/<base>/<slug> as the runner, and
583
+ * grantInstanceRepoReadCmd gives the project's own account group READ, nothing more).
584
+ * Everything the move writes — the pin, node_modules, .claude/, the pin commit — and
585
+ * the `git status` it starts with, belong to this account. Running them as the
586
+ * project's account is what made every such move die on `fatal: detected dubious
587
+ * ownership in repository` and report a dirty tree that did not exist.
588
+ * • `migrateAs` — the project's OWN account, which is also its Postgres login role
589
+ * (provision-repo.js instanceDbRole); its database is owned by that role with CONNECT
590
+ * revoked from PUBLIC, so this is the only identity that can migrate it. That is where
591
+ * the isolation of task 1003369 actually lives, and it is kept.
575
592
  *
576
593
  * The fleet is heterogeneous: instances provisioned before task 1003369 run as the
577
594
  * shared app user, instances provisioned after run as their own `bongos-<slug>`. Neither
@@ -580,13 +597,15 @@ function parsePreflight(out) {
580
597
  * tenant leg has been broken.
581
598
  *
582
599
  * Falling back is NOT a privilege escalation: an instance with no account of its own is
583
- * already running as the app user (its unit says so), so this runs the upgrade as precisely
584
- * the account that owns the files and the database it is about to touch. It IS reported,
585
- * because a missing per-instance account is a provisioning gap someone should close, and
586
- * a silent fallback would hide it forever.
600
+ * already running as the app user (its unit says so), so this migrates as precisely the
601
+ * account that owns the database it is about to touch. It IS reported, because a missing
602
+ * per-instance account is a provisioning gap someone should close, and a silent fallback
603
+ * would hide it forever. On that shape the two answers coincide and the move is byte-for-
604
+ * byte what it was before this task.
587
605
  */
588
606
  function resolveRunAs(inst, deps, { probe: allowProbe = true } = {}) {
589
- if (inst.hosting_shape === 'control-plane') return { runAs: appUser(), fellBack: false };
607
+ const owner = appUser(); // owns the checkout on every shape — see above
608
+ if (inst.hosting_shape === 'control-plane') return { runAs: owner, migrateAs: owner, fellBack: false };
590
609
  // Re-checked HERE rather than left to instanceUser()'s own throw, for the reason
591
610
  // upgradeInvocation states about itself: a call site whose safety depends on a
592
611
  // helper's internals is one refactor away from a shell-interpolation path.
@@ -594,7 +613,7 @@ function resolveRunAs(inst, deps, { probe: allowProbe = true } = {}) {
594
613
  throw new Error(`resolveRunAs: refusing to probe an account for invalid slug ${JSON.stringify(inst && inst.slug)}`);
595
614
  }
596
615
  const wanted = instanceUser(inst);
597
- if (!allowProbe) return { runAs: wanted, fellBack: false };
616
+ if (!allowProbe) return { runAs: owner, migrateAs: wanted, fellBack: false };
598
617
  // THE SAME EXECUTOR THE UPGRADE ITSELF WILL USE. Every sibling here picks
599
618
  // `standalone ? (controlExec || exec) : exec`, because under PROVISION_REMOTE_HOST
600
619
  // (ADR 0130) `exec` is the REMOTE ssh executor while `controlExec` stays local. A
@@ -604,7 +623,7 @@ function resolveRunAs(inst, deps, { probe: allowProbe = true } = {}) {
604
623
  const boxExec = inst.hosting_shape === 'cloud-host' ? (deps.controlExec || deps.exec) : deps.exec;
605
624
  const r = boxExec(`id -u ${wanted}`, { allowFail: true });
606
625
  const failed = !r || r.softFailed === true || r.ok === false;
607
- if (!failed) return { runAs: wanted, fellBack: false };
626
+ if (!failed) return { runAs: owner, migrateAs: wanted, fellBack: false };
608
627
  // ONLY A DEFINITIVE "no such user" JUSTIFIES THE FALLBACK. A probe that could not
609
628
  // answer — an exec timeout, an NSS hiccup — must not silently narrow the per-instance
610
629
  // isolation of task 1003369 for this operation. So an inconclusive probe keeps the
@@ -612,9 +631,9 @@ function resolveRunAs(inst, deps, { probe: allowProbe = true } = {}) {
612
631
  // task a failure now carries the reason instead of "the dry run did not complete".
613
632
  const said = `${(r && r.stdout) || ''}\n${(r && r.stderr) || ''}`;
614
633
  if (!/no such user|unknown user/i.test(said)) {
615
- return { runAs: wanted, fellBack: false, probeInconclusive: true };
634
+ return { runAs: owner, migrateAs: wanted, fellBack: false, probeInconclusive: true };
616
635
  }
617
- return { runAs: appUser(), fellBack: true, wanted };
636
+ return { runAs: owner, migrateAs: owner, fellBack: true, wanted };
618
637
  }
619
638
 
620
639
  // ONE BUILDER FOR BOTH LEGS (task 1004121).
@@ -630,7 +649,7 @@ function resolveRunAs(inst, deps, { probe: allowProbe = true } = {}) {
630
649
  // HERE rather than left to instanceUser()'s own throw: that throw is correct today, but
631
650
  // a call site whose safety depends on a helper's internals is one refactor away from a
632
651
  // shell-injection path (and the apply leg's own guard sits at the route, not here).
633
- function upgradeInvocation(inst, to, { privileged = false, dryRun = false, requestedBy = '', selfRoot = selfInstanceRoot, runAs = null } = {}) {
652
+ function upgradeInvocation(inst, to, { privileged = false, dryRun = false, requestedBy = '', selfRoot = selfInstanceRoot, runAs = null, migrateAs = null } = {}) {
634
653
  if (!isValidSlug(inst && inst.slug)) {
635
654
  throw new Error(`upgradeInvocation: refusing to build a shell command from an invalid slug ${JSON.stringify(inst && inst.slug)}`);
636
655
  }
@@ -665,11 +684,22 @@ function upgradeInvocation(inst, to, { privileged = false, dryRun = false, reque
665
684
  // (resolveRunAs below) instead of either side assuming. Passing it in keeps this
666
685
  // builder pure; the default preserves the old derivation for callers that have not
667
686
  // probed.
668
- const account = runAs || (inst.hosting_shape === 'control-plane' ? appUser() : instanceUser(inst));
687
+ //
688
+ // AND WHICH ACCOUNT DOES WHAT (task 1004512). `account` runs the move; `dbAccount` runs
689
+ // its three database steps. They differ on exactly the projects that have an account of
690
+ // their own, because the checkout is the app user's and the database is theirs —
691
+ // resolveRunAs above argues the split. `--migrate-as` is emitted ONLY when they differ,
692
+ // so a control plane and a pre-1003369 project produce the command they always did.
693
+ const account = runAs || appUser();
694
+ const dbAccount = migrateAs || (inst.hosting_shape === 'control-plane' ? appUser() : instanceUser(inst));
669
695
  const sudoP = privileged ? `sudo -u ${account} env PGDATABASE=${dbName(inst)} ` : '';
670
696
  const cli = path.posix.join('scripts', 'gds', 'upgrade.js');
671
697
  const shared = [`${sudoP}node ${cli}`, '--registry', `--to ${to}`,
672
- instanceDir === REPO_ROOT ? '' : `--instance ${instanceDir}`]; // REPO_ROOT is where the CLI runs (runDir below)
698
+ instanceDir === REPO_ROOT ? '' : `--instance ${instanceDir}`, // REPO_ROOT is where the CLI runs (runDir below)
699
+ // SHARED, not in the tail: the dry run's own database pre-check connects as this
700
+ // account too, so a preview that omitted it would test a different identity than the
701
+ // move — the exact drift one builder for both legs exists to prevent.
702
+ dbAccount === account ? '' : `--migrate-as ${dbAccount}`];
673
703
  const tail = dryRun
674
704
  ? ['--dry-run']
675
705
  : [`--service ${inst.slug}`,
@@ -681,7 +711,7 @@ function upgradeInvocation(inst, to, { privileged = false, dryRun = false, reque
681
711
  hubPushesPin(inst) ? '--pin-no-push' : '',
682
712
  requestedBy ? `--requested-by ${requestedBy}` : '',
683
713
  requestedBy ? '--upgrade-source owner-control' : ''];
684
- return { cmd: [...shared, ...tail].filter(Boolean).join(' '), runDir: REPO_ROOT, instanceDir, runAs: account };
714
+ return { cmd: [...shared, ...tail].filter(Boolean).join(' '), runDir: REPO_ROOT, instanceDir, runAs: account, migrateAs: dbAccount };
685
715
  }
686
716
 
687
717
  // Ask the upgrade CLI what it WOULD do, changing nothing. `allowFail` because a refusal
@@ -692,8 +722,8 @@ function preflightUpgrade(inst, to, deps) {
692
722
  // A distinct local name: `deps.selfInstanceRoot || selfInstanceRoot` with a const of
693
723
  // the same name would TDZ-shadow the import and crash every uninjected call.
694
724
  const selfRoot = deps.selfInstanceRoot || selfInstanceRoot;
695
- const { runAs, fellBack, wanted } = resolveRunAs(inst, deps);
696
- const { cmd, runDir } = upgradeInvocation(inst, to, { privileged, dryRun: true, selfRoot, runAs });
725
+ const { runAs, migrateAs, fellBack, wanted } = resolveRunAs(inst, deps);
726
+ const { cmd, runDir } = upgradeInvocation(inst, to, { privileged, dryRun: true, selfRoot, runAs, migrateAs });
697
727
  const r = boxExec(cmd, { cwd: runDir, allowFail: true });
698
728
  const out = `${(r && r.stdout) || ''}\n${(r && r.stderr) || ''}`;
699
729
  const { checks, error } = parsePreflight(out);
@@ -709,10 +739,12 @@ function preflightUpgrade(inst, to, deps) {
709
739
  const raw = out.trim().split('\n').filter(Boolean).slice(-4).join('\n').slice(0, 600);
710
740
  const why = error || (raw || null);
711
741
  const note = fellBack
712
- ? `ran as ${runAs} — this instance has no ${wanted} account yet (it predates the per-instance account model)`
742
+ ? `migrated as ${migrateAs} — this instance has no ${wanted} account yet (it predates the per-instance account model)`
713
743
  : null;
714
744
  return {
715
- target: to, ok, lines: checks, ran_as: runAs, account_note: note,
745
+ // Both accounts, because since task 1004512 there are two and "which user did this run
746
+ // as" has no single answer: `ran_as` writes the files, `migrated_as` touches the database.
747
+ target: to, ok, lines: checks, ran_as: runAs, migrated_as: migrateAs, account_note: note,
716
748
  error: ok ? null : (why || 'the dry run did not complete, and said nothing this could report'),
717
749
  };
718
750
  }
@@ -1,8 +1,9 @@
1
1
  'use strict';
2
2
 
3
- // scripts/gds/upgrade-migrate.js — WHICH command `bongos upgrade` migrates an instance
4
- // with (task 1004274, found preparing the task 1004047 catch-up pass). Kept out of
5
- // upgrade.js, which sits at the 1500-line cap.
3
+ // scripts/gds/upgrade-migrate.js — the DATABASE side of a core move: which command
4
+ // migrates this instance (task 1004274, found preparing the task 1004047 catch-up pass),
5
+ // WHICH ACCOUNT that command runs as, and whether that account can reach the database at
6
+ // all (task 1004512). Kept out of upgrade.js, which sits at the 1500-line cap.
6
7
  //
7
8
  // THE GAP. upgrade.js migrated with `npm run migrate`, and that only works where the
8
9
  // instance's package.json DECLARES a migrate script. A greenfield scaffold always does
@@ -26,6 +27,7 @@
26
27
 
27
28
  const fs = require('node:fs');
28
29
  const path = require('node:path');
30
+ const { spawnSync } = require('node:child_process');
29
31
 
30
32
  // Relative to the instance root, POSIX on purpose: it is an argument to bash, and it
31
33
  // must stay byte-identical to the scaffold's script (tests/upgrade_migrate.mjs pins it
@@ -64,4 +66,99 @@ function migratePlan(instanceDir, { fsImpl = fs, env = process.env } = {}) {
64
66
  };
65
67
  }
66
68
 
67
- module.exports = { migratePlan, CORE_MIGRATE_SCRIPT };
69
+ // ---- which ACCOUNT the database work runs as (task 1004512) -----------------
70
+ //
71
+ // THE BUG THIS EXISTS FOR. A core move on a project provisioned since task 1003369 ran
72
+ // the WHOLE of upgrade.js as the project's own `bongos-<slug>` account. That account only
73
+ // READS the checkout (provision-repo.js grantInstanceRepoReadCmd adds it to the app user's
74
+ // group; /srv/<base>/<slug> is owned by the app user), so the move died at its very first
75
+ // step: `git status` refused with `fatal: detected dubious ownership in repository`, the
76
+ // clean-tree pre-flight reported "working tree not clean", and the self-heal chain
77
+ // committed a tree that was never dirty and retried into the identical failure. Every move
78
+ // on such a project ended in a blocker, and the dirt it named did not exist. Had git been
79
+ // appeased, `npm install`, the pin commit and the .claude materialization — all WRITES to a
80
+ // checkout that account cannot write — would have failed next.
81
+ //
82
+ // SO THE MOVE IS SPLIT, rather than papered over with a `safe.directory` entry:
83
+ // • everything that touches the CHECKOUT runs as the account that owns it (the app
84
+ // user) — that is also what pushUpgradePin and the wedge remedy's `git` already do;
85
+ // • everything that touches the DATABASE runs as the project's own account, which is
86
+ // where the isolation actually lives: it is the project's Postgres login role
87
+ // (provision-repo.js instanceDbRole), the database is owned by it, and CONNECT is
88
+ // revoked from PUBLIC — so the app user cannot reach it even by accident.
89
+ //
90
+ // The runner passes `--migrate-as <account>` ONLY when the two differ. A control plane and
91
+ // a project old enough to still run as the app user emit no flag and behave exactly as
92
+ // they did before, which is the half of the fleet that was already working.
93
+ //
94
+ // Narrower than a unix account name may legally be, on purpose: the value arrives on a
95
+ // command line and is refused rather than quoted if it falls outside. ONE copy for the
96
+ // upgrade CLI — wedge-remedy.js imports this rather than re-declaring it, because two
97
+ // copies of a security predicate drift one edit at a time. provision-repo.js keeps its own
98
+ // UNIX_ACCOUNT_RE: that file is the control-plane provisioner and nothing it does should
99
+ // depend on the upgrade CLI, so the duplication there buys a layer boundary.
100
+ const ACCOUNT_RE = /^[a-z_][a-z0-9_-]{0,31}$/;
101
+
102
+ /**
103
+ * `cmd args` as `account`, or unchanged when there is no account to drop to. PURE.
104
+ *
105
+ * argv form, never a shell string, so nothing here is quoted or interpolated. `sudo -n`
106
+ * because a sudoers rule that would PROMPT must fail fast: an upgrade runs unattended and
107
+ * a password prompt nobody can answer would hang the deploy rather than fail it.
108
+ *
109
+ * sudo resets the environment, so the variables migrate genuinely needs are re-stated as
110
+ * `env K=V` arguments: PGDATABASE (migrate.sh defaults to production when it is unset, so
111
+ * it is always passed and never inherited) and, for the core-script fallback, INIT_CWD
112
+ * (which is how migrate.sh finds the instance — task 2180).
113
+ */
114
+ function asAccount(account, cmd, args, extraEnv = {}, env = process.env) {
115
+ if (!account) return { cmd, args };
116
+ if (!ACCOUNT_RE.test(String(account))) throw new Error(`asAccount: ${JSON.stringify(account)} is not a unix account name`);
117
+ const carry = { ...(env.PGDATABASE ? { PGDATABASE: env.PGDATABASE } : {}), ...extraEnv };
118
+ const pairs = Object.entries(carry).map(([k, v]) => `${k}=${v}`);
119
+ return { cmd: 'sudo', args: ['-n', '-u', account, 'env', ...pairs, cmd, ...args] };
120
+ }
121
+
122
+ // migrate.sh reaches Postgres over the local socket as the CURRENT OS USER (peer
123
+ // auth) — so running an upgrade under `sudo` connects as `root`, which usually has
124
+ // no Postgres role. The failure surfaces mid-run as a raw
125
+ // `FATAL: role "root" does not exist`, AFTER the pin has already moved, which then
126
+ // triggers the whole auto-rollback dance for what is really an operator mistake.
127
+ // This names it up front instead (task 1002712). `account` (task 1004512) makes the
128
+ // probe connect as whoever migrate will, so the pre-flight and the migration cannot
129
+ // disagree about which role is being tested.
130
+ function preflightDbIdentity({ instanceDir, db, account = null }, run = spawnSync) {
131
+ const q = asAccount(account, 'psql', ['-tAc', 'select 1'], db ? { PGDATABASE: db } : {});
132
+ const r = run(q.cmd, q.args, {
133
+ cwd: instanceDir, encoding: 'utf8',
134
+ env: { ...process.env, ...(db ? { PGDATABASE: db } : {}) },
135
+ });
136
+ if (!r || r.error) return { ok: true, skipped: 'psql not on PATH — leaving it to migrate' };
137
+ if (r.status === 0) return { ok: true };
138
+
139
+ const out = `${r.stderr || ''}${r.stdout || ''}`;
140
+ // The escalation itself not working is this pre-flight's business, not migrate's (task
141
+ // 1004512). Soft-skipping it would let the pin move and then fail at migrate for the one
142
+ // reason that was knowable up front, costing a full rollback — the exact shape the
143
+ // role-does-not-exist check below exists to avoid. Only ever reachable with an account.
144
+ if (account && /^sudo:/m.test(out)) {
145
+ return { ok: false, reason: `the database steps cannot run as "${account}" — sudo refused.\n`
146
+ + ` ${out.trim().split('\n').find((l) => l.startsWith('sudo:')) || ''}\n`
147
+ + ` The move writes files as the account that owns the checkout and hands only the\n`
148
+ + ` database to the project's own account (--migrate-as), so the first needs\n`
149
+ + ` passwordless sudo to the second. Checked BEFORE the pin moves.` };
150
+ }
151
+ const m = out.match(/role "([^"]+)" does not exist/i);
152
+ if (!m) return { ok: true, skipped: 'psql failed for another reason — leaving it to migrate' };
153
+ return {
154
+ ok: false,
155
+ role: m[1],
156
+ reason: `Postgres has no role "${m[1]}" — migrate would fail after the pin moved.\n`
157
+ + ` migrate.sh connects over the local socket as the CURRENT OS USER. Running the\n`
158
+ + ` upgrade under sudo connects as root, which is almost never a Postgres role.\n`
159
+ + ` Re-run as the user that owns the instance (e.g. sudo -u <owner> …), passing the\n`
160
+ + ` npm token through if the core comes from the registry.`,
161
+ };
162
+ }
163
+
164
+ module.exports = { migratePlan, preflightDbIdentity, asAccount, ACCOUNT_RE, CORE_MIGRATE_SCRIPT };
@@ -45,6 +45,11 @@ const REASON_CLASS = Object.freeze({
45
45
  schema_pending: 'known_wedge', // the database is behind the code it runs NOW (task 1004448)
46
46
  // upgrade.js refusals, before the pin moves
47
47
  dirty_tree: 'known_wedge',
48
+ // git REFUSED to read the checkout, so nothing is known about the tree (task 1004512).
49
+ // Deliberately NOT known_wedge: the dirty_tree remedy commits whatever git reports, and
50
+ // here git reported nothing — the live shape is `detected dubious ownership`, a tree that
51
+ // was never dirty. The fix is which ACCOUNT the move runs as, which no remedy can apply.
52
+ git_unreadable: 'unknown',
48
53
  downgrade: 'needs_decision',
49
54
  artist_gate: 'needs_decision',
50
55
  module_incompatible: 'needs_decision',
@@ -73,6 +78,7 @@ const UPGRADE_REASONS = [
73
78
  [/no space left on device|ENOSPC/i, 'disk_full'],
74
79
  [/^refusing to DOWNGRADE/, 'downgrade'],
75
80
  [/^already on /, 'already_on'],
81
+ [/^git could not read the working tree/, 'git_unreadable'],
76
82
  [/^working tree not clean/, 'dirty_tree'],
77
83
  [/^module pre-check failed/, 'module_incompatible'],
78
84
  [/^cannot determine which database/, 'db_unresolvable'],