@bongos/core 1.19.838 → 1.19.840
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 +69 -39
- package/clients/bongos-client/index.d.ts +1 -1
- package/config/branding.neutral.json +2 -1
- 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/branding-contract.md +1 -1
- package/docs/branding-fill-prompt.md +1 -1
- package/docs/copy-inventory.md +3 -3
- package/docs/copy-registry.json +5 -5
- package/docs/module-api-changelog.md +4 -0
- 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/status-ui/public/index.html +2 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/fitness-checks-identity.js +9 -1
- package/scripts/gds/fitness.js +34 -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/ship-land.js +12 -0
- package/scripts/status-mirror-sync.js +6 -2
- package/src/bongos/routes/auth.js +24 -3
- package/src/bongos/serve-internal.js +9 -2
- package/src/branding.js +22 -2
- package/src/module-api.js +1 -1
- package/tests/bongos_cli.mjs +13 -4
- package/tests/cli_token_reissue.mjs +54 -7
- package/tests/fitness.mjs +47 -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/tests/status_company_attribution.mjs +60 -0
package/src/branding.js
CHANGED
|
@@ -221,7 +221,11 @@ function clientBranding(b = branding()) {
|
|
|
221
221
|
identity: {
|
|
222
222
|
productName: b.identity?.productName || '',
|
|
223
223
|
worldName: b.identity?.worldName || '',
|
|
224
|
-
company: {
|
|
224
|
+
company: {
|
|
225
|
+
name: b.identity?.company?.name || '',
|
|
226
|
+
site: b.identity?.company?.site || '',
|
|
227
|
+
tagline: b.identity?.company?.tagline || '',
|
|
228
|
+
},
|
|
225
229
|
},
|
|
226
230
|
currency: {
|
|
227
231
|
label: b.currency?.label || 'credits',
|
|
@@ -308,4 +312,20 @@ function clientReward() {
|
|
|
308
312
|
}
|
|
309
313
|
}
|
|
310
314
|
|
|
311
|
-
|
|
315
|
+
// Resolve the `<!--brand-if:KEY-->…<!--/brand-if:KEY-->` regions of a served page:
|
|
316
|
+
// kept (markers removed) when `values[KEY]` is non-empty, dropped whole otherwise,
|
|
317
|
+
// so an optional pack field removes its entire clause instead of rendering half a
|
|
318
|
+
// sentence (task 1002853). Lives here, not in serve-internal, because the off-box
|
|
319
|
+
// status mirror stamps the same page without loading the server. Repeats until
|
|
320
|
+
// stable so a region nested inside a kept one resolves too.
|
|
321
|
+
const BRAND_IF_REGION = /<!--brand-if:(\w+)-->([\s\S]*?)<!--\/brand-if:\1-->/g;
|
|
322
|
+
function applyBrandConditionals(html, values) {
|
|
323
|
+
let out = String(html);
|
|
324
|
+
for (let prev; prev !== out;) {
|
|
325
|
+
prev = out;
|
|
326
|
+
out = out.replace(BRAND_IF_REGION, (_, key, inner) => (values[key] ? inner : ''));
|
|
327
|
+
}
|
|
328
|
+
return out;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
module.exports = { resolveBranding, loadBranding, deepMerge, branding, clientBranding, userAgent, applyBrandConditionals, NEUTRAL_PATH, INSTANCE_PATH };
|
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.840'; // 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
|
// ---------------------------------------------------------------------------
|
package/tests/fitness.mjs
CHANGED
|
@@ -51,6 +51,51 @@ test('CLAUDE.md char/token budget actually fires on within-line bloat (not a no-
|
|
|
51
51
|
assert.ok(r.violations.some((v) => /char/i.test(v) && /token/i.test(v)), 'the violation must name the char/token budget');
|
|
52
52
|
});
|
|
53
53
|
|
|
54
|
+
// --- the charter is RELOCATED, not deleted (task 1003687) --------------------
|
|
55
|
+
// The charter body moved to a paths-scoped rule, so the kernel keeps only a heading
|
|
56
|
+
// and a pointer. That makes the heading trivially fakeable: a section with nothing
|
|
57
|
+
// behind it satisfies a substring check while the rules it names are gone. These
|
|
58
|
+
// pin that the check resolves the pointer rather than trusting the heading — a green
|
|
59
|
+
// check over a missing charter would be a false claim of coverage.
|
|
60
|
+
const CHARTER_HEADING = '## Working with this file (content charter)';
|
|
61
|
+
const CHARTER_RULE_REL = '.claude/rules/claudemd-authoring.md';
|
|
62
|
+
|
|
63
|
+
test('the charter rule file exists and is paths-scoped to CLAUDE.md', () => {
|
|
64
|
+
const rule = fs.readFileSync(path.join(ROOT, CHARTER_RULE_REL), 'utf8');
|
|
65
|
+
assert.match(rule, /^---\r?\n[\s\S]*?\bpaths:[\s\S]*?["']\*\*\/CLAUDE\.md["'][\s\S]*?^---$/m,
|
|
66
|
+
'without `paths:` frontmatter matching **/CLAUDE.md the rule never loads, and the charter is simply gone');
|
|
67
|
+
assert.ok(rule.includes('Per-line test:'), 'the per-line test must survive the move');
|
|
68
|
+
assert.ok(rule.includes('## What goes where'), 'the "what goes where" table must survive the move');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('the live CLAUDE.md delegates the charter to the rule file', () => {
|
|
72
|
+
const kernel = fs.readFileSync(path.join(ROOT, 'CLAUDE.md'), 'utf8');
|
|
73
|
+
assert.ok(kernel.includes(CHARTER_HEADING), 'the heading keeps the rule findable from the file it governs');
|
|
74
|
+
assert.ok(kernel.includes(CHARTER_RULE_REL), 'the heading must point somewhere, or it is decoration');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('a hollow charter heading hard-fails (the heading alone is not the charter)', () => {
|
|
78
|
+
const r = fitness.checkClaudeMdBudget({ text: `${CHARTER_HEADING}\n` });
|
|
79
|
+
assert.equal(r.hardFail, true, 'a heading with neither inline rules nor a pointer must fail');
|
|
80
|
+
assert.ok(r.violations.some((v) => /hollow/i.test(v)), 'the violation must name the hollow section');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('an inline charter still passes (a downstream kernel that never split it)', () => {
|
|
84
|
+
const r = fitness.checkClaudeMdBudget({ text: `${CHARTER_HEADING}\n- **Per-line test:** …\n` });
|
|
85
|
+
assert.equal(r.hardFail, false, 'relocation is the cure, not the requirement — inline is still a real charter');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('a pointer at a missing rule file hard-fails', () => {
|
|
89
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'charter-ptr-'));
|
|
90
|
+
fs.writeFileSync(path.join(dir, 'CLAUDE.md'), `${CHARTER_HEADING}\ncharter: ${CHARTER_RULE_REL}\n`);
|
|
91
|
+
const out = require('node:child_process').execFileSync(process.execPath,
|
|
92
|
+
['-e', `const f=require(${JSON.stringify(path.join(ROOT, 'scripts/gds/fitness.js'))});process.stdout.write(JSON.stringify(f.checkClaudeMdBudget()))`],
|
|
93
|
+
{ env: { ...process.env, BONGOS_INSTANCE_ROOT: dir }, encoding: 'utf8' });
|
|
94
|
+
const r = JSON.parse(out);
|
|
95
|
+
assert.equal(r.hardFail, true, 'a pointer to nothing is worse than no pointer');
|
|
96
|
+
assert.ok(r.violations.some((v) => /does not exist/i.test(v)), 'the violation must say the target is missing');
|
|
97
|
+
});
|
|
98
|
+
|
|
54
99
|
test('CLAUDE.md char budget is ratcheted and ordered (target < ceiling, live file under it)', () => {
|
|
55
100
|
assert.ok(fitness.ROOT_CHAR_TARGET < fitness.ROOT_CHAR_HARD_MAX, 'target must sit below the hard ceiling');
|
|
56
101
|
const r = fitness.checkClaudeMdBudget();
|
|
@@ -778,6 +823,8 @@ test('identity gate actually CATCHES a re-hardcode (code, URL, HTML) — not a n
|
|
|
778
823
|
assert.equal(scanPromptIdentity('x.js', 'const u = "https://amazonprimea.com/x";').length, 1, 'a leak inside a URL must be caught');
|
|
779
824
|
assert.equal(scanPromptIdentity('x.html', '<title>Amazonprimea</title>').length, 1, 'an HTML title leak must be caught');
|
|
780
825
|
assert.equal(scanPromptIdentity('x.js', 'const p = "Off the Boats";').length, 1, 'the product name must be caught');
|
|
826
|
+
// task 1002853: the founder's company rode the core status footer because only the product was listed
|
|
827
|
+
assert.equal(scanPromptIdentity('x.html', '<a href="https://hermeslines.com">Hermeslines</a>').length, 1, 'the host company must be caught');
|
|
781
828
|
});
|
|
782
829
|
|
|
783
830
|
test('identity gate skips comments + instance-injected tokens (no false positives)', () => {
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// tests/pr_assign.mjs — a shipped pull request is assigned to the builder who wrote it
|
|
2
|
+
// (task 1003991).
|
|
3
|
+
//
|
|
4
|
+
// The server opens every builder-box PR with its own credential, so the PR's AUTHOR is
|
|
5
|
+
// the owner account: 259 of the last 300 PRs. The builder who wrote the work found
|
|
6
|
+
// "created by me" empty on GitHub — and the PR is where CI and merge state live. The
|
|
7
|
+
// server now assigns the PR to the task's claim holder and names them in the body.
|
|
8
|
+
//
|
|
9
|
+
// Two layers, no DB / network:
|
|
10
|
+
// 1. modules/lifecycle/pr-assign.js through its injectable fetch — the assignment call
|
|
11
|
+
// and every fail-soft shape (it must NEVER reject: an assignment is a courtesy).
|
|
12
|
+
// 2. The real POST /tasks/:id/publish-branch router with the data layer, the auth gate
|
|
13
|
+
// and the git/GitHub calls stubbed at the module boundary — proving the route assigns
|
|
14
|
+
// the CLAIM HOLDER (not the Archon who re-shipped it), names them in the body, and
|
|
15
|
+
// still answers 201 when the assignment fails.
|
|
16
|
+
//
|
|
17
|
+
// Run: node tests/pr_assign.mjs
|
|
18
|
+
|
|
19
|
+
import { strict as assert } from 'node:assert';
|
|
20
|
+
import { createRequire } from 'node:module';
|
|
21
|
+
import http from 'node:http';
|
|
22
|
+
import { prAssigneeLine } from '../scripts/gds/ship-land.js';
|
|
23
|
+
|
|
24
|
+
process.env.NODE_ENV = 'test';
|
|
25
|
+
const require = createRequire(import.meta.url);
|
|
26
|
+
|
|
27
|
+
const express = require('express');
|
|
28
|
+
const api = require('../src/module-api.js');
|
|
29
|
+
const db = require('../modules/lifecycle/db.js');
|
|
30
|
+
const gp = require('../modules/lifecycle/github-push.js');
|
|
31
|
+
const prAssign = require('../modules/lifecycle/pr-assign.js');
|
|
32
|
+
|
|
33
|
+
let passed = 0, failed = 0;
|
|
34
|
+
function t(name, fn) {
|
|
35
|
+
return Promise.resolve().then(fn)
|
|
36
|
+
.then(() => { passed++; console.log(` PASS ${name}`); })
|
|
37
|
+
.catch((e) => { failed++; console.log(` FAIL ${name}\n ${e.stack || e.message}`); });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const REPO = { owner: 'someowner', repo: 'somerepo', branch: 'main' };
|
|
41
|
+
const okJson = (body, status = 201) => ({ ok: status < 300, status, json: async () => body, text: async () => JSON.stringify(body) });
|
|
42
|
+
const quiet = { warn() {} };
|
|
43
|
+
|
|
44
|
+
// ---- layer 1: the assignment call -----------------------------------------
|
|
45
|
+
await t('assignPullRequest POSTs the login to /issues/:number/assignees', async () => {
|
|
46
|
+
let seen = null;
|
|
47
|
+
const fetchImpl = async (url, opts) => { seen = { url, method: opts.method, body: JSON.parse(opts.body), auth: opts.headers.Authorization, signal: opts.signal }; return okJson({ assignees: [{ login: 'Some-Builder' }] }); };
|
|
48
|
+
const out = await prAssign.assignPullRequest({ number: 946, login: 'some-builder' }, { token: 'T', repoInfo: REPO, fetchImpl, logImpl: quiet });
|
|
49
|
+
assert.deepEqual(out, { assigned: true });
|
|
50
|
+
assert.equal(seen.url, 'https://api.github.com/repos/someowner/somerepo/issues/946/assignees');
|
|
51
|
+
assert.equal(seen.method, 'POST');
|
|
52
|
+
assert.deepEqual(seen.body, { assignees: ['some-builder'] });
|
|
53
|
+
assert.equal(seen.auth, 'Bearer T');
|
|
54
|
+
assert.ok(seen.signal instanceof AbortSignal, 'the one optional call before the 201 is time-boxed');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
await t('fail-soft: every failure RESOLVES with a reason — it never rejects', async () => {
|
|
58
|
+
const run = (args, deps) => prAssign.assignPullRequest(args, { token: 'T', repoInfo: REPO, logImpl: quiet, ...deps });
|
|
59
|
+
const never = async () => { throw new Error('must not fetch'); };
|
|
60
|
+
assert.deepEqual(await run({ number: 1, login: 'x' }, { fetchImpl: async () => okJson({ message: 'Resource not accessible by integration' }, 403) }), { assigned: false, reason: 'http_403' });
|
|
61
|
+
assert.deepEqual(await run({ number: 1, login: 'x' }, { fetchImpl: async () => { throw new Error('socket hang up'); } }), { assigned: false, reason: 'threw' });
|
|
62
|
+
// GitHub answers 201 and silently drops a login that cannot be assigned (not a collaborator).
|
|
63
|
+
assert.deepEqual(await run({ number: 1, login: 'x' }, { fetchImpl: async () => okJson({ assignees: [] }) }), { assigned: false, reason: 'not_assignable' });
|
|
64
|
+
assert.deepEqual(await run({ number: 1, login: 'x' }, { token: '', fetchImpl: never }), { assigned: false, reason: 'unconfigured' });
|
|
65
|
+
assert.deepEqual(await run({ number: 1, login: 'x' }, { repoInfo: { error: 'nope' }, fetchImpl: never }), { assigned: false, reason: 'unconfigured' });
|
|
66
|
+
assert.deepEqual(await run({ number: 'abc', login: 'x' }, { fetchImpl: never }), { assigned: false, reason: 'bad_pr_number' });
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
await t('a login that is not a GitHub login is refused before any request, and never reaches a PR body', async () => {
|
|
70
|
+
const never = async () => { throw new Error('must not fetch'); };
|
|
71
|
+
for (const login of ['', null, undefined, 'a b', '-lead', 'x/../y', 'a'.repeat(40), 'name\n@everyone', '@already']) {
|
|
72
|
+
assert.deepEqual(await prAssign.assignPullRequest({ number: 1, login }, { token: 'T', repoInfo: REPO, fetchImpl: never, logImpl: quiet }), { assigned: false, reason: 'bad_login' }, JSON.stringify(login));
|
|
73
|
+
assert.equal(prAssign.prBuilderLine(login), '', JSON.stringify(login));
|
|
74
|
+
}
|
|
75
|
+
assert.equal(prAssign.prBuilderLine('Some-Builder1'), 'Built by @Some-Builder1.');
|
|
76
|
+
assert.equal(prAssign.prBuilderLine('name_shortcode'), 'Built by @name_shortcode.', 'a managed-user login carries an underscore');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
await t('a failure is logged without the credential', async () => {
|
|
80
|
+
const lines = [];
|
|
81
|
+
await prAssign.assignPullRequest({ number: 1, login: 'x' }, {
|
|
82
|
+
token: 'SECRET_TOKEN', repoInfo: REPO, logImpl: { warn: (...a) => lines.push(JSON.stringify(a)) },
|
|
83
|
+
fetchImpl: async () => { throw new Error('connect https://x-access-token:SECRET_TOKEN@github.com failed'); },
|
|
84
|
+
});
|
|
85
|
+
assert.equal(lines.length, 1);
|
|
86
|
+
assert.ok(!lines[0].includes('SECRET_TOKEN'), lines[0]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
await t('the ship CLI says where the PR is findable, and says nothing when nobody was assigned', () => {
|
|
90
|
+
assert.match(prAssigneeLine('some-builder'), /assigned to @some-builder .*Assigned to you/);
|
|
91
|
+
for (const v of [null, undefined, '', 'a b', 'x\n[otb-plain] forged', 'a'.repeat(40)]) assert.equal(prAssigneeLine(v), null, JSON.stringify(v));
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// ---- layer 2: the real publish route --------------------------------------
|
|
95
|
+
const HEAD = 'a'.repeat(40);
|
|
96
|
+
let caller = null; // req.builder
|
|
97
|
+
let holder = null; // db.getLastClaimHolder
|
|
98
|
+
let assignResult = null; // what the GitHub assignees call answers
|
|
99
|
+
let opened = null; // the PR the route asked for
|
|
100
|
+
let assignCalls = [];
|
|
101
|
+
|
|
102
|
+
api.requireBuilder = (req, res, next) => { req.builder = caller; next(); };
|
|
103
|
+
db.getLastClaimHolder = async () => holder;
|
|
104
|
+
db.setClaimPublishedSha = async () => {};
|
|
105
|
+
gp.isConfigured = () => true;
|
|
106
|
+
gp.pushBundle = async () => ({ head_sha: HEAD });
|
|
107
|
+
gp.openOrFindPullRequest = async (args) => { opened = args; return { number: 77, url: 'https://github.com/o/r/pull/77', node_id: 'N' }; };
|
|
108
|
+
gp.enableAutoMergeDetailed = async () => ({ ok: true });
|
|
109
|
+
gp.setCommitStatus = async () => true;
|
|
110
|
+
prAssign.assignPullRequest = async (args) => { assignCalls.push(args); if (assignResult instanceof Error) throw assignResult; return assignResult; };
|
|
111
|
+
|
|
112
|
+
const buildTasksRouter = require('../modules/lifecycle/routes/tasks.js');
|
|
113
|
+
const app = express();
|
|
114
|
+
app.use((req, res, next) => {
|
|
115
|
+
res.fail = (code, status, extra) => res.status(typeof status === 'number' ? status : (status && status.status) || 500).json({ error: code, ...(extra || {}) });
|
|
116
|
+
next();
|
|
117
|
+
});
|
|
118
|
+
app.use(buildTasksRouter());
|
|
119
|
+
const server = http.createServer(app);
|
|
120
|
+
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
|
121
|
+
const base = `http://127.0.0.1:${server.address().port}`;
|
|
122
|
+
|
|
123
|
+
async function publish() {
|
|
124
|
+
opened = null; assignCalls = [];
|
|
125
|
+
const res = await fetch(`${base}/tasks/1003848/publish-branch`, {
|
|
126
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
127
|
+
body: JSON.stringify({ branch: 'task-1003848', head_sha: HEAD, bundle_b64: Buffer.from('bundle').toString('base64'), value_summary: 'A thing got better.' }),
|
|
128
|
+
});
|
|
129
|
+
return { status: res.status, body: await res.json() };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
await t('the route assigns the PR to the claim holder and names them in the body', async () => {
|
|
133
|
+
caller = { id: 5, rank: 'thetes', github_login: 'some-builder' };
|
|
134
|
+
holder = { builder_id: 5, github_login: 'some-builder' };
|
|
135
|
+
assignResult = { assigned: true };
|
|
136
|
+
const r = await publish();
|
|
137
|
+
assert.equal(r.status, 201, JSON.stringify(r.body));
|
|
138
|
+
assert.deepEqual(assignCalls, [{ number: 77, login: 'some-builder' }]);
|
|
139
|
+
assert.match(opened.body, /Built by @some-builder\./);
|
|
140
|
+
assert.equal(r.body.pr_assignee, 'some-builder');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
await t('an Archon re-shipping someone else\'s task assigns the AUTHOR, not themselves', async () => {
|
|
144
|
+
caller = { id: 1, rank: 'archon', github_login: 'the-archon' };
|
|
145
|
+
holder = { builder_id: 5, github_login: 'some-builder' };
|
|
146
|
+
assignResult = { assigned: true };
|
|
147
|
+
const r = await publish();
|
|
148
|
+
assert.equal(r.status, 201);
|
|
149
|
+
assert.equal(assignCalls[0].login, 'some-builder');
|
|
150
|
+
assert.ok(!opened.body.includes('the-archon'));
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
await t('a failed assignment never fails the ship: still 201, pr_assignee null', async () => {
|
|
154
|
+
caller = { id: 5, rank: 'thetes', github_login: 'some-builder' };
|
|
155
|
+
holder = { builder_id: 5, github_login: 'some-builder' };
|
|
156
|
+
for (const result of [{ assigned: false, reason: 'http_403' }, new Error('assign blew up')]) {
|
|
157
|
+
assignResult = result;
|
|
158
|
+
const r = await publish();
|
|
159
|
+
assert.equal(r.status, 201, JSON.stringify(r.body));
|
|
160
|
+
assert.equal(r.body.pr_url, 'https://github.com/o/r/pull/77');
|
|
161
|
+
assert.equal(r.body.pr_assignee, null);
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
await t('a never-claimed task falls back to the caller', async () => {
|
|
166
|
+
caller = { id: 1, rank: 'archon', github_login: 'the-archon' };
|
|
167
|
+
holder = null;
|
|
168
|
+
assignResult = { assigned: true };
|
|
169
|
+
const r = await publish();
|
|
170
|
+
assert.equal(r.status, 201, JSON.stringify(r.body));
|
|
171
|
+
assert.equal(assignCalls[0].login, 'the-archon');
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// A PR body is never rewritten, so a wrong name would be permanent: a lookup that FAILS
|
|
175
|
+
// must name nobody rather than credit the Archon who happened to re-ship it.
|
|
176
|
+
await t('a claim-holder lookup that THROWS names nobody, and still ships', async () => {
|
|
177
|
+
caller = { id: 1, rank: 'archon', github_login: 'the-archon' };
|
|
178
|
+
db.getLastClaimHolder = async () => { throw new Error('simulated DB outage'); };
|
|
179
|
+
assignResult = { assigned: false, reason: 'bad_login' };
|
|
180
|
+
const r = await publish();
|
|
181
|
+
assert.equal(r.status, 201, JSON.stringify(r.body));
|
|
182
|
+
assert.equal(assignCalls[0].login, null);
|
|
183
|
+
assert.ok(!opened.body.includes('Built by'), opened.body);
|
|
184
|
+
assert.equal(r.body.pr_assignee, null);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
server.close();
|
|
188
|
+
console.log(`\npr_assign: ${passed} passed, ${failed} failed`);
|
|
189
|
+
process.exitCode = failed ? 1 : 0;
|
package/tests/provision.mjs
CHANGED
|
@@ -415,6 +415,8 @@ function recorder({ apply = false } = {}) {
|
|
|
415
415
|
// Stub the DNS token-scope preflight (task 1980) so the composition stays hermetic
|
|
416
416
|
// (no real CF call). Default: reachable; a test overrides it to exercise the block.
|
|
417
417
|
checkDnsTokenScope: async () => ({ reachable: true, zone: 'example.com' }),
|
|
418
|
+
// Caddy's resolved-config check (task 1002860) stubbed wired; tests/provision_caddy_wiring.mjs covers it.
|
|
419
|
+
checkCaddyWiring: () => null,
|
|
418
420
|
// Owner OAuth token lookup for the scaffold push (task 1002317). Default: none →
|
|
419
421
|
// the scaffold takes the SSH `origin` fallback (so existing push assertions hold
|
|
420
422
|
// without touching the real db module). A test overrides it to exercise the token path.
|
|
@@ -1115,6 +1117,7 @@ function dnsRecorder({ apply = false } = {}) {
|
|
|
1115
1117
|
// Preflight (task 1980) stubbed reachable so --apply DNS tests reach the step-5
|
|
1116
1118
|
// behavior they assert (the preflight itself is covered by its own test below).
|
|
1117
1119
|
checkDnsTokenScope: async () => ({ reachable: true, zone: 'example.com' }),
|
|
1120
|
+
checkCaddyWiring: () => null,
|
|
1118
1121
|
};
|
|
1119
1122
|
return { deps, cmds, envs, cleared };
|
|
1120
1123
|
}
|
|
@@ -1525,6 +1528,7 @@ function privRecorder({ apply = false } = {}) {
|
|
|
1525
1528
|
writeFile: (p, _body, opts) => { writes.push({ p, opts: opts || {} }); return { ok: true }; },
|
|
1526
1529
|
doApi: null,
|
|
1527
1530
|
checkDnsTokenScope: async () => ({ reachable: true, zone: 'example.com' }),
|
|
1531
|
+
checkCaddyWiring: () => null,
|
|
1528
1532
|
};
|
|
1529
1533
|
return { deps, cmds, writes };
|
|
1530
1534
|
}
|
|
@@ -1666,6 +1670,7 @@ await ta('provisionInstance(co-tenant, remote): box steps use the box `exec`, bu
|
|
|
1666
1670
|
controlWriteFile: () => ({ ok: true }),
|
|
1667
1671
|
doApi: null,
|
|
1668
1672
|
checkDnsTokenScope: async () => ({ reachable: true, zone: 'example.com' }),
|
|
1673
|
+
checkCaddyWiring: () => null,
|
|
1669
1674
|
};
|
|
1670
1675
|
await P.provisionInstance({ ...coTenant, domain: 'mercury.example.com' }, deps);
|
|
1671
1676
|
// box-mutating steps → the box executor (which, in prod, ssh's to the co-hosting box)
|
|
@@ -1697,6 +1702,7 @@ await ta('provisionInstance(standalone, remote): EVERY box step runs control-pla
|
|
|
1697
1702
|
controlWriteFile: (p) => { controlWrites.push(p); return { ok: true }; }, // the control-plane writer
|
|
1698
1703
|
doApi: null,
|
|
1699
1704
|
checkDnsTokenScope: async () => ({ reachable: true, zone: 'cloudbongos.com' }),
|
|
1705
|
+
checkCaddyWiring: () => null,
|
|
1700
1706
|
};
|
|
1701
1707
|
await P.provisionInstance({ ...coTenant, hosting_shape: 'standalone', target_ref: 'example-owner/mercury', domain: 'mercury.cloudbongos.com' }, deps);
|
|
1702
1708
|
// The bug (task 2074): these landed on the REMOTE box, which never had the scaffolded
|
|
@@ -1736,6 +1742,7 @@ await ta('provisionInstance(standalone, remote, --apply): FAILS LOUD when the co
|
|
|
1736
1742
|
exec: () => ({ ok: true, stdout: '' }), controlExec: () => ({ ok: true, stdout: '' }),
|
|
1737
1743
|
writeFile: () => ({ ok: true }), controlWriteFile: () => ({ ok: true }), doApi: null,
|
|
1738
1744
|
checkDnsTokenScope: async () => ({ reachable: true, zone: 'cloudbongos.com' }),
|
|
1745
|
+
checkCaddyWiring: () => null,
|
|
1739
1746
|
};
|
|
1740
1747
|
await assert.rejects(
|
|
1741
1748
|
() => P.provisionInstance({ ...coTenant, hosting_shape: 'standalone', target_ref: 'example-owner/mercury', domain: 'mercury.cloudbongos.com' }, deps),
|
|
@@ -1980,6 +1987,7 @@ function attachRecorder({ apply = false, envBody = WEB_ENV_ON_BOX } = {}) {
|
|
|
1980
1987
|
writeFile: (p, body) => { writes.push({ path: p, body }); return { ok: true }; },
|
|
1981
1988
|
doApi: null,
|
|
1982
1989
|
checkDnsTokenScope: async () => ({ reachable: true, zone: 'cloudbongos.com' }),
|
|
1990
|
+
checkCaddyWiring: () => null,
|
|
1983
1991
|
};
|
|
1984
1992
|
return { deps, cmds, envs, writes, events, dnsNotes, cleared };
|
|
1985
1993
|
}
|
|
@@ -2226,6 +2234,7 @@ await ta('cmdRunIntents (--apply) routes a domain-attach intent to the attach le
|
|
|
2226
2234
|
exec: (cmd) => ({ ok: true, stdout: /\bcat\b.*web\.env/.test(String(cmd)) ? 'PORT=3010\n' : '' }),
|
|
2227
2235
|
writeFile: () => ({ ok: true }), doApi: null,
|
|
2228
2236
|
checkDnsTokenScope: async () => ({ reachable: true, zone: 'cloudbongos.com' }),
|
|
2237
|
+
checkCaddyWiring: () => null,
|
|
2229
2238
|
};
|
|
2230
2239
|
const r = await P.cmdRunIntents(deps);
|
|
2231
2240
|
assert.equal(r.ran, 1);
|
|
@@ -2288,6 +2297,7 @@ function truthRecorder({ healthzFails = false } = {}) {
|
|
|
2288
2297
|
writeFile: () => ({ ok: true }),
|
|
2289
2298
|
doApi: null,
|
|
2290
2299
|
checkDnsTokenScope: async () => ({ reachable: true, zone: 'cloudbongos.com' }),
|
|
2300
|
+
checkCaddyWiring: () => null,
|
|
2291
2301
|
getGithubToken: async () => null,
|
|
2292
2302
|
};
|
|
2293
2303
|
return { deps, cmds, statuses, patches, probes, logs, events, dnsNotes };
|
|
@@ -2361,6 +2371,7 @@ await ta('F19: a RETIRED intent records its reason on the instance without chang
|
|
|
2361
2371
|
},
|
|
2362
2372
|
exec: () => ({ ok: true }), writeFile: () => ({ ok: true }), doApi: null,
|
|
2363
2373
|
checkDnsTokenScope: async () => ({ reachable: true, zone: 'z' }),
|
|
2374
|
+
checkCaddyWiring: () => null,
|
|
2364
2375
|
};
|
|
2365
2376
|
const r = await P.cmdRunIntents(deps);
|
|
2366
2377
|
assert.equal(r.errors, 1);
|
|
@@ -2386,6 +2397,7 @@ await ta('F19: a NON-final failure re-pends the intent and writes nothing on the
|
|
|
2386
2397
|
},
|
|
2387
2398
|
exec: () => ({ ok: true }), writeFile: () => ({ ok: true }), doApi: null,
|
|
2388
2399
|
checkDnsTokenScope: async () => ({ reachable: true, zone: 'z' }),
|
|
2400
|
+
checkCaddyWiring: () => null,
|
|
2389
2401
|
};
|
|
2390
2402
|
await P.cmdRunIntents(deps);
|
|
2391
2403
|
assert.equal(resolved.length, 0, 'not resolved — it re-pends for a later tick');
|