@bongos/core 1.19.927 → 1.19.929

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 (53) hide show
  1. package/.bongos-core.json +83 -53
  2. package/docs/copy-inventory.md +36 -15
  3. package/docs/copy-registry.json +239 -28
  4. package/docs/file-map.md +1 -1
  5. package/docs/module-api-changelog.md +4 -0
  6. package/modules/hall-ui/public/atlas.html +1 -1
  7. package/modules/hall-ui/public/blockers.html +1 -1
  8. package/modules/hall-ui/public/board-room.html +1 -1
  9. package/modules/hall-ui/public/collab.html +1 -1
  10. package/modules/hall-ui/public/copy-desk.html +1 -1
  11. package/modules/hall-ui/public/deploy.html +1 -1
  12. package/modules/hall-ui/public/diagrams.html +1 -1
  13. package/modules/hall-ui/public/drachmae.html +1 -1
  14. package/modules/hall-ui/public/gate.html +1 -1
  15. package/modules/hall-ui/public/goals.html +1 -1
  16. package/modules/hall-ui/public/government.html +1 -1
  17. package/modules/hall-ui/public/idea-objects.js +109 -0
  18. package/modules/hall-ui/public/idea.html +1 -1
  19. package/modules/hall-ui/public/ideas.html +1 -1
  20. package/modules/hall-ui/public/index.html +1 -1
  21. package/modules/hall-ui/public/modules.html +1 -1
  22. package/modules/hall-ui/public/people.html +1 -1
  23. package/modules/hall-ui/public/primer.html +1 -1
  24. package/modules/hall-ui/public/profile.html +1 -1
  25. package/modules/hall-ui/public/project-settings.html +1 -1
  26. package/modules/hall-ui/public/ranks.html +1 -1
  27. package/modules/hall-ui/public/roadmap.html +1 -1
  28. package/modules/hall-ui/public/roster.html +1 -1
  29. package/modules/hall-ui/public/sessions.html +1 -1
  30. package/modules/hall-ui/public/settings.html +1 -1
  31. package/modules/hall-ui/public/shell.js +11 -0
  32. package/modules/hall-ui/public/studio.html +1 -1
  33. package/modules/hall-ui/public/task.html +1 -1
  34. package/modules/hall-ui/public/thinking.css +286 -0
  35. package/modules/hall-ui/public/thinking.html +74 -0
  36. package/modules/hall-ui/public/thinking.js +418 -0
  37. package/modules/hall-ui/public/thinking.states.json +20 -0
  38. package/modules/hall-ui/public/watch.html +1 -1
  39. package/modules/hall-ui/public/work.html +1 -1
  40. package/package-lock.json +2 -2
  41. package/package.json +1 -1
  42. package/scripts/gds/provision-config.js +11 -0
  43. package/scripts/gds/provision-core-upgrade.js +90 -10
  44. package/scripts/gds/provision.js +3 -3
  45. package/src/bongos/serve-internal.js +7 -1
  46. package/src/module-api.js +1 -1
  47. package/tests/core_upgrade_runner.mjs +153 -0
  48. package/tests/hall_nav.mjs +1 -1
  49. package/tests/hall_page_gate_map.mjs +6 -0
  50. package/tests/hall_thinking_room.mjs +366 -0
  51. package/tests/helpers.mjs +124 -0
  52. package/tests/nav_permission_atoms.mjs +1 -1
  53. package/tests/provision.mjs +41 -0
@@ -203,7 +203,11 @@ async function coreUpgradeInstance(inst, deps, intent) {
203
203
  : '';
204
204
  // Built by the SAME function the preview uses, so "what you were shown" and "what runs"
205
205
  // cannot drift apart into two hand-maintained flag lists (task 1004121).
206
- const { cmd, runDir } = upgradeInvocation(inst, to, { privileged, requestedBy, selfRoot });
206
+ // The preview probes; the move must probe identically or the two commands diverge on
207
+ // the one field task 1004121 exists to keep identical. Skipped when !apply, where the
208
+ // command is printed and never run — a plan should not spend a subprocess per tick.
209
+ const { runAs } = resolveRunAs(inst, deps, { probe: apply });
210
+ const { cmd, runDir } = upgradeInvocation(inst, to, { privileged, requestedBy, selfRoot, runAs });
207
211
 
208
212
  if (!apply) {
209
213
  // A --dry-run runner tick used to only PRINT this command. It now runs the CLI's own
@@ -269,6 +273,53 @@ function parsePreflight(out) {
269
273
  return { checks, error };
270
274
  }
271
275
 
276
+ /**
277
+ * Which unix account should this instance's upgrade run as? Asks the box (task 1004128).
278
+ *
279
+ * The fleet is heterogeneous: instances provisioned before task 1003369 run as the
280
+ * shared APP_USER, instances provisioned after run as their own `bongos-<slug>`. Neither
281
+ * is guessable from the row, and getting it wrong is not a soft failure — `sudo` refuses
282
+ * an unknown user and the upgrade dies before pre-flight, which is exactly how the
283
+ * tenant leg has been broken.
284
+ *
285
+ * Falling back is NOT a privilege escalation: an instance with no account of its own is
286
+ * already running as APP_USER (its unit says so), so this runs the upgrade as precisely
287
+ * the account that owns the files and the database it is about to touch. It IS reported,
288
+ * because a missing per-instance account is a provisioning gap someone should close, and
289
+ * a silent fallback would hide it forever.
290
+ */
291
+ function resolveRunAs(inst, deps, { probe: allowProbe = true } = {}) {
292
+ if (inst.hosting_shape === 'control-plane') return { runAs: APP_USER, fellBack: false };
293
+ // Re-checked HERE rather than left to instanceUser()'s own throw, for the reason
294
+ // upgradeInvocation states about itself: a call site whose safety depends on a
295
+ // helper's internals is one refactor away from a shell-interpolation path.
296
+ if (!isValidSlug(inst && inst.slug)) {
297
+ throw new Error(`resolveRunAs: refusing to probe an account for invalid slug ${JSON.stringify(inst && inst.slug)}`);
298
+ }
299
+ const wanted = instanceUser(inst);
300
+ if (!allowProbe) return { runAs: wanted, fellBack: false };
301
+ // THE SAME EXECUTOR THE UPGRADE ITSELF WILL USE. Every sibling here picks
302
+ // `standalone ? (controlExec || exec) : exec`, because under PROVISION_REMOTE_HOST
303
+ // (ADR 0130) `exec` is the REMOTE ssh executor while `controlExec` stays local. A
304
+ // probe on `exec` would then ask the co-hosting box whether an account exists while
305
+ // the command it is deciding for runs on the control plane — a wrong answer that
306
+ // looks like a right one.
307
+ const boxExec = inst.hosting_shape === 'standalone' ? (deps.controlExec || deps.exec) : deps.exec;
308
+ const r = boxExec(`id -u ${wanted}`, { allowFail: true });
309
+ const failed = !r || r.softFailed === true || r.ok === false;
310
+ if (!failed) return { runAs: wanted, fellBack: false };
311
+ // ONLY A DEFINITIVE "no such user" JUSTIFIES THE FALLBACK. A probe that could not
312
+ // answer — an exec timeout, an NSS hiccup — must not silently narrow the per-instance
313
+ // isolation of task 1003369 for this operation. So an inconclusive probe keeps the
314
+ // intended account: if it really is missing the upgrade fails loudly, and since this
315
+ // task a failure now carries the reason instead of "the dry run did not complete".
316
+ const said = `${(r && r.stdout) || ''}\n${(r && r.stderr) || ''}`;
317
+ if (!/no such user|unknown user/i.test(said)) {
318
+ return { runAs: wanted, fellBack: false, probeInconclusive: true };
319
+ }
320
+ return { runAs: APP_USER, fellBack: true, wanted };
321
+ }
322
+
272
323
  // ONE BUILDER FOR BOTH LEGS (task 1004121).
273
324
  //
274
325
  // The preview and the move must be the same command or the preview is worthless — and
@@ -282,7 +333,7 @@ function parsePreflight(out) {
282
333
  // HERE rather than left to instanceUser()'s own throw: that throw is correct today, but
283
334
  // a call site whose safety depends on a helper's internals is one refactor away from a
284
335
  // shell-injection path (and the apply leg's own guard sits at the route, not here).
285
- function upgradeInvocation(inst, to, { privileged = false, dryRun = false, requestedBy = '', selfRoot = selfInstanceRoot } = {}) {
336
+ function upgradeInvocation(inst, to, { privileged = false, dryRun = false, requestedBy = '', selfRoot = selfInstanceRoot, runAs = null } = {}) {
286
337
  if (!isValidSlug(inst && inst.slug)) {
287
338
  throw new Error(`upgradeInvocation: refusing to build a shell command from an invalid slug ${JSON.stringify(inst && inst.slug)}`);
288
339
  }
@@ -300,10 +351,25 @@ function upgradeInvocation(inst, to, { privileged = false, dryRun = false, reque
300
351
  // the project. So ask, rather than assume; in the legacy shape the answer is
301
352
  // REPO_ROOT and the command below is byte-identical to what it always was.
302
353
  const instanceDir = inst.hosting_shape === 'standalone' ? standaloneRoot(inst) : selfRoot();
303
- // The control plane is not a provisioned tenant and has no `bongos-` account; every
304
- // other shape runs as its own, which is also its Postgres login role (task 1004119).
305
- const runAs = inst.hosting_shape === 'control-plane' ? APP_USER : instanceUser(inst);
306
- const sudoP = privileged ? `sudo -u ${runAs} env PGDATABASE=${dbName(inst)} ` : '';
354
+ // WHICH ACCOUNT, AND WHY IT IS RESOLVED RATHER THAN DERIVED (task 1004128).
355
+ //
356
+ // Task 1004119 wrote this as `control-plane ? APP_USER : instanceUser(inst)` and was
357
+ // half right. The control-plane leg is correct and verified live. The tenant leg
358
+ // assumed the per-instance account model of task 1003369 is provisioned everywhere —
359
+ // and on the live box NO `bongos-*` account exists, no such Postgres role exists, and
360
+ // every instance unit still says `User=lars`. So the fix for "Postgres has no role
361
+ // root" swapped one absent account for another, and every tenant upgrade died with
362
+ // `sudo: unknown user bongos-test4`.
363
+ //
364
+ // The account model is a real security control, not an accident — a distinct uid per
365
+ // instance is what stops a co-tenant reading a sibling's /proc/<pid>/environ. So the
366
+ // answer is NOT to route everyone back to the shared account. It is to run as the
367
+ // account the instance ACTUALLY runs as, which a caller establishes by probing
368
+ // (resolveRunAs below) instead of either side assuming. Passing it in keeps this
369
+ // builder pure; the default preserves the old derivation for callers that have not
370
+ // probed.
371
+ const account = runAs || (inst.hosting_shape === 'control-plane' ? APP_USER : instanceUser(inst));
372
+ const sudoP = privileged ? `sudo -u ${account} env PGDATABASE=${dbName(inst)} ` : '';
307
373
  const cli = path.posix.join('scripts', 'gds', 'upgrade.js');
308
374
  const shared = [`${sudoP}node ${cli}`, '--registry', `--to ${to}`,
309
375
  instanceDir === REPO_ROOT ? '' : `--instance ${instanceDir}`]; // REPO_ROOT is where the CLI runs (runDir below)
@@ -315,7 +381,7 @@ function upgradeInvocation(inst, to, { privileged = false, dryRun = false, reque
315
381
  '--commit-pin',
316
382
  requestedBy ? `--requested-by ${requestedBy}` : '',
317
383
  requestedBy ? '--upgrade-source owner-control' : ''];
318
- return { cmd: [...shared, ...tail].filter(Boolean).join(' '), runDir: REPO_ROOT, instanceDir, runAs };
384
+ return { cmd: [...shared, ...tail].filter(Boolean).join(' '), runDir: REPO_ROOT, instanceDir, runAs: account };
319
385
  }
320
386
 
321
387
  // Ask the upgrade CLI what it WOULD do, changing nothing. `allowFail` because a refusal
@@ -326,7 +392,8 @@ function preflightUpgrade(inst, to, deps) {
326
392
  // A distinct local name: `deps.selfInstanceRoot || selfInstanceRoot` with a const of
327
393
  // the same name would TDZ-shadow the import and crash every uninjected call.
328
394
  const selfRoot = deps.selfInstanceRoot || selfInstanceRoot;
329
- const { cmd, runDir } = upgradeInvocation(inst, to, { privileged, dryRun: true, selfRoot });
395
+ const { runAs, fellBack, wanted } = resolveRunAs(inst, deps);
396
+ const { cmd, runDir } = upgradeInvocation(inst, to, { privileged, dryRun: true, selfRoot, runAs });
330
397
  const r = boxExec(cmd, { cwd: runDir, allowFail: true });
331
398
  const out = `${(r && r.stdout) || ''}\n${(r && r.stderr) || ''}`;
332
399
  const { checks, error } = parsePreflight(out);
@@ -334,7 +401,20 @@ function preflightUpgrade(inst, to, deps) {
334
401
  // recognisable ✖ is still a refusal, and reporting it as a pass is the failure mode
335
402
  // this whole task exists to remove.
336
403
  const ok = !!(r && !r.softFailed && r.ok !== false) && !error;
337
- return { target: to, ok, lines: checks, error: error || (ok ? null : 'the dry run did not complete') };
404
+ // WHEN THERE IS NO ✖, SAY WHAT THE COMMAND SAID (task 1004128). The generic sentence
405
+ // is the LAST resort, not the first: the first live pre-flight reported "the dry run
406
+ // did not complete" while `sudo: unknown user bongos-test4` sat in the journal, and
407
+ // the second did the same over `✖ already on 1.19.927`. Both were in hand. The trim
408
+ // is because this reaches a DOM.
409
+ const raw = out.trim().split('\n').filter(Boolean).slice(-4).join('\n').slice(0, 600);
410
+ const why = error || (raw || null);
411
+ const note = fellBack
412
+ ? `ran as ${runAs} — this instance has no ${wanted} account yet (it predates the per-instance account model)`
413
+ : null;
414
+ return {
415
+ target: to, ok, lines: checks, ran_as: runAs, account_note: note,
416
+ error: ok ? null : (why || 'the dry run did not complete, and said nothing this could report'),
417
+ };
338
418
  }
339
419
 
340
420
  // Run a dry run for one instance and cache its verdict — the ON-DEMAND leg, drained from
@@ -439,4 +519,4 @@ async function snapshotAllCoreVersions(deps) {
439
519
  return { seen };
440
520
  }
441
521
 
442
- module.exports = { UPGRADE_SHAPES, versionCmd, parseServedVersion, targetFault, parsePreflight, upgradeInvocation, preflightUpgrade, preflightCoreUpgrade, coreUpgradeInstance, snapshotCoreVersions, snapshotAllCoreVersions };
522
+ module.exports = { UPGRADE_SHAPES, resolveRunAs, versionCmd, parseServedVersion, targetFault, parsePreflight, upgradeInvocation, preflightUpgrade, preflightCoreUpgrade, coreUpgradeInstance, snapshotCoreVersions, snapshotAllCoreVersions };
@@ -49,7 +49,7 @@ const path = require('node:path');
49
49
  const cp = require('node:child_process');
50
50
  const crypto = require('node:crypto');
51
51
 
52
- const { CONFIG, MANIFEST_UA, MANIFEST_VERIFY_INTERVAL_MS, MANIFEST_VERIFY_TRIES, MAX_INTENT_ATTEMPTS, REPO_ROOT, coreVersionSafe, hasFlag, loadDeps, oauthSecret, provisionerBotEmail } = require('./provision-config.js');
52
+ const { CONFIG, MANIFEST_UA, softFailResult, MANIFEST_VERIFY_INTERVAL_MS, MANIFEST_VERIFY_TRIES, MAX_INTENT_ATTEMPTS, REPO_ROOT, coreVersionSafe, hasFlag, loadDeps, oauthSecret, provisionerBotEmail } = require('./provision-config.js');
53
53
  const { APP_USER, accountPreflight, alreadyScaffolded, buildCorePinRefreshCommit, coreCheckoutHasGit, dbCreateCmd, dbName, dbRoleCmd, dbRoleCmdShown, dbRoleGrantsCmd, ensureInstanceUserCmd, generateDbPassword, grantInstanceRepoReadCmd, instanceDbRole, instanceStateDir, instanceUser, deployKeyPath, deployKeyTitle, ensurePrivateRepoAccess, installedCorePackDir, installedCoreTarball, instanceInitSpec, instanceRepoRemote, migrateCmd, onboardMode, ownerLoginOf, parseTargetRef, planCorePinRefresh, refreshStandaloneCorePin, resolveOwnerGithubToken, resolveVendorableCoreTarball, safeVersionLabel, scaffoldStandaloneRepo, seedFirstVersionCmd, standaloneInstallCmd, standaloneMigrateCmd, standalonePullCmd, standaloneRegenDocsCmd, standaloneRoot } = require('./provision-repo.js');
54
54
  const { backupScriptPath, backupService, backupServicePath, backupTimer, backupTimerPath, backupUnitName, instanceManifestCmd, originEnvVarsFor, serviceUnit, serviceUnitPath, settingsConsumed, settingsEnvVarsFor, upsertEnvVars, webEnvBody, webEnvPath } = require('./provision-units.js');
55
55
  const { caddyBlock, caddySnippetPath, checkCaddyWiring, checkDnsTokenScope, classifyZoneScope, dnsReleaseEnv, dnsUpsertEnv, federateInstance, federationHubOrigin, healthzCmd, identityCmd, identityVerdict, ingressPlaneGap, ingressPreflight, shouldFederate, zoneForDomain } = require('./provision-net.js');
@@ -95,7 +95,7 @@ function makeExec({ apply, log = console.log, remoteHost = null, remoteCwd = nul
95
95
  // `shown` call site is secret-bearing by declaration: log the exit status
96
96
  // alone, never stderr (a remote shell may echo the failing line back).
97
97
  const why = shownAs ? `exit status ${e && e.status != null ? e.status : '?'}` : (e.stderr || e.message || '').toString().trim().slice(0, 120);
98
- if (allowFail) { log(` ~ ${shown} (non-fatal: ${why})`); return { ok: true, stdout: '', softFailed: true }; }
98
+ if (allowFail) { log(` ~ ${shown} (non-fatal: ${why})`); return softFailResult(e, !!shownAs); }
99
99
  log(` ✖ ${shown}`);
100
100
  throw e;
101
101
  }
@@ -113,7 +113,7 @@ function makeExec({ apply, log = console.log, remoteHost = null, remoteCwd = nul
113
113
  // non-fatal line carries only the exit status — never e.message, whose
114
114
  // "Command failed: <cmd>" would echo the command back into the journal.
115
115
  const why = shownAs ? `exit status ${e && e.status != null ? e.status : '?'}` : (e.stderr || e.message || '').toString().trim().slice(0, 120);
116
- if (allowFail) { log(` ~ ${label} (non-fatal: ${why})`); return { ok: true, stdout: '', softFailed: true }; }
116
+ if (allowFail) { log(` ~ ${label} (non-fatal: ${why})`); return softFailResult(e, !!shownAs); }
117
117
  log(` ✖ ${label}`);
118
118
  throw e;
119
119
  }
@@ -1154,8 +1154,14 @@ function mountInternalSurfaces(app) {
1154
1154
  // shells — and matched no gate at all. The shell carries no data and every
1155
1155
  // read behind it is gated server-side, so this closed an inconsistency rather
1156
1156
  // than an exposure; the point is that one URL cannot be two tiers at once.
1157
+ // task 1004098 (BV2.R03): `thinking` — THE ROOM, a builder's own idea trail —
1158
+ // joins the tier `ideas` sits on, for the same settled reason: there is no
1159
+ // permission gate on having a thought. It needs no floor ABOVE sign-in either,
1160
+ // because the page reads the VIEWER's own trail and nobody else's — the id it
1161
+ // asks for comes from GET /me, never from the URL, so a rank could protect
1162
+ // nothing here that the session does not already.
1157
1163
  const SIGNEDIN_BUILDERS_PAGE_RE =
1158
- /^\/builders\/(?:work|settings|atlas|modules|goals|roadmap|copy-desk|studio|ideas|collab|task|idea|primer(?:\.md)?|diagrams)(?:\.html)?\/?$/;
1164
+ /^\/builders\/(?:work|settings|atlas|modules|goals|roadmap|copy-desk|studio|ideas|thinking|collab|task|idea|primer(?:\.md)?|diagrams)(?:\.html)?\/?$/;
1159
1165
  // The diagram image assets under /builders/diagrams/ ride the same tier as the
1160
1166
  // page that embeds them.
1161
1167
  const SIGNEDIN_BUILDERS_PREFIX_RE = /^\/builders\/diagrams\//;
package/src/module-api.js CHANGED
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
71
71
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
72
72
  // the entry to that file. Look for a version's history there, not here.
73
73
  // ---------------------------------------------------------------------------
74
- const CORE_VERSION = '1.19.927'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.929'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
75
75
 
76
76
  // A namespaced logger so a module's log lines are attributable + consistent.
77
77
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -647,5 +647,158 @@ await t('the runner refuses, and runs NOTHING, when it cannot see where the inst
647
647
  assert.equal(d.ranWith.filter((c) => /upgrade\.js/.test(c.cmd)).length, 0, 'the box was not touched');
648
648
  });
649
649
 
650
+
651
+ console.log('\nwhich account, and saying what went wrong (task 1004128):');
652
+
653
+ // A deps bag whose `id -u` probe can be made to succeed or fail, and whose upgrade
654
+ // invocation can be made to soft-fail with real command output.
655
+ function acctDeps({ accountExists = true, dryRunStdout = '', dryRunStderr = '', softFail = false } = {}) {
656
+ const d = makeDeps({ served: '1.19.762' });
657
+ d.privileged = true;
658
+ const ran = [];
659
+ d.ranWith = ran;
660
+ d.exec = (cmd, opts) => {
661
+ ran.push({ cmd, cwd: opts && opts.cwd });
662
+ if (/^id -u /.test(cmd)) {
663
+ if (accountExists) return { ok: true, stdout: '1001\n' };
664
+ // What `id -u` really says for an absent account — the definitive answer the
665
+ // fallback is allowed to act on.
666
+ return { ok: true, stdout: '', stderr: `id: '${cmd.split(' ').pop()}': no such user\n`, softFailed: true };
667
+ }
668
+ if (/\/version/.test(cmd)) return { ok: true, stdout: JSON.stringify({ coreVersion: '1.19.762' }) };
669
+ if (/upgrade\.js/.test(cmd)) {
670
+ return softFail
671
+ ? { ok: true, stdout: dryRunStdout, stderr: dryRunStderr, softFailed: true }
672
+ : { ok: true, stdout: dryRunStdout, stderr: dryRunStderr };
673
+ }
674
+ return { ok: true, stdout: '' };
675
+ };
676
+ return d;
677
+ }
678
+
679
+ await t('a tenant WITH its own account runs as that account — the security model is kept', () => {
680
+ // task 1003369: a distinct uid per instance is what stops a co-tenant reading a
681
+ // sibling's /proc/<pid>/environ. Falling back everywhere would quietly undo it.
682
+ const d = acctDeps({ accountExists: true });
683
+ const r = R.resolveRunAs(inst({ hosting_shape: 'standalone', slug: 'test4' }), d);
684
+ assert.deepEqual({ runAs: r.runAs, fellBack: r.fellBack }, { runAs: 'bongos-test4', fellBack: false });
685
+ });
686
+
687
+ await t('a tenant WITHOUT one falls back to the app user, and says so', () => {
688
+ // The live box has no bongos-* account at all, so every tenant upgrade died on
689
+ // `sudo: unknown user bongos-test4`. Falling back is not an escalation — the
690
+ // instance's own unit already says User=lars — but it IS a provisioning gap, so it
691
+ // is reported rather than silently papered over.
692
+ const d = acctDeps({ accountExists: false });
693
+ const r = R.resolveRunAs(inst({ hosting_shape: 'standalone', slug: 'test4' }), d);
694
+ assert.equal(r.runAs, 'lars');
695
+ assert.equal(r.fellBack, true);
696
+ assert.equal(r.wanted, 'bongos-test4', 'and names the account that is missing');
697
+ });
698
+
699
+ await t('the control plane is never probed — it has no instance account by design', () => {
700
+ const d = acctDeps({ accountExists: false });
701
+ const r = R.resolveRunAs(inst({ hosting_shape: 'control-plane', slug: 'cloudbongos' }), d);
702
+ assert.deepEqual({ runAs: r.runAs, fellBack: r.fellBack }, { runAs: 'lars', fellBack: false });
703
+ assert.equal(d.ranWith.filter((c) => /^id -u /.test(c.cmd)).length, 0, 'no probe was spent');
704
+ });
705
+
706
+ await t('the dry run is invoked as the account that actually exists', async () => {
707
+ const d = acctDeps({ accountExists: false, dryRunStdout: ' ✓ pre-flight: ok\n' });
708
+ await R.preflightUpgrade(inst({ hosting_shape: 'standalone', slug: 'test4', port: 3006 }), '1.19.762', d);
709
+ const cmd = (d.ranWith.find((c) => /upgrade\.js/.test(c.cmd)) || {}).cmd || '';
710
+ assert.match(cmd, /sudo -u lars /, `must not sudo to an account that does not exist — got: ${cmd}`);
711
+ assert.doesNotMatch(cmd, /bongos-test4/);
712
+ });
713
+
714
+ await t('the MOVE probes identically, so preview and move cannot diverge on the account', async () => {
715
+ const d = acctDeps({ accountExists: false });
716
+ await R.coreUpgradeInstance(inst({ hosting_shape: 'standalone', slug: 'test4', port: 3006 }), d, intent());
717
+ const cmd = (d.ranWith.find((c) => /upgrade\.js/.test(c.cmd)) || {}).cmd || '';
718
+ assert.match(cmd, /sudo -u lars /, 'the move follows the same probe as the preview');
719
+ });
720
+
721
+ await t('an INCONCLUSIVE probe keeps the isolated account rather than quietly widening it', () => {
722
+ // A probe that could not answer (exec timeout, NSS hiccup) is not evidence the
723
+ // account is absent. Falling back on it would narrow task 1003369's per-instance
724
+ // /proc isolation for that operation, silently, on a fleet where the account may
725
+ // well exist. Only `no such user` is evidence.
726
+ const d = makeDeps({ served: '1.19.762' });
727
+ d.exec = () => ({ ok: true, stdout: '', stderr: 'id: cannot connect to name service\n', softFailed: true });
728
+ const r = R.resolveRunAs(inst({ hosting_shape: 'standalone', slug: 'test4' }), d);
729
+ assert.equal(r.runAs, 'bongos-test4', 'the intended account is kept');
730
+ assert.equal(r.fellBack, false);
731
+ assert.equal(r.probeInconclusive, true, 'and the caller can tell it was not a clean answer');
732
+ });
733
+
734
+ await t('the probe uses the SAME executor the upgrade will use', () => {
735
+ // Under PROVISION_REMOTE_HOST (ADR 0130) `exec` is the remote ssh executor while
736
+ // `controlExec` stays local, and every sibling here picks controlExec for a
737
+ // standalone. A probe on the other one asks the wrong machine and gets a confident
738
+ // wrong answer.
739
+ const asked = { control: [], remote: [] };
740
+ const d = makeDeps({ served: '1.19.762' });
741
+ d.controlExec = (cmd) => { asked.control.push(cmd); return { ok: true, stdout: '1001\n' }; };
742
+ d.exec = (cmd) => { asked.remote.push(cmd); return { ok: true, stdout: '', stderr: "id: 'x': no such user\n", softFailed: true }; };
743
+ const r = R.resolveRunAs(inst({ hosting_shape: 'standalone', slug: 'test4' }), d);
744
+ assert.deepEqual(asked.remote, [], 'the remote executor was not asked');
745
+ assert.equal(asked.control.length, 1, 'the control-plane executor was');
746
+ assert.equal(r.runAs, 'bongos-test4', 'and its answer is the one that counts');
747
+ });
748
+
749
+ await t('a planning tick probes ONCE, not twice', async () => {
750
+ // The non-apply path prints the move it would make AND runs the CLI's own dry run, so
751
+ // one probe is real work. The second — for a command string that is only logged — was
752
+ // a subprocess per tick for nothing.
753
+ const d = acctDeps({ accountExists: false });
754
+ d.apply = false;
755
+ await R.coreUpgradeInstance(inst({ hosting_shape: 'standalone', slug: 'test4', port: 3006 }), d, intent());
756
+ assert.equal(d.ranWith.filter((c) => /^id -u /.test(c.cmd)).length, 1,
757
+ 'exactly one probe: the dry run that actually executes');
758
+ });
759
+
760
+ await t('an invalid slug is refused by THIS call site, not merely by the helper it calls', () => {
761
+ // Asserting /invalid slug/ alone proves nothing: instanceUser() throws its own
762
+ // message matching that, so the test passes with this guard deleted — verified by
763
+ // deleting it. The point of the finding is that safety here must not DEPEND on the
764
+ // helper's internals, so the assertion names the thrower.
765
+ const d = acctDeps({});
766
+ assert.throws(
767
+ () => R.resolveRunAs({ hosting_shape: 'standalone', slug: 'bad slug;rm -rf /' }, d),
768
+ /resolveRunAs: refusing to probe/,
769
+ 'the refusal must come from this function, not from instanceUser further down',
770
+ );
771
+ assert.equal(d.ranWith.filter((c) => /^id -u /.test(c.cmd)).length, 0, 'and nothing was executed');
772
+ });
773
+
774
+ await t('a refusal reports what the command SAID, not "did not complete"', async () => {
775
+ const d = acctDeps({ softFail: true, dryRunStderr: 'sudo: unknown user bongos-test4\n' });
776
+ const v = await R.preflightUpgrade(inst({ hosting_shape: 'standalone', slug: 'test4', port: 3006 }), '1.19.762', d);
777
+ assert.equal(v.ok, false);
778
+ assert.match(v.error, /unknown user bongos-test4/,
779
+ `the reason was in hand and must travel — got: ${JSON.stringify(v.error)}`);
780
+ });
781
+
782
+ await t('a ✖ line still wins over the raw tail', async () => {
783
+ const d = acctDeps({ softFail: true, dryRunStdout: 'noise\n ✖ already on 1.19.927 (pass --force to re-run)\n' });
784
+ const v = await R.preflightUpgrade(inst({ hosting_shape: 'standalone', slug: 'test4', port: 3006 }), '1.19.762', d);
785
+ assert.match(v.error, /already on 1\.19\.927/);
786
+ assert.doesNotMatch(v.error, /^noise/, 'the parsed refusal is preferred to the raw tail');
787
+ });
788
+
789
+ await t('a fallback is recorded on the verdict the owner reads', async () => {
790
+ const d = acctDeps({ accountExists: false, dryRunStdout: ' ✓ pre-flight: ok\n' });
791
+ const v = await R.preflightUpgrade(inst({ hosting_shape: 'standalone', slug: 'test4', port: 3006 }), '1.19.762', d);
792
+ assert.equal(v.ran_as, 'lars');
793
+ assert.match(v.account_note, /no bongos-test4 account yet/);
794
+ });
795
+
796
+ await t('and a silent failure STILL refuses rather than inventing a pass', async () => {
797
+ const d = acctDeps({ softFail: true, dryRunStdout: '', dryRunStderr: '' });
798
+ const v = await R.preflightUpgrade(inst({ hosting_shape: 'standalone', slug: 'test4', port: 3006 }), '1.19.762', d);
799
+ assert.equal(v.ok, false, 'a command that said nothing is not a pass');
800
+ assert.match(v.error, /said nothing/);
801
+ });
802
+
650
803
  console.log(`\ncore_upgrade_runner: ${passed} passed, ${failed} failed`);
651
804
  if (failed) process.exit(1);
@@ -258,7 +258,7 @@ test('sidebar groups read by audience, in order', () => {
258
258
  // task 1003455 (goal 1000089): 'blockers' joins the same group, after Tasks —
259
259
  // its subject is the project (what's stuck on the owner), not a person or a
260
260
  // craft, and its one data source (GET /blockers) is public like Roadmap's.
261
- assert.deepEqual(nav[0].items.map((i) => i.id), ['home', 'roadmap', 'goals', 'work', 'blockers', 'board-room', 'ideas', 'copy-desk', 'studio'],
261
+ assert.deepEqual(nav[0].items.map((i) => i.id), ['home', 'roadmap', 'goals', 'work', 'blockers', 'board-room', 'ideas', 'thinking', 'copy-desk', 'studio'],
262
262
  'the unlabeled group is the project itself (task 1002425 order)');
263
263
  assert.deepEqual(nav[1].items.map((i) => i.id), ['standing', 'profile'], 'You = your own numbers');
264
264
  // People (task 1002577): the /scouting directory graduated into the hall.
@@ -60,6 +60,12 @@ const ORACLE = {
60
60
  // themselves server-side.
61
61
  '/builders/studio': 'requireBuilderPage',
62
62
  '/builders/ideas': 'requireBuilderPage',
63
+ // THE ROOM (task 1004098 / BV2.R03) — a builder's own idea trail. Sign-in and
64
+ // no rank floor, the tier /ideas sits on: there is no permission gate on
65
+ // having a thought, and the page reads the VIEWER's own trail (the id comes
66
+ // from GET /me, never from the URL), so a rank could protect nothing the
67
+ // session does not already.
68
+ '/builders/thinking': 'requireBuilderPage',
63
69
  '/builders/collab': 'requireBuilderPage',
64
70
  // task 1002710: the orientation reading rooms moved DOWN to this tier from a
65
71
  // thetes+ gate. They are the onboarding documents, so gating them above xenos