@bongos/core 1.21.13 → 1.21.15

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.
@@ -207,10 +207,22 @@ async function pickTask(goals, deps = {}) {
207
207
  // AFTER ordering, so a dependent of one is still halted by its own unmet
208
208
  // dependency rather than promoted by the task's absence.
209
209
  const exclude = deps.exclude || null;
210
- const open = exclude && exclude.size ? order.filter((t) => !exclude.has(String(t.id))) : order;
210
+ const unexcluded = exclude && exclude.size ? order.filter((t) => !exclude.has(String(t.id))) : order;
211
+ // Work this machine structurally cannot do — SSH, root, systemd on a live
212
+ // host (task 1004537) — is skipped BEFORE the claim, and listed with its
213
+ // reason. It is decided here and not in sequence.js's matrix because the
214
+ // matrix also serves /builder-sequence, where the builder may well hold the
215
+ // host access an unattended runner never has.
216
+ const hostSkipped = [];
217
+ const open = unexcluded.filter((t) => {
218
+ const host = fence.needsHostAccess(t);
219
+ if (!host.needs) return true;
220
+ hostSkipped.push({ id: t.id, title: t.title, reasons: [{ code: 'needs_host_access', detail: `${fence.REFUSALS.needs_host_access} (${host.signals.join('; ')})` }] });
221
+ return false;
222
+ });
211
223
  const { next, skipped } = seq.pickNext(open, { rank, claimableIds });
212
- if (next) return { task: next, goalId, skipped };
213
- for (const sk of skipped) allSkipped.push({ ...sk, goal_id: goalId });
224
+ if (next) return { task: next, goalId, skipped: [...hostSkipped, ...skipped] };
225
+ for (const sk of [...hostSkipped, ...skipped]) allSkipped.push({ ...sk, goal_id: goalId });
214
226
  }
215
227
  return { none: true, skipped: allSkipped };
216
228
  }
@@ -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'],
@@ -31,6 +31,9 @@ const { spawnSync } = require('node:child_process');
31
31
  const { arg, hasFlag } = require('./cli-lib');
32
32
  const { parseSemver, compareSemver, isStable, listAvailableVersions } = require('./update-channel');
33
33
  const { checkArtistGate } = require('./upgrade-artist-gate');
34
+ // The database side of a move: which command migrates, as which account, and whether that
35
+ // account can reach the database (tasks 1004274, 1002712, 1004512).
36
+ const { preflightDbIdentity, asAccount, ACCOUNT_RE } = require('./upgrade-migrate');
34
37
 
35
38
  const CORE_PKG = '@bongos/core';
36
39
  const ARTIFACT = 'bongos-core';
@@ -130,7 +133,14 @@ function restorePin(instanceDir, depString, fsImpl = fs) {
130
133
  // `ignore` is waived (it comes from — and is argued for in — claude-materialize.js materializeWillRewrite, task 1004122); every OTHER dirty path still halts the bump. -uall (task 1004188): a bare status collapses a NEW untracked dir to one '?? dir/' entry the file-path waiver never matches.
131
134
  function gitTreeClean(instanceDir, run = spawnSync, ignore = []) {
132
135
  const r = run('git', ['status', '--porcelain', '-uall'], { cwd: instanceDir, encoding: 'utf8' });
133
- if (!r || r.error || r.status !== 0) return { ok: false, reason: 'git status failed (not a git repo?)' };
136
+ // git's OWN words, not a guess at them (task 1004512). `not a git repo?` was the only
137
+ // explanation ever offered, and the failure that actually happens in the field is
138
+ // `detected dubious ownership` — a repo git can see perfectly well and refuses to read
139
+ // for the account asking. Swallowing the reason is what let that read as dirt.
140
+ if (!r || r.error || r.status !== 0) {
141
+ const said = String((r && (r.stderr || r.stdout)) || (r && r.error && r.error.message) || '').trim().split('\n').slice(0, 4).join('\n');
142
+ return { ok: false, reason: said || 'git status failed (not a git repo?)' };
143
+ }
134
144
  const skip = new Set(ignore), all = (r.stdout || '').split('\n').filter((l) => l.trim());
135
145
  const kept = all.filter((l) => !skip.has(l.slice(3).trim()));
136
146
  return { ok: !kept.length, dirty: kept.join('\n'), ignored: all.length - kept.length };
@@ -509,37 +519,12 @@ function npmInstall(instanceDir, run = spawnSync) {
509
519
  return !r || r.error ? { ok: false, reason: r && r.error ? r.error.message : 'spawn failed' } : { ok: r.status === 0, code: r.status };
510
520
  }
511
521
 
512
- // migrate.sh reaches Postgres over the local socket as the CURRENT OS USER (peer
513
- // auth) — so running an upgrade under `sudo` connects as `root`, which usually has
514
- // no Postgres role. The failure surfaces mid-run as a raw
515
- // `FATAL: role "root" does not exist`, AFTER the pin has already moved, which then
516
- // triggers the whole auto-rollback dance for what is really an operator mistake.
517
- // This names it up front instead (task 1002712).
518
- function preflightDbIdentity({ instanceDir, db }, run = spawnSync) {
519
- const r = run('psql', ['-tAc', 'select 1'], {
520
- cwd: instanceDir, encoding: 'utf8',
521
- env: { ...process.env, ...(db ? { PGDATABASE: db } : {}) },
522
- });
523
- if (!r || r.error) return { ok: true, skipped: 'psql not on PATH — leaving it to migrate' };
524
- if (r.status === 0) return { ok: true };
525
-
526
- const out = `${r.stderr || ''}${r.stdout || ''}`;
527
- const m = out.match(/role "([^"]+)" does not exist/i);
528
- if (!m) return { ok: true, skipped: 'psql failed for another reason — leaving it to migrate' };
529
- return {
530
- ok: false,
531
- role: m[1],
532
- reason: `Postgres has no role "${m[1]}" — migrate would fail after the pin moved.\n`
533
- + ` migrate.sh connects over the local socket as the CURRENT OS USER. Running the\n`
534
- + ` upgrade under sudo connects as root, which is almost never a Postgres role.\n`
535
- + ` Re-run as the user that owns the instance (e.g. sudo -u <owner> …), passing the\n`
536
- + ` npm token through if the core comes from the registry.`,
537
- };
538
- }
539
-
540
- function runMigrate(instanceDir, run = spawnSync) {
522
+ function runMigrate(instanceDir, run = spawnSync, account = null) {
541
523
  const { cmd, args, env } = require('./upgrade-migrate').migratePlan(instanceDir); // its own migrate script, else the core's migrate.sh (task 1004274)
542
- const r = run(cmd, args, { cwd: instanceDir, stdio: 'inherit', ...(env ? { env } : {}) });
524
+ // `account`: the project's own unix+Postgres identity when the move itself is running as
525
+ // the account that owns the CHECKOUT instead (--migrate-as; upgrade-migrate.js, task 1004512).
526
+ const q = asAccount(account, cmd, args, env ? { INIT_CWD: instanceDir } : {});
527
+ const r = run(q.cmd, q.args, { cwd: instanceDir, stdio: 'inherit', ...(env ? { env } : {}) });
543
528
  return !r || r.error ? { ok: false, reason: r && r.error ? r.error.message : 'spawn failed' } : { ok: r.status === 0, code: r.status };
544
529
  }
545
530
 
@@ -787,7 +772,7 @@ function ledgerInsert({ fromVersion, toVersion, sourceCommit, note, requestedByB
787
772
  // Skips silently only when NEITHER is present (a local rehearsal with no DB pointed at) — the bump's
788
773
  // verification never depends on the ledger. An attempted-but-failed insert returns skipped:false so
789
774
  // the caller can surface it (the manual-INSERT footgun from 1976), never lose it.
790
- async function recordLedger({ fromVersion, toVersion, sourceCommit, note, requestedByBuilderId, source }, deps = {}) {
775
+ async function recordLedger({ fromVersion, toVersion, sourceCommit, note, requestedByBuilderId, source, account = null }, deps = {}) {
791
776
  const url = deps.databaseUrl !== undefined ? deps.databaseUrl : process.env.DATABASE_URL;
792
777
  const database = deps.pgDatabase !== undefined ? deps.pgDatabase : process.env.PGDATABASE;
793
778
  if (!url && !database) return { ok: false, skipped: true, reason: 'no DATABASE_URL or PGDATABASE' };
@@ -828,12 +813,11 @@ async function recordLedger({ fromVersion, toVersion, sourceCommit, note, reques
828
813
  const sql = `${stmt.sql.slice(0, stmt.sql.indexOf(' VALUES '))}\n VALUES (${stmt.cols.map(psqlValue).join(', ')});\n`;
829
814
  const sets = [];
830
815
  for (let i = 0; i < stmt.cols.length; i++) sets.push('--set', `${stmt.cols[i]}=${stmt.vals[i] == null ? '' : stmt.vals[i]}`);
831
- const r = run('psql', [
832
- '-d', database,
833
- '-v', 'ON_ERROR_STOP=1',
834
- '-q', '--no-psqlrc',
835
- ...sets,
836
- ], { encoding: 'utf8', input: sql });
816
+ // Through the project's own account when the move is not running as it (task 1004512):
817
+ // this psql peer-auths exactly as migrate does, so it must be the same identity or the
818
+ // ledger row silently stops being written on every own-account project.
819
+ const q = asAccount(account, 'psql', ['-d', database, '-v', 'ON_ERROR_STOP=1', '-q', '--no-psqlrc', ...sets]);
820
+ const r = run(q.cmd, q.args, { encoding: 'utf8', input: sql });
837
821
  if (!r || r.error) {
838
822
  const enoent = !!(r && r.error && r.error.code === 'ENOENT');
839
823
  return { ok: false, skipped: enoent, reason: enoent ? 'psql not found' : (r && r.error ? r.error.message : 'psql spawn failed') };
@@ -1065,7 +1049,7 @@ async function rollback({ instanceDir, snapshot, fromVersion, toVersion, opts },
1065
1049
  // 5. record the reversal in the ledger via the always-present `note` column (no schema
1066
1050
  // dependency — so it records even when a FAILED migrate means a newer column wouldn't exist).
1067
1051
  const note = `rolled_back: ${reason} (attempted ${fromVersion || '(none)'} → ${toVersion}, restored ${snapshot.prevVersion || 'previous'})`;
1068
- const ledger = await recordLedger({ fromVersion, toVersion, sourceCommit: opts.sourceCommit, note, requestedByBuilderId: opts.requestedBy, source: opts.upgradeSource }, deps);
1052
+ const ledger = await recordLedger({ fromVersion, toVersion, sourceCommit: opts.sourceCommit, note, requestedByBuilderId: opts.requestedBy, source: opts.upgradeSource, account: opts.migrateAs || null }, deps);
1069
1053
  if (ledger.ok) log(' ✓ recorded rollback in core_upgrades ledger');
1070
1054
  else if (ledger.skipped) log(` • rollback ledger skipped (${ledger.reason})`);
1071
1055
  else err(` ! rollback ledger insert failed (${ledger.reason}) — record it manually`);
@@ -1094,6 +1078,9 @@ async function runUpgrade(opts, deps = {}) {
1094
1078
  // failure AFTER that point (install / migrate / health) can revert. null until then = nothing to undo.
1095
1079
  let snapshot = null;
1096
1080
  if (!targetVersion) return { ok: false, error: 'missing --to <version> (the target core version)' };
1081
+ // Checked once, here, rather than at each of the three places it is spent: an account
1082
+ // name that reaches `sudo` is refused outright, never quoted into shape (task 1004512).
1083
+ if (opts.migrateAs && !ACCOUNT_RE.test(opts.migrateAs)) return { ok: false, error: `--migrate-as ${JSON.stringify(opts.migrateAs)} is not a unix account name` };
1097
1084
 
1098
1085
  const pinnedVersion = readPinnedCoreVersion(instanceDir, fsImpl);
1099
1086
  const fromVersion = readInstalledCoreVersion(instanceDir, fsImpl) || pinnedVersion;
@@ -1118,7 +1105,13 @@ async function runUpgrade(opts, deps = {}) {
1118
1105
  // Step 5's own output is not dirt (task 1004122) — but only when step 5 is going to run.
1119
1106
  const preview = opts.skipMaterialize ? () => [] : (deps.willRewrite || require('./claude-materialize').materializeWillRewrite);
1120
1107
  const tree = gitTreeClean(instanceDir, run, preview({ instanceDir, materialize }, err));
1121
- if (!tree.ok) return { ok: false, error: `working tree not clean — commit/stash first, or pass --force.\n${tree.dirty || tree.reason || ''}` };
1108
+ // TWO DIFFERENT FAILURES, TWO DIFFERENT SENTENCES (task 1004512). git ANSWERING with a
1109
+ // list of dirty paths is a dirty tree, which the self-heal chain knows how to fix. git
1110
+ // REFUSING to answer is not: committing anything would be committing a guess. They used
1111
+ // to share one message, so a `dubious ownership` refusal was classified dirty_tree, the
1112
+ // runner committed a tree that was never dirty, and the retry failed identically.
1113
+ if (!tree.ok && tree.reason) return { ok: false, error: `git could not read the working tree in ${instanceDir} — the clean-tree pre-flight never ran, so this is NOT a report of uncommitted work.\n${tree.reason}` };
1114
+ if (!tree.ok) return { ok: false, error: `working tree not clean — commit/stash first, or pass --force.\n${tree.dirty || ''}` };
1122
1115
  if (tree.ignored) log(` • pre-flight: ignored ${tree.ignored} dirty path(s) this upgrade rewrites from the core (.claude/ materialization)${opts.commitPin ? ' — they are committed with the pin' : ' — pass --commit-pin to commit them'}.`);
1123
1116
  }
1124
1117
  const mods = preflightModules({ instanceDir, targetVersion }, deps);
@@ -1139,10 +1132,10 @@ async function runUpgrade(opts, deps = {}) {
1139
1132
  + ' Checked BEFORE the pin moves: migrate runs after the install, so a name that\n'
1140
1133
  + ' cannot be resolved there costs a full rollback to discover (task 1002755).' };
1141
1134
  }
1142
- const dbId = (deps.preflightDbIdentity || preflightDbIdentity)({ instanceDir, db }, run);
1135
+ const dbId = (deps.preflightDbIdentity || preflightDbIdentity)({ instanceDir, db, account: opts.migrateAs || null }, run);
1143
1136
  if (!dbId.ok) return { ok: false, error: dbId.reason };
1144
1137
  if (dbId.skipped) log(` • db identity pre-check${db ? ` (${db})` : ''}: ${dbId.skipped}`);
1145
- else log(` ✓ pre-flight: database${db ? ` "${db}"` : ''} reachable as the current user`);
1138
+ else log(` ✓ pre-flight: database${db ? ` "${db}"` : ''} reachable as ${opts.migrateAs || 'the current user'}`);
1146
1139
  const { note: migrateNote } = require('./upgrade-migrate').migratePlan(instanceDir); if (migrateNote) log(` ✓ pre-flight: ${migrateNote}`);
1147
1140
  }
1148
1141
 
@@ -1231,7 +1224,7 @@ async function runUpgrade(opts, deps = {}) {
1231
1224
 
1232
1225
  // 4. migrate (additive core_* migrations).
1233
1226
  if (!opts.skipMigrate) {
1234
- const mig = runMigrate(instanceDir, run);
1227
+ const mig = runMigrate(instanceDir, run, opts.migrateAs || null);
1235
1228
  if (!mig.ok) {
1236
1229
  const error = `migrate failed (${mig.reason || 'exit ' + mig.code})`;
1237
1230
  if (rollbackOnFailure && snapshot) {
@@ -1366,7 +1359,7 @@ async function runUpgrade(opts, deps = {}) {
1366
1359
  return { ok: false, error: `${why} — rolled back to ${snapshot.prevVersion || 'the previous core'}`, fromVersion, toVersion: targetVersion, versionOk, healthOk, servedOk, servedVersion, rolledBack: true, rollback: rb };
1367
1360
  }
1368
1361
 
1369
- const ledger = await recordLedger({ fromVersion, toVersion: targetVersion, sourceCommit: opts.sourceCommit, note: isDowngrade ? `downgrade (--allow-downgrade)${opts.note ? `: ${opts.note}` : ''}` : opts.note, requestedByBuilderId: opts.requestedBy, source: opts.upgradeSource }, deps);
1362
+ const ledger = await recordLedger({ fromVersion, toVersion: targetVersion, sourceCommit: opts.sourceCommit, note: isDowngrade ? `downgrade (--allow-downgrade)${opts.note ? `: ${opts.note}` : ''}` : opts.note, requestedByBuilderId: opts.requestedBy, source: opts.upgradeSource, account: opts.migrateAs || null }, deps);
1370
1363
  if (ledger.ok) log(' ✓ recorded in core_upgrades ledger');
1371
1364
  else if (ledger.skipped) log(` • ledger skipped (${ledger.reason})`);
1372
1365
  else err(` ! ledger insert failed (${ledger.reason}) — record it manually`);
@@ -1422,6 +1415,7 @@ function parseArgs(argv) {
1422
1415
  requestedBy: arg('--requested-by', argv),
1423
1416
  upgradeSource: arg('--upgrade-source', argv),
1424
1417
  pinPath: arg('--pin-path', argv),
1418
+ migrateAs: arg('--migrate-as', argv), // task 1004512: the project's own unix+Postgres identity, when the move itself runs as the account that owns the checkout
1425
1419
  registry: hasFlag('--registry', argv), // ADR 0134: pull @bongos/core@<--to> from the npm registry instead of vendoring
1426
1420
  commitPin: hasFlag('--commit-pin', argv), // task 1002712: commit + push package.json/-lock so a pull-deploy reset cannot revert the bump
1427
1421
  pinNoPush: hasFlag('--pin-no-push', argv), // task 1003521 co-tenant mode: commit the pin but leave the customer's own remote alone
@@ -1458,6 +1452,7 @@ async function main(argv = process.argv.slice(2)) {
1458
1452
  ' --version-url <url> read the RUNNING process\'s coreVersion back after restart and require it to equal --to (default: /version on the --health-url origin). A health check alone cannot tell a new core from an old one that never went down.',
1459
1453
  ' --no-health-check explicitly skip the post-restart health confirmation (default: the check is ON — a bump that restarts the service must confirm it serves); also skips the served-version read-back',
1460
1454
  ' --skip-migrate do not migrate (the default runs the instance\'s `migrate` script, else the core\'s scripts/migrate.sh)',
1455
+ ' --migrate-as <user> run the database steps (identity pre-check, migrate, ledger) as this unix account via `sudo -n -u`. For a project whose checkout is owned by the operator account but whose database is owned by its OWN account: the move needs the first to write files, the migration needs the second to connect. Omit it and everything runs as the current user, as before',
1461
1456
  ' --skip-materialize do not refresh .claude/',
1462
1457
  ' --skip-regen-docs do not regenerate the OpenAPI spec + typed client from the new core',
1463
1458
  ' --skip-pin-verify do not verify the integrity pin (tree_sha256 + tarball.sha256) after install',
@@ -34,7 +34,7 @@
34
34
 
35
35
  const { CONFIG } = require('./provision-config.js');
36
36
  const { dbName, selfInstanceRoot, standaloneRoot } = require('./provision-repo.js');
37
- const { migratePlan, CORE_MIGRATE_SCRIPT } = require('./upgrade-migrate.js');
37
+ const { migratePlan, CORE_MIGRATE_SCRIPT, ACCOUNT_RE } = require('./upgrade-migrate.js');
38
38
  const { isValidSlug } = require('../../modules/provisioning/provisioning.js');
39
39
  const outcome = require('./upgrade-outcome.js');
40
40
  const { DEFAULT_RETRY_DELAY_MS } = require('./intent-retry.js');
@@ -48,7 +48,10 @@ const REMEDY_EVENT = 'core-upgrade-remedy';
48
48
  // purpose: anything outside it is refused, never quoted.
49
49
  const SHELL_SAFE_RE = /^[A-Za-z0-9._/-]{1,200}$/;
50
50
  const DB_NAME_RE = /^[A-Za-z0-9_-]{1,63}$/;
51
- const ACCOUNT_RE = /^[a-z_][a-z0-9_-]{0,31}$/;
51
+ // ACCOUNT_RE is imported, not re-declared: this file and upgrade-migrate.js check the same
52
+ // value for the same reason (it reaches `sudo -u`), and two copies of a security predicate
53
+ // drift one edit at a time. provision-repo.js keeps its own UNIX_ACCOUNT_RE deliberately —
54
+ // importing from here would point the provisioner at the upgrade CLI, the wrong direction.
52
55
 
53
56
  /**
54
57
  * The fix for one known snag, as a command to run on the box. PURE.
@@ -105,9 +108,12 @@ function planRemedy(inst, reason, o = {}) {
105
108
  return { reason, cwd: dir, env: { PGDATABASE: db, ...(initCwd ? { INIT_CWD: dir } : {}) }, cmd: run, describe: `migrated ${db} to match the code it is running` };
106
109
  }
107
110
 
108
- // The account the project runs as, probed the way the move itself probes (task 1004128).
111
+ // The account the project's DATABASE belongs to, probed the way the move itself probes
112
+ // (task 1004128). `migrateAs`, not `runAs`: since task 1004512 those are two different
113
+ // answers, and this remedy is a migration — it needs the Postgres login role, which the
114
+ // account that owns the checkout is not.
109
115
  function runAsFor(inst, deps) {
110
- try { return require('./provision-core-upgrade.js').resolveRunAs(inst, deps).runAs; } catch { return null; }
116
+ try { return require('./provision-core-upgrade.js').resolveRunAs(inst, deps).migrateAs; } catch { return null; }
111
117
  }
112
118
 
113
119
  /**
package/src/module-api.js CHANGED
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
75
75
  // MAJOR (see allowBoxScope below): passes the request through untouched.
76
76
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
77
77
 
78
- const CORE_VERSION = '1.21.13'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.21.15'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
79
79
 
80
80
  // A namespaced logger so a module's log lines are attributable + consistent.
81
81
  // Usage: const log = api.logger('discord'); log.info('mounted');