@bongos/core 1.24.44 → 1.24.46

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.
Files changed (145) hide show
  1. package/.bongos-core.json +179 -144
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +4 -0
  4. package/clients/bongos-client/index.cjs +4 -0
  5. package/clients/bongos-client/index.d.ts +8 -0
  6. package/clients/bongos-client/index.mjs +4 -0
  7. package/docs/adr/0307-autobongos-a-deterministic-supervisor-and-agentic-workers.md +15 -0
  8. package/docs/api/openapi.json +152 -3
  9. package/docs/api-reference.md +4 -2
  10. package/docs/copy-inventory.md +1111 -1111
  11. package/docs/copy-registry.json +1547 -1547
  12. package/docs/module-api-changelog.md +4 -0
  13. package/docs/onboarding/diagrams/assertions.json +2 -2
  14. package/docs/page-inventory.json +11 -0
  15. package/docs/page-readings.json +823 -823
  16. package/migrations/core_295_runner_logins.sql +50 -0
  17. package/modules/government/session-scopes.json +5 -0
  18. package/modules/hall-ui/public/ai-studio.html +1 -1
  19. package/modules/hall-ui/public/ai-studio.states.json +2 -0
  20. package/modules/hall-ui/public/atlas.html +1 -1
  21. package/modules/hall-ui/public/atlas.states.json +2 -0
  22. package/modules/hall-ui/public/blockers.html +1 -1
  23. package/modules/hall-ui/public/board-room.html +1 -1
  24. package/modules/hall-ui/public/board-room.states.json +2 -0
  25. package/modules/hall-ui/public/city.html +1 -1
  26. package/modules/hall-ui/public/city.states.json +2 -0
  27. package/modules/hall-ui/public/collab.html +1 -1
  28. package/modules/hall-ui/public/collab.states.json +2 -0
  29. package/modules/hall-ui/public/commands.html +1 -1
  30. package/modules/hall-ui/public/commands.states.json +2 -0
  31. package/modules/hall-ui/public/copy-desk.html +1 -1
  32. package/modules/hall-ui/public/copy-desk.states.json +2 -0
  33. package/modules/hall-ui/public/craft-mode.js +4 -1
  34. package/modules/hall-ui/public/deploy.html +1 -1
  35. package/modules/hall-ui/public/deploy.states.json +2 -0
  36. package/modules/hall-ui/public/goal-map.html +1 -1
  37. package/modules/hall-ui/public/goal-map.states.json +2 -0
  38. package/modules/hall-ui/public/goals.css +1 -0
  39. package/modules/hall-ui/public/goals.html +1 -1
  40. package/modules/hall-ui/public/goals.states.json +2 -0
  41. package/modules/hall-ui/public/government.html +1 -1
  42. package/modules/hall-ui/public/government.states.json +2 -0
  43. package/modules/hall-ui/public/idea.html +1 -1
  44. package/modules/hall-ui/public/idea.states.json +2 -0
  45. package/modules/hall-ui/public/ideas.html +1 -1
  46. package/modules/hall-ui/public/ideas.states.json +2 -0
  47. package/modules/hall-ui/public/index-founding.states.json +2 -0
  48. package/modules/hall-ui/public/index-genesis-decide.states.json +2 -0
  49. package/modules/hall-ui/public/index-genesis-demo.states.json +2 -0
  50. package/modules/hall-ui/public/index-genesis.states.json +2 -0
  51. package/modules/hall-ui/public/index.html +1 -1
  52. package/modules/hall-ui/public/index.states.json +5 -0
  53. package/modules/hall-ui/public/market-watch-item.html +1 -1
  54. package/modules/hall-ui/public/market-watch-item.states.json +2 -0
  55. package/modules/hall-ui/public/market-watch-rules.html +1 -1
  56. package/modules/hall-ui/public/market-watch-rules.states.json +2 -0
  57. package/modules/hall-ui/public/market-watch.html +1 -1
  58. package/modules/hall-ui/public/market-watch.states.json +2 -0
  59. package/modules/hall-ui/public/modules.html +1 -1
  60. package/modules/hall-ui/public/modules.states.json +2 -0
  61. package/modules/hall-ui/public/money.html +1 -1
  62. package/modules/hall-ui/public/money.states.json +2 -0
  63. package/modules/hall-ui/public/my-agents.html +1 -1
  64. package/modules/hall-ui/public/my-agents.states.json +2 -0
  65. package/modules/hall-ui/public/notes-panel.css +1 -1
  66. package/modules/hall-ui/public/primer.html +1 -1
  67. package/modules/hall-ui/public/primer.states.json +2 -0
  68. package/modules/hall-ui/public/profile.html +1 -1
  69. package/modules/hall-ui/public/profile.states.json +2 -0
  70. package/modules/hall-ui/public/project-settings.html +1 -1
  71. package/modules/hall-ui/public/roster.html +1 -1
  72. package/modules/hall-ui/public/roster.states.json +2 -0
  73. package/modules/hall-ui/public/settings.html +1 -1
  74. package/modules/hall-ui/public/settings.states.json +2 -0
  75. package/modules/hall-ui/public/shell.js +21 -1
  76. package/modules/hall-ui/public/street-card.states.json +2 -0
  77. package/modules/hall-ui/public/street.html +1 -1
  78. package/modules/hall-ui/public/street.states.json +2 -0
  79. package/modules/hall-ui/public/studio.html +1 -1
  80. package/modules/hall-ui/public/studio.states.json +2 -0
  81. package/modules/hall-ui/public/style.css +27 -24
  82. package/modules/hall-ui/public/task.html +1 -1
  83. package/modules/hall-ui/public/task.states.json +2 -0
  84. package/modules/hall-ui/public/thinking.html +1 -1
  85. package/modules/hall-ui/public/thinking.states.json +2 -0
  86. package/modules/hall-ui/public/tweak-editor.html +1 -1
  87. package/modules/hall-ui/public/tweak-editor.states.json +2 -0
  88. package/modules/hall-ui/public/watch.html +1 -1
  89. package/modules/hall-ui/public/watch.states.json +2 -0
  90. package/modules/hall-ui/public/welcome-boot.js +6 -2
  91. package/modules/hall-ui/public/work-founding.states.json +2 -0
  92. package/modules/hall-ui/public/work.html +1 -1
  93. package/modules/hall-ui/public/work.states.json +3 -1
  94. package/modules/hall-ui/records/hall-navigation.md +5 -0
  95. package/modules/lifecycle/db-claims.js +6 -3
  96. package/modules/lifecycle/routes/claims.js +10 -0
  97. package/modules/lifecycle/routes/tasks.js +1 -1
  98. package/modules/lifecycle/runner-self-guard.js +19 -10
  99. package/modules/platform-identity/platform-identity.js +1 -1
  100. package/modules/platform-identity/tests/platform-identity.mjs +13 -2
  101. package/modules/provisioning/core-upgrade-authz.js +8 -0
  102. package/modules/public-landing/public/account.html +1 -0
  103. package/modules/public-landing/public/community.html +1 -0
  104. package/modules/public-landing/public/connections.html +1 -0
  105. package/modules/public-landing/public/contact.html +1 -0
  106. package/modules/public-landing/public/guild.html +1 -0
  107. package/modules/public-landing/public/index.html +1 -0
  108. package/modules/public-landing/public/invitations.html +1 -0
  109. package/modules/public-landing/public/privacy.html +1 -0
  110. package/modules/public-landing/public/projects.html +1 -0
  111. package/modules/public-landing/public/terms.html +1 -0
  112. package/modules/raise/views/gate.html +1 -0
  113. package/modules/raise/views/nda.html +1 -0
  114. package/modules/raise/views/page.html +1 -0
  115. package/modules/status-ui/public/index.html +1 -0
  116. package/modules/ui-design/kit/README.md +1 -1
  117. package/modules/ui-design/kit/fixtures/versions.json +1 -0
  118. package/modules/ui-design/kit/lib.js +48 -3
  119. package/modules/ui-design/kit/probe.js +1 -0
  120. package/modules/ui-design/kit/render.js +2 -0
  121. package/modules/ui-design/kit/serve.js +1 -0
  122. package/modules/ui-design/module.json +1 -1
  123. package/modules/ui-design/public/tokens.css +125 -0
  124. package/package-lock.json +2 -2
  125. package/package.json +1 -1
  126. package/release-notes.json +20 -0
  127. package/scripts/bongos/fitness.js +1 -1
  128. package/scripts/bongos/gen-hall-sitemap.js +3 -1
  129. package/scripts/bongos/run-unit-tests.js +3 -0
  130. package/src/bongos/auth.js +25 -4
  131. package/src/bongos/route-rank-check.js +5 -0
  132. package/src/bongos/routes/auth.js +48 -0
  133. package/src/bongos/runner-login.js +151 -0
  134. package/src/bongos/serve-internal.js +6 -1
  135. package/src/module-api.js +1 -1
  136. package/tests/autobongos_runner_login.mjs +296 -0
  137. package/tests/autobongos_runner_login_db.mjs +105 -0
  138. package/tests/craft_mode_nav.mjs +51 -3
  139. package/tests/design_tokens_shared.mjs +183 -0
  140. package/tests/government_session_scope.mjs +2 -1
  141. package/tests/hall_reading_world.mjs +5 -1
  142. package/tests/hall_tokens.mjs +22 -3
  143. package/tests/nav_permission_atoms.mjs +2 -1
  144. package/tests/ui_design_kit.mjs +30 -0
  145. package/tests/welcome_boot_browser.mjs +4 -0
@@ -140,6 +140,9 @@ const INTEGRATION = new Set([
140
140
  // task 1004677: two marked auto-pickup claims racing for the last slot of the
141
141
  // monthly cap; only real connections prove the advisory lock. Scratch DB only.
142
142
  'autopickup_cap_db',
143
+ // task 1004547: an Autobongos runner's one-time code works once, even when two
144
+ // PCs race it, and not after it expires; only real SQL proves both. Scratch DB.
145
+ 'autobongos_runner_login_db',
143
146
  'claims_requested_worktree_db',
144
147
  'clawback_confirmed_backlog',
145
148
  // task 1003113: the copy_desk_flags schema half. The module's own tests are
@@ -51,6 +51,8 @@ const { pool } = require('./pool');
51
51
  // the optional `?.` degrades to a no-op when the module is off (the stage tick is
52
52
  // best-effort anyway).
53
53
  const seams = require('../module-seams');
54
+ // task 1004547: an Autobongos runner's own login holds no SYSTEM permission.
55
+ const { isRunnerSession, withoutSystemPermissions, capRunnerRank } = require('./runner-login');
54
56
  const ic = require('../instance-config');
55
57
  const { branding, userAgent } = require('../branding');
56
58
 
@@ -284,7 +286,11 @@ async function requireBuilder(req, res, next) {
284
286
  // rank is the single source of authority (ADR 0016). Read fresh from the
285
287
  // DB on every request via lookupSession — never cached, never from the
286
288
  // token — so demotion takes effect on the builder's very next request.
287
- rank: session.rank,
289
+ // An Autobongos runner's own login acts at Metic at most (task 1004547,
290
+ // runner-login.js capRunnerRank), so every requireRank gate and every handler
291
+ // that reads this field sees the capped rank; `runner` says why.
292
+ rank: isRunnerSession(session) ? capRunnerRank(session.rank, RANKS) : session.rank,
293
+ runner: isRunnerSession(session),
288
294
  // system_role identifies a non-human service principal (NULL for every
289
295
  // human builder). The BFG cross-builder write gate (requireBfgPrincipal)
290
296
  // keys on this EXACT value — never on rank — so an Archon token cannot
@@ -456,7 +462,22 @@ async function pageGateAlsoAdmits(name, session) {
456
462
  // query fold, not the cross-request memo ADR 0016 forbids. Anything unexpected —
457
463
  // no raw keys on the row, an older port without the pure half, a throw — falls
458
464
  // through to the DB resolve, so the fold can only ever be an optimization.
459
- async function resolveHeldPermissions(builderId, rawKeys) {
465
+ //
466
+ // A RUNNER SESSION HOLDS NO SYSTEM PERMISSION (task 1004547). `session` is the
467
+ // caller's session row; when its source is 'runner' (an Autobongos PC's own login,
468
+ // src/bongos/runner-login.js) every key the catalog marks system:true is dropped —
469
+ // core.pin.move, autonomy.fence.manage, builder.rank.set and the rest. The owner
470
+ // rule (ADR 0307 §6) is that an unattended machine never moves a live version or
471
+ // touches its own kill switch; this is where the server, which the PC cannot edit,
472
+ // makes that true. An unknown key, or a port without isSystem, drops too.
473
+ async function resolveHeldPermissions(builderId, rawKeys, session = null) {
474
+ const held = await resolveHeldPermissionsForBuilder(builderId, rawKeys);
475
+ if (held === null || !isRunnerSession(session)) return held;
476
+ const governance = seams.resolveOptional('government');
477
+ return withoutSystemPermissions(held, governance && governance.isSystem);
478
+ }
479
+
480
+ async function resolveHeldPermissionsForBuilder(builderId, rawKeys) {
460
481
  if (builderId === null || builderId === undefined || builderId === '') return null;
461
482
  const governance = seams.resolveOptional('government');
462
483
  if (!governance || typeof governance.resolveBuilderPermissions !== 'function') return null;
@@ -506,7 +527,7 @@ async function runPageGate(name, req, res, next) {
506
527
  res.redirect(302, signInWithReturn(req));
507
528
  return;
508
529
  }
509
- const held = await resolveHeldPermissions(session.builder_id, session.raw_permission_keys);
530
+ const held = await resolveHeldPermissions(session.builder_id, session.raw_permission_keys, session);
510
531
  if (held === null || !held.has(permission)) {
511
532
  // The rank door said no. A page whose data route admits a SECOND audience
512
533
  // asks that door before redirecting (ADR 0210) — consulted here and nowhere
@@ -770,7 +791,7 @@ function requirePermission(...perms) {
770
791
  // (no port / malformed port / a throwing resolver) and is always a denial.
771
792
  // req.rawPermissionKeys rides the fold (task 1002556) when the session query
772
793
  // carried the grant keys; absent, the DB resolve runs exactly as before.
773
- const held = await resolveHeldPermissions(builderId, req.rawPermissionKeys);
794
+ const held = await resolveHeldPermissions(builderId, req.rawPermissionKeys, req.gdsSession);
774
795
  if (held === null) return deny(null);
775
796
  if (required.every((key) => held.has(key))) return next();
776
797
  return deny(required.filter((key) => held.has(key)));
@@ -199,6 +199,11 @@ const EXPECTED_RANKS = {
199
199
  // session revocation (kill another builder's / all sessions)
200
200
  'POST /auth/revoke': 'perm:auth.session.revoke',
201
201
  'POST /auth/revoke-all': 'perm:auth.session.revoke',
202
+ // An Autobongos runner's own login (task 1004547). Issuing the one-time code is
203
+ // Metic+ (who may run a runner); pinned so a widening cannot hand a Xenos a
204
+ // machine login. The exchange is a public auth-establishment route like
205
+ // /auth/device/exchange (the code IS the credential), so it carries no pin.
206
+ 'POST /auth/runner-code/issue': 'metic+archon',
202
207
  // deliberately any-builder (task 1003341) — the OWN-scoped half of the same
203
208
  // capability. These reach only the caller's own sessions (every statement is
204
209
  // keyed on req.builder.id), so a rank floor here would just put an Archon back
@@ -35,6 +35,8 @@ const { reportActivityRollupForShippedTask } = require('../auth-admission.js');
35
35
  // endpoint refuses. Same composed answer the gate itself reads.
36
36
  const { effectiveJoinDoor } = require('../project-door.js');
37
37
  const db = require('../db');
38
+ // task 1004547: an Autobongos PC's own login, from a one-time code.
39
+ const { issueCode: issueRunnerCode, exchangeCode: exchangeRunnerCode } = require('../runner-login');
38
40
  const { publicOrigin, parseCookie, validateOrRespond } = require('./_helpers');
39
41
  const { accountExistenceReadRateLimit } = require('../middleware/rate-limit');
40
42
  const { branding } = require('../../branding');
@@ -1364,6 +1366,52 @@ module.exports = function buildAuthRouter() {
1364
1366
  }
1365
1367
  });
1366
1368
 
1369
+ // POST /auth/runner-code/issue — task 1004547 (goal 1000139). A ONE-TIME CODE an
1370
+ // Autobongos PC trades for its own login (src/bongos/runner-login.js). Body:
1371
+ // { label? } — the PC's name, shown later in the builder's sessions list.
1372
+ //
1373
+ // rank: metic+archon — running a runner is Metic+ today (owner, 2026-11-06).
1374
+ // Cookie ONLY, like /auth/cli-token/issue: a bearer — a CLI token or a runner's
1375
+ // own login — must never be able to mint another login. The code is returned
1376
+ // once; only its hash is stored. It works once and expires in 15 minutes.
1377
+ router.post('/auth/runner-code/issue', auth.requireBuilder, auth.requireRank('metic', 'archon'), async (req, res) => {
1378
+ if (validateOrRespond(req, res, { label: { type: 'string', maxLength: 200 } })) return;
1379
+ if (!sessionCookieToken(req)) {
1380
+ return res.fail('cookie_required', { status: 403, message: 'A runner code is issued from the builders hall in a browser (Settings), never from a token, so a leaked token cannot mint a login.' });
1381
+ }
1382
+ try {
1383
+ const out = await issueRunnerCode({ builderId: req.builder.id, label: req.body && req.body.label });
1384
+ const issuer = String(resolveEnv('PUBLIC_ORIGIN') || configuredOAuthOrigin() || '').trim().replace(/\/+$/, '');
1385
+ res.status(201).json({ ok: true, code: out.code, expires_at: out.expires_at, api_base: issuer || null });
1386
+ } catch (err) {
1387
+ log.error('[gds] POST /auth/runner-code/issue', err && err.message);
1388
+ res.fail('runner_code_failed', { status: 500, message: 'internal error' });
1389
+ }
1390
+ });
1391
+
1392
+ // POST /auth/runner-code/exchange — task 1004547. The PC sends the one-time code
1393
+ // and gets back its own login (a 'runner' session). Body: { code }.
1394
+ //
1395
+ // rank: public — auth establishment; the one-time code is the bearer. A wrong,
1396
+ // used or expired code all answer the same 401, so nothing is learned from which.
1397
+ // The router's per-IP write limit (rateLimitMiddleware) is its abuse ceiling; the
1398
+ // code is 256 bits, so guessing one is not a race worth a limiter of its own.
1399
+ router.post('/auth/runner-code/exchange', async (req, res) => {
1400
+ if (validateOrRespond(req, res, { code: { required: true, type: 'string', maxLength: 200 } })) return;
1401
+ try {
1402
+ const out = await exchangeRunnerCode({ code: req.body.code, userAgent: req.headers['user-agent'] ?? null });
1403
+ if (out.refused === 'builder_inactive') return res.fail('builder_inactive', 403);
1404
+ if (out.refused) {
1405
+ return res.fail('runner_code_invalid', { status: 401, message: 'That runner code is wrong, already used or expired. Make a new one in Settings.' });
1406
+ }
1407
+ const issuer = String(resolveEnv('PUBLIC_ORIGIN') || configuredOAuthOrigin() || '').trim().replace(/\/+$/, '');
1408
+ res.json({ ok: true, token: out.token, session_id: out.session_id, builder_login: out.github_login, label: out.label, api_base: issuer || null });
1409
+ } catch (err) {
1410
+ log.error('[gds] POST /auth/runner-code/exchange', err && err.message);
1411
+ res.fail('runner_code_failed', { status: 500, message: 'internal error' });
1412
+ }
1413
+ });
1414
+
1367
1415
  // POST /auth/revoke — V3.R38 / #259. Archon-only. Kill a SINGLE session by
1368
1416
  // its primary-key id. Used for surgical lockouts (e.g. a compromised CLI on
1369
1417
  // a known builder's laptop) without disturbing that builder's other sessions.
@@ -0,0 +1,151 @@
1
+ 'use strict';
2
+
3
+ // src/bongos/runner-login.js — an Autobongos runner's OWN login (task 1004547, goal
4
+ // 1000139, ADR 0307 §6).
5
+ //
6
+ // WHY. A runner used to run under its builder's CLI session. The server then could
7
+ // not tell a runner's work from the person's, except by the worktree name the runner
8
+ // chose, and the worker carried the person's full authority (it could move a live
9
+ // core pin, task 1004974). Now the PC gets a login of its own:
10
+ //
11
+ // 1. In the builders hall (a browser, so a cookie session), a Metic+ builder asks
12
+ // for a ONE-TIME CODE (issueCode). The plaintext is shown once; only its sha256
13
+ // is stored. It expires after CODE_TTL_MS and works once.
14
+ // 2. The setup command on the PC trades the code for a session whose source is
15
+ // 'runner' (exchangeCode). That session belongs to the same builder, so the
16
+ // ledger credits them, but the server knows it is a machine working alone.
17
+ // 3. What 'runner' changes, server-side, where the PC cannot reach:
18
+ // - every claim it makes is marked autonomous (claims.autonomous), whatever
19
+ // the worktree is called, and the publish guard reads that mark;
20
+ // - it holds no SYSTEM permission (core.pin.move, autonomy.fence.manage, …),
21
+ // and the project-owner door to a core move is shut to it (auth.js,
22
+ // provisioning core-upgrade-authz.js);
23
+ // - it may not mint another login: issuing a code needs a browser cookie.
24
+ // 4. "Remove this PC" is the ordinary own-session revoke; the next call is a 401.
25
+ //
26
+ // The exchange is unauthenticated by design (the PC has no login yet); the code IS
27
+ // the credential, so it is 256 bits of CSPRNG, single-use, short-lived and looked up
28
+ // by its full digest, exactly as a session bearer is (db-kernel sessionTokenHash).
29
+
30
+ const crypto = require('node:crypto');
31
+ const { pool } = require('./pool');
32
+ const { sessionTokenHash } = require('./db-kernel');
33
+
34
+ const RUNNER_SOURCE = 'runner';
35
+ const CODE_TTL_MS = 15 * 60 * 1000; // 15 minutes: long enough to paste, short enough to waste if leaked
36
+ const CODE_PREFIX = 'abr_'; // recognisable in a pasted command, never part of the secret's entropy
37
+ const LABEL_MAX = 80;
38
+
39
+ const isRunnerSession = (session) => !!session && session.source === RUNNER_SOURCE;
40
+
41
+ function cleanLabel(label) {
42
+ if (typeof label !== 'string') return null;
43
+ const s = label.replace(/[\u0000-\u001f\u007f]/g, ' ').trim().slice(0, LABEL_MAX);
44
+ return s || null;
45
+ }
46
+
47
+ // Issue a one-time code for `builderId`. Returns { code, expires_at, id }. The
48
+ // plaintext code exists only in this return value.
49
+ async function issueCode({ builderId, label = null, now = Date.now() }, exec = pool) {
50
+ const code = CODE_PREFIX + crypto.randomBytes(32).toString('hex');
51
+ const expiresAt = new Date(now + CODE_TTL_MS);
52
+ const { rows } = await exec.query(
53
+ `INSERT INTO runner_enrollment_codes (builder_id, code_hash, label, expires_at)
54
+ VALUES ($1, $2, $3, $4)
55
+ RETURNING id, expires_at`,
56
+ [builderId, sessionTokenHash(code), cleanLabel(label), expiresAt]
57
+ );
58
+ return { id: rows[0].id, code, expires_at: rows[0].expires_at };
59
+ }
60
+
61
+ // Trade a code for a runner session. Returns { token, session_id, builder_id,
62
+ // github_login, label } or a refusal { refused: 'code_invalid' | 'builder_inactive' }.
63
+ // One transaction: the code is burned (used_at) by the same UPDATE that proves it
64
+ // was live, so two PCs racing one code cannot both win. A wrong, used or expired
65
+ // code all answer the same 'code_invalid', so the route leaks nothing about which.
66
+ async function exchangeCode({ code, userAgent = null }, exec = pool) {
67
+ if (typeof code !== 'string' || !code.startsWith(CODE_PREFIX) || code.length > 200) {
68
+ return { refused: 'code_invalid' };
69
+ }
70
+ const client = typeof exec.connect === 'function' ? await exec.connect() : exec;
71
+ const release = () => { if (client !== exec && typeof client.release === 'function') client.release(); };
72
+ try {
73
+ await client.query('BEGIN');
74
+ const { rows } = await client.query(
75
+ `UPDATE runner_enrollment_codes c
76
+ SET used_at = now()
77
+ FROM builders b
78
+ WHERE c.code_hash = $1
79
+ AND c.used_at IS NULL
80
+ AND c.expires_at > now()
81
+ AND b.id = c.builder_id
82
+ RETURNING c.id, c.builder_id, c.label, b.github_login, b.status`,
83
+ [sessionTokenHash(code)]
84
+ );
85
+ const hit = rows[0];
86
+ if (!hit) {
87
+ await client.query('ROLLBACK');
88
+ return { refused: 'code_invalid' };
89
+ }
90
+ if (hit.status === 'inactive') {
91
+ await client.query('ROLLBACK');
92
+ return { refused: 'builder_inactive' };
93
+ }
94
+ const token = crypto.randomBytes(32).toString('hex');
95
+ const ua = [hit.label ? `runner:${hit.label}` : 'runner', userAgent].filter(Boolean).join(' · ').slice(0, 400);
96
+ const ins = await client.query(
97
+ `INSERT INTO builder_sessions (builder_id, token_hash, source, user_agent, expires_at)
98
+ VALUES ($1, $2, $3, $4, NULL)
99
+ RETURNING id`,
100
+ [hit.builder_id, sessionTokenHash(token), RUNNER_SOURCE, ua]
101
+ );
102
+ await client.query(
103
+ `UPDATE runner_enrollment_codes SET used_session_id = $2 WHERE id = $1`,
104
+ [hit.id, ins.rows[0].id]
105
+ );
106
+ await client.query('COMMIT');
107
+ return { token, session_id: ins.rows[0].id, builder_id: hit.builder_id, github_login: hit.github_login, label: hit.label };
108
+ } catch (err) {
109
+ try { await client.query('ROLLBACK'); } catch (_) { /* the original error wins */ }
110
+ throw err;
111
+ } finally {
112
+ release();
113
+ }
114
+ }
115
+
116
+ // The highest rank a runner session ACTS at (task 1004547). A runner does its
117
+ // builder's ordinary work (claim, ship, grade, publish, file a blocker), and every
118
+ // one of those routes is open at Metic. Anything gated above that is an owner's
119
+ // decision, which an unattended machine does not make. `ranks` is the ladder,
120
+ // highest authority first (db-kernel RANKS); an unknown rank is returned as is,
121
+ // because requireRank already fails it closed.
122
+ const RUNNER_RANK_CAP = 'metic';
123
+ function capRunnerRank(rank, ranks) {
124
+ const i = ranks.indexOf(rank);
125
+ const cap = ranks.indexOf(RUNNER_RANK_CAP);
126
+ if (i === -1 || cap === -1) return rank;
127
+ return i < cap ? RUNNER_RANK_CAP : rank;
128
+ }
129
+
130
+ // Drop every SYSTEM permission from a held set, for a runner session. `isSystem` is
131
+ // the government port's catalog.isSystem: true = system, false = not, null =
132
+ // unknown key. Anything but an explicit false is dropped, so an unreadable catalog
133
+ // leaves a runner with less, never more. Without a port at all every key goes.
134
+ function withoutSystemPermissions(held, isSystem) {
135
+ const out = new Set();
136
+ if (typeof isSystem !== 'function') return out;
137
+ for (const key of held) {
138
+ let sys = null;
139
+ try { sys = isSystem(key); } catch (_) { sys = null; }
140
+ if (sys === false) out.add(key);
141
+ }
142
+ return out;
143
+ }
144
+
145
+ module.exports = {
146
+ isRunnerSession,
147
+ issueCode,
148
+ exchangeCode,
149
+ withoutSystemPermissions,
150
+ capRunnerRank,
151
+ };
@@ -1214,10 +1214,15 @@ function mountInternalSurfaces(app) {
1214
1214
  // static handler. Gated per-request on the live enabled set so a disabled module's
1215
1215
  // assets 404 (defense in depth behind the no-injection above). Same Cache-Control
1216
1216
  // posture as the hall's own scripts.
1217
+ //
1218
+ // Served on EVERY host, the generic surface hosts (status.) included (task
1219
+ // 1005081): the shared token sheet /modules/ui-design/public/tokens.css is one
1220
+ // URL the hall, the landing, the status page and raise all load. A surface host
1221
+ // still answers its own files first — its static handler above falls through
1222
+ // only when the surface dir has no such file.
1217
1223
  const MODULE_ASSET_RE = /^\/modules\/([a-z][a-z0-9-]*)\/public\/(.+)$/;
1218
1224
  const MODULES_ROOT = path.join(ROOT, 'modules');
1219
1225
  app.get(MODULE_ASSET_RE, (req, res, next) => {
1220
- if (isModuleSurfaceHost(req)) return next();
1221
1226
  const m = req.path.match(MODULE_ASSET_RE);
1222
1227
  const key = m[1];
1223
1228
  let enabled = false;
package/src/module-api.js CHANGED
@@ -81,7 +81,7 @@ const { sleep } = require('./sleep');
81
81
  // MAJOR (see allowBoxScope below): passes the request through untouched.
82
82
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
83
83
 
84
- const CORE_VERSION = '1.24.44'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
84
+ const CORE_VERSION = '1.24.46'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
85
85
 
86
86
  // A namespaced logger so a module's log lines are attributable + consistent.
87
87
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -0,0 +1,296 @@
1
+ // tests/autobongos_runner_login.mjs — an Autobongos runner's OWN login (task 1004547,
2
+ // goal 1000139).
3
+ //
4
+ // The done-when this proves: a runner session claiming under a NON-runner worktree
5
+ // name is still recognised as autonomous by the server and refused a self-modifying
6
+ // publish. Plus the owner's extras: the one-time code works once, expires, is
7
+ // Metic+-only to issue; the runner login acts at Metic at most, holds no system
8
+ // permission, and cannot take the owner door to a core move.
9
+ //
10
+ // DB-free: the session lookup, the government port and the pool are stubbed, the
11
+ // way tests/government_session_scope.mjs and tests/government_require_permission.mjs
12
+ // do. The claim route and the publish guard are driven through their real code.
13
+ import assert from 'node:assert/strict';
14
+ import { test } from 'node:test';
15
+ import { createRequire } from 'node:module';
16
+ import fs from 'node:fs';
17
+ import path from 'node:path';
18
+ import { fileURLToPath } from 'node:url';
19
+
20
+ const require = createRequire(import.meta.url);
21
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
22
+
23
+ // auth.js destructures lookupSession at load, so the stub goes in first.
24
+ const dbKernel = require(path.join(ROOT, 'src', 'bongos', 'db-kernel.js'));
25
+ let nextSession = null;
26
+ dbKernel.lookupSession = async () => nextSession;
27
+
28
+ const auth = require(path.join(ROOT, 'src', 'bongos', 'auth.js'));
29
+ const seams = require(path.join(ROOT, 'src', 'module-seams.js'));
30
+ const runnerLogin = require(path.join(ROOT, 'src', 'bongos', 'runner-login.js'));
31
+ const catalog = require(path.join(ROOT, 'modules', 'government', 'catalog.js'));
32
+ const guard = require(path.join(ROOT, 'modules', 'lifecycle', 'runner-self-guard.js'));
33
+ const authz = require(path.join(ROOT, 'modules', 'provisioning', 'core-upgrade-authz.js'));
34
+ const { attachFail } = require(path.join(ROOT, 'src', 'bongos', 'middleware', 'error-envelope.js'));
35
+
36
+ function fakeRes() {
37
+ const res = {
38
+ _status: null,
39
+ _json: null,
40
+ status(code) { this._status = code; return this; },
41
+ json(body) { this._json = body; return this; },
42
+ };
43
+ attachFail({}, res, () => {});
44
+ return res;
45
+ }
46
+
47
+ async function throughRequireBuilder(session) {
48
+ nextSession = session;
49
+ const req = { headers: { authorization: 'Bearer anytoken' } };
50
+ const res = fakeRes();
51
+ let nexted = false;
52
+ await auth.requireBuilder(req, res, () => { nexted = true; });
53
+ return { req, res, nexted };
54
+ }
55
+
56
+ const ARCHON_RUNNER = { session_id: 91, builder_id: 3, source: 'runner', rank: 'archon', status: 'active', github_login: 'owner' };
57
+ const ARCHON_CLI = { ...ARCHON_RUNNER, session_id: 90, source: 'cli' };
58
+
59
+ // ── the login ────────────────────────────────────────────────────────────────
60
+
61
+ test('a runner login is a declared session source, and requireBuilder admits it', async () => {
62
+ const { nexted, req } = await throughRequireBuilder(ARCHON_RUNNER);
63
+ assert.equal(nexted, true);
64
+ assert.equal(req.builder.runner, true);
65
+ });
66
+
67
+ test('a runner login acts at Metic at most, so every archon rank gate refuses it', async () => {
68
+ const { req } = await throughRequireBuilder(ARCHON_RUNNER);
69
+ assert.equal(req.builder.rank, 'metic', 'an Archon\'s runner is capped');
70
+ const res = fakeRes();
71
+ let nexted = false;
72
+ auth.requireRank('archon')(req, res, () => { nexted = true; });
73
+ assert.equal(nexted, false);
74
+ assert.equal(res._status, 403);
75
+ // The same person's own CLI session keeps their rank: the cap is the login's, not the person's.
76
+ const cli = await throughRequireBuilder(ARCHON_CLI);
77
+ assert.equal(cli.req.builder.rank, 'archon');
78
+ assert.equal(cli.req.builder.runner, false);
79
+ });
80
+
81
+ test('a lower rank is never raised by the cap', () => {
82
+ const R = dbKernel.RANKS;
83
+ assert.equal(runnerLogin.capRunnerRank('xenos', R), 'xenos');
84
+ assert.equal(runnerLogin.capRunnerRank('metic', R), 'metic');
85
+ assert.equal(runnerLogin.capRunnerRank('olympian', R), 'metic');
86
+ assert.equal(runnerLogin.capRunnerRank('nonsense', R), 'nonsense', 'an unknown rank is left for requireRank to fail closed');
87
+ });
88
+
89
+ // ── system permissions ───────────────────────────────────────────────────────
90
+
91
+ async function permissionGate(session, key, held) {
92
+ seams._reset();
93
+ seams.registerProvider('government', {
94
+ resolveBuilderPermissions: async () => held,
95
+ isSystem: catalog.isSystem,
96
+ });
97
+ const req = { builder: { id: 3, rank: 'archon' }, gdsSession: session, rawPermissionKeys: null };
98
+ const res = fakeRes();
99
+ let nexted = false;
100
+ await auth.requirePermission(key)(req, res, () => { nexted = true; });
101
+ seams._reset();
102
+ return { res, nexted };
103
+ }
104
+
105
+ test('a runner login is refused every system permission its builder holds', async () => {
106
+ const systemKeys = catalog.systemKeys().filter((k) => !catalog.isPrincipalKey(k));
107
+ assert.ok(systemKeys.includes('core.pin.move') && systemKeys.includes('autonomy.fence.manage'));
108
+ for (const key of systemKeys) {
109
+ const runner = await permissionGate(ARCHON_RUNNER, key, systemKeys);
110
+ assert.equal(runner.nexted, false, `${key} must be refused to a runner login`);
111
+ assert.equal(runner.res._status, 403);
112
+ const person = await permissionGate(ARCHON_CLI, key, systemKeys);
113
+ assert.equal(person.nexted, true, `${key} stays with the person's own session`);
114
+ }
115
+ });
116
+
117
+ test('a runner login keeps the ordinary permissions its builder holds', async () => {
118
+ const ordinary = catalog.permissionKeys().find((k) => catalog.isSystem(k) === false);
119
+ const r = await permissionGate(ARCHON_RUNNER, ordinary, [ordinary]);
120
+ assert.equal(r.nexted, true);
121
+ });
122
+
123
+ test('with no way to tell a system key, a runner holds nothing', () => {
124
+ assert.equal(runnerLogin.withoutSystemPermissions(new Set(['a', 'b']), undefined).size, 0);
125
+ assert.equal(runnerLogin.withoutSystemPermissions(new Set(['a']), () => null).size, 0, 'an unknown key drops');
126
+ assert.equal(runnerLogin.withoutSystemPermissions(new Set(['a']), () => { throw new Error('x'); }).size, 0);
127
+ });
128
+
129
+ test('a runner login never takes the owner door to a core move', async () => {
130
+ let ownerLookups = 0;
131
+ let pinGateCalled = 0;
132
+ const gate = authz.requireOwnProjectOrPin({
133
+ pinGate: (req, res) => { pinGateCalled++; res.status(403).json({ refused: true }); },
134
+ provisioning: { getInstanceById: async () => { ownerLookups++; return { hosting_shape: 'cloud-host', owner_builder_id: 3 }; } },
135
+ pool: {},
136
+ log: null,
137
+ });
138
+ const res = fakeRes();
139
+ let nexted = false;
140
+ await gate({ params: { id: '41' }, builder: { id: 3, runner: true } }, res, () => { nexted = true; });
141
+ assert.equal(nexted, false);
142
+ assert.equal(pinGateCalled, 1, 'it falls to the permission gate, which a runner fails');
143
+ assert.equal(ownerLookups, 0, 'the ownership door is never asked');
144
+ // The owner's own session still goes through the owner door.
145
+ let ownerNexted = false;
146
+ await gate({ params: { id: '41' }, builder: { id: 3, runner: false } }, fakeRes(), () => { ownerNexted = true; });
147
+ assert.equal(ownerNexted, true);
148
+ });
149
+
150
+ // ── the one-time code ────────────────────────────────────────────────────────
151
+
152
+ // A pool double holding the codes table and the sessions inserted from it.
153
+ function fakePool({ builderStatus = 'active' } = {}) {
154
+ const codes = [];
155
+ const sessions = [];
156
+ const client = {
157
+ async query(sql, params = []) {
158
+ if (/^(BEGIN|COMMIT|ROLLBACK)/.test(sql)) return { rows: [] };
159
+ if (/INSERT INTO runner_enrollment_codes/.test(sql)) {
160
+ const row = { id: codes.length + 1, builder_id: params[0], code_hash: params[1], label: params[2], expires_at: params[3], used_at: null };
161
+ codes.push(row);
162
+ return { rows: [{ id: row.id, expires_at: row.expires_at }] };
163
+ }
164
+ if (/UPDATE runner_enrollment_codes c\s+SET used_at/.test(sql)) {
165
+ const hit = codes.find((c) => c.code_hash === params[0] && !c.used_at && c.expires_at > new Date());
166
+ if (!hit) return { rows: [] };
167
+ hit.used_at = new Date();
168
+ return { rows: [{ id: hit.id, builder_id: hit.builder_id, label: hit.label, github_login: 'owner', status: builderStatus }] };
169
+ }
170
+ if (/INSERT INTO builder_sessions/.test(sql)) {
171
+ sessions.push({ builder_id: params[0], token_hash: params[1], source: params[2], user_agent: params[3] });
172
+ return { rows: [{ id: 500 + sessions.length }] };
173
+ }
174
+ if (/UPDATE runner_enrollment_codes SET used_session_id/.test(sql)) return { rows: [] };
175
+ throw new Error(`unexpected SQL: ${sql}`);
176
+ },
177
+ release() {},
178
+ };
179
+ return { codes, sessions, connect: async () => client, query: client.query };
180
+ }
181
+
182
+ test('a code is issued once, stored only as a hash, and expires in 15 minutes', async () => {
183
+ const pool = fakePool();
184
+ const now = Date.UTC(2026, 10, 6, 12, 0, 0);
185
+ const out = await runnerLogin.issueCode({ builderId: 3, label: 'nucbox\nm8', now }, pool);
186
+ assert.match(out.code, /^abr_[0-9a-f]{64}$/);
187
+ assert.equal(pool.codes.length, 1);
188
+ assert.equal(pool.codes[0].code_hash, dbKernel.sessionTokenHash(out.code));
189
+ assert.ok(!JSON.stringify(pool.codes).includes(out.code), 'the plaintext is never stored');
190
+ assert.equal(pool.codes[0].expires_at.getTime() - now, 15 * 60 * 1000);
191
+ assert.equal(pool.codes[0].label, 'nucbox m8', 'a control character in the label is flattened');
192
+ });
193
+
194
+ test('a code works exactly once and yields a runner session', async () => {
195
+ const pool = fakePool();
196
+ const { code } = await runnerLogin.issueCode({ builderId: 3, label: 'pc' }, pool);
197
+ const first = await runnerLogin.exchangeCode({ code, userAgent: 'setup/1' }, pool);
198
+ assert.equal(first.builder_id, 3);
199
+ assert.match(first.token, /^[0-9a-f]{64}$/);
200
+ assert.equal(pool.sessions.length, 1);
201
+ assert.equal(pool.sessions[0].source, 'runner');
202
+ assert.equal(pool.sessions[0].token_hash, dbKernel.sessionTokenHash(first.token), 'the token is stored only as a hash');
203
+ const again = await runnerLogin.exchangeCode({ code }, pool);
204
+ assert.deepEqual(again, { refused: 'code_invalid' }, 'a used code is refused');
205
+ assert.equal(pool.sessions.length, 1);
206
+ });
207
+
208
+ test('an expired, wrong or malformed code is refused with the same answer', async () => {
209
+ const pool = fakePool();
210
+ const { code } = await runnerLogin.issueCode({ builderId: 3, now: Date.now() - 16 * 60 * 1000 }, pool);
211
+ assert.deepEqual(await runnerLogin.exchangeCode({ code }, pool), { refused: 'code_invalid' });
212
+ assert.deepEqual(await runnerLogin.exchangeCode({ code: 'abr_' + '0'.repeat(64) }, pool), { refused: 'code_invalid' });
213
+ assert.deepEqual(await runnerLogin.exchangeCode({ code: 'not-a-code' }, pool), { refused: 'code_invalid' });
214
+ assert.deepEqual(await runnerLogin.exchangeCode({ code: null }, pool), { refused: 'code_invalid' });
215
+ assert.equal(pool.sessions.length, 0);
216
+ });
217
+
218
+ test('an offboarded builder\'s code mints nothing', async () => {
219
+ const pool = fakePool({ builderStatus: 'inactive' });
220
+ const { code } = await runnerLogin.issueCode({ builderId: 3 }, pool);
221
+ assert.deepEqual(await runnerLogin.exchangeCode({ code }, pool), { refused: 'builder_inactive' });
222
+ assert.equal(pool.sessions.length, 0);
223
+ });
224
+
225
+ // ── the routes ───────────────────────────────────────────────────────────────
226
+
227
+ test('issuing a code is Metic+, cookie-only, and the exchange is a public auth route', () => {
228
+ const src = fs.readFileSync(path.join(ROOT, 'src', 'bongos', 'routes', 'auth.js'), 'utf8');
229
+ assert.match(src, /router\.post\('\/auth\/runner-code\/issue', auth\.requireBuilder, auth\.requireRank\('metic', 'archon'\),/);
230
+ const issue = src.slice(src.indexOf("'/auth/runner-code/issue'"), src.indexOf("'/auth/runner-code/exchange'"));
231
+ assert.match(issue, /if \(!sessionCookieToken\(req\)\)/, 'a bearer (CLI or runner) cannot mint a login');
232
+ const rr = require(path.join(ROOT, 'src', 'bongos', 'route-rank-check.js')).checkRouteRanks();
233
+ const ranks = Object.fromEntries(rr.routes.map((r) => [r.key, r.rank]));
234
+ assert.equal(ranks['POST /auth/runner-code/issue'], 'metic+archon');
235
+ assert.equal(ranks['POST /auth/runner-code/exchange'], 'public');
236
+ assert.equal(rr.ok, true);
237
+ });
238
+
239
+ // ── the claim and the publish ────────────────────────────────────────────────
240
+
241
+ test('the claim route marks a runner login\'s claim autonomous from the session, never the body', () => {
242
+ const src = fs.readFileSync(path.join(ROOT, 'modules', 'lifecycle', 'routes', 'claims.js'), 'utf8');
243
+ assert.match(src, /autonomous: !!\(req\.builder && req\.builder\.runner\),/);
244
+ assert.doesNotMatch(src, /autonomous: [^\n]*req\.body/);
245
+ assert.match(src, /if \(req\.builder && req\.builder\.runner\) \{\n\s*return res\.fail\('runner_batch_refused'/);
246
+ const db = fs.readFileSync(path.join(ROOT, 'modules', 'lifecycle', 'db-claims.js'), 'utf8');
247
+ assert.match(db, /\$\{autonomous \? ', autonomous' : ''\}/);
248
+ });
249
+
250
+ const files = (...p) => p.map((x) => ({ path: x }));
251
+
252
+ test('DONE WHEN: a runner claim under a person-looking worktree name is still refused a self-modifying publish', async () => {
253
+ const disguised = { worktree_name: 'fix-typo', requested_worktree_name: 'fix-typo', autonomous: true };
254
+ const r = await guard.runnerSelfEditRefusal({ taskId: 4242, prNumber: 77, branch: 'fix-typo' }, {
255
+ readLatestClaim: async () => disguised,
256
+ getPullRequestFiles: async () => files('modules/autonomy/fence.js'),
257
+ });
258
+ assert.equal(r && r.code, 'self_modification');
259
+ assert.deepEqual(r.files, ['modules/autonomy/fence.js']);
260
+ });
261
+
262
+ test('a runner login publishing someone else\'s claim is checked too', async () => {
263
+ const person = { worktree_name: 'mine', requested_worktree_name: 'mine', autonomous: false };
264
+ const r = await guard.runnerSelfEditRefusal({ taskId: 4242, prNumber: 77, publisherIsRunner: true }, {
265
+ readLatestClaim: async () => person,
266
+ getPullRequestFiles: async () => files('scripts/bongos/autobongos-run.js'),
267
+ });
268
+ assert.equal(r && r.code, 'self_modification');
269
+ });
270
+
271
+ test('a person\'s claim published by a person is still never checked', async () => {
272
+ let read = 0;
273
+ const r = await guard.runnerSelfEditRefusal({ taskId: 4242, prNumber: 77, publisherIsRunner: false }, {
274
+ readLatestClaim: async () => ({ worktree_name: 'mine', requested_worktree_name: null, autonomous: false }),
275
+ getPullRequestFiles: async () => { read++; return files('modules/autonomy/fence.js'); },
276
+ });
277
+ assert.equal(r, null);
278
+ assert.equal(read, 0);
279
+ });
280
+
281
+ test('the guard reads the autonomous mark from the claim row, and the route passes the publisher', async () => {
282
+ const seen = [];
283
+ await guard.readLatestClaim(4242, { query: async (sql) => { seen.push(sql); return { rows: [] }; } });
284
+ assert.match(seen[0], /c\.autonomous/);
285
+ const tasks = fs.readFileSync(path.join(ROOT, 'modules', 'lifecycle', 'routes', 'tasks.js'), 'utf8');
286
+ assert.match(tasks, /publisherIsRunner: !!\(req\.builder && req\.builder\.runner\)/);
287
+ });
288
+
289
+ test('the migration adds the source, the codes table and the claim mark, and stores no plaintext', () => {
290
+ const sql = fs.readFileSync(path.join(ROOT, 'migrations', 'core_295_runner_logins.sql'), 'utf8');
291
+ assert.match(sql, /CHECK \(source IN \('cli', 'web', 'runner'\)\)/);
292
+ assert.match(sql, /CREATE TABLE IF NOT EXISTS runner_enrollment_codes/);
293
+ assert.match(sql, /code_hash\s+text NOT NULL UNIQUE/);
294
+ assert.doesNotMatch(sql, /\bcode\s+text\b/, 'no plaintext code column');
295
+ assert.match(sql, /ADD COLUMN IF NOT EXISTS autonomous boolean NOT NULL DEFAULT false/);
296
+ });