@bongos/core 1.19.916 → 1.19.918

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.
@@ -201,6 +201,56 @@ function grantInstanceRepoReadCmd(inst, { privileged = false } = {}) {
201
201
  return `${privileged ? 'sudo ' : ''}usermod -aG ${APP_USER} ${instanceUser(inst)}`;
202
202
  }
203
203
 
204
+ // Can this plane give a child instance its OWN unix account AT ALL? (task 1004018, ADR 0317)
205
+ //
206
+ // ensureInstanceUserCmd above needs uid 0 — directly, or through the `privileged` sudo
207
+ // prefix. The self-host image runs `USER node` and installs no sudo, so there it cannot
208
+ // run, and every child on that plane would share one uid: exactly the /proc/<pid>/environ
209
+ // vector ADR 0281 exists to close. Probed, not configured — a runner either reaches root
210
+ // on this plane or it does not, and an operator who wants provisioning to proceed needs
211
+ // privilege, not a flag that says to pretend.
212
+ //
213
+ // The probe runs the SAME escalation the mutation will, so a pass is evidence rather than
214
+ // inference. Read-only, no mutation, and no pipeline: a pipeline's exit status is the last
215
+ // stage's, which would erase the difference below.
216
+ //
217
+ // A shell that RAN AND ANSWERED is the only failure that proves the plane cannot. Under
218
+ // remote exec boxExec is `ssh <host> bash -s`, so an unreachable co-hosting box throws
219
+ // here too — and answering that with "this plane cannot create accounts, provision
220
+ // elsewhere" would be a confident wrong diagnosis that burns the intent's retry budget on
221
+ // a network blip. Exit 1 (not root) and 127 (no sudo binary) are read as proof; ssh
222
+ // reports its own failures as 255, so both still mean what they say across the hop.
223
+ const ACCOUNT_PROOF_STATUS = new Set([1, 127, 9009]);
224
+ function accountPlaneGap(exec, { privileged = false } = {}) {
225
+ // sh -c, not a bare test: under sudo the outer (unprivileged) shell would expand
226
+ // $(id -u) before sudo ever ran, and the probe would answer about the wrong user.
227
+ const cmd = `${privileged ? 'sudo ' : ''}sh -c 'test "$(id -u)" = 0'`;
228
+ try { exec(cmd); return null; } catch (e) {
229
+ // e.stderr is a zero-length Buffer when the command failed SILENTLY — truthy, and
230
+ // String()s to '' — so it has to be length-checked or it shadows the only cause there
231
+ // is. `test` exiting 1 writes nothing, and that is this probe's commonest refusal.
232
+ const stderr = e && e.stderr && e.stderr.length ? e.stderr : null;
233
+ const why = String(stderr || (e && e.message) || e).trim().slice(0, 200) || `exit status ${e && e.status}`;
234
+ if (!ACCOUNT_PROOF_STATUS.has(e && e.status)) {
235
+ return `could not confirm the runner can create a per-instance unix account on this plane: '${cmd}' failed (${why}). Every instance is given its own account, so provisioning refuses rather than assume — re-run once the box answers.`;
236
+ }
237
+ return `this plane cannot create a per-instance unix account, so every child instance would run as one uid and could read its siblings' secrets: '${cmd}' failed (${why}). Provision from a host where the runner is root or holds sudo (PROVISION_SUDO=1) — a shared uid is the isolation ADR 0281 exists to provide, not a detail.`;
238
+ }
239
+ }
240
+
241
+ // The preflight around accountPlaneGap: the narration its sibling preflights carry,
242
+ // including the dry-run line without which the plan prints step 1c's useradd as though it
243
+ // will work. Returns the refusal, or null to proceed. `dedicated` skips it — that account
244
+ // is created by cloud-init, which runs as root. Lives here beside the account it guards.
245
+ function accountPreflight({ exec, apply, log, shape, privileged = false, probe = accountPlaneGap }) {
246
+ if (shape === 'dedicated') return null;
247
+ if (!apply) { log(' [preflight] would probe whether the runner can create this instance\'s own unix account'); return null; }
248
+ const missing = probe(exec, { privileged });
249
+ if (missing) return `Account preflight failed: ${missing}`;
250
+ log(' [preflight] the runner can create the instance\'s own unix account ✓');
251
+ return null;
252
+ }
253
+
204
254
  // Create-or-rotate the instance's PG login role and hand it its OWN database — and
205
255
  // nothing else. Three statements, one psql, ON_ERROR_STOP:
206
256
  // 1. CREATE ROLE ... LOGIN PASSWORD, or ALTER ... PASSWORD when it already exists (the
@@ -1020,6 +1070,8 @@ async function refreshStandaloneCorePin(inst, deps) {
1020
1070
 
1021
1071
  module.exports = {
1022
1072
  APP_USER,
1073
+ accountPlaneGap,
1074
+ accountPreflight,
1023
1075
  CORE_PKG_DIR,
1024
1076
  INSTANCE_GITIGNORE,
1025
1077
  alreadyScaffolded,
@@ -50,7 +50,7 @@ const cp = require('node:child_process');
50
50
  const crypto = require('node:crypto');
51
51
 
52
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');
53
- const { APP_USER, 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');
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');
56
56
  const { resolveProcessManager } = require('./provision-process.js');
@@ -276,6 +276,12 @@ async function provisionInstance(inst, deps) {
276
276
  // skipped 0303 entirely, reached step 6 and died on the snippet write.
277
277
  const ingressGap = ingressPreflight({ exec: boxExec, apply, log, shape: inst.hosting_shape, probe: deps.ingressPlaneGap });
278
278
  if (ingressGap) return { ok: false, error: ingressGap };
279
+ // Account preflight (task 1004018, ADR 0317). The third and last PLANE question — can this
280
+ // box give the child its own unix account? — before 0303 below asks the per-DOMAIN one. A
281
+ // plane that cannot would run every child as one uid, the ADR 0281 vector, and step 1c's
282
+ // useradd would crash mid-provision instead of refusing.
283
+ const accountGap = accountPreflight({ exec: boxExec, apply, log, shape: inst.hosting_shape, privileged, probe: deps.accountPlaneGap });
284
+ if (accountGap) return { ok: false, error: accountGap };
279
285
  // Caddy preflight (task 1002860, ADR 0303): a box whose base Caddyfile has no tls-check ask
280
286
  // can never certify this domain, so refuse before touching it. Injectable like the DNS check.
281
287
  const caddyCheck = deps.checkCaddyWiring || checkCaddyWiring;
@@ -62,6 +62,14 @@ const MODULE_GLOBS = {
62
62
  // exploit-detail engagement-doc store (/security/docs). Stays kernel when the
63
63
  // red-team FEATURE carved to modules/security/ (BV1.R79 / 02-module-map §5).
64
64
  'src/bongos/routes/security.js',
65
+ // The GET /me AGGREGATOR (task 1004110, ADR 0318). A core facade, not a
66
+ // builder-settings file: it resolves TEN module ports (lifecycle, reward,
67
+ // onboarding, government, specialities, grade, box, art, discord) and
68
+ // builder-settings is merely one of them. It was listed under that module
69
+ // for task AFFINITY, and the affinity listing alone made the whole module
70
+ // protected - ADR 0314 in a second costume. Ownership follows what the
71
+ // file IS, and what it is is the core read facade over ten modules.
72
+ 'src/bongos/routes/me.js',
65
73
  'server.js', 'config/',
66
74
 
67
75
  // ADOPTED (task 1004111): a protected path owned by no module is authorable
@@ -140,11 +148,13 @@ const MODULE_GLOBS = {
140
148
  // --- builder prefs (wandering/skill/sound/render) + builder-needs ---
141
149
  // Carved into modules/builder-settings/ (BV1.R80 / ADR 0093 §1): the five pref
142
150
  // libs + builder-needs + their db slice + the `/me/*-prefs` routes + the
143
- // `builder-settings` kernel PORT. The `GET /me` aggregator stays a thin core
144
- // facade (routes/me.js) resolving that port — listed for task affinity. The
145
- // pref-mirroring CLIs travel with the domain but stay under scripts/.
151
+ // `builder-settings` kernel PORT. The `GET /me` aggregator is NOT here: it
152
+ // stays a thin core facade resolving that port, and since task 1004110
153
+ // (ADR 0318) it is owned by KERNEL. Listing it here for task AFFINITY was
154
+ // the single glob that made this whole module protected, on a file the
155
+ // comment itself said was core. The pref-mirroring CLIs travel with the
156
+ // domain but stay under scripts/.
146
157
  'builder-settings': [
147
- 'src/bongos/routes/me.js',
148
158
  'scripts/gds/fetch-sound-prefs.js',
149
159
  'modules/builder-settings/',
150
160
  ],
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.916'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.918'; // 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');
@@ -2,6 +2,7 @@
2
2
  import assert from 'node:assert/strict';
3
3
  import { test } from 'node:test';
4
4
  import { createRequire } from 'node:module';
5
+ import fs from 'node:fs';
5
6
  import path from 'node:path';
6
7
  import { fileURLToPath } from 'node:url';
7
8
 
@@ -78,12 +79,24 @@ test('`protected` is DERIVED from the imported PROTECTED_GLOBS, never hand-set',
78
79
  // three is still refused by the path wall (asserted below). What changed is who
79
80
  // may CREATE a goal whose territory happens to include them.
80
81
  //
81
- // `builder-settings` STAYS, deliberately: its overlap is src/bongos/routes/me.js
82
- // caught by the blanket src/bongos/routes/ prefix, not a command, so narrowing it
83
- // needs the me.js strangler move or a blanket-glob change — out of scope here.
82
+ // NARROWED NINE -> EIGHT BY ADR 0318 (task 1004110). `builder-settings` left the
83
+ // set. It derived protected on exactly one glob, src/bongos/routes/me.js, caught
84
+ // by the blanket src/bongos/routes/ prefix. That glob was never an ownership
85
+ // claim: MODULE_GLOBS listed it for task AFFINITY and said in the same comment
86
+ // that the GET /me aggregator stays a thin CORE facade. It resolves ten module
87
+ // ports and builder-settings is one of them, so the file is kernel territory and
88
+ // is now owned there. Same shape as ADR 0314 one step on: owning the command is
89
+ // not owning the authority, and neither is being listed next to it.
90
+ //
91
+ // Neither of the two routes the task proposed was taken, and the measurements are
92
+ // why. Narrowing the blanket prefix was rejected because all 18 files under
93
+ // src/bongos/routes/ are protected by that prefix AND NOTHING ELSE, so narrowing
94
+ // removes the only wall from auth.js, builders.js, backup.js and core-update.js.
95
+ // Moving me.js into modules/builder-settings/ was rejected because a ten-port
96
+ // aggregator inside one module would have to resolve nine foreign ports from
97
+ // inside a module, which is the core->module edge ADR 0083 rule 1 forbids.
84
98
  const PROTECTED_MODULES = [
85
99
  'agents',
86
- 'builder-settings',
87
100
  'dev-box',
88
101
  'devsecops',
89
102
  'government',
@@ -632,9 +645,16 @@ test('adopting the unowned paths did NOT change which modules are protected', ()
632
645
  for (const k of ['kernel', 'lifecycle', 'dev-box', 'devsecops', 'methodology']) {
633
646
  assert.equal(scope.scopeForModule(k).protected, true, `${k} was already protected and must stay so`);
634
647
  }
635
- // and the ADR 0314 nine are still exactly the nine — PROTECTED_MODULES above
636
- // is the by-name pin; this states the intent of THIS task against it.
637
- assert.equal(scope.protectedModules().length, 9, 'adoption must not change the size of the protected set');
648
+ // and the adoption changed no COUNT either. Pinned against PROTECTED_MODULES
649
+ // rather than a literal, so the by-name pin above stays the single place the
650
+ // set is spelled out: a deliberate departure (ADR 0318 removing
651
+ // builder-settings) edits one list, while an ACCIDENTAL widening still reds
652
+ // both this and the by-name test. A literal here duplicated the roster and
653
+ // made an intended change look like two unrelated failures.
654
+ assert.equal(
655
+ scope.protectedModules().length, PROTECTED_MODULES.length,
656
+ 'adoption must not change the size of the protected set',
657
+ );
638
658
  });
639
659
 
640
660
  test('the probe trap: matchOne takes an ARRAY, and a string reports everything unowned', () => {
@@ -651,3 +671,76 @@ test('the probe trap: matchOne takes an ARRAY, and a string reports everything u
651
671
  'a STRING second argument must not be mistaken for a working query',
652
672
  );
653
673
  });
674
+
675
+ // --- ADR 0318: the GET /me aggregator is kernel territory (task 1004110) ---
676
+
677
+ test('the GET /me aggregator is owned by kernel, not by builder-settings', () => {
678
+ // The affinity listing that made a whole module Archon-only. MODULE_GLOBS put
679
+ // src/bongos/routes/me.js under builder-settings and said in the same comment
680
+ // that the aggregator stays a thin CORE facade - so the glob was task affinity,
681
+ // never an ownership claim, and isProtected() could not tell the two apart.
682
+ const ME = 'src/bongos/routes/me.js';
683
+ assert.ok(
684
+ matchOne(ME, scope.scopeForModule('kernel').globs),
685
+ 'kernel must own the /me aggregator - it is the core read facade over ten module ports',
686
+ );
687
+ assert.equal(
688
+ matchOne(ME, scope.scopeForModule('builder-settings').globs), null,
689
+ 'builder-settings must NOT own /me: it is one of the ten ports the facade resolves, not its home',
690
+ );
691
+ });
692
+
693
+ test('builder-settings is not protected, and its own territory is what says so', () => {
694
+ assert.equal(
695
+ scope.scopeForModule('builder-settings').protected, false,
696
+ 'builder-settings derived protected on the me.js affinity listing alone - removing it is the whole point of ADR 0318',
697
+ );
698
+ // Prove the module did not simply lose its territory on the way: it still owns
699
+ // its own directory, and that directory is genuinely not protected. Without
700
+ // this, blanking the globs entirely would pass the assertion above.
701
+ assert.ok(
702
+ matchOne('modules/builder-settings/interaction-prefs.js', scope.scopeForModule('builder-settings').globs),
703
+ 'builder-settings must still own its own module directory',
704
+ );
705
+ });
706
+
707
+ test('unprotecting builder-settings did NOT open the path wall on /me', () => {
708
+ // The half that would make this a false success, mirroring the ADR 0314 test
709
+ // above. The module flag decides who may CREATE a goal over the territory; the
710
+ // per-file wall decides who may WRITE it. Only the first was meant to move.
711
+ const ME = 'src/bongos/routes/me.js';
712
+ assert.ok(
713
+ touchesOverlap([ME], PROTECTED_GLOBS),
714
+ 'fixture assumption: /me is still a protected surface via the src/bongos/routes/ prefix',
715
+ );
716
+ assert.equal(gsc.scopeSubset(['kernel'], [ME]).ok, true, 'fixture assumption: kernel now owns /me');
717
+ const r = gsc.check(['kernel'], [ME], 'thetes');
718
+ assert.equal(r.ok, false, 'a sub-Metic builder must still be refused on /me');
719
+ assert.equal(r.code, 'protected_path', '/me must be refused BY THE PATH WALL, not by scope');
720
+ assert.equal(gsc.check(['kernel'], [ME], 'metic').ok, true, 'a Metic must still be able to author on /me');
721
+ });
722
+
723
+ test('every file under src/bongos/routes/ is protected by the blanket prefix AND NOTHING ELSE', () => {
724
+ // The measurement that killed the other option on task 1004110, pinned so the
725
+ // next reader does not have to re-derive it. Narrowing the blanket
726
+ // src/bongos/routes/ prefix looks like a small change and is not: it is the
727
+ // ONLY protection every route file has, auth.js and core-update.js included.
728
+ //
729
+ // If this reds because a route gained its own PROTECTED_GLOBS entry, that is
730
+ // fine and the exemption should be stated deliberately. If it reds because the
731
+ // blanket entry went away, read task 1004116 before touching anything.
732
+ const BLANKET = 'src/bongos/routes/';
733
+ assert.ok(PROTECTED_GLOBS.includes(BLANKET), 'the blanket route prefix must still be a protected glob');
734
+ const others = PROTECTED_GLOBS.filter((g) => g !== BLANKET);
735
+ const dir = path.join(ROOT, 'src', 'bongos', 'routes');
736
+ const files = fs.readdirSync(dir).filter((f) => f.endsWith('.js') || f.endsWith('.md'));
737
+ assert.ok(files.length >= 18, 'expected the route directory to still be populated, saw ' + files.length);
738
+ for (const f of files) {
739
+ const rel = BLANKET + f;
740
+ assert.ok(touchesOverlap([rel], PROTECTED_GLOBS), rel + ' must be protected');
741
+ assert.deepEqual(
742
+ others.filter((g) => matchOne(rel, [g])), [],
743
+ rel + ' is protected by the blanket prefix alone - narrowing the prefix would leave it unwalled',
744
+ );
745
+ }
746
+ });
@@ -0,0 +1,135 @@
1
+ // tests/provision_account_identity.mjs — a plane that cannot create a per-instance unix
2
+ // account is refused, not handed a shared uid (task 1004018, ADR 0317). ADR 0281 makes an
3
+ // instance's identity its own account; the self-host image runs `USER node` with no sudo,
4
+ // where `useradd` cannot run at all. Before this, that plane crashed mid-provision at step
5
+ // 1c — or, once someone gives the container a front door, would have quietly co-located
6
+ // every child under one uid. Hermetic: exec is a fake, nothing touches a box.
7
+ import assert from 'node:assert/strict';
8
+ import { test } from 'node:test';
9
+ import { createRequire } from 'node:module';
10
+
11
+ const require = createRequire(import.meta.url);
12
+ const P = require('../scripts/gds/provision.js');
13
+ const { accountPlaneGap, accountPreflight } = require('../scripts/gds/provision-repo.js');
14
+
15
+ const PROBE = `sh -c 'test "$(id -u)" = 0'`;
16
+ const isRoot = () => ({ ok: true, stdout: '' });
17
+ // What a shell that RAN reports: exit 1 from `test`, or 127 when sudo is not installed.
18
+ // execSync ALWAYS sets stderr to a Buffer, empty when the command failed silently — the
19
+ // fakes carry it because a fake that omits it hides a message with no cause in it.
20
+ const answered = (status, msg, stderr = '') => () => {
21
+ const e = new Error(msg); e.status = status; e.stderr = Buffer.from(stderr); throw e;
22
+ };
23
+ const unreachable = () => {
24
+ const e = new Error('Command failed'); e.status = 255;
25
+ e.stderr = Buffer.from('ssh: connect to host box port 22: Connection timed out'); throw e;
26
+ };
27
+
28
+ test('accountPlaneGap: a runner that reaches root passes; one that cannot is named as sharing a uid', () => {
29
+ const cmds = [];
30
+ assert.equal(accountPlaneGap((c) => { cmds.push(c); return isRoot(); }), null);
31
+ assert.deepEqual(cmds, [PROBE], 'one read-only command, no mutation');
32
+ const gap = accountPlaneGap(answered(1, 'Command failed'));
33
+ assert.match(gap, /cannot create a per-instance unix account/);
34
+ assert.match(gap, /one uid/, 'names the consequence, not just the failed command');
35
+ assert.match(gap, /ADR 0281/);
36
+ });
37
+
38
+ test('accountPlaneGap: the probe runs the SAME escalation step 1c will, so a pass is evidence', () => {
39
+ const cmds = [];
40
+ const rec = (c) => { cmds.push(c); return isRoot(); };
41
+ accountPlaneGap(rec, { privileged: true });
42
+ accountPlaneGap(rec, { privileged: false });
43
+ assert.deepEqual(cmds, [`sudo ${PROBE}`, PROBE]);
44
+ // sh -c, not a bare `test`: under sudo an outer-shell expansion would answer about the
45
+ // unprivileged user and pass on exactly the plane this refuses.
46
+ assert.ok(cmds.every((c) => /sh -c '/.test(c)), 'the command substitution runs INSIDE the escalation');
47
+ });
48
+
49
+ test('accountPlaneGap: only a shell that answered proves it — a transport failure says it could not confirm', () => {
50
+ const unsure = accountPlaneGap(unreachable);
51
+ assert.match(unsure, /could not confirm/);
52
+ assert.doesNotMatch(unsure, /Provision from a host/, 'a network blip is never diagnosed as a plane property');
53
+ assert.match(unsure, /Connection timed out/, 'the real cause leads');
54
+ // The container's two real shapes, both proof.
55
+ assert.match(accountPlaneGap(answered(127, 'Command failed', 'sh: 1: sudo: not found'), { privileged: true }), /sudo: not found/);
56
+ assert.match(accountPlaneGap(answered(1, 'Command failed')), /cannot create a per-instance unix account/);
57
+ });
58
+
59
+ test('accountPlaneGap: a SILENT failure still names a cause — an empty stderr is not a blank reason', () => {
60
+ // The commonest refusal there is: `test` exits 1 and writes nothing. execSync hands back a
61
+ // zero-length stderr Buffer, which is truthy and String()s to '' — so a naive
62
+ // `e.stderr || e.message` prints "failed ()" and tells the operator nothing.
63
+ const gap = accountPlaneGap(answered(1, 'Command failed: sh -c'));
64
+ assert.doesNotMatch(gap, /failed \(\)/, 'the parenthetical exists to name the cause');
65
+ assert.match(gap, /failed \(Command failed: sh -c\)/, 'falls through the empty Buffer to the message');
66
+ });
67
+
68
+ test('accountPreflight: a dedicated droplet skips it, and a dry run asks the box nothing', () => {
69
+ const cmds = [];
70
+ const rec = (c) => { cmds.push(c); return isRoot(); };
71
+ assert.equal(accountPreflight({ exec: rec, apply: true, log: () => {}, shape: 'dedicated' }), null);
72
+ assert.deepEqual(cmds, [], 'cloud-init creates that account as root — nothing to ask');
73
+ const lines = [];
74
+ assert.equal(accountPreflight({ exec: rec, apply: false, log: (m) => lines.push(m), shape: 'co-tenant' }), null);
75
+ assert.deepEqual(cmds, [], 'a dry run changes nothing and asks nothing');
76
+ assert.match(lines.join('\n'), /would probe/, 'the plan says the probe is coming, rather than printing step 1c as though it works');
77
+ });
78
+
79
+ // -- the runner leg ---------------------------------------------------------
80
+
81
+ const coTenant = { id: 1, slug: 'mercury', hosting_shape: 'co-tenant', status: 'requested', owner_builder_id: 7, port: 3003, db_name: 'mercury', domain: null };
82
+
83
+ // `noRoot` is the self-host image: USER node, no sudo, so the probe's shell answers 127.
84
+ function harness({ noRoot = false } = {}) {
85
+ const cmds = [], writes = [], statuses = [];
86
+ const exec = (cmd) => {
87
+ cmds.push(cmd);
88
+ if (noRoot && cmd.includes('id -u')) { const e = new Error('sh: 1: sudo: not found'); e.status = 127; throw e; }
89
+ return { ok: true, stdout: '' };
90
+ };
91
+ const deps = {
92
+ apply: true, privileged: true, log: () => {}, sleep: async () => {},
93
+ db: { query: async () => ({ rows: [] }) },
94
+ provisioning: {
95
+ setInstanceStatus: async (_db, _id, s) => { statuses.push(s); }, recordEvent: async () => {},
96
+ recordDnsNote: async () => {}, clearReleasedDomains: async () => {},
97
+ },
98
+ exec,
99
+ writeFile: (p) => { writes.push(p); return { ok: true }; },
100
+ doApi: null,
101
+ checkDnsTokenScope: async () => ({ reachable: true, zone: 'example.com' }),
102
+ checkCaddyWiring: () => null,
103
+ getGithubToken: async () => null,
104
+ };
105
+ return { deps, cmds, writes, statuses, ran: (re) => cmds.some((c) => re.test(c)) };
106
+ }
107
+
108
+ test('provision: a plane that cannot give the child its own account is refused before a single write', async () => {
109
+ const h = harness({ noRoot: true });
110
+ const r = await P.provisionInstance(coTenant, h.deps);
111
+ assert.equal(r.ok, false);
112
+ assert.match(r.error, /^Account preflight failed: /);
113
+ assert.match(r.error, /one uid/);
114
+ assert.equal(h.writes.length, 0, 'nothing written — not web.env, not the unit');
115
+ assert.ok(!h.ran(/useradd/), 'the useradd that cannot work is never attempted');
116
+ assert.ok(!h.ran(/createdb/), 'no database either — the refusal precedes every mutation');
117
+ assert.deepEqual(h.statuses, [], 'the instance never flips to provisioning');
118
+ });
119
+
120
+ test('provision: the probe runs BEFORE step 1c, so the refusal replaces the crash rather than following it', async () => {
121
+ const h = harness({ noRoot: true });
122
+ await P.provisionInstance(coTenant, h.deps);
123
+ assert.equal(h.cmds.filter((c) => c.includes('id -u')).length, 1, 'asked once');
124
+ assert.ok(h.cmds[h.cmds.length - 1].includes('id -u'), 'the probe is the last thing the box was asked');
125
+ });
126
+
127
+ test('provision: a box where the runner reaches root is unchanged — the live server still provisions', async () => {
128
+ const h = harness();
129
+ const r = await P.provisionInstance(coTenant, h.deps);
130
+ assert.equal(r.ok, true);
131
+ assert.ok(h.ran(/useradd --system/), 'step 1c still creates the per-instance account');
132
+ assert.ok(h.ran(/createdb mercury/));
133
+ const idx = (re) => h.cmds.findIndex((c) => re.test(c));
134
+ assert.ok(idx(/id -u.*= 0/) < idx(/useradd --system/), 'the probe precedes the account it guards');
135
+ });