@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.
- package/.bongos-core.json +54 -24
- package/docs/adr/0314-module-protection-is-per-path-not-per-module.md +10 -5
- package/docs/adr/0317-a-plane-that-cannot-give-an-instance-its-own-account-is-refused.md +148 -0
- package/docs/adr/0318-listing-a-file-for-task-affinity-is-not-owning-it.md +101 -0
- package/docs/adr/README.md +2 -0
- package/docs/module-api-changelog.md +4 -0
- package/docs/recipes/self-host.md +1 -1
- package/migrations/core_252_claims_published_head_sha_idx.sql +33 -0
- package/modules/lifecycle/cascade.js +92 -4
- package/modules/lifecycle/db-ship.js +43 -0
- package/modules/lifecycle/db.js +2 -0
- package/modules/lifecycle/removal-derivation.js +332 -0
- package/modules/lifecycle/routes/lifecycle.js +24 -0
- package/modules/lifecycle/tests/cascade.mjs +21 -4
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/provision-repo.js +52 -0
- package/scripts/gds/provision.js +7 -1
- package/src/bongos/module-scope-map.js +14 -4
- package/src/module-api.js +1 -1
- package/tests/module-scope-map.mjs +100 -7
- package/tests/provision_account_identity.mjs +135 -0
- package/tests/removal_proposal.mjs +583 -0
|
@@ -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,
|
package/scripts/gds/provision.js
CHANGED
|
@@ -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
|
|
144
|
-
// facade
|
|
145
|
-
//
|
|
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.
|
|
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`
|
|
82
|
-
//
|
|
83
|
-
//
|
|
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
|
|
636
|
-
//
|
|
637
|
-
|
|
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
|
+
});
|