@bongos/core 1.19.839 → 1.19.841
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 +73 -33
- package/clients/bongos-client/index.d.ts +1 -1
- package/config/scheduled-routines.json +29 -0
- package/docs/adr/0303-provisioning-verifies-the-base-caddyfile-and-never-edits-it.md +49 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +2 -0
- package/docs/architecture.md +2 -2
- package/docs/module-api-changelog.md +4 -0
- package/modules/government/protected-surfaces.json +2 -1
- package/modules/lifecycle/github-push.js +2 -2
- package/modules/lifecycle/pr-assign.js +77 -0
- package/modules/lifecycle/routes/tasks.js +16 -1
- package/modules/provisioning/pollers/liveness-notify.js +110 -0
- package/modules/provisioning/pollers/liveness-sweep.js +27 -2
- package/modules/provisioning/provisioning.js +9 -9
- package/modules/provisioning/tests/liveness-notify.mjs +199 -0
- package/modules/provisioning/tests/liveness.mjs +4 -3
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/paste-token.js +65 -14
- package/scripts/gds/provision-config.js +3 -0
- package/scripts/gds/provision-net.js +32 -0
- package/scripts/gds/provision.js +21 -4
- package/scripts/gds/publish-manifest.js +1 -0
- package/scripts/gds/ship-land.js +12 -0
- package/src/bongos/routes/auth.js +24 -3
- package/src/module-api.js +1 -1
- package/tests/bongos_cli.mjs +13 -4
- package/tests/cli_token_reissue.mjs +54 -7
- package/tests/government_protected_surfaces.mjs +181 -0
- package/tests/paste_token_api_base.mjs +87 -0
- package/tests/pr_assign.mjs +189 -0
- package/tests/provision.mjs +12 -0
- package/tests/provision_caddy_wiring.mjs +148 -0
package/scripts/gds/provision.js
CHANGED
|
@@ -52,7 +52,7 @@ const crypto = require('node:crypto');
|
|
|
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
53
|
const { APP_USER, alreadyScaffolded, buildCorePinRefreshCommit, 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
|
-
const { caddyBlock, caddySnippetPath, checkDnsTokenScope, classifyZoneScope, dnsReleaseEnv, dnsUpsertEnv, federateInstance, federationHubOrigin, healthzCmd, identityCmd, identityVerdict, shouldFederate, zoneForDomain } = require('./provision-net.js');
|
|
55
|
+
const { caddyBlock, caddySnippetPath, checkCaddyWiring, checkDnsTokenScope, classifyZoneScope, dnsReleaseEnv, dnsUpsertEnv, federateInstance, federationHubOrigin, healthzCmd, identityCmd, identityVerdict, shouldFederate, zoneForDomain } = require('./provision-net.js');
|
|
56
56
|
const { capacityPreflight, statusCapacityLine } = require('./provision-capacity.js');
|
|
57
57
|
const { installedCoreVersion, runnerBanner } = require('./runner-drift.js');
|
|
58
58
|
const { teardownInstance } = require('./provision-teardown.js');
|
|
@@ -263,6 +263,13 @@ async function provisionInstance(inst, deps) {
|
|
|
263
263
|
log(` [preflight] would verify the Cloudflare token can reach zone ${zoneForDomain(inst.domain)} before the address step`);
|
|
264
264
|
}
|
|
265
265
|
}
|
|
266
|
+
// Caddy preflight (task 1002860, ADR 0303): a box whose base Caddyfile has no tls-check ask
|
|
267
|
+
// can never certify this domain, so refuse before touching it. Injectable like the DNS check.
|
|
268
|
+
const caddyCheck = deps.checkCaddyWiring || checkCaddyWiring;
|
|
269
|
+
if (inst.domain && inst.hosting_shape !== 'dedicated' && apply) {
|
|
270
|
+
const gap = caddyCheck(boxExec, { domain: inst.domain });
|
|
271
|
+
if (gap) return { ok: false, error: `Caddy preflight failed: ${gap}` };
|
|
272
|
+
}
|
|
266
273
|
const room = await capacityPreflight(inst, deps); // box capacity (ADR 0285, task 1003927): an honest refusal BEFORE the status flip — never a silent oversubscribe
|
|
267
274
|
if (room.ok === false) return room;
|
|
268
275
|
if (apply) await provisioning.setInstanceStatus(db, inst.id, 'provisioning');
|
|
@@ -502,11 +509,14 @@ async function provisionInstance(inst, deps) {
|
|
|
502
509
|
// Write the instance's OWN <slug>.caddy snippet (idempotent overwrite via the
|
|
503
510
|
// injected writeFile — no read-modify-append to a shared Caddyfile), then reload.
|
|
504
511
|
// The main Caddyfile carries a one-time `import <snippetDir>/*.caddy` (operator
|
|
505
|
-
// setup)
|
|
512
|
+
// setup) — verified after the write, since only a written snippet proves the import
|
|
513
|
+
// reaches it. The reload is loud: Caddy keeps its old config when it rejects the new one.
|
|
506
514
|
log(' [caddy] write site snippet + reload');
|
|
507
515
|
if (local) {
|
|
508
516
|
boxWriteFile(caddySnippetPath(inst), caddyBlock(inst), { mode: 0o644, sudo: true });
|
|
509
|
-
|
|
517
|
+
const gap = inst.domain && apply ? caddyCheck(boxExec, { domain: inst.domain, requireHost: true }) : null;
|
|
518
|
+
if (gap) return { ok: false, error: `Caddy wiring failed: ${gap}` };
|
|
519
|
+
boxExec(`${sudoP}systemctl reload caddy`);
|
|
510
520
|
} else { log(' (Caddy on the droplet via cloud-init)'); }
|
|
511
521
|
|
|
512
522
|
// ── step 7: healthz ───────────────────────────────────────────────────────
|
|
@@ -740,6 +750,11 @@ async function domainAttachInstance(inst, deps) {
|
|
|
740
750
|
}
|
|
741
751
|
log(` [preflight] DNS token can reach zone ${scope.zone} ✓`);
|
|
742
752
|
}
|
|
753
|
+
const caddyCheck = deps.checkCaddyWiring || checkCaddyWiring; // Caddy preflight (task 1002860, ADR 0303)
|
|
754
|
+
if (apply) {
|
|
755
|
+
const gap = caddyCheck(boxExec, { domain: inst.domain });
|
|
756
|
+
if (gap) return { ok: false, error: `Caddy preflight failed: ${gap}` };
|
|
757
|
+
}
|
|
743
758
|
|
|
744
759
|
// The A-record target — the same derivation as provisionInstance step 1.
|
|
745
760
|
const recordIp = standalone
|
|
@@ -791,7 +806,9 @@ async function domainAttachInstance(inst, deps) {
|
|
|
791
806
|
// (and tls-check stops vouching for it) with no separate un-wire step.
|
|
792
807
|
log(' [caddy] write site snippet + reload');
|
|
793
808
|
boxWriteFile(caddySnippetPath(inst), caddyBlock(inst), { mode: 0o644, sudo: true });
|
|
794
|
-
|
|
809
|
+
const gap = apply ? caddyCheck(boxExec, { domain: inst.domain, requireHost: true }) : null;
|
|
810
|
+
if (gap) return { ok: false, error: `Caddy wiring failed: ${gap}` };
|
|
811
|
+
boxExec(`${sudoP}systemctl reload caddy`);
|
|
795
812
|
|
|
796
813
|
// Release what the re-point moved off, AFTER the new address is wired: the DNS
|
|
797
814
|
// record is the only half of an abandoned address the platform still holds, and
|
|
@@ -80,6 +80,7 @@ const PUBLISH_ALLOWLIST = [
|
|
|
80
80
|
'config/design-tokens.schema.json', // the design-token contract (generic schema, no instance values)
|
|
81
81
|
'config/hierarchy.neutral.json',
|
|
82
82
|
'config/government.neutral.json', // the starting rank templates a fresh instance inherits (BV1.R96; renamed from governance.neutral.json by ADR 0174)
|
|
83
|
+
'config/scheduled-routines.json', // the routine SWITCHBOARD (ADR 0115) — per routine: enabled, cadence, requiresAutonomy. It carries no *.neutral.json twin because it IS the neutral starter: ADR 0115's contract is that "a fresh instance inherits this whole set from the core template", and the operator then opts in/out HERE. PUBLISHES rather than topology-excludes for that reason — a released core without it would hand a new instance an empty scheduler and no record of what the routines under .claude/scheduled-tasks/ are for. It reached this list when task 1002500 registered it as a protected surface, which forces the choice; before that it was default-denied by silence, which is the accident that rule exists to catch.
|
|
83
84
|
// --- the portable methodology core, governance, and sanitized docs ---
|
|
84
85
|
'CLAUDE.md',
|
|
85
86
|
'README.md',
|
package/scripts/gds/ship-land.js
CHANGED
|
@@ -209,6 +209,8 @@ async function ciLandServer(taskId, branch, valueSummary) {
|
|
|
209
209
|
return landBailed(`server publish failed — ${e}`, strandCommand(taskId, 'server'));
|
|
210
210
|
}
|
|
211
211
|
console.log(` ci-land: server pushed ${branch} + PR ${r.data.pr_url} (auto-merge ${r.data.auto_merge ? 'enabled' : 'NOT enabled — merge it on GitHub'}).`);
|
|
212
|
+
const assigneeLine = prAssigneeLine(r.data.pr_assignee);
|
|
213
|
+
if (assigneeLine) console.log(assigneeLine);
|
|
212
214
|
return pollServerPublish(taskId, branch);
|
|
213
215
|
} finally {
|
|
214
216
|
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) { /* best-effort */ }
|
|
@@ -332,6 +334,15 @@ function reconcileBlockedLines(taskId, reason) {
|
|
|
332
334
|
];
|
|
333
335
|
}
|
|
334
336
|
|
|
337
|
+
// task 1003991 — the server opens the PR with ITS credential, so "created by me" on
|
|
338
|
+
// GitHub is empty for the builder; it assigns the PR to them instead, and this says
|
|
339
|
+
// where that makes it findable. Null when the server assigned nobody. Pure + exported.
|
|
340
|
+
function prAssigneeLine(login) {
|
|
341
|
+
return /^[A-Za-z\d_-]{1,39}$/.test(String(login || ''))
|
|
342
|
+
? ` ci-land: the PR is assigned to @${login} — on GitHub it is under Pull requests → Assigned to you (the server opened it, so "Created by you" stays empty).`
|
|
343
|
+
: null;
|
|
344
|
+
}
|
|
345
|
+
|
|
335
346
|
// task 1003988 — what to tell a builder while their PR sits RED. The poll used to
|
|
336
347
|
// wait out its nine minutes in silence, so a credential-less box learned only that
|
|
337
348
|
// the land was "pending" and pushed the same commit again (PR 922: nine red runs).
|
|
@@ -724,6 +735,7 @@ module.exports = {
|
|
|
724
735
|
landed,
|
|
725
736
|
pickStaleGateReviewRun,
|
|
726
737
|
reconcileBlockedLines,
|
|
738
|
+
prAssigneeLine,
|
|
727
739
|
redPrLine,
|
|
728
740
|
redPrLines,
|
|
729
741
|
reconciledCardStatus,
|
|
@@ -1066,20 +1066,41 @@ module.exports = function buildAuthRouter() {
|
|
|
1066
1066
|
// The builder kills it from this same Settings page, under Active sessions.
|
|
1067
1067
|
expiresAt: null,
|
|
1068
1068
|
});
|
|
1069
|
+
// NAME THE INSTANCE THAT MINTED THIS (task 1003722). A bearer is only
|
|
1070
|
+
// meaningful against the instance that issued it, and the CLI cannot work
|
|
1071
|
+
// that out on its own: paste-token resolves its base as env → the EXISTING
|
|
1072
|
+
// session's api_base → branding default. So on any machine that has talked
|
|
1073
|
+
// to another instance, the STALE api_base wins and a perfectly good token is
|
|
1074
|
+
// verified against — and then bound to — the wrong host. That is the reported
|
|
1075
|
+
// bug: a token minted here landed as a token for a different instance.
|
|
1076
|
+
//
|
|
1077
|
+
// The issuer is the one fact that cannot be recovered at the other end, so it
|
|
1078
|
+
// travels with the token. Same discipline as configuredOAuthOrigin() above:
|
|
1079
|
+
// this instance may state its OWN identity and must assume nothing about the
|
|
1080
|
+
// recipient's machine.
|
|
1081
|
+
const issuer = String(resolveEnv('PUBLIC_ORIGIN') || configuredOAuthOrigin() || '')
|
|
1082
|
+
.trim().replace(/\/+$/, '');
|
|
1069
1083
|
res.json({
|
|
1070
1084
|
ok: true,
|
|
1071
1085
|
token,
|
|
1072
1086
|
expires_at: row.expires_at,
|
|
1073
1087
|
builder_login: req.builder.github_login,
|
|
1088
|
+
api_base: issuer || null,
|
|
1074
1089
|
// A VERB, never a path (task 1003961). This string is composed HERE and run
|
|
1075
1090
|
// THERE — on an instance, where the core is an installed dependency and
|
|
1076
1091
|
// `scripts/gds/` does not exist, so the previous
|
|
1077
1092
|
// `node scripts/gds/paste-token.js <token>` named a file the builder did not
|
|
1078
1093
|
// have and the copy button handed out a command that could not run. `bongos`
|
|
1079
1094
|
// is on PATH in any installed instance, so the verb form is layout- and
|
|
1080
|
-
// directory-independent.
|
|
1081
|
-
//
|
|
1082
|
-
|
|
1095
|
+
// directory-independent.
|
|
1096
|
+
//
|
|
1097
|
+
// The --api-base flag carries the issuer above. Omitted when this instance
|
|
1098
|
+
// cannot state its own origin, because a flag with an empty value would be
|
|
1099
|
+
// worse than none: paste-token would take '' as an explicit instruction and
|
|
1100
|
+
// stop falling back to the resolution that works today.
|
|
1101
|
+
paste_command: issuer
|
|
1102
|
+
? `bongos paste-token ${token} --api-base ${issuer}`
|
|
1103
|
+
: `bongos paste-token ${token}`,
|
|
1083
1104
|
});
|
|
1084
1105
|
} catch (err) {
|
|
1085
1106
|
log.error('[gds] POST /auth/cli-token/issue', err);
|
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.841'; // 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');
|
package/tests/bongos_cli.mjs
CHANGED
|
@@ -65,10 +65,19 @@ test('paste-token is bundled into the standalone binary, like reauth', () => {
|
|
|
65
65
|
|
|
66
66
|
test('the server emits exactly the verb the CLI registers', () => {
|
|
67
67
|
const routes = readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'src', 'bongos', 'routes', 'auth.js'), 'utf8');
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
68
|
+
// Read EVERY branch, not just a literal sitting immediately after the key. Since
|
|
69
|
+
// task 1003722 paste_command is a ternary (the issuing instance is stamped in when
|
|
70
|
+
// known), and a matcher anchored on `paste_command:\s*\`` silently stopped matching
|
|
71
|
+
// at all — reporting "auth.js should emit a paste_command template literal" for a
|
|
72
|
+
// field that was right there. A test that cannot find what it checks fails as though
|
|
73
|
+
// the thing were missing, which sends the reader hunting in the wrong file.
|
|
74
|
+
const tail = routes.slice(routes.indexOf('paste_command:'));
|
|
75
|
+
const cmds = [...tail.matchAll(/`([^`]*bongos paste-token[^`]*)`/g)].map((x) => x[1]);
|
|
76
|
+
assert.ok(cmds.length > 0, 'auth.js should emit at least one paste_command template literal');
|
|
77
|
+
for (const cmd of cmds) {
|
|
78
|
+
const verb = cmd.split(' ')[1];
|
|
79
|
+
assert.ok(cli.VERBS[verb], `server emits "bongos ${verb}" but the CLI has no such verb`);
|
|
80
|
+
}
|
|
72
81
|
});
|
|
73
82
|
|
|
74
83
|
test('VERBS: selfHelp is only claimed by scripts that really print their own usage', () => {
|
|
@@ -115,10 +115,24 @@ t('POST /auth/cli-token/issue is defined', () => {
|
|
|
115
115
|
const issueBlock = (() => {
|
|
116
116
|
const start = routeSrc.indexOf("'/auth/cli-token/issue'");
|
|
117
117
|
if (start === -1) return '';
|
|
118
|
-
//
|
|
119
|
-
|
|
118
|
+
// Bound the window at the NEXT route registration rather than a fixed char count.
|
|
119
|
+
// A fixed ~3000-char window silently truncates as soon as the handler grows: adding
|
|
120
|
+
// a comment to this route pushed paste_command out of the slice, and every assertion
|
|
121
|
+
// about it failed as though the field had been deleted (task 1003722). A byte budget
|
|
122
|
+
// is not a unit of code, and a scan that can quietly stop seeing what it checks is
|
|
123
|
+
// the same failure shape as a scan that quietly finds nothing.
|
|
124
|
+
const next = routeSrc.indexOf('\n router.', start);
|
|
125
|
+
return routeSrc.slice(start, next === -1 ? routeSrc.length : next);
|
|
120
126
|
})();
|
|
121
127
|
|
|
128
|
+
// The window above must actually contain the handler — otherwise every assertion in
|
|
129
|
+
// this file passes or fails for reasons that have nothing to do with the route.
|
|
130
|
+
t('the extracted route window covers the whole handler', () => {
|
|
131
|
+
assert.ok(issueBlock.includes('sessionCookieToken(req)'), 'window should reach the cookie gate near the top');
|
|
132
|
+
assert.ok(issueBlock.includes('paste_command'), 'window should reach the response at the bottom');
|
|
133
|
+
assert.ok(!issueBlock.includes("'/auth/revoke'"), 'window should stop before the next route');
|
|
134
|
+
});
|
|
135
|
+
|
|
122
136
|
// Refreshed for the current handler (task 1003961). These two assertions named
|
|
123
137
|
// `parseCookie(req, SESSION_COOKIE)` and `res.status(403)`, the shape BEFORE the
|
|
124
138
|
// handler moved to the `sessionCookieToken` helper and the `res.fail` envelope.
|
|
@@ -154,15 +168,48 @@ t('returns a paste_command field the portal can show verbatim', () => {
|
|
|
154
168
|
// instance builder a command naming a file they did not have. A verb has no such
|
|
155
169
|
// coupling — `bongos` is on PATH wherever the core is installed.
|
|
156
170
|
t('paste_command names a CLI VERB, never a filesystem path', () => {
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
171
|
+
// Read EVERY template literal the field can evaluate to, not just the first. It is a
|
|
172
|
+
// ternary since task 1003722 (issuer present / absent), and an assertion that saw
|
|
173
|
+
// only one branch would leave the other unguarded — which is how a rule stops
|
|
174
|
+
// applying without anyone editing it.
|
|
175
|
+
const tail = issueBlock.slice(issueBlock.indexOf('paste_command:'));
|
|
176
|
+
const cmds = [...tail.matchAll(/`([^`]*bongos paste-token[^`]*)`/g)].map((m) => m[1]);
|
|
177
|
+
assert.equal(cmds.length, 2, `expected both ternary branches, got ${cmds.length}`);
|
|
178
|
+
for (const cmd of cmds) {
|
|
160
179
|
assert.ok(/^bongos paste-token /.test(cmd),
|
|
161
180
|
`paste_command must start with the verb form, got: ${cmd}`);
|
|
162
|
-
|
|
163
|
-
|
|
181
|
+
// The invariant is "names no FILE ON THE RECIPIENT'S DISK", which the original
|
|
182
|
+
// spelling approximated as "contains no slash". That was too broad once the command
|
|
183
|
+
// had to carry the issuer URL (task 1003722) — `https://host` is not a filesystem
|
|
184
|
+
// path, and forbidding every slash would have blocked the fix for a different bug
|
|
185
|
+
// while protecting nothing extra. Pin what the comment above actually says instead.
|
|
186
|
+
assert.ok(!/(^|\s)[.~]?\/|\bscripts\b|\.js\b/.test(cmd),
|
|
187
|
+
`paste_command must name no path or script file — it runs where the layout differs. Got: ${cmd}`);
|
|
164
188
|
assert.ok(!/\bnode\b/.test(cmd),
|
|
165
189
|
`paste_command must not invoke node directly (that assumes a script on disk). Got: ${cmd}`);
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// The bug this pins (task 1003722): a bearer is only valid against the instance that
|
|
194
|
+
// minted it, but paste-token infers its base as env → the EXISTING session's api_base
|
|
195
|
+
// → branding default. On a machine that has used a second instance the stale api_base
|
|
196
|
+
// wins, so a token minted here was bound to the wrong host and every /builder-* command
|
|
197
|
+
// then authenticated somewhere unintended. The issuer cannot be recovered at the other
|
|
198
|
+
// end, so it has to travel with the token.
|
|
199
|
+
t('paste_command carries the issuing instance, so the CLI cannot guess wrong', () => {
|
|
200
|
+
const m = issueBlock.match(/paste_command:\s*issuer\s*\?\s*`([^`]+)`/);
|
|
201
|
+
assert.ok(m, 'paste_command should still be a readable template literal');
|
|
202
|
+
assert.ok(/--api-base \$\{issuer\}/.test(m[1]),
|
|
203
|
+
`the issuing origin must be stamped into the command, got: ${m[1]}`);
|
|
204
|
+
assert.ok(/api_base:\s*issuer/.test(issueBlock),
|
|
205
|
+
'the response should also expose api_base for non-copy-paste consumers');
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
t('an instance that cannot state its own origin omits the flag rather than sending an empty one', () => {
|
|
209
|
+
// `--api-base ''` would read at the far end as an explicit instruction and suppress
|
|
210
|
+
// the fallback that works today — strictly worse than sending no flag at all.
|
|
211
|
+
assert.ok(/:\s*`bongos paste-token \$\{token\}`/.test(issueBlock),
|
|
212
|
+
'there must be a flagless branch for the empty-issuer case');
|
|
166
213
|
});
|
|
167
214
|
|
|
168
215
|
// ---------------------------------------------------------------------------
|
|
@@ -17,6 +17,7 @@ import { test } from 'node:test';
|
|
|
17
17
|
import { createRequire } from 'node:module';
|
|
18
18
|
import { execFileSync } from 'node:child_process';
|
|
19
19
|
import fs from 'node:fs';
|
|
20
|
+
import os from 'node:os';
|
|
20
21
|
import path from 'node:path';
|
|
21
22
|
import { fileURLToPath } from 'node:url';
|
|
22
23
|
|
|
@@ -226,6 +227,19 @@ const ADDED_SINCE_R101 = [
|
|
|
226
227
|
+ 'that computed set is expected. Registered because it is the code that puts the '
|
|
227
228
|
+ 'credential on the wire.',
|
|
228
229
|
},
|
|
230
|
+
// task 1002500. The routine SWITCHBOARD — the counterpart to the hooks/settings entries
|
|
231
|
+
// in group 'automation', which protect how a session behaves but said nothing about what
|
|
232
|
+
// fires with no session at all. The routine-switch tripwire below recomputes this set
|
|
233
|
+
// from config/ by SHAPE, so a second switchboard cannot land unregistered.
|
|
234
|
+
{
|
|
235
|
+
after: '.claude/settings.json',
|
|
236
|
+
glob: 'config/scheduled-routines.json',
|
|
237
|
+
why: 'decides, per routine, enabled / cadence / requiresAutonomy. Arming '
|
|
238
|
+
+ 'overnight-builder there starts an autonomous claim-and-ship loop; clearing '
|
|
239
|
+
+ "main-audit's entry silences the post-merge backstop that catches a sub-Metic "
|
|
240
|
+
+ 'edit which slipped past the pre-push hook and the grader — the one ADR 0043 '
|
|
241
|
+
+ 'layer that does not depend on the builder cooperating.',
|
|
242
|
+
},
|
|
229
243
|
];
|
|
230
244
|
|
|
231
245
|
// Surfaces RENAMED since R101 — still protected, under a new path. Distinct from
|
|
@@ -561,6 +575,173 @@ test('registering the credential readers widened no module wall', () => {
|
|
|
561
575
|
+ 'is not, the premise of the box.js entry has changed.');
|
|
562
576
|
});
|
|
563
577
|
|
|
578
|
+
// ---------- the routine-switch tripwire: every SWITCHBOARD is a protected surface ----------
|
|
579
|
+
//
|
|
580
|
+
// task 1002500. Group 'automation' protected how a SESSION behaves — .claude/hooks/ and
|
|
581
|
+
// .claude/settings.json — and said nothing about what fires when no session exists at all.
|
|
582
|
+
// config/scheduled-routines.json is that file: per routine it carries enabled, cadence and
|
|
583
|
+
// requiresAutonomy, so one edit there arms overnight-builder (an autonomous claim-and-ship
|
|
584
|
+
// loop) or silences main-audit, which is the only ADR 0043 layer that does not depend on
|
|
585
|
+
// the builder cooperating.
|
|
586
|
+
//
|
|
587
|
+
// DETECTED BY SHAPE, NOT BY NAME. Naming the one file we know about today is the failure
|
|
588
|
+
// this project keeps re-learning (the audit's stale "gen-* trio" above, the filing task's
|
|
589
|
+
// wrong path for the Cloudflare reader): the registry would record the gap as closed while
|
|
590
|
+
// a second switchboard lands beside it unprotected. So the set is recomputed from config/
|
|
591
|
+
// on every run, and an entry whose values decide whether-and-how-often a routine runs must
|
|
592
|
+
// be registered or this reds.
|
|
593
|
+
//
|
|
594
|
+
// WHY ONLY THE SWITCHBOARD, NOT THE ROUTINE BODIES. .claude/scheduled-tasks/ is the
|
|
595
|
+
// autonomy module's source surface, and `protected` DERIVES per module from overlap with
|
|
596
|
+
// this registry — so registering the bodies would rank-wall the whole autonomy module and
|
|
597
|
+
// every goal scoped to it. That is a policy decision with three deliberate edits attached
|
|
598
|
+
// (registry, publish-manifest carve-out, the module-scope-map roster), exactly as recorded
|
|
599
|
+
// for .claude/agents/ at the head of this file. Protecting the switchboard gates who may
|
|
600
|
+
// ARM a routine; it deliberately says nothing about who may edit what one does.
|
|
601
|
+
const CONFIG_DIR = path.join(ROOT, 'config');
|
|
602
|
+
|
|
603
|
+
// Does this JSON declare routine SWITCHES — entries deciding whether a routine runs and
|
|
604
|
+
// how often? True for an object carrying `requiresAutonomy`, or `enabled` alongside a
|
|
605
|
+
// `cadence`. Recursive so a flat manifest is caught as readily as the nested `routines`
|
|
606
|
+
// map today; `enabled` ALONE is deliberately not enough, or every feature-flag file in
|
|
607
|
+
// config/ would match and the tripwire would mean nothing.
|
|
608
|
+
//
|
|
609
|
+
// MAX_SWITCH_DEPTH is the deepest nesting a switchboard is searched at. The shapes that
|
|
610
|
+
// exist are depth 0 (a flat `{ "<routine>": {...} }` map, where the entry sits one level
|
|
611
|
+
// in) and depth 1 (today's `{ "routines": { "<routine>": {...} } }`). Three is that plus a
|
|
612
|
+
// level of headroom, and it is a bound rather than a guess: the cost of it being too low
|
|
613
|
+
// is a switchboard buried deeper than any we write going unnoticed, and the cost of
|
|
614
|
+
// removing it is walking arbitrarily deep into unrelated config for a key that only ever
|
|
615
|
+
// appears near the top. Raise it and the tripwire still holds; the negative test below is
|
|
616
|
+
// what would catch a raise that starts matching ordinary config.
|
|
617
|
+
const MAX_SWITCH_DEPTH = 3;
|
|
618
|
+
|
|
619
|
+
function declaresRoutineSwitches(node, depth = 0) {
|
|
620
|
+
if (!node || typeof node !== 'object' || Array.isArray(node) || depth > MAX_SWITCH_DEPTH) return false;
|
|
621
|
+
if (typeof node.requiresAutonomy === 'boolean') return true;
|
|
622
|
+
if (typeof node.enabled === 'boolean' && typeof node.cadence === 'string') return true;
|
|
623
|
+
return Object.values(node).some((v) => declaresRoutineSwitches(v, depth + 1));
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// Every config/*.json that declares routine switches, as repo-relative paths.
|
|
627
|
+
function routineSwitchManifests(dir = CONFIG_DIR) {
|
|
628
|
+
const out = [];
|
|
629
|
+
for (const name of fs.readdirSync(dir).sort()) {
|
|
630
|
+
if (!name.endsWith('.json')) continue;
|
|
631
|
+
let doc;
|
|
632
|
+
try { doc = JSON.parse(fs.readFileSync(path.join(dir, name), 'utf8')); } catch (_) { continue; }
|
|
633
|
+
if (declaresRoutineSwitches(doc)) out.push(`config/${name}`);
|
|
634
|
+
}
|
|
635
|
+
return out;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
test('declaresRoutineSwitches detects a switchboard in both shapes', () => {
|
|
639
|
+
assert.equal(declaresRoutineSwitches({ routines: { a: { enabled: true, cadence: 'daily' } } }), true,
|
|
640
|
+
'the nested shape config/scheduled-routines.json uses today');
|
|
641
|
+
assert.equal(declaresRoutineSwitches({ 'overnight-builder': { requiresAutonomy: true } }), true,
|
|
642
|
+
'a FLAT manifest — the shape a second switchboard is most likely to arrive in');
|
|
643
|
+
assert.equal(declaresRoutineSwitches({ a: { b: { c: { requiresAutonomy: false } } } }), true,
|
|
644
|
+
'requiresAutonomy:false still declares the switch — the value is policy, the key is the shape');
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
test('declaresRoutineSwitches does not fire on a near-miss', () => {
|
|
648
|
+
assert.equal(declaresRoutineSwitches({ game: false, discord: true }), false,
|
|
649
|
+
'a feature-flag map is not a routine switchboard');
|
|
650
|
+
assert.equal(declaresRoutineSwitches({ mod: { enabled: true } }), false,
|
|
651
|
+
'enabled ALONE is not enough — it must decide how often something fires');
|
|
652
|
+
assert.equal(declaresRoutineSwitches({ chart: { cadence: 'weekly' } }), false,
|
|
653
|
+
'a cadence with nothing enabling it is prose, not a switch');
|
|
654
|
+
assert.equal(declaresRoutineSwitches(null), false);
|
|
655
|
+
assert.equal(declaresRoutineSwitches([{ enabled: true, cadence: 'daily' }]), false,
|
|
656
|
+
'an array is not an entry map');
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
// The near-miss test above uses hand-built objects, which only proves the detector rejects
|
|
660
|
+
// the shapes I thought to write. This proves the negative against the REAL directory: of
|
|
661
|
+
// the config files actually in the tree, exactly one is a switchboard. Without it, a
|
|
662
|
+
// detector that matched everything in config/ would still pass the tripwire (every other
|
|
663
|
+
// file would simply have to be registered too, and the failure would read as a coverage
|
|
664
|
+
// gap rather than as an over-broad detector).
|
|
665
|
+
test('exactly one file in the real config/ is a switchboard — the rest do not match', () => {
|
|
666
|
+
const all = fs.readdirSync(CONFIG_DIR).filter((n) => n.endsWith('.json')).sort();
|
|
667
|
+
assert.ok(all.length >= 5,
|
|
668
|
+
`fixture sanity: config/ should hold several json files, found ${all.length}`);
|
|
669
|
+
const matched = routineSwitchManifests();
|
|
670
|
+
assert.deepEqual(matched, ['config/scheduled-routines.json'],
|
|
671
|
+
`the detector matched ${matched.length} of the ${all.length} files in config/. One is `
|
|
672
|
+
+ 'correct. More means the shape test is too loose — a feature-flag or token file is '
|
|
673
|
+
+ 'being read as a routine switchboard, which would turn this tripwire into a demand '
|
|
674
|
+
+ 'to rank-wall ordinary config. Scanned: ' + all.join(', '));
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
test('every routine switchboard in config/ is a protected surface (1002500 tripwire)', () => {
|
|
678
|
+
const boards = routineSwitchManifests();
|
|
679
|
+
assert.ok(boards.includes('config/scheduled-routines.json'),
|
|
680
|
+
'fixture sanity: the known switchboard must be detected, or this tripwire is vacuous '
|
|
681
|
+
+ '— the failure mode where a scan passes by quietly finding nothing');
|
|
682
|
+
for (const rel of boards) {
|
|
683
|
+
const surface = ppc.surfaceFor(rel);
|
|
684
|
+
assert.ok(surface,
|
|
685
|
+
`${rel} decides whether scheduled routines run and how often — arming one starts `
|
|
686
|
+
+ 'unattended work, silencing one removes a gate — but no protected surface covers '
|
|
687
|
+
+ 'it. Add it to modules/government/protected-surfaces.json (group "automation") and '
|
|
688
|
+
+ 'declare it in ADDED_SINCE_R101 above.');
|
|
689
|
+
assert.ok(ppc.RANK_TIER[surface.floor] >= ppc.RANK_TIER.metic,
|
|
690
|
+
`${rel} is a routine switchboard floored at '${surface.floor}' — below Metic it `
|
|
691
|
+
+ 'stays writable by the ranks ADR 0043 keeps out of the pipeline.');
|
|
692
|
+
}
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
// The tripwire above passes today because the one switchboard IS registered — which is
|
|
696
|
+
// also what it would look like if it were broken and finding nothing. This proves the RED
|
|
697
|
+
// path without mutating the shipped registry: a switchboard that is not in it must be
|
|
698
|
+
// detected AND must resolve to no protected surface, which is exactly the pair the loop
|
|
699
|
+
// above turns into a failure.
|
|
700
|
+
test('the tripwire fires on an UNREGISTERED switchboard', () => {
|
|
701
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'bongos-switchboard-probe-'));
|
|
702
|
+
try {
|
|
703
|
+
fs.writeFileSync(path.join(dir, 'unrelated.json'), JSON.stringify({ game: false }));
|
|
704
|
+
fs.writeFileSync(path.join(dir, 'rogue-routines.json'),
|
|
705
|
+
JSON.stringify({ routines: { 'overnight-builder': { enabled: true, cadence: 'nightly', requiresAutonomy: true } } }));
|
|
706
|
+
const found = routineSwitchManifests(dir);
|
|
707
|
+
assert.deepEqual(found, ['config/rogue-routines.json'],
|
|
708
|
+
'the detector must find the rogue switchboard and ignore the feature-flag file');
|
|
709
|
+
assert.equal(ppc.surfaceFor('config/rogue-routines.json'), null,
|
|
710
|
+
'an unregistered config path must resolve to no surface — the falsy half the '
|
|
711
|
+
+ 'tripwire asserts on. If this ever returns a surface, a config/ subtree glob was '
|
|
712
|
+
+ 'added and the tripwire can no longer distinguish registered from unregistered.');
|
|
713
|
+
} finally {
|
|
714
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
715
|
+
}
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
test('registering the switchboard widened no module wall', () => {
|
|
719
|
+
// The same second-order effect asserted for the credential readers above. config/ is
|
|
720
|
+
// kernel territory (module-scope-map's kernel key), and kernel was already protected via
|
|
721
|
+
// src/bongos/auth.js and db.js — so this entry must change nothing.
|
|
722
|
+
//
|
|
723
|
+
// `agents` is the module to watch, and the ONLY one this test can honestly cite.
|
|
724
|
+
// `autonomy` — which owns .claude/scheduled-tasks/, the routine bodies — was ALREADY
|
|
725
|
+
// protected before this entry, through scripts/gds/conductor-dispatch.js in group
|
|
726
|
+
// 'pipeline' (verified glob by glob: none of its other five globs is protected). So its
|
|
727
|
+
// flag is not evidence either way here, and asserting it unprotected would have been a
|
|
728
|
+
// test that fails for a reason unrelated to what it claims to check.
|
|
729
|
+
const map = require(path.join(ROOT, 'src', 'bongos', 'module-scope-map.js'));
|
|
730
|
+
const protectedNow = map.protectedModules();
|
|
731
|
+
assert.ok(protectedNow.includes('kernel'),
|
|
732
|
+
'kernel owns config/ and was already protected before this entry; if it is not, the '
|
|
733
|
+
+ 'premise that registering a config/ file widens nothing has changed.');
|
|
734
|
+
assert.ok(!protectedNow.includes('agents'),
|
|
735
|
+
'the agents module derived protected:true — a routine entry reached modules/agents/ '
|
|
736
|
+
+ 'or .claude/agents/, which rank-walls every goal scoped to it. Protect the '
|
|
737
|
+
+ 'SWITCHBOARD, not the agent or routine sources; walling that module is the separate '
|
|
738
|
+
+ 'policy decision recorded for .claude/agents/ at the head of this file.');
|
|
739
|
+
assert.ok(ppc.surfaceFor('scripts/gds/conductor-dispatch.js'),
|
|
740
|
+
'the autonomy module was protected through conductor-dispatch.js before this entry. '
|
|
741
|
+
+ 'If that glob is gone, re-derive which modules this registry walls before trusting '
|
|
742
|
+
+ 'the assertion above.');
|
|
743
|
+
});
|
|
744
|
+
|
|
564
745
|
// Metic is a FLOOR, not a fixed value (task 1003191). Pinning every surface to
|
|
565
746
|
// exactly 'metic' made the registry's own contract untestable and, worse,
|
|
566
747
|
// illegal: governance owns this data and raising one surface to 'archon' is a
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// tests/paste_token_api_base.mjs — which INSTANCE a pasted CLI token belongs to
|
|
2
|
+
// (task 1003722, re-filed from task 1003312).
|
|
3
|
+
//
|
|
4
|
+
// A bearer is only meaningful against the instance that minted it. paste-token used to
|
|
5
|
+
// decide that entirely by inference — env → the EXISTING session's api_base → branding
|
|
6
|
+
// default — which is correct right up until the builder touches a second instance. The
|
|
7
|
+
// stale api_base then outranks the instance that actually issued the token, so the
|
|
8
|
+
// paste is verified against, and written as, the wrong host. The reported symptom was a
|
|
9
|
+
// token minted on this instance landing as a token for an unrelated one, after which
|
|
10
|
+
// every /builder-* command authenticated somewhere unintended.
|
|
11
|
+
//
|
|
12
|
+
// The hall now stamps the issuer into the paste command, and the flag outranks every
|
|
13
|
+
// inferred source. These exercise the two pure helpers directly rather than scanning
|
|
14
|
+
// the file, because the thing that broke is a DECISION, not a spelling.
|
|
15
|
+
//
|
|
16
|
+
// Run: node --test tests/paste_token_api_base.mjs
|
|
17
|
+
|
|
18
|
+
import { test } from 'node:test';
|
|
19
|
+
import assert from 'node:assert/strict';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
import { createRequire } from 'node:module';
|
|
22
|
+
import { fileURLToPath } from 'node:url';
|
|
23
|
+
|
|
24
|
+
const require = createRequire(import.meta.url);
|
|
25
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
26
|
+
const { parseArgs, resolveBaseForPaste } = require(path.join(ROOT, 'scripts', 'gds', 'paste-token.js'));
|
|
27
|
+
|
|
28
|
+
const STALE = 'https://some-other-instance.example';
|
|
29
|
+
const stale = () => STALE;
|
|
30
|
+
|
|
31
|
+
test('the flag wins over the inferred base — the whole point of the fix', () => {
|
|
32
|
+
const r = resolveBaseForPaste({ flag: 'https://cloudbongos.com', resolve: stale });
|
|
33
|
+
assert.equal(r.base, 'https://cloudbongos.com');
|
|
34
|
+
assert.equal(r.source, 'flag');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('no flag keeps the existing resolution exactly as it was', () => {
|
|
38
|
+
// An older instance's paste command carries no flag. That path must not change, or
|
|
39
|
+
// this fix trades one wrong-instance bug for another.
|
|
40
|
+
for (const flag of [undefined, null, '', ' ']) {
|
|
41
|
+
const r = resolveBaseForPaste({ flag, resolve: stale });
|
|
42
|
+
assert.equal(r.base, STALE, `flag ${JSON.stringify(flag)} should fall through`);
|
|
43
|
+
assert.equal(r.source, 'resolved');
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('a trailing slash is normalised, so the verify URL never doubles it', () => {
|
|
48
|
+
// API_BASE is concatenated with '/api/bongos/me'; a kept slash makes '//api/...'.
|
|
49
|
+
assert.equal(resolveBaseForPaste({ flag: 'https://x.example/', resolve: stale }).base, 'https://x.example');
|
|
50
|
+
assert.equal(resolveBaseForPaste({ flag: 'https://x.example///', resolve: stale }).base, 'https://x.example');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('a malformed --api-base is refused, never silently ignored', () => {
|
|
54
|
+
// Refusing matters more than it looks: falling back on a malformed flag would send
|
|
55
|
+
// the token to the stale host — the exact failure the flag exists to prevent, but
|
|
56
|
+
// now silent, because the builder believes they named the instance.
|
|
57
|
+
for (const bad of ['cloudbongos.com', 'ftp://x.example', 'javascript:alert(1)', '/builders', 'https://']) {
|
|
58
|
+
const r = resolveBaseForPaste({ flag: bad, resolve: stale });
|
|
59
|
+
assert.equal(r.base, null, `${bad} should not resolve`);
|
|
60
|
+
assert.equal(r.source, 'invalid');
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('parseArgs reads the token and both flag spellings', () => {
|
|
65
|
+
const T = 'a'.repeat(64);
|
|
66
|
+
assert.deepEqual(parseArgs([T]), { token: T, apiBase: null });
|
|
67
|
+
assert.deepEqual(parseArgs([T, '--api-base', 'https://x.example']), { token: T, apiBase: 'https://x.example' });
|
|
68
|
+
assert.deepEqual(parseArgs([T, '--api-base=https://x.example']), { token: T, apiBase: 'https://x.example' });
|
|
69
|
+
// Order must not matter: the hall composes the command, and a future change to that
|
|
70
|
+
// string should not be able to break the paste silently.
|
|
71
|
+
assert.deepEqual(parseArgs(['--api-base', 'https://x.example', T]), { token: T, apiBase: 'https://x.example' });
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('a flag value is never mistaken for the token', () => {
|
|
75
|
+
// The bug this forecloses: treating argv[2] as the token, as the script used to,
|
|
76
|
+
// makes `--api-base https://x T` paste the URL as a bearer.
|
|
77
|
+
assert.equal(parseArgs(['--api-base', 'https://x.example', 'tok']).token, 'tok');
|
|
78
|
+
assert.equal(parseArgs(['--api-base=https://x.example', 'tok']).token, 'tok');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('a dangling --api-base does not swallow the token', () => {
|
|
82
|
+
// `bongos paste-token --api-base` with nothing after it: the flag is empty and
|
|
83
|
+
// invalid, and main() exits on that rather than pasting whatever followed.
|
|
84
|
+
const a = parseArgs(['--api-base']);
|
|
85
|
+
assert.equal(a.apiBase, '');
|
|
86
|
+
assert.equal(a.token, null);
|
|
87
|
+
});
|