@bongos/core 1.19.839 → 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.
@@ -32,27 +32,71 @@ const { SESSION_FILENAMES, readSessionConfigSync } = require('../../src/instance
32
32
  // WRITE the session to the configured instance dir (OTB → ~/.config/otb,
33
33
  // unchanged; a fresh instance → its neutral dir). R61 / task 1200.
34
34
  const SESSION_PATH = ic.configPath(SESSION_FILENAMES[0]);
35
- // env → the EXISTING session's api_base → this instance's branding default.
36
- // On a RE-auth a session file already exists, and its api_base is the best signal
37
- // of which instance the builder is on — this file used to hardcode the founder
38
- // host here (a straggler the task 1002201 sweep missed), so a token minted by
39
- // another instance was verified against example.com and rejected 401 with a
40
- // perfectly valid token (task 1002427).
41
- const API_BASE = ic.resolveApiBase();
35
+
36
+ // `--api-base <url>` / `--api-base=<url>`, plus the positional token. Hand-rolled
37
+ // because this script has no arg parser and one flag does not earn a dependency.
38
+ function parseArgs(argv) {
39
+ const out = { token: null, apiBase: null };
40
+ for (let i = 0; i < argv.length; i += 1) {
41
+ const a = String(argv[i]);
42
+ if (a === '--api-base') { out.apiBase = argv[i + 1] === undefined ? '' : String(argv[i + 1]); i += 1; continue; }
43
+ if (a.startsWith('--api-base=')) { out.apiBase = a.slice('--api-base='.length); continue; }
44
+ if (out.token === null && !a.startsWith('-')) out.token = a;
45
+ }
46
+ return out;
47
+ }
48
+
49
+ // WHICH INSTANCE IS THIS TOKEN FOR? (task 1003722)
50
+ //
51
+ // The old order was env → the EXISTING session's api_base → branding default, on the
52
+ // reasoning that a re-auth already knows which instance the builder is on. That holds
53
+ // right up until the builder uses a SECOND instance: the stale api_base then wins over
54
+ // the instance that actually minted the token, so the paste is verified against — and
55
+ // written as — the wrong host. The reported symptom was a token from this instance
56
+ // landing as a token for an unrelated one, after which every /builder-* command
57
+ // authenticated somewhere unintended.
58
+ //
59
+ // A token is minted BY an instance, so the issuer is a property of the token, not of
60
+ // the machine. The hall now stamps it into the paste command, and an explicit flag
61
+ // outranks every inferred source: it is per-invocation, it came from the minting
62
+ // instance, and it is the only source that is right by construction.
63
+ //
64
+ // Below the flag the old order is untouched, so an older instance whose paste command
65
+ // carries no flag behaves exactly as before — and so does the founder-host regression
66
+ // this file already guards (task 1002427: hardcoding a host here rejected a valid
67
+ // token with a 401).
68
+ function resolveBaseForPaste({ flag, resolve = () => ic.resolveApiBase() } = {}) {
69
+ const f = typeof flag === 'string' ? flag.trim() : '';
70
+ if (!f) return { base: resolve(), source: 'resolved' };
71
+ if (!/^https?:\/\/[^\s/]+/i.test(f)) return { base: null, source: 'invalid' };
72
+ return { base: f.replace(/\/+$/, ''), source: 'flag' };
73
+ }
74
+
75
+ const ARGS = parseArgs(process.argv.slice(2));
76
+ const RESOLVED = resolveBaseForPaste({ flag: ARGS.apiBase });
77
+ const API_BASE = RESOLVED.base;
42
78
 
43
79
  function usage(extra) {
44
80
  if (extra) console.error(extra);
45
81
  console.error('');
46
- console.error('Usage: bongos paste-token <token>');
82
+ console.error('Usage: bongos paste-token <token> [--api-base <url>]');
47
83
  console.error(' (inside a core checkout: node scripts/gds/paste-token.js <token>)');
48
84
  console.error('');
49
- console.error('Get a token on your Settings page: ' + API_BASE + '/builders/settings');
85
+ // Fall back to the inferred base for this hint when --api-base was the thing that
86
+ // was malformed — otherwise the error message that explains the bad flag would
87
+ // itself print "null/builders/settings".
88
+ const hintBase = API_BASE || ic.resolveApiBase();
89
+ console.error('Get a token on your Settings page: ' + hintBase + '/builders/settings');
50
90
  console.error('— under "CLI Access" click "Re-issue CLI token" (you must be signed in).');
51
91
  console.error('The page prints the exact paste-command — just run that line.');
52
92
  }
53
93
 
54
94
  async function main() {
55
- const token = process.argv[2];
95
+ if (RESOLVED.source === 'invalid') {
96
+ usage(`--api-base must be an http(s) URL, got: ${ARGS.apiBase}`);
97
+ process.exit(2);
98
+ }
99
+ const token = ARGS.token;
56
100
  if (!token) {
57
101
  usage('No token argument provided.');
58
102
  process.exit(2);
@@ -138,7 +182,14 @@ async function main() {
138
182
  }
139
183
  }
140
184
 
141
- main().catch((err) => {
142
- console.error('paste-token failed:', err && err.message ? err.message : err);
143
- process.exit(1);
144
- });
185
+ // Run only as a script. Exporting the two pure helpers lets the decision that caused
186
+ // task 1003722 — WHICH instance a pasted token belongs to — be tested directly, rather
187
+ // than inferred from a text scan of this file.
188
+ if (require.main === module) {
189
+ main().catch((err) => {
190
+ console.error('paste-token failed:', err && err.message ? err.message : err);
191
+ process.exit(1);
192
+ });
193
+ }
194
+
195
+ module.exports = { parseArgs, resolveBaseForPaste };
@@ -55,6 +55,9 @@ const CONFIG = {
55
55
  // <slug>.caddy file (idempotent overwrite, no fragile append to a shared file);
56
56
  // the main Caddyfile carries a one-time `import <dir>/*.caddy` (operator setup).
57
57
  caddySnippetDir: process.env.PROVISION_CADDY_SNIPPET_DIR || '/etc/caddy/sites',
58
+ // The base Caddyfile the running Caddy loads — read (never written) to prove it imports
59
+ // the snippets and carries the on-demand TLS ask (task 1002860, ADR 0303).
60
+ caddyfile: process.env.PROVISION_CADDYFILE || '/etc/caddy/Caddyfile',
58
61
  // Where nightly pg_dump backups land (db-backup-nightly.sh's GDS_BACKUP_DIR
59
62
  // default). Shared across co-tenant instances on one box — dumps are named
60
63
  // `<db>-<ts>.sql.gz`, so each instance's files + retention prune stay scoped to
@@ -240,6 +240,36 @@ function caddyBlock(inst) {
240
240
  function caddySnippetPath(inst, { snippetDir = CONFIG.caddySnippetDir } = {}) {
241
241
  return path.posix.join(snippetDir, `${inst.slug}.caddy`);
242
242
  }
243
+ // Does the base Caddyfile carry what a provisioned domain needs (task 1002860, ADR 0303)?
244
+ // Read from Caddy's RESOLVED config, not the file's text, so the answer holds whatever form
245
+ // the operator wrote it in: an `ask` (2.7) or a `permission` module (2.8+), an absolute or
246
+ // relative import, directives split across imported files. `requireHost` asks whether the
247
+ // domain is served, which only a written snippet can make true. Returns the operator
248
+ // instruction, or null when wired.
249
+ function caddyWiringGap(adaptedJson, { domain, requireHost = false, caddyfile = CONFIG.caddyfile, snippetDir = CONFIG.caddySnippetDir } = {}) {
250
+ let apps;
251
+ try { apps = JSON.parse(adaptedJson).apps || {}; } catch (_) {
252
+ return `could not read Caddy's resolved config ('caddy adapt --config ${caddyfile}' printed no JSON)`;
253
+ }
254
+ const onDemand = apps.tls && apps.tls.automation && apps.tls.automation.on_demand;
255
+ if (!JSON.stringify(onDemand || {}).includes('/provisioning/tls-check')) {
256
+ return `${caddyfile} has no on_demand_tls ask for the provisioning tls-check, so no provisioned domain can get a certificate — add 'on_demand_tls { ask <platform origin>/api/bongos/provisioning/tls-check }' to its global options block and reload Caddy`;
257
+ }
258
+ if (requireHost && !JSON.stringify(apps.http || {}).includes(JSON.stringify(domain))) {
259
+ return `${caddyfile} does not import ${snippetDir}/*.caddy, so Caddy will never serve ${domain} — add 'import ${snippetDir}/*.caddy' at the top level of ${caddyfile} and reload Caddy`;
260
+ }
261
+ return null;
262
+ }
263
+ // `caddy adapt` reads config files only: no privilege, no certificate keys, no running server.
264
+ function checkCaddyWiring(exec, opts = {}) {
265
+ const caddyfile = opts.caddyfile || CONFIG.caddyfile;
266
+ let res;
267
+ try { res = exec(`caddy adapt --config ${caddyfile} --adapter caddyfile`); } catch (e) {
268
+ const why = String((e && (e.stderr || e.message)) || e).trim().slice(0, 200);
269
+ return `'caddy adapt --config ${caddyfile}' failed (${why}) — the base Caddyfile must load before a site is added to it`;
270
+ }
271
+ return caddyWiringGap(res && res.stdout, { ...opts, caddyfile });
272
+ }
243
273
  function healthzCmd(inst) {
244
274
  return `curl -sS --max-time 5 http://127.0.0.1:${inst.port}/healthz`;
245
275
  }
@@ -281,6 +311,8 @@ module.exports = {
281
311
  DNS_BLACKHOLE_IP,
282
312
  caddyBlock,
283
313
  caddySnippetPath,
314
+ caddyWiringGap,
315
+ checkCaddyWiring,
284
316
  checkDnsTokenScope,
285
317
  classifyZoneScope,
286
318
  dnsReleaseEnv,
@@ -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). The on-demand-ACME TLS hardening of this block is task P4 (#1945).
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
- boxExec(`${sudoP}systemctl reload caddy`, { allowFail: true });
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
- boxExec(`${sudoP}systemctl reload caddy`, { allowFail: true });
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
@@ -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. Same discipline as configuredOAuthOrigin() above:
1081
- // nothing about the RECIPIENT's filesystem may be assumed here.
1082
- paste_command: `bongos paste-token ${token}`,
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.839'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
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');
@@ -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
- const m = routes.match(/paste_command:\s*`([^`]+)`/);
69
- assert.ok(m, 'auth.js should emit a paste_command template literal');
70
- const verb = m[1].split(' ')[1];
71
- assert.ok(cli.VERBS[verb], `server emits "bongos ${verb}" but the CLI has no such verb`);
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
- // crude but enough: take ~3000 chars from the route literal forward.
119
- return routeSrc.slice(start, start + 3000);
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
- const m = issueBlock.match(/paste_command:\s*`([^`]+)`/);
158
- assert.ok(m, 'paste_command should be a template literal the test can read');
159
- const cmd = m[1];
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
- assert.ok(!cmd.includes('/'),
163
- `paste_command must contain no path separator — it runs where the layout differs. Got: ${cmd}`);
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
  // ---------------------------------------------------------------------------
@@ -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
+ });