@bongos/core 1.21.14 → 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.
@@ -507,12 +507,20 @@ await t('the control plane upgrades as the operator account, never as root', asy
507
507
  assert.doesNotMatch(cmd, /sudo node /, 'a bare `sudo node` is the bug — it lands on root');
508
508
  });
509
509
 
510
- await t('a tenant upgrades as its OWN account, which is also its Postgres login role', async () => {
510
+ await t('a tenant MOVES as the account that owns its checkout and MIGRATES as its own (task 1004512)', async () => {
511
+ // The move writes: the pin, node_modules, .claude/, the pin commit — and before any of
512
+ // that, `git status`. Those belong to the app user, which is who scaffolded the
513
+ // checkout; the instance account only has group READ on it, so running the move as that
514
+ // account made git refuse with "detected dubious ownership" and the clean-tree pre-flight
515
+ // report dirt that did not exist. The DATABASE is the opposite: it is owned by the
516
+ // instance's own Postgres login role with CONNECT revoked from PUBLIC, so only that
517
+ // account can migrate it. Hence one move, two accounts.
511
518
  const d = { ...makeDeps({ served: '1.19.762' }), privileged: true };
512
519
  await R.coreUpgradeInstance(inst({ hosting_shape: 'cloud-host', slug: 'test4', port: 3006 }), d, intent());
513
520
  const cmd = upgradeCall(d);
514
- assert.match(cmd, /sudo -u bongos-test4 /, 'the instance account, not the operator and not root');
515
- assert.doesNotMatch(cmd, /sudo -u appsvc /, 'one tenant must never be upgraded under another account');
521
+ assert.match(cmd, /sudo -u appsvc /, 'the move runs as the account that owns the checkout');
522
+ assert.match(cmd, /--migrate-as bongos-test4\b/, 'and hands the database steps to the instance account');
523
+ assert.doesNotMatch(cmd, /sudo -u bongos-test4 /, 'the instance account must not be asked to write a checkout it can only read');
516
524
  });
517
525
 
518
526
  await t('the database is NAMED, not discovered — an adopted repo has no core at its root to ask', async () => {
@@ -568,7 +576,9 @@ await t('ONE builder serves both legs — the preview and the move cannot drift
568
576
  const box = { id: 7, slug: 'test4', port: 3006, hosting_shape: 'cloud-host', db_name: 'test4' };
569
577
  const move = R.upgradeInvocation(box, '1.19.921', { privileged: true, requestedBy: '42' });
570
578
  const peek = R.upgradeInvocation(box, '1.19.921', { privileged: true, dryRun: true });
571
- for (const shared of ['sudo -u bongos-test4 ', 'env PGDATABASE=test4 ', '--to 1.19.921', '--registry']) {
579
+ // --migrate-as is SHARED, not tail: the dry run's own database pre-check connects as that
580
+ // account too, so a preview that omitted it would test a different identity than the move.
581
+ for (const shared of ['sudo -u appsvc ', 'env PGDATABASE=test4 ', '--to 1.19.921', '--registry', '--migrate-as bongos-test4']) {
572
582
  assert.ok(move.cmd.includes(shared), `move carries ${shared}`);
573
583
  assert.ok(peek.cmd.includes(shared), `preview carries the same ${shared}`);
574
584
  }
@@ -746,12 +756,15 @@ function acctDeps({ accountExists = true, dryRunStdout = '', dryRunStderr = '',
746
756
  return d;
747
757
  }
748
758
 
749
- await t('a tenant WITH its own account runs as that account — the security model is kept', () => {
759
+ await t('a tenant WITH its own account MIGRATES as that account — the security model is kept', () => {
750
760
  // task 1003369: a distinct uid per instance is what stops a co-tenant reading a
751
- // sibling's /proc/<pid>/environ. Falling back everywhere would quietly undo it.
761
+ // sibling's /proc/<pid>/environ. Falling back everywhere would quietly undo it. Task
762
+ // 1004512 moved the file half of the work to the checkout's owner and left this half
763
+ // exactly where it was: the database is where the per-instance identity does its job.
752
764
  const d = acctDeps({ accountExists: true });
753
765
  const r = R.resolveRunAs(inst({ hosting_shape: 'cloud-host', slug: 'test4' }), d);
754
- assert.deepEqual({ runAs: r.runAs, fellBack: r.fellBack }, { runAs: 'bongos-test4', fellBack: false });
766
+ assert.deepEqual({ runAs: r.runAs, migrateAs: r.migrateAs, fellBack: r.fellBack },
767
+ { runAs: 'appsvc', migrateAs: 'bongos-test4', fellBack: false });
755
768
  });
756
769
 
757
770
  await t('a tenant WITHOUT one falls back to the app user, and says so', () => {
@@ -762,6 +775,7 @@ await t('a tenant WITHOUT one falls back to the app user, and says so', () => {
762
775
  const d = acctDeps({ accountExists: false });
763
776
  const r = R.resolveRunAs(inst({ hosting_shape: 'cloud-host', slug: 'test4' }), d);
764
777
  assert.equal(r.runAs, 'appsvc');
778
+ assert.equal(r.migrateAs, 'appsvc', 'both halves land on the app user — the shape that was already working');
765
779
  assert.equal(r.fellBack, true);
766
780
  assert.equal(r.wanted, 'bongos-test4', 'and names the account that is missing');
767
781
  });
@@ -769,7 +783,8 @@ await t('a tenant WITHOUT one falls back to the app user, and says so', () => {
769
783
  await t('the control plane is never probed — it has no instance account by design', () => {
770
784
  const d = acctDeps({ accountExists: false });
771
785
  const r = R.resolveRunAs(inst({ hosting_shape: 'control-plane', slug: 'cloudbongos' }), d);
772
- assert.deepEqual({ runAs: r.runAs, fellBack: r.fellBack }, { runAs: 'appsvc', fellBack: false });
786
+ assert.deepEqual({ runAs: r.runAs, migrateAs: r.migrateAs, fellBack: r.fellBack },
787
+ { runAs: 'appsvc', migrateAs: 'appsvc', fellBack: false });
773
788
  assert.equal(d.ranWith.filter((c) => /^id -u /.test(c.cmd)).length, 0, 'no probe was spent');
774
789
  });
775
790
 
@@ -796,7 +811,7 @@ await t('an INCONCLUSIVE probe keeps the isolated account rather than quietly wi
796
811
  const d = makeDeps({ served: '1.19.762' });
797
812
  d.exec = () => ({ ok: true, stdout: '', stderr: 'id: cannot connect to name service\n', softFailed: true });
798
813
  const r = R.resolveRunAs(inst({ hosting_shape: 'cloud-host', slug: 'test4' }), d);
799
- assert.equal(r.runAs, 'bongos-test4', 'the intended account is kept');
814
+ assert.equal(r.migrateAs, 'bongos-test4', 'the intended account is kept');
800
815
  assert.equal(r.fellBack, false);
801
816
  assert.equal(r.probeInconclusive, true, 'and the caller can tell it was not a clean answer');
802
817
  });
@@ -813,7 +828,7 @@ await t('the probe uses the SAME executor the upgrade will use', () => {
813
828
  const r = R.resolveRunAs(inst({ hosting_shape: 'cloud-host', slug: 'test4' }), d);
814
829
  assert.deepEqual(asked.remote, [], 'the remote executor was not asked');
815
830
  assert.equal(asked.control.length, 1, 'the control-plane executor was');
816
- assert.equal(r.runAs, 'bongos-test4', 'and its answer is the one that counts');
831
+ assert.equal(r.migrateAs, 'bongos-test4', 'and its answer is the one that counts');
817
832
  });
818
833
 
819
834
  await t('a planning tick probes ONCE, not twice', async () => {
@@ -860,9 +875,18 @@ await t('a fallback is recorded on the verdict the owner reads', async () => {
860
875
  const d = acctDeps({ accountExists: false, dryRunStdout: ' ✓ pre-flight: ok\n' });
861
876
  const v = await R.preflightUpgrade(inst({ hosting_shape: 'cloud-host', slug: 'test4', port: 3006 }), '1.19.762', d);
862
877
  assert.equal(v.ran_as, 'appsvc');
878
+ assert.equal(v.migrated_as, 'appsvc', 'the verdict names BOTH accounts — since task 1004512 there are two');
863
879
  assert.match(v.account_note, /no bongos-test4 account yet/);
864
880
  });
865
881
 
882
+ await t('a project WITH its own account has its two accounts named separately (task 1004512)', async () => {
883
+ const d = acctDeps({ accountExists: true, dryRunStdout: ' ✓ pre-flight: ok\n' });
884
+ const v = await R.preflightUpgrade(inst({ hosting_shape: 'cloud-host', slug: 'test4', port: 3006 }), '1.19.762', d);
885
+ assert.equal(v.ran_as, 'appsvc', 'the files are written by the account that owns the checkout');
886
+ assert.equal(v.migrated_as, 'bongos-test4', 'the database is touched by the account that owns it');
887
+ assert.equal(v.account_note, null, 'and nothing is missing, so there is nothing to report');
888
+ });
889
+
866
890
  await t('and a silent failure STILL refuses rather than inventing a pass', async () => {
867
891
  const d = acctDeps({ softFail: true, dryRunStdout: '', dryRunStderr: '' });
868
892
  const v = await R.preflightUpgrade(inst({ hosting_shape: 'cloud-host', slug: 'test4', port: 3006 }), '1.19.762', d);
@@ -32,7 +32,7 @@ const tenant = { slug: 'charter', db_name: 'charter_db', hosting_shape: 'co-tena
32
32
 
33
33
  test('bongos upgrade migrates before it restarts, and only --skip-migrate opts out', () => {
34
34
  const src = fs.readFileSync(path.join(ROOT, 'scripts/gds/upgrade.js'), 'utf8');
35
- const migrate = src.indexOf('const mig = runMigrate(instanceDir, run);');
35
+ const migrate = src.indexOf('const mig = runMigrate(instanceDir, run, opts.migrateAs || null);');
36
36
  const restart = src.lastIndexOf('restartService({ service: opts.service, restartCmd: opts.restartCmd }');
37
37
  assert.ok(migrate > 0, 'the upgrade flow runs migrate');
38
38
  assert.ok(restart > migrate, 'the restart that makes new code live comes AFTER migrate');
@@ -42,14 +42,32 @@ test('bongos upgrade migrates before it restarts, and only --skip-migrate opts o
42
42
  // no scripts is migrated rather than rolled back. tests/upgrade_migrate.mjs exercises both.
43
43
  assert.match(src, /function runMigrate\([^)]*\) \{\n\s*const \{ cmd, args, env \} = require\('\.\/upgrade-migrate'\)\.migratePlan\(instanceDir\)/,
44
44
  'the migrate step spawns what migratePlan decides');
45
+ // …and as the account that OWNS the database, when the move itself runs as the one that
46
+ // owns the checkout (task 1004512). Those are two different accounts on every project
47
+ // provisioned since task 1003369, and the wrong one cannot connect at all: the project's
48
+ // database is owned by its own Postgres login role with CONNECT revoked from PUBLIC.
49
+ // Asserting the CALL SITE, not just the helper — the helper was never the gap.
50
+ assert.match(src, /const mig = runMigrate\(instanceDir, run, opts\.migrateAs \|\| null\);/,
51
+ 'the migrate step is handed --migrate-as, not left on whoever the move happens to be');
45
52
  assert.equal(`bash ${CORE_MIGRATE_SCRIPT}`, INSTANCE_MIGRATE_SCRIPT, 'migrate is the instance scaffold script, i.e. the core migrate.sh');
46
53
  });
47
54
 
48
55
  test('the control-plane runner targets the instance database, not its own', () => {
49
- const { cmd } = upgradeInvocation(tenant, '1.19.940', { privileged: true, runAs: 'appsvc' });
56
+ const { cmd } = upgradeInvocation(tenant, '1.19.940', { privileged: true, runAs: 'appsvc', migrateAs: 'appsvc' });
50
57
  assert.match(cmd, new RegExp(`PGDATABASE=${dbName(tenant)} `));
51
58
  assert.match(cmd, /upgrade\.js/);
52
59
  assert.doesNotMatch(cmd, /--skip-migrate/, 'the Deploy door never skips the schema');
60
+ assert.doesNotMatch(cmd, /--migrate-as/, 'one account for both halves emits no flag — unchanged from before task 1004512');
61
+ });
62
+
63
+ test('a project with its OWN account is migrated as it, whoever the move runs as (task 1004512)', () => {
64
+ // Naming the right database is only half of reaching it; the other half is connecting as
65
+ // a role that may. The runner used to solve both by running the WHOLE move as the
66
+ // project's account — which can only READ its checkout, so the move died at `git status`
67
+ // and the schema moved nowhere. Splitting the two keeps the database leg where it was.
68
+ const { cmd } = upgradeInvocation(tenant, '1.19.940', { privileged: true, runAs: 'appsvc', migrateAs: 'bongos-charter' });
69
+ assert.match(cmd, new RegExp(`PGDATABASE=${dbName(tenant)} `), 'still the instance database');
70
+ assert.match(cmd, /--migrate-as bongos-charter\b/, 'and now also the account that owns it');
53
71
  });
54
72
 
55
73
  test('provisioning migrates the new instance database at stand-up', () => {
package/tests/upgrade.mjs CHANGED
@@ -1926,6 +1926,153 @@ t('parseArgs: --allow-downgrade is read, and is OFF unless asked for', () => {
1926
1926
  assert.equal(u.parseArgs(['--to', '1.0.0', '--allow-downgrade']).allowDowngrade, true);
1927
1927
  });
1928
1928
 
1929
+ // ---- 11. WHICH ACCOUNT DOES WHAT (task 1004512) -----------------------------
1930
+ //
1931
+ // Found live on cloudbongos.com: every core move on a project provisioned since task
1932
+ // 1003369 failed, and reported a dirty tree it had invented. The move ran wholly as the
1933
+ // project's own `bongos-<slug>` account, which can only READ the checkout, so `git status`
1934
+ // refused with `detected dubious ownership`, the clean-tree pre-flight read that as dirt,
1935
+ // the self-heal chain committed a tree nobody had touched, and the retry failed identically.
1936
+ // Both halves are pinned here: git's refusal is its own failure, and the database steps run
1937
+ // as the account that actually owns the database.
1938
+
1939
+ t('a `git status` that could NOT ANSWER is not reported as uncommitted work', async () => {
1940
+ const dir = scratchConsumer();
1941
+ const res = await u.runUpgrade({ to: '1.15.0', instance: dir }, {
1942
+ log: () => {}, err: () => {},
1943
+ run: (cmd, args) => (cmd === 'git' && args[0] === 'status'
1944
+ ? { status: 128, stdout: '', stderr: `fatal: detected dubious ownership in repository at '${dir}'\n` }
1945
+ : { status: 0 }),
1946
+ });
1947
+ assert.equal(res.ok, false, 'it still refuses — nothing is known about the tree');
1948
+ assert.match(res.error, /git could not read the working tree/, 'and says which question went unanswered');
1949
+ assert.match(res.error, /dubious ownership/, 'carrying git\'s OWN words, not "not a git repo?"');
1950
+ assert.doesNotMatch(res.error, /^working tree not clean/m, 'it must never claim there is uncommitted work');
1951
+ });
1952
+
1953
+ t('…and that refusal is classified as unknown, so no remedy commits a guess', async () => {
1954
+ const o = require('../scripts/gds/upgrade-outcome.js');
1955
+ const dir = scratchConsumer();
1956
+ const res = await u.runUpgrade({ to: '1.15.0', instance: dir }, {
1957
+ log: () => {}, err: () => {},
1958
+ run: (cmd, args) => (cmd === 'git' && args[0] === 'status'
1959
+ ? { status: 128, stdout: '', stderr: 'fatal: detected dubious ownership in repository\n' }
1960
+ : { status: 0 }),
1961
+ });
1962
+ // The whole point: dirty_tree is known_wedge, and wedge-remedy.js answers it by running
1963
+ // `git add -u && git commit`. Doing that to a tree git would not describe commits whatever
1964
+ // happens to be lying there, under a runner identity, in someone else's repository.
1965
+ const reason = o.upgradeReason(res);
1966
+ assert.equal(reason, 'git_unreadable', `got ${reason} — a failed "git status" is not a dirty tree`);
1967
+ assert.equal(o.classifyFailure(reason).class, 'unknown', 'unknown reaches a person; known_wedge would "fix" it');
1968
+ assert.notEqual(o.classifyFailure(reason).class, 'known_wedge');
1969
+ });
1970
+
1971
+ t('--migrate-as hands the DATABASE steps to another account, and nothing else', async () => {
1972
+ const dir = scratchConsumer();
1973
+ const calls = [];
1974
+ const res = await u.runUpgrade(
1975
+ { to: '1.15.0', registry: true, instance: dir, migrateAs: 'bongos-p', healthCheck: false },
1976
+ {
1977
+ log: () => {}, err: () => {},
1978
+ run: (cmd, args) => {
1979
+ calls.push(`${cmd} ${(args || []).join(' ')}`.trim());
1980
+ if (cmd === 'git' && args[0] === 'status') return { status: 0, stdout: '' };
1981
+ if (cmd === 'npm' && args[0] === 'install') {
1982
+ writeFileSync(join(dir, 'node_modules', '@bongos', 'core', 'package.json'),
1983
+ JSON.stringify({ name: '@bongos/core', version: '1.15.0' }) + '\n');
1984
+ }
1985
+ return { status: 0, stdout: '' };
1986
+ },
1987
+ materialize: () => ({ skills: 0, writtenPaths: [] }),
1988
+ regenerateApiArtifacts: () => ({ ok: true, ran: [] }),
1989
+ resolveInstanceDb: () => ({ db: 'p', ran: true }),
1990
+ databaseUrl: '', pgDatabase: '',
1991
+ }
1992
+ );
1993
+ assert.equal(res.ok, true, `the bump ran: ${res.error || ''}`);
1994
+ const migrate = calls.find((c) => /\bnpm run migrate\b/.test(c));
1995
+ assert.ok(migrate, `migrate ran: ${JSON.stringify(calls)}`);
1996
+ assert.match(migrate, /^sudo -n -u bongos-p env /, 'migrate connects as the account that owns the database');
1997
+ const identity = calls.find((c) => /psql -tAc/.test(c));
1998
+ assert.match(identity, /^sudo -n -u bongos-p env /, 'and so does the pre-check that decides whether it could');
1999
+ // The other half of the split: the file work stays with whoever is running, because the
2000
+ // project's own account cannot write this checkout.
2001
+ for (const bare of ['git status', 'npm install']) {
2002
+ assert.ok(calls.some((c) => c.startsWith(bare)), `${bare} runs as the move itself, unescalated`);
2003
+ assert.ok(!calls.some((c) => c.includes(`bongos-p env ${bare}`)), `${bare} must NOT be handed to the project's account`);
2004
+ }
2005
+ });
2006
+
2007
+ t('with no --migrate-as, every step is spawned exactly as it was before', async () => {
2008
+ // The shape half the fleet is on (a control plane, a project older than the per-instance
2009
+ // account model). A flag that quietly started escalating there would be a new failure.
2010
+ const dir = scratchConsumer();
2011
+ const calls = [];
2012
+ await u.runUpgrade(
2013
+ { to: '1.15.0', registry: true, instance: dir, healthCheck: false },
2014
+ {
2015
+ log: () => {}, err: () => {},
2016
+ run: (cmd, args) => {
2017
+ calls.push(`${cmd} ${(args || []).join(' ')}`.trim());
2018
+ if (cmd === 'npm' && args[0] === 'install') {
2019
+ writeFileSync(join(dir, 'node_modules', '@bongos', 'core', 'package.json'),
2020
+ JSON.stringify({ name: '@bongos/core', version: '1.15.0' }) + '\n');
2021
+ }
2022
+ return { status: 0, stdout: '' };
2023
+ },
2024
+ materialize: () => ({ skills: 0, writtenPaths: [] }),
2025
+ regenerateApiArtifacts: () => ({ ok: true, ran: [] }),
2026
+ resolveInstanceDb: () => ({ db: 'p', ran: true }),
2027
+ databaseUrl: '', pgDatabase: '',
2028
+ }
2029
+ );
2030
+ assert.ok(!calls.some((c) => c.startsWith('sudo')), `nothing escalated: ${JSON.stringify(calls.filter((c) => c.startsWith('sudo')))}`);
2031
+ assert.ok(calls.some((c) => /^npm run migrate$/.test(c)), 'migrate is still the bare command');
2032
+ });
2033
+
2034
+ t('sudo refusing the hand-over is caught BEFORE the pin moves, not at migrate', async () => {
2035
+ // The one failure this split can add. Soft-skipping it (as "psql failed for another
2036
+ // reason") would move the pin, install the new core, and then fail at migrate for a
2037
+ // reason that was knowable up front — a full rollback, restarting a live service twice,
2038
+ // to learn something one `select 1` already said.
2039
+ const dir = scratchConsumer();
2040
+ const calls = [];
2041
+ const res = await u.runUpgrade(
2042
+ { to: '1.15.0', registry: true, instance: dir, migrateAs: 'bongos-p', healthCheck: false },
2043
+ {
2044
+ log: () => {}, err: () => {},
2045
+ run: (cmd, args) => {
2046
+ calls.push(`${cmd} ${(args || []).join(' ')}`.trim());
2047
+ if (cmd === 'sudo') return { status: 1, stdout: '', stderr: 'sudo: a password is required\n' };
2048
+ return { status: 0, stdout: '' };
2049
+ },
2050
+ resolveInstanceDb: () => ({ db: 'p', ran: true }),
2051
+ materialize: () => ({ skills: 0, writtenPaths: [] }),
2052
+ databaseUrl: '', pgDatabase: '',
2053
+ }
2054
+ );
2055
+ assert.equal(res.ok, false);
2056
+ assert.match(res.error, /cannot run as "bongos-p"/);
2057
+ assert.match(res.error, /a password is required/, 'carrying what sudo actually said');
2058
+ assert.ok(!calls.some((c) => c.startsWith('npm install')), 'and nothing was installed — the pin never moved');
2059
+ assert.equal(u.readPinnedCoreVersion(dir), '1.14.1', 'the pin is untouched');
2060
+ });
2061
+
2062
+ t('an account name that is not one is refused before anything moves', () => {
2063
+ // It reaches `sudo` as an argv entry, so it is refused rather than quoted into shape.
2064
+ return u.runUpgrade({ to: '1.15.0', instance: scratchConsumer(), migrateAs: 'root; rm -rf /' }, { log: () => {}, err: () => {} })
2065
+ .then((res) => {
2066
+ assert.equal(res.ok, false);
2067
+ assert.match(res.error, /not a unix account name/);
2068
+ });
2069
+ });
2070
+
2071
+ t('parseArgs: --migrate-as is read, and is absent unless passed', () => {
2072
+ assert.equal(u.parseArgs(['--to', '1.0.0']).migrateAs, null);
2073
+ assert.equal(u.parseArgs(['--to', '1.0.0', '--migrate-as', 'bongos-p']).migrateAs, 'bongos-p');
2074
+ });
2075
+
1929
2076
  await Promise.all(pending);
1930
2077
  console.log(`\nupgrade: ${passed} passed, ${failed} failed`);
1931
2078
  process.exit(failed ? 1 : 0);
@@ -33,6 +33,9 @@ const TABLE = [
33
33
  ['downgrade refusal', { ok: false, downgrade: true, error: 'refusing to DOWNGRADE: --to 1.19.1 is older' }, 'refused', 'downgrade', 'needs_decision'],
34
34
  ['already on the target', { ok: false, error: 'already on 1.20.1 (pass --force to re-run the steps anyway)' }, 'refused', 'already_on', 'unknown'],
35
35
  ['dirty working tree', { ok: false, error: 'working tree not clean — commit/stash first, or pass --force.\n M package.json' }, 'refused', 'dirty_tree', 'known_wedge'],
36
+ // task 1004512: git REFUSING to read the checkout is not a report that the checkout is
37
+ // dirty, and must not be classified as one — see the dedicated section at the end.
38
+ ['git would not read the tree', { ok: false, error: 'git could not read the working tree in /srv/cloudbongos/p — the clean-tree pre-flight never ran, so this is NOT a report of uncommitted work.\nfatal: detected dubious ownership in repository at \'/srv/cloudbongos/p\'' }, 'refused', 'git_unreadable', 'unknown'],
36
39
  ['module pre-check', { ok: false, error: 'module pre-check failed: game incompatible with core 1.20.1' }, 'refused', 'module_incompatible', 'needs_decision'],
37
40
  ['database unresolvable', { ok: false, error: 'cannot determine which database to migrate.\n Set PGDATABASE' }, 'refused', 'db_unresolvable', 'unknown'],
38
41
  ['database identity', { ok: false, error: 'database "cb" is not reachable as the current user: role "root" does not exist' }, 'refused', 'db_identity', 'unknown'],
@@ -37,10 +37,10 @@ const intent = (over = {}) => ({ id: 99, action: 'core-upgrade', target_version:
37
37
  const wedge = (reason) => ({ ok: false, error: `core upgrade to 1.20.31 was refused (${reason})`, failure: { class: 'known_wedge', reason } });
38
38
  const selfRoot = () => '/srv/charter';
39
39
 
40
- function harness({ execFails = false } = {}) {
40
+ function harness({ execFails = false, privileged = false } = {}) {
41
41
  const ran = []; const events = []; const queries = [];
42
42
  const deps = {
43
- apply: true, privileged: false, log: () => {}, selfInstanceRoot: selfRoot,
43
+ apply: true, privileged, log: () => {}, selfInstanceRoot: selfRoot,
44
44
  exec: (cmd, opts) => { ran.push({ cmd, ...opts }); return execFails ? { ok: false, softFailed: true, stderr: 'fatal: nope' } : { ok: true, stdout: '' }; },
45
45
  db: { query: async (sql, params) => { queries.push({ sql, params }); return { rows: [] }; } },
46
46
  provisioning: { recordEvent: async (_db, e) => { events.push(e); } },
@@ -143,6 +143,40 @@ for (const reason of W.REMEDY_REASONS) {
143
143
  });
144
144
  }
145
145
 
146
+ await t('schema_pending on a PRIVILEGED runner migrates as the account that owns the DATABASE (task 1004512)', async () => {
147
+ // THE ONE PATH THAT CALLS runAsFor, and until this test nothing did: every other case
148
+ // above runs unprivileged, where planRemedy takes its no-sudo branch and the account is
149
+ // never resolved at all. So the account-selection change this task is about — runAsFor
150
+ // reading `migrateAs` instead of `runAs` — shipped with no coverage.
151
+ //
152
+ // The two answers are DIFFERENT accounts, and only one of them can connect: the project's
153
+ // database is owned by its own Postgres login role with CONNECT revoked from PUBLIC, so a
154
+ // migration run as the app user (which owns the checkout) fails outright. Asserting the
155
+ // assembled command, because that is where the wrong account would actually show up.
156
+ const h = harness({ privileged: true });
157
+ const r = await W.remedyAfterFailure({ intent: intent(), inst: inst(), result: wedge('schema_pending'), deps: h.deps });
158
+ assert.deepEqual(r, { repended: true });
159
+ // Two commands now: the `id -u` probe resolveRunAs spends to learn whether the project
160
+ // HAS an account of its own, then the migration itself.
161
+ assert.ok(h.ran.some((c) => /^id -u bongos-charter$/.test(c.cmd)), `the account is probed, not assumed: ${JSON.stringify(h.ran.map((c) => c.cmd))}`);
162
+ const migrate = (h.ran.find((c) => /migrate/.test(c.cmd)) || {}).cmd || '';
163
+ assert.match(migrate, /^sudo -u bongos-charter env PGDATABASE=charter /,
164
+ `must migrate as the project's own account — got: ${migrate}`);
165
+ assert.doesNotMatch(migrate, /sudo -u lars\b/,
166
+ 'the app user owns the checkout, not the database — it has no role to connect with');
167
+ });
168
+
169
+ await t('…and the OTHER two fixes never escalate, privileged or not', async () => {
170
+ // The split is per-step, not per-run: dirty_tree touches the checkout and disk_full
171
+ // touches the backup dir, both of which belong to the runner. A sudo appearing here
172
+ // would mean the account change had leaked beyond the database.
173
+ for (const reason of ['dirty_tree', 'disk_full']) {
174
+ const h = harness({ privileged: true });
175
+ await W.remedyAfterFailure({ intent: intent(), inst: inst(), result: wedge(reason), deps: h.deps });
176
+ assert.doesNotMatch(h.ran[0].cmd, /sudo/, `${reason} runs as the runner, which owns what it touches`);
177
+ }
178
+ });
179
+
146
180
  await t('the same reason again after a fix: no second fix — retired as unknown for a person', async () => {
147
181
  const h = harness();
148
182
  const r = await W.remedyAfterFailure({ intent: intent({ remedied_reason: 'dirty_tree' }), inst: inst(), result: wedge('dirty_tree'), deps: h.deps });