@bongos/core 1.19.926 → 1.19.928

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.bongos-core.json CHANGED
@@ -2,11 +2,11 @@
2
2
  "artifact": "bongos-core",
3
3
  "manifest_schema": 1,
4
4
  "generator": "scripts/gds/package-core.js",
5
- "core_version": "1.19.926",
6
- "core_contract": "1.19.926",
7
- "source_commit": "e29c4e2c4c22fc015eaaa56174f643b557051564",
5
+ "core_version": "1.19.928",
6
+ "core_contract": "1.19.928",
7
+ "source_commit": "261ed6d91994bae7a06841fd943e0737687a4306",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-20T23:12:12.395Z",
9
+ "built_at": "2026-09-21T00:22:12.769Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 511,
@@ -17,7 +17,7 @@
17
17
  "gate": "passed"
18
18
  },
19
19
  "file_count": 2851,
20
- "tree_sha256": "7760b472bbc2c5cd345c6c4becda6c92d06d09c727c967667d7794740b51b8d6",
20
+ "tree_sha256": "b4640600eaa88633d42e88e234e812d059f570eaa8029a8ee15a71e6d3d3c910",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/ask-for-help/SKILL.md",
@@ -2677,7 +2677,7 @@
2677
2677
  {
2678
2678
  "path": "docs/module-api-changelog.md",
2679
2679
  "mode": "0000644",
2680
- "sha256": "b656ffb12e3d62e7042fbde0a6dc547d9b8b480ce2acacc21da0795443b117ac"
2680
+ "sha256": "f346c24c2f8283aab581f3567876fd8a95cd625f7cfb71efdbcc5f240c850aea"
2681
2681
  },
2682
2682
  {
2683
2683
  "path": "docs/modules-contract.md",
@@ -6412,12 +6412,12 @@
6412
6412
  {
6413
6413
  "path": "modules/provisioning/control-plane-guard.js",
6414
6414
  "mode": "0000644",
6415
- "sha256": "b70ad687c7699a3023e8be973b67ceeb7ff67afb38ff47665c7089614901c200"
6415
+ "sha256": "e1d060477287884f878748da4c2b7cfe164c782538728ffc7efc473c8b6a4997"
6416
6416
  },
6417
6417
  {
6418
6418
  "path": "modules/provisioning/core-upgrade.js",
6419
6419
  "mode": "0000644",
6420
- "sha256": "bd0109f1e270c868cc1c1d27d1c3b51ccb48c8b3d54c8274ffedbf2a4bbce051"
6420
+ "sha256": "291a3765197f9429c70dd240fe5cdfa72a245ad3698c822032e71bb88999d533"
6421
6421
  },
6422
6422
  {
6423
6423
  "path": "modules/provisioning/credential-preflight.js",
@@ -7922,12 +7922,12 @@
7922
7922
  {
7923
7923
  "path": "package-lock.json",
7924
7924
  "mode": "0000644",
7925
- "sha256": "3ec884fff4704eceb947c709b0143b4428c435f8a7cad753d4c758028cf13bfd"
7925
+ "sha256": "cbd8d9b4fa353962ff64a3f6a34ecec361a606052643189ab4d37512a7dd69ef"
7926
7926
  },
7927
7927
  {
7928
7928
  "path": "package.json",
7929
7929
  "mode": "0000644",
7930
- "sha256": "2e4530f1e38ef785e735ae6fc924451aab9d22beecdf2bb99af943cdfb470453"
7930
+ "sha256": "5d1494f1844cf2fc5684fc5f2b01a5eaa12c3d096ed4e21cbaa18a8ec070e57e"
7931
7931
  },
7932
7932
  {
7933
7933
  "path": "public-docs/index.html",
@@ -8867,12 +8867,12 @@
8867
8867
  {
8868
8868
  "path": "scripts/gds/provision-config.js",
8869
8869
  "mode": "0000644",
8870
- "sha256": "d9d76ed5992ea84b347e0dbe37c78dda014df62208772ff00a2987d34ec27059"
8870
+ "sha256": "68882e5e600881c0e3a96d4b84a9ad38dd77c3216a09a7886a263cfe40044f1e"
8871
8871
  },
8872
8872
  {
8873
8873
  "path": "scripts/gds/provision-core-upgrade.js",
8874
8874
  "mode": "0000644",
8875
- "sha256": "05292d556595bb3ce88a3e0b76c10c7cab2fef7f9d7e1af1e0b3610d48830692"
8875
+ "sha256": "8ad61c85e0cf3f515fbd970fedcd6c33871d70e4dd42c36cb275ae907e31b655"
8876
8876
  },
8877
8877
  {
8878
8878
  "path": "scripts/gds/provision-disconnect.js",
@@ -8912,7 +8912,7 @@
8912
8912
  {
8913
8913
  "path": "scripts/gds/provision.js",
8914
8914
  "mode": "0000644",
8915
- "sha256": "2dee107429b1391e6e00a0c4915f9972c0e9d16f90a3d2ac111a34ffa5e7bc1d"
8915
+ "sha256": "e384ffc0ecfcdb05d275d1245a6bc1214f2737c0fa67ae21eb8228ce1a398abf"
8916
8916
  },
8917
8917
  {
8918
8918
  "path": "scripts/gds/publish-credential-check.js",
@@ -9927,7 +9927,7 @@
9927
9927
  {
9928
9928
  "path": "src/module-api.js",
9929
9929
  "mode": "0000644",
9930
- "sha256": "938fdfedf2e8b99ae44c637371144016754b82cb8b96e254e47f8766e945a3b5"
9930
+ "sha256": "06cd6203ef000d02b7a483f2423dcbf07d4fb3ed4a8376bd07d8ddb4f602edfe"
9931
9931
  },
9932
9932
  {
9933
9933
  "path": "src/module-loader/catalog.js",
@@ -10837,12 +10837,12 @@
10837
10837
  {
10838
10838
  "path": "tests/core_upgrade_door.mjs",
10839
10839
  "mode": "0000644",
10840
- "sha256": "c0457b6d1b0b0b3ee4117544e9203c28306a99772e2b085f0611132cf3d358d9"
10840
+ "sha256": "bb2a39d645141a15b7391580069bf05b6f812e40f20d10fd55dbc564eb8f00ec"
10841
10841
  },
10842
10842
  {
10843
10843
  "path": "tests/core_upgrade_runner.mjs",
10844
10844
  "mode": "0000644",
10845
- "sha256": "994af91fda0df0ceb8b6e3eb80dd8bd23595a127a0e1d2e0381cb55e97beb557"
10845
+ "sha256": "6077bfd9eca120db9dcfebec30fa355d0ae281df6cf9fbd6489203640051fcc1"
10846
10846
  },
10847
10847
  {
10848
10848
  "path": "tests/cosmos_token_contrast.mjs",
@@ -12937,7 +12937,7 @@
12937
12937
  {
12938
12938
  "path": "tests/provision.mjs",
12939
12939
  "mode": "0000644",
12940
- "sha256": "642ba54a20f08df1514f94114d39cdb70427f93542f4c060c83251ad1f46c80a"
12940
+ "sha256": "4e1d463693cb7d5e363964360b72085d2307578bd430f2fee1129698d907a567"
12941
12941
  },
12942
12942
  {
12943
12943
  "path": "tests/provision_account_identity.mjs",
@@ -2326,5 +2326,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
2326
2326
  landed since 1.19.924 with no explicit bump. run 35542154729. (task 1002620)
2327
2327
  1.19.926 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2328
2328
  landed since 1.19.925 with no explicit bump. run 35543911646. (task 1002620)
2329
+ 1.19.927 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2330
+ landed since 1.19.926 with no explicit bump. run 35545006099. (task 1002620)
2331
+ 1.19.928 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2332
+ landed since 1.19.927 with no explicit bump. run 35547446984. (task 1002620)
2329
2333
  ---------------------------------------------------------------------------
2330
2334
  ```
@@ -30,10 +30,18 @@
30
30
  // the door task 1003159 builds.
31
31
  // restart — the liveness remedy (task 1003238). Restarting the platform is
32
32
  // recoverable and already owner-initiated elsewhere.
33
+ // core-upgrade-preflight
34
+ // — `upgrade.js --dry-run`. It RUNS NOTHING: it returns at pre-flight
35
+ // before the pin moves, before npm, before any restart. Refusing it
36
+ // here protected the box from nothing and removed the feature from
37
+ // everyone, because the control plane is the ONLY instance that runs
38
+ // the provisioning module — so its hall is the only one with an
39
+ // Upgrade page at all. A preview that cannot run where the button
40
+ // lives is not a preview (task 1004125).
33
41
  //
34
42
  // Adding to this list is a deliberate edit someone has to argue for. It is pinned
35
43
  // by name in tests/core_upgrade_door.mjs so it cannot grow as a side effect.
36
- const CONTROL_PLANE_ACTIONS = Object.freeze(['core-upgrade', 'restart']);
44
+ const CONTROL_PLANE_ACTIONS = Object.freeze(['core-upgrade', 'core-upgrade-preflight', 'restart']);
37
45
 
38
46
  /**
39
47
  * May this intent action be aimed at an instance of this hosting shape? PURE.
@@ -209,6 +209,17 @@ function makeCoreUpgradeHandler({ provisioning, pool, parseId, validateOrRespond
209
209
  });
210
210
  } catch (err) {
211
211
  log.error('[provisioning] POST /provisioning/instances/:id/core-upgrade', err);
212
+ // A REFUSAL IS NOT AN OUTAGE. The control-plane guard throws an error carrying
213
+ // `code`, `status` and a `publicMessage` it wrote for exactly this moment — and
214
+ // this catch used to discard all three, so every guard refusal reached the owner
215
+ // as a 500 "internal error" while the real sentence sat in the journal. Nothing
216
+ // in the repo honoured `publicMessage`; it was assigned once and read never
217
+ // (task 1004125). Only an error that has already declared itself public is
218
+ // forwarded — anything else still gets the opaque 500, because an unexpected
219
+ // throw may carry anything.
220
+ if (err && err.publicMessage && err.code && err.status) {
221
+ return res.fail(err.code, { status: err.status, message: err.publicMessage });
222
+ }
212
223
  return res.fail('core_upgrade_failed', { status: 500, message: 'internal error' });
213
224
  }
214
225
  };
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.926",
3
+ "version": "1.19.928",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.926",
9
+ "version": "1.19.928",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.926",
3
+ "version": "1.19.928",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -30,6 +30,16 @@ function coreVersionSafe() {
30
30
  try { return require(path.join(REPO_ROOT, 'src/module-api')).CORE_VERSION; } catch { return null; }
31
31
  }
32
32
 
33
+ // What an allowFail step returns when its command failed (task 1004128). It KEEPS the
34
+ // output: a caller that opts into allowFail needs the reason, and returning an empty
35
+ // string made every upgrade pre-flight report a generic "the dry run did not complete"
36
+ // while `sudo: unknown user …` sat in the journal. WITHHELD when the step is
37
+ // secret-bearing (a `shown` call site), where only an exit status may ever surface.
38
+ function softFailResult(e, secretBearing) {
39
+ if (secretBearing) return { ok: true, stdout: '', softFailed: true };
40
+ return { ok: true, stdout: (e.stdout || '').toString(), stderr: (e.stderr || '').toString(), softFailed: true };
41
+ }
42
+
33
43
  function numEnv(name, dflt) {
34
44
  const v = process.env[name];
35
45
  if (v == null || v === '') return dflt;
@@ -219,4 +229,5 @@ module.exports = {
219
229
  numEnv,
220
230
  oauthSecret,
221
231
  provisionerBotEmail,
232
+ softFailResult,
222
233
  };
@@ -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
  }
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.926'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.928'; // 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');
@@ -93,10 +93,29 @@ await t('every action not explicitly allowed is refused on the self row', () =>
93
93
  }
94
94
  });
95
95
 
96
- await t('the allow-list is exactly the two acts that cannot destroy the box', () => {
96
+ await t('the allow-list is exactly the acts that cannot destroy the box', () => {
97
97
  // Pinned by NAME so widening it is a deliberate edit someone has to argue for,
98
- // not a side effect of adding an action to INTENT_ACTIONS.
99
- assert.deepEqual([...G.CONTROL_PLANE_ACTIONS].sort(), ['core-upgrade', 'restart']);
98
+ // not a side effect of adding an action to INTENT_ACTIONS. It did its job: adding
99
+ // 'core-upgrade-preflight' to INTENT_ACTIONS in task 1004121 did NOT reach here, and
100
+ // this line is what forced the argument to be made (task 1004125). The argument: a
101
+ // dry run returns at pre-flight having moved no pin, run no npm and restarted
102
+ // nothing, and the control plane is the only instance whose hall HAS an Upgrade
103
+ // page — so refusing the preview there protected nothing and removed everything.
104
+ assert.deepEqual([...G.CONTROL_PLANE_ACTIONS].sort(),
105
+ ['core-upgrade', 'core-upgrade-preflight', 'restart']);
106
+ });
107
+
108
+ await t('the preview is permitted on the control plane — the one row that can use it', () => {
109
+ assert.equal(G.intentAllowedForShape('core-upgrade-preflight', 'control-plane'), true);
110
+ });
111
+
112
+ await t('and widening it did NOT let the destructive acts through', () => {
113
+ // The whole risk of arguing one action onto an allow-list is arguing four.
114
+ for (const a of P.INTENT_ACTIONS) {
115
+ if (['core-upgrade', 'core-upgrade-preflight', 'restart'].includes(a)) continue;
116
+ assert.equal(G.intentAllowedForShape(a, 'control-plane'), false,
117
+ `'${a}' must still be refused against the box the platform runs on`);
118
+ }
100
119
  });
101
120
 
102
121
  await t('an ordinary instance is untouched by the guard', () => {
@@ -489,7 +508,7 @@ const validatorHarness = () => {
489
508
  fail: (code, o) => { body = { code, ...(typeof o === 'object' ? o : { status: o }) }; status = (o && o.status) || o || 400; return res; },
490
509
  status: (c) => { status = c; return res; },
491
510
  };
492
- return { calls, handler, res, read: () => ({ body, status }) };
511
+ return { calls, handler, res, provisioning, read: () => ({ body, status }) };
493
512
  };
494
513
 
495
514
  await t('the preview payload the page posts is ACCEPTED, not refused as an unknown field', async () => {
@@ -503,6 +522,38 @@ await t('the preview payload the page posts is ACCEPTED, not refused as an unkno
503
522
  assert.ok(queued, 'and it reaches the queue as a PREVIEW, not as a move');
504
523
  });
505
524
 
525
+ await t('a guard refusal reaches the owner as ITS OWN sentence, not "internal error"', async () => {
526
+ // The guard writes `code`, `status: 409` and a publicMessage for exactly this moment.
527
+ // The catch discarded all three, so every control-plane refusal this route ever made
528
+ // was shown as a 500 "internal error" while the real sentence sat in the journal —
529
+ // which is how the preview refusal stayed invisible until it was pressed live.
530
+ // Elsewhere in this file we already assert that sentence is well-formed; nothing
531
+ // asserted it ARRIVED.
532
+ const h = validatorHarness();
533
+ const refusal = Object.assign(new Error('blocked'), {
534
+ code: 'control_plane_protected', status: 409,
535
+ publicMessage: 'That action cannot be aimed at the machine the platform itself runs on.',
536
+ });
537
+ h.provisioning.enqueueIntent = async () => { throw refusal; };
538
+ await h.handler({ builder: { id: 42 }, params: { id: '7' }, body: { to: '1.19.926' } }, h.res);
539
+ const { body } = h.read();
540
+ assert.equal(body && body.code, 'control_plane_protected', 'the guard\'s own code, not core_upgrade_failed');
541
+ assert.equal(body && body.status, 409, 'a refusal is not a 500');
542
+ assert.match((body && body.message) || '', /cannot be aimed at the machine/);
543
+ });
544
+
545
+ await t('an UNEXPECTED throw is still opaque — this is not a blanket error passthrough', async () => {
546
+ // Forwarding every error would leak internals. Only an error that declared itself
547
+ // public (code + status + publicMessage) is forwarded.
548
+ const h = validatorHarness();
549
+ h.provisioning.enqueueIntent = async () => { throw new Error('connection terminated: relation "secrets" does not exist'); };
550
+ await h.handler({ builder: { id: 42 }, params: { id: '7' }, body: { to: '1.19.926' } }, h.res);
551
+ const { body } = h.read();
552
+ assert.equal(body && body.code, 'core_upgrade_failed');
553
+ assert.equal(body && body.message, 'internal error');
554
+ assert.ok(!/secrets|relation/.test(JSON.stringify(body)), 'no internals reach the client');
555
+ });
556
+
506
557
  await t('the move payload still validates, and still queues a move', async () => {
507
558
  const h = validatorHarness();
508
559
  await h.handler({ builder: { id: 42 }, params: { id: '7' }, body: { to: '1.19.925' } }, h.res);
@@ -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);
@@ -3126,5 +3126,46 @@ await ta('provisionInstance: the password IS written to web.env, which makeWrite
3126
3126
  assert.ok(env.body.includes('PGUSER=bongos-mercury'));
3127
3127
  });
3128
3128
 
3129
+
3130
+ // A soft failure must KEEP what the command said (task 1004128). `allowFail` exists so
3131
+ // a caller can act on a failure; returning stdout:'' left every such caller inventing a
3132
+ // generic sentence while the real reason sat in the journal. Two live pre-flights
3133
+ // reported "the dry run did not complete" over `sudo: unknown user bongos-test4` and
3134
+ // `✖ already on 1.19.927` — both were in hand and neither travelled.
3135
+ //
3136
+ // Driven through the REAL cp.execSync against a real failing shell, because the thing
3137
+ // being relied on is that Node populates e.stdout/e.stderr on a throw. A fake that
3138
+ // simply returns what the fix expects would prove nothing about that.
3139
+ t('makeExec(allowFail): a soft failure carries the command output back to the caller', () => {
3140
+ const logs = [];
3141
+ const run = P.makeExec({ apply: true, log: (m) => logs.push(m) });
3142
+ const r = run(`sh -c 'echo "✖ already on 1.19.927"; echo "sudo: unknown user bongos-test4" 1>&2; exit 1'`, { allowFail: true });
3143
+ assert.equal(r.softFailed, true, 'still a soft failure');
3144
+ assert.equal(r.ok, true, 'and still ok:true — the caller decides, not the runner');
3145
+ assert.match(r.stdout, /already on 1\.19\.927/, 'stdout survives');
3146
+ assert.match(r.stderr, /unknown user bongos-test4/, 'stderr survives');
3147
+ });
3148
+
3149
+ t('makeExec(allowFail): a SECRET-BEARING step still withholds its output', () => {
3150
+ // `shownAs` means the command text is secret-bearing by declaration, and the journal
3151
+ // deliberately carries only an exit status there. Returning its stderr would route
3152
+ // around that control on the return value instead of the log.
3153
+ const logs = [];
3154
+ const run = P.makeExec({ apply: true, log: (m) => logs.push(m) });
3155
+ const r = run(`sh -c 'echo "token=abcd1234"; echo "token=abcd1234" 1>&2; exit 1'`, { allowFail: true, shown: 'npm publish (token redacted)' });
3156
+ assert.equal(r.softFailed, true);
3157
+ assert.equal(r.stdout, '', 'no output escapes a secret-bearing step');
3158
+ assert.ok(!/abcd1234/.test(JSON.stringify(r)), 'nothing token-shaped is on the result at all');
3159
+ assert.ok(!logs.join('\n').includes('abcd1234'), 'nor in the log');
3160
+ });
3161
+
3162
+ t('makeExec: a SUCCEEDING step is unchanged', () => {
3163
+ const run = P.makeExec({ apply: true, log: () => {} });
3164
+ const r = run(`sh -c 'echo fine'`, { allowFail: true });
3165
+ assert.equal(r.ok, true);
3166
+ assert.ok(!r.softFailed, 'no soft-fail marker on a success');
3167
+ assert.match(r.stdout, /fine/);
3168
+ });
3169
+
3129
3170
  console.log(`\nprovision: ${passed} passed, ${failed} failed`);
3130
3171
  process.exit(failed ? 1 : 0);