@bongos/core 1.19.332 → 1.19.333

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 (58) hide show
  1. package/.bongos-core.json +160 -40
  2. package/docs/api/openapi.json +2 -2
  3. package/docs/api-reference.md +1 -1
  4. package/docs/copy-inventory.md +202 -202
  5. package/docs/copy-registry.json +384 -384
  6. package/modules/grading/grader-config.js +98 -0
  7. package/modules/grading/grader-model.js +150 -0
  8. package/modules/grading/grader-prompt.js +139 -0
  9. package/modules/grading/grader-score.js +706 -0
  10. package/modules/grading/grader-subagent.js +328 -0
  11. package/modules/grading/grader.js +5 -1287
  12. package/modules/hall-ui/public/builders.js +19 -1182
  13. package/modules/hall-ui/public/goals-page.js +11 -997
  14. package/modules/hall-ui/public/goals-render.js +1032 -0
  15. package/modules/hall-ui/public/goals.html +4 -0
  16. package/modules/hall-ui/public/hall-render.js +1211 -0
  17. package/modules/hall-ui/public/index.html +6 -0
  18. package/modules/lifecycle/routes/goal-route-authz.js +400 -0
  19. package/modules/lifecycle/routes/goals.js +1 -367
  20. package/modules/lifecycle/routes/task-route-helpers.js +219 -0
  21. package/modules/lifecycle/routes/task-write-routes.js +542 -0
  22. package/modules/lifecycle/routes/tasks.js +3 -696
  23. package/package-lock.json +2 -2
  24. package/package.json +1 -1
  25. package/scripts/gds/box-infra.js +280 -0
  26. package/scripts/gds/box.js +1 -245
  27. package/scripts/gds/fitness-checks-identity.js +320 -0
  28. package/scripts/gds/fitness-checks-vocabulary.js +230 -0
  29. package/scripts/gds/fitness-checks-write-validation.js +144 -0
  30. package/scripts/gds/fitness-lib.js +82 -0
  31. package/scripts/gds/fitness.js +55 -668
  32. package/scripts/gds/provision-config.js +204 -0
  33. package/scripts/gds/provision-net.js +221 -0
  34. package/scripts/gds/provision-repo.js +849 -0
  35. package/scripts/gds/provision-units.js +262 -0
  36. package/scripts/gds/provision.js +4 -1395
  37. package/src/bongos/auth-admission.js +264 -0
  38. package/src/bongos/auth-config.js +151 -0
  39. package/src/bongos/auth-discord.js +198 -0
  40. package/src/bongos/auth-github.js +336 -0
  41. package/src/bongos/auth-idp.js +330 -0
  42. package/src/bongos/auth.js +5 -1100
  43. package/src/module-api.js +3 -1
  44. package/tests/auth_admission_gate.mjs +3 -1
  45. package/tests/est_advisory.mjs +3 -1
  46. package/tests/goal_advisory.mjs +3 -2
  47. package/tests/goal_criterion_confirm_ui.mjs +6 -0
  48. package/tests/goal_subtitle.mjs +5 -2
  49. package/tests/goals_card_view.mjs +10 -3
  50. package/tests/grader_generated_provenance.mjs +3 -1
  51. package/tests/hall_palette.mjs +3 -1
  52. package/tests/helpers.mjs +58 -25
  53. package/tests/lib_sh_resolution.mjs +3 -1
  54. package/tests/membership_kind_lockstep.mjs +3 -1
  55. package/tests/migration_allocation.mjs +5 -2
  56. package/tests/patch_gate_ordering.mjs +3 -1
  57. package/tests/rank_tier_single_source.mjs +16 -3
  58. package/tests/reward_gate.mjs +3 -2
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.332",
3
+ "version": "1.19.333",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.332",
9
+ "version": "1.19.333",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.332",
3
+ "version": "1.19.333",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -0,0 +1,280 @@
1
+ // scripts/gds/box-infra.js — the dev-box CONFIG block plus everything that
2
+ // turns it into a bootable droplet: infra/ resolution, naming, the DNS hook,
3
+ // and cloud-init rendering.
4
+ //
5
+ // Carved out of scripts/gds/box.js by task 1003206 (goal 1000079, the R03
6
+ // de-monolith plan) to bring that file under the 1,500-line ratchet. A
7
+ // STRUCTURAL move: the lines below are box.js's originals unchanged, and box.js
8
+ // requires every name straight back, so the operator CLI is untouched.
9
+
10
+ 'use strict';
11
+
12
+ const fs = require('node:fs');
13
+ const path = require('node:path');
14
+ const zlib = require('node:zlib');
15
+ const { execFileSync } = require('node:child_process');
16
+ const REPO_ROOT = path.resolve(__dirname, '..', '..');
17
+ const instanceConfig = require(path.join(REPO_ROOT, 'src/instance-config'));
18
+ const BOX_INFRA_TEMPLATE = 'builder-box-cloud-init.yaml';
19
+
20
+ function boxInfraCandidates({ env = process.env, repoRoot = REPO_ROOT } = {}) {
21
+ const out = [];
22
+ const seen = new Set();
23
+ const add = (d) => { if (d && !seen.has(d)) { seen.add(d); out.push(d); } };
24
+ // 1. explicit override: <PREFIX>_BOX_INFRA_DIR (operator escape hatch).
25
+ const override = instanceConfig.resolveEnv('BOX_INFRA_DIR', { env });
26
+ if (override) add(path.resolve(override));
27
+ // 2. the instance root via an EXPLICIT pointer (<PREFIX>_INSTANCE_ROOT env or a
28
+ // bongos.config.json {"instanceRoot":"."}) — where a vendored instance keeps infra/.
29
+ const inst = instanceConfig.resolveInstanceRootExplicit({ env });
30
+ if (inst) add(path.join(inst, 'infra'));
31
+ // 3. REPO_ROOT/infra — the dev single-checkout (and the ADR 0144 node_modules workaround).
32
+ add(path.join(repoRoot, 'infra'));
33
+ // 4. walk up out of node_modules from the core root, so a vendored
34
+ // …/node_modules/@bongos/core still finds its host instance's infra/ with no pointer set.
35
+ let dir = repoRoot;
36
+ for (let i = 0; i < 6; i += 1) {
37
+ const parent = path.dirname(dir);
38
+ if (parent === dir) break;
39
+ dir = parent;
40
+ add(path.join(dir, 'infra'));
41
+ }
42
+ return out;
43
+ }
44
+
45
+ // The resolved infra/ dir (first candidate holding the template), or null when the
46
+ // box cloud-init cannot be located anywhere — the caller MUST fail loud on null
47
+ // rather than provision a box with empty user-data (ADR 0150).
48
+ function resolveBoxInfraDir(opts = {}) {
49
+ for (const c of boxInfraCandidates(opts)) {
50
+ try { if (fs.existsSync(path.join(c, BOX_INFRA_TEMPLATE))) return c; }
51
+ catch { /* unreadable candidate → try the next */ }
52
+ }
53
+ return null;
54
+ }
55
+
56
+ // task 1002757 — the instance's own apex; null when unconfigured (callers refuse).
57
+ const { instanceApex } = require('./instance-apex');
58
+
59
+ const CONFIG = {
60
+ floor: process.env.BOX_PROVISION_MIN_RANK || 'xenos',
61
+ region: process.env.BOX_REGION || 'nyc3',
62
+ size: process.env.BOX_SIZE || 's-2vcpu-4gb',
63
+ baseImage: process.env.BOX_BASE_IMAGE || 'ubuntu-24-04-x64',
64
+ idleMinutes: numEnv('BOX_IDLE_MINUTES', 10),
65
+ dormantDays: numEnv('BOX_DORMANT_DAYS', 14),
66
+ // task 1002727 — how many days BEFORE the reclaim a builder gets warned.
67
+ dormantWarnLeadDays: numEnv('BOX_DORMANT_WARN_LEAD_DAYS', 3),
68
+ sshKeyIds: (process.env.BOX_SSH_KEY_IDS || '').split(',').map((s) => s.trim()).filter(Boolean),
69
+ dnsHook: process.env.BOX_DNS_HOOK || null,
70
+ tag: process.env.BOX_TAG || 'otb-builder-box',
71
+ // task 1002757 settled this file's old carve-out: an operator-plane DNS zone is
72
+ // still a FUNCTIONAL DEFAULT, and a founder literal aimed another operator's
73
+ // fleet at the founder's zone. Derived from the instance apex, null when
74
+ // unconfigured — hostnameFor() then refuses instead of minting a dead FQDN.
75
+ dnsZone: process.env.BOX_DNS_ZONE || (instanceApex() ? `dev.${instanceApex()}` : null),
76
+ };
77
+
78
+ // Named-tunnel automation for the browser terminal (#771, ADR 0038 Part B).
79
+ // OPT-IN, default OFF — an unset BOX_NAMED_TUNNEL leaves the box on the tokenless
80
+ // quick tunnel (trycloudflare.com), the zero-secret #760 default. When the operator
81
+ // turns it on AND supplies a Cloudflare token with Tunnel + DNS scope (the same
82
+ // CLOUDFLARE_API_TOKEN box-dns uses) + the account id, every provision creates a
83
+ // stable term-<login>.<terminalZone> tunnel and bakes its connector token into the
84
+ // box; deprovision tears it down.
85
+ const CF = {
86
+ enabled: /^(1|true|yes)$/i.test(process.env.BOX_NAMED_TUNNEL || ''),
87
+ apiToken: process.env.CLOUDFLARE_API_TOKEN || null,
88
+ accountId: process.env.CLOUDFLARE_ACCOUNT_ID || null,
89
+ // The Cloudflare ZONE the record lives in (the registrable apex) — the terminal
90
+ // host is a record WITHIN it, matching box-dns-cloudflare.sh. Distinct from
91
+ // terminalZone (the host suffix). Both resolve from the instance (task 1002757).
92
+ zone: process.env.CLOUDFLARE_ZONE || instanceApex(),
93
+ // Host suffix for term-<login>.<terminalZone>. DEFAULTS TO THE APEX, NOT dev.*:
94
+ // the terminal rides a PROXIED tunnel, and free Universal SSL covers <apex> +
95
+ // *.<apex> (ONE label) but not a TWO-label host like term-x.dev.<apex>, which
96
+ // fails TLS at the edge (verified live, #771). An operator holding an Advanced
97
+ // Certificate for *.dev.<apex> can override via BOX_TERMINAL_ZONE. (The SSH
98
+ // A-record path stays on dev.* — proxied=false, so it needs no edge cert.)
99
+ terminalZone: process.env.BOX_TERMINAL_ZONE || instanceApex(),
100
+ };
101
+
102
+ // Named-tunnel automation is live only when the operator opted in AND the
103
+ // credentials are present. Missing creds while enabled is a loud config error at
104
+ // provision time (see cmdProvision), not a silent fall-back, so an operator who
105
+ // meant to use named tunnels learns immediately.
106
+ function numEnv(name, dflt) {
107
+ const v = process.env[name];
108
+ if (v == null || v === '') return dflt;
109
+ const n = Number(v);
110
+ return Number.isFinite(n) && n > 0 ? n : dflt;
111
+ }
112
+
113
+ function hasFlag(args, name) { return args.includes(name); }
114
+
115
+ // Lazy + memoized DB + lib load so `--help` and arg errors don't require Postgres
116
+ // (this CLI runs on a non-droplet machine for dry-run planning of pure logic too):
117
+ // nothing is require()d until the first makeDo/makeCf/IIFE call, and repeat calls
118
+ // reuse the first resolution instead of re-running the six requires.
119
+ let _deps;
120
+ function shortStamp() {
121
+ return new Date().toISOString().replace(/[-:T.Z]/g, '').slice(2, 14);
122
+ }
123
+
124
+ function dropletName(login) { return `otb-box-${login}`; }
125
+ // Refuses on an unresolved zone (task 1002757) — `box-x.undefined` would mint a
126
+ // DNS record and a box that can never be reached.
127
+ function hostnameFor(login) {
128
+ if (!CONFIG.dnsZone) throw new Error('box: no DNS zone configured — set BOX_DNS_ZONE, or give this instance a domains.publicOrigin in config/branding.json (task 1002757).');
129
+ return `box-${login}.${CONFIG.dnsZone}`;
130
+ }
131
+
132
+ // Run the optional DNS hook so the per-builder hostname tracks the new IP; with no
133
+ // hook configured, print the reminder — the stable-hostname CUTOVER is #599's, #598
134
+ // only records IP + hostname and offers the hook so an operator can wire DNS without
135
+ // editing this file. → { ok, error?, skipped? }. task 1180: callers MUST act on
136
+ // ok:false (box_events row + flag the box), never continue silently — that swallow
137
+ // left @kingaonline's A record pinned at a dead IP while provision reported success.
138
+ // The hook retries transient Cloudflare blips, so ok:false is a real, exhausted
139
+ // failure. `hook` is injectable so the contract is unit-testable (#1034).
140
+ function runDnsHook(hostname, ip, hook = CONFIG.dnsHook) {
141
+ if (!ip) return { ok: true, skipped: true };
142
+ if (!hook) {
143
+ console.log(` ↳ DNS: point ${hostname} → ${ip} (no BOX_DNS_HOOK set; #599 owns the cutover)`);
144
+ return { ok: true, skipped: true };
145
+ }
146
+ try {
147
+ execFileSync('bash', ['-c', hook], {
148
+ cwd: REPO_ROOT, stdio: 'pipe',
149
+ env: { ...process.env, BOX_HOSTNAME: hostname, BOX_IP: ip },
150
+ });
151
+ console.log(` ↳ DNS hook updated ${hostname} → ${ip}`);
152
+ return { ok: true };
153
+ } catch (e) {
154
+ const msg = (e.stderr && e.stderr.toString().trim()) || e.message;
155
+ console.log(` ⚠ DNS hook failed: ${msg} (point ${hostname} → ${ip} manually)`);
156
+ return { ok: false, error: msg };
157
+ }
158
+ }
159
+
160
+ // Blackhole sentinel for a torn-down box's A record (idea 309). 192.0.2.1 is in
161
+ // TEST-NET-1 (RFC 5737) — reserved for documentation and GUARANTEED never to be a
162
+ // routable host, so no recycled DigitalOcean IP can ever land here. A perfect
163
+ // dead end to park a record at.
164
+ const DNS_BLACKHOLE_IP = '192.0.2.1';
165
+
166
+ // Park the per-builder A record at the blackhole sentinel on deprovision (idea 309,
167
+ // refining task 1180). 1180 DELETED the record so it couldn't dangle at a recycled
168
+ // DO IP, but delete-then-recreate across a reap→rebuild left an NXDOMAIN window that
169
+ // resolvers negative-cache for the zone's SOA neg-TTL (~30 min, not builder-editable)
170
+ // — a client querying then kept getting ENOTFOUND long after the box was back.
171
+ // REPOINTING to a non-routable sentinel keeps the name always-resolving (nothing to
172
+ // negative-cache) while preserving 1180's guarantee that a torn-down box's name can
173
+ // never dial a stranger's recycled IP; the next provision/wake PUTs the live IP
174
+ // in-place at the 60s TTL, no gap. Best-effort like the prior delete. Reuses
175
+ // runDnsHook's UPSERT path; `hook` injectable for tests. → { ok, error?, skipped? }
176
+ function runDnsHookBlackhole(hostname, hook = CONFIG.dnsHook) {
177
+ if (!hook || !hostname) return { ok: true, skipped: true };
178
+ return runDnsHook(hostname, DNS_BLACKHOLE_IP, hook);
179
+ }
180
+
181
+ // Read the cloud-init template and substitute the repo URL from BOX_REPO_URL
182
+ // (kept out of the repo file so no GitHub identity is hardcoded — V3.R12 rule).
183
+ // An unset URL leaves the placeholder; the cloud-init then logs guidance rather
184
+ // than cloning, and #600 wires the scoped credential.
185
+ // Read + render the cloud-init. `authorizedKeysBlock` is the otb-managed block
186
+ // for the builder being provisioned (#685) — base64'd here and baked in so the
187
+ // FIRST SSH login works before the box has a GDS token. An empty/absent block
188
+ // renders an empty managed block (a keyless box; the operator is warned upstream).
189
+ function readCloudInit({ authorizedKeysBlock = '', boxEnv = '', gdsSession = '', infraDir = resolveBoxInfraDir() } = {}) {
190
+ // Fail loud, never provision a BARE box (ADR 0150). No infra/ located → there is
191
+ // no cloud-init to render, so THROW here rather than the old silent `return null`
192
+ // that let cmdProvision ship empty user-data (a box that boots as bare Ubuntu).
193
+ if (!infraDir) {
194
+ throw new Error(
195
+ `box cloud-init not found: no infra/${BOX_INFRA_TEMPLATE} in any candidate location `
196
+ + `(set <PREFIX>_BOX_INFRA_DIR or <PREFIX>_INSTANCE_ROOT, or ship infra/). A box must `
197
+ + `never be provisioned without user-data — it would boot bare (ADR 0150).`,
198
+ );
199
+ }
200
+ const p = path.join(infraDir, BOX_INFRA_TEMPLATE);
201
+ // gzip+base64 a repo file into the cloud-init write_files (`encoding: gz+b64`).
202
+ // EVERY inlined infra file goes through this: cloud-init gunzips it back to the
203
+ // exact bytes on the box, so it is byte-identical to plain b64 but ~2-3x smaller,
204
+ // which keeps the rendered user-data well under DO's 64 KiB cap (#903; the source
205
+ // -fetch growth in task 1314 had pushed the all-b64-but-source-fetch render to
206
+ // 67 KB — gz+b64-ing the rest brings it back to ~51 KB, task 1321). Only the
207
+ // dynamic runtime values (authorized_keys, box.env, gds-session) stay plain b64
208
+ // below — they are minted per-provision, not read from infra/. A missing inlined
209
+ // file is COLLECTED and fails loud after the render (ADR 0150) — a half-wired box
210
+ // (that component silently never boots) is a bug, not something to warn past.
211
+ const missingInfra = [];
212
+ const fileGzB64 = (name) => {
213
+ try {
214
+ return zlib.gzipSync(fs.readFileSync(path.join(infraDir, name))).toString('base64');
215
+ } catch {
216
+ missingInfra.push(name);
217
+ return '';
218
+ }
219
+ };
220
+ const raw = fs.readFileSync(p, 'utf8');
221
+ // Inject the rank-gated source-fetch (#600) + authorized-keys-fetch (#685)
222
+ // scripts as gz+b64 (a single YAML-safe token — no indentation/escaping
223
+ // pitfalls; cloud-init gunzips encoding: gz+b64 back to the exact file), plus
224
+ // the baked authorized_keys block. ONE canonical copy of each lives in infra/.
225
+ const rendered = raw
226
+ .replace(/__REPO_URL__/g, process.env.BOX_REPO_URL || '')
227
+ .replace(/__BOX_SOURCE_FETCH_B64__/g, fileGzB64('box-source-fetch.sh'))
228
+ .replace(/__BOX_AUTHKEYS_FETCH_B64__/g, fileGzB64('box-authorized-keys-fetch.sh'))
229
+ .replace(/__BUILDER_AUTHORIZED_KEYS_B64__/g, Buffer.from(authorizedKeysBlock || '', 'utf8').toString('base64'))
230
+ // /etc/otb/box.env — empty by default (quick tunnel); the named-tunnel
231
+ // connector token + stable hostname when BOX_NAMED_TUNNEL is on (#771).
232
+ .replace(/__BOX_ENV_B64__/g, Buffer.from(boxEnv || '', 'utf8').toString('base64'))
233
+ // /root/.config/otb/gds-session.json — the builder's GDS session, minted at
234
+ // provision so box-source-fetch (#600) can authenticate on FIRST boot and
235
+ // clone the rank-scoped repo. Without it the box never clones (the #818
236
+ // chicken-and-egg: the session normally comes from /builder-setup, which
237
+ // lives in the un-cloned repo).
238
+ .replace(/__GDS_SESSION_B64__/g, Buffer.from(gdsSession || '', 'utf8').toString('base64'))
239
+ // #774: the browser-terminal units, inlined so they start at FIRST BOOT
240
+ // (independent of the repo clone). ONE canonical copy lives in infra/.
241
+ .replace(/__BOX_TERMINAL_SERVICE_B64__/g, fileGzB64('box-terminal.service'))
242
+ // #917: credential-drift watcher pair — restarts ttyd when the credential
243
+ // file lands/changes after the service started (the wake/first-boot race).
244
+ .replace(/__BOX_TERMINAL_CREDSYNC_SERVICE_B64__/g, fileGzB64('box-terminal-credsync.service'))
245
+ .replace(/__BOX_TERMINAL_CREDENTIAL_PATH_B64__/g, fileGzB64('box-terminal-credential.path'))
246
+ .replace(/__CLOUDFLARED_SERVICE_B64__/g, fileGzB64('cloudflared.service'))
247
+ .replace(/__CLOUDFLARED_RUN_B64__/g, fileGzB64('cloudflared-run.sh'))
248
+ // #893: the `close-box` teardown command, present at first boot so a bare
249
+ // browser-only box has it before any repo clone (box-source-fetch refreshes
250
+ // it on existing boxes). ONE canonical copy lives in infra/close-box.sh.
251
+ .replace(/__CLOSE_BOX_B64__/g, fileGzB64('close-box.sh'))
252
+ // task 986: the browser-terminal auto-launcher — runs `claude` on first
253
+ // connect so the builder skips the "type claude" step. box-source-fetch
254
+ // refreshes it on existing boxes. ONE canonical copy in infra/otb-box-launch.sh.
255
+ .replace(/__OTB_BOX_LAUNCH_B64__/g, fileGzB64('otb-box-launch.sh'));
256
+ if (missingInfra.length) {
257
+ throw new Error(
258
+ `box cloud-init is incomplete under ${infraDir}: missing ${missingInfra.join(', ')} `
259
+ + `— a box would boot half-wired (ADR 0150). Fix the instance infra/ before provisioning.`,
260
+ );
261
+ }
262
+ return rendered;
263
+ }
264
+
265
+
266
+ module.exports = {
267
+ CF,
268
+ CONFIG,
269
+ boxInfraCandidates,
270
+ dropletName,
271
+ hasFlag,
272
+ hostnameFor,
273
+ instanceApex,
274
+ numEnv,
275
+ readCloudInit,
276
+ resolveBoxInfraDir,
277
+ runDnsHook,
278
+ runDnsHookBlackhole,
279
+ shortStamp,
280
+ };
@@ -58,92 +58,7 @@ const BOX_INFRA_TEMPLATE = 'builder-box-cloud-init.yaml';
58
58
  // Ordered candidate infra/ dirs — first that actually holds the cloud-init
59
59
  // template wins. Pure (env + repoRoot injected) so it is unit-testable without
60
60
  // touching the real filesystem layout.
61
- function boxInfraCandidates({ env = process.env, repoRoot = REPO_ROOT } = {}) {
62
- const out = [];
63
- const seen = new Set();
64
- const add = (d) => { if (d && !seen.has(d)) { seen.add(d); out.push(d); } };
65
- // 1. explicit override: <PREFIX>_BOX_INFRA_DIR (operator escape hatch).
66
- const override = instanceConfig.resolveEnv('BOX_INFRA_DIR', { env });
67
- if (override) add(path.resolve(override));
68
- // 2. the instance root via an EXPLICIT pointer (<PREFIX>_INSTANCE_ROOT env or a
69
- // bongos.config.json {"instanceRoot":"."}) — where a vendored instance keeps infra/.
70
- const inst = instanceConfig.resolveInstanceRootExplicit({ env });
71
- if (inst) add(path.join(inst, 'infra'));
72
- // 3. REPO_ROOT/infra — the dev single-checkout (and the ADR 0144 node_modules workaround).
73
- add(path.join(repoRoot, 'infra'));
74
- // 4. walk up out of node_modules from the core root, so a vendored
75
- // …/node_modules/@bongos/core still finds its host instance's infra/ with no pointer set.
76
- let dir = repoRoot;
77
- for (let i = 0; i < 6; i += 1) {
78
- const parent = path.dirname(dir);
79
- if (parent === dir) break;
80
- dir = parent;
81
- add(path.join(dir, 'infra'));
82
- }
83
- return out;
84
- }
85
-
86
- // The resolved infra/ dir (first candidate holding the template), or null when the
87
- // box cloud-init cannot be located anywhere — the caller MUST fail loud on null
88
- // rather than provision a box with empty user-data (ADR 0150).
89
- function resolveBoxInfraDir(opts = {}) {
90
- for (const c of boxInfraCandidates(opts)) {
91
- try { if (fs.existsSync(path.join(c, BOX_INFRA_TEMPLATE))) return c; }
92
- catch { /* unreadable candidate → try the next */ }
93
- }
94
- return null;
95
- }
96
-
97
- // task 1002757 — the instance's own apex; null when unconfigured (callers refuse).
98
- const { instanceApex } = require('./instance-apex');
99
-
100
- const CONFIG = {
101
- floor: process.env.BOX_PROVISION_MIN_RANK || 'xenos',
102
- region: process.env.BOX_REGION || 'nyc3',
103
- size: process.env.BOX_SIZE || 's-2vcpu-4gb',
104
- baseImage: process.env.BOX_BASE_IMAGE || 'ubuntu-24-04-x64',
105
- idleMinutes: numEnv('BOX_IDLE_MINUTES', 10),
106
- dormantDays: numEnv('BOX_DORMANT_DAYS', 14),
107
- // task 1002727 — how many days BEFORE the reclaim a builder gets warned.
108
- dormantWarnLeadDays: numEnv('BOX_DORMANT_WARN_LEAD_DAYS', 3),
109
- sshKeyIds: (process.env.BOX_SSH_KEY_IDS || '').split(',').map((s) => s.trim()).filter(Boolean),
110
- dnsHook: process.env.BOX_DNS_HOOK || null,
111
- tag: process.env.BOX_TAG || 'otb-builder-box',
112
- // task 1002757 settled this file's old carve-out: an operator-plane DNS zone is
113
- // still a FUNCTIONAL DEFAULT, and a founder literal aimed another operator's
114
- // fleet at the founder's zone. Derived from the instance apex, null when
115
- // unconfigured — hostnameFor() then refuses instead of minting a dead FQDN.
116
- dnsZone: process.env.BOX_DNS_ZONE || (instanceApex() ? `dev.${instanceApex()}` : null),
117
- };
118
-
119
- // Named-tunnel automation for the browser terminal (#771, ADR 0038 Part B).
120
- // OPT-IN, default OFF — an unset BOX_NAMED_TUNNEL leaves the box on the tokenless
121
- // quick tunnel (trycloudflare.com), the zero-secret #760 default. When the operator
122
- // turns it on AND supplies a Cloudflare token with Tunnel + DNS scope (the same
123
- // CLOUDFLARE_API_TOKEN box-dns uses) + the account id, every provision creates a
124
- // stable term-<login>.<terminalZone> tunnel and bakes its connector token into the
125
- // box; deprovision tears it down.
126
- const CF = {
127
- enabled: /^(1|true|yes)$/i.test(process.env.BOX_NAMED_TUNNEL || ''),
128
- apiToken: process.env.CLOUDFLARE_API_TOKEN || null,
129
- accountId: process.env.CLOUDFLARE_ACCOUNT_ID || null,
130
- // The Cloudflare ZONE the record lives in (the registrable apex) — the terminal
131
- // host is a record WITHIN it, matching box-dns-cloudflare.sh. Distinct from
132
- // terminalZone (the host suffix). Both resolve from the instance (task 1002757).
133
- zone: process.env.CLOUDFLARE_ZONE || instanceApex(),
134
- // Host suffix for term-<login>.<terminalZone>. DEFAULTS TO THE APEX, NOT dev.*:
135
- // the terminal rides a PROXIED tunnel, and free Universal SSL covers <apex> +
136
- // *.<apex> (ONE label) but not a TWO-label host like term-x.dev.<apex>, which
137
- // fails TLS at the edge (verified live, #771). An operator holding an Advanced
138
- // Certificate for *.dev.<apex> can override via BOX_TERMINAL_ZONE. (The SSH
139
- // A-record path stays on dev.* — proxied=false, so it needs no edge cert.)
140
- terminalZone: process.env.BOX_TERMINAL_ZONE || instanceApex(),
141
- };
142
-
143
- // Named-tunnel automation is live only when the operator opted in AND the
144
- // credentials are present. Missing creds while enabled is a loud config error at
145
- // provision time (see cmdProvision), not a silent fall-back, so an operator who
146
- // meant to use named tunnels learns immediately.
61
+ const { CF, CONFIG, boxInfraCandidates, dropletName, hasFlag, hostnameFor, numEnv, readCloudInit, resolveBoxInfraDir, runDnsHook, runDnsHookBlackhole, shortStamp } = require('./box-infra.js');
147
62
  function namedTunnelEnabled() { return CF.enabled; }
148
63
  function namedTunnelConfigured() { return CF.enabled && CF.apiToken && CF.accountId; }
149
64
 
@@ -194,20 +109,6 @@ async function preflightCookieStripScope() {
194
109
  return scope;
195
110
  }
196
111
 
197
- function numEnv(name, dflt) {
198
- const v = process.env[name];
199
- if (v == null || v === '') return dflt;
200
- const n = Number(v);
201
- return Number.isFinite(n) && n > 0 ? n : dflt;
202
- }
203
-
204
- function hasFlag(args, name) { return args.includes(name); }
205
-
206
- // Lazy + memoized DB + lib load so `--help` and arg errors don't require Postgres
207
- // (this CLI runs on a non-droplet machine for dry-run planning of pure logic too):
208
- // nothing is require()d until the first makeDo/makeCf/IIFE call, and repeat calls
209
- // reuse the first resolution instead of re-running the six requires.
210
- let _deps;
211
112
  function loadDeps() {
212
113
  if (_deps) return _deps;
213
114
  // eslint-disable-next-line global-require
@@ -274,151 +175,6 @@ async function resolveBuilder(pool, ref) {
274
175
  }
275
176
 
276
177
  // A short, DNS-safe-ish suffix from the current time (CLI context — Date is fine).
277
- function shortStamp() {
278
- return new Date().toISOString().replace(/[-:T.Z]/g, '').slice(2, 14);
279
- }
280
-
281
- function dropletName(login) { return `otb-box-${login}`; }
282
- // Refuses on an unresolved zone (task 1002757) — `box-x.undefined` would mint a
283
- // DNS record and a box that can never be reached.
284
- function hostnameFor(login) {
285
- if (!CONFIG.dnsZone) throw new Error('box: no DNS zone configured — set BOX_DNS_ZONE, or give this instance a domains.publicOrigin in config/branding.json (task 1002757).');
286
- return `box-${login}.${CONFIG.dnsZone}`;
287
- }
288
-
289
- // Run the optional DNS hook so the per-builder hostname tracks the new IP; with no
290
- // hook configured, print the reminder — the stable-hostname CUTOVER is #599's, #598
291
- // only records IP + hostname and offers the hook so an operator can wire DNS without
292
- // editing this file. → { ok, error?, skipped? }. task 1180: callers MUST act on
293
- // ok:false (box_events row + flag the box), never continue silently — that swallow
294
- // left @kingaonline's A record pinned at a dead IP while provision reported success.
295
- // The hook retries transient Cloudflare blips, so ok:false is a real, exhausted
296
- // failure. `hook` is injectable so the contract is unit-testable (#1034).
297
- function runDnsHook(hostname, ip, hook = CONFIG.dnsHook) {
298
- if (!ip) return { ok: true, skipped: true };
299
- if (!hook) {
300
- console.log(` ↳ DNS: point ${hostname} → ${ip} (no BOX_DNS_HOOK set; #599 owns the cutover)`);
301
- return { ok: true, skipped: true };
302
- }
303
- try {
304
- execFileSync('bash', ['-c', hook], {
305
- cwd: REPO_ROOT, stdio: 'pipe',
306
- env: { ...process.env, BOX_HOSTNAME: hostname, BOX_IP: ip },
307
- });
308
- console.log(` ↳ DNS hook updated ${hostname} → ${ip}`);
309
- return { ok: true };
310
- } catch (e) {
311
- const msg = (e.stderr && e.stderr.toString().trim()) || e.message;
312
- console.log(` ⚠ DNS hook failed: ${msg} (point ${hostname} → ${ip} manually)`);
313
- return { ok: false, error: msg };
314
- }
315
- }
316
-
317
- // Blackhole sentinel for a torn-down box's A record (idea 309). 192.0.2.1 is in
318
- // TEST-NET-1 (RFC 5737) — reserved for documentation and GUARANTEED never to be a
319
- // routable host, so no recycled DigitalOcean IP can ever land here. A perfect
320
- // dead end to park a record at.
321
- const DNS_BLACKHOLE_IP = '192.0.2.1';
322
-
323
- // Park the per-builder A record at the blackhole sentinel on deprovision (idea 309,
324
- // refining task 1180). 1180 DELETED the record so it couldn't dangle at a recycled
325
- // DO IP, but delete-then-recreate across a reap→rebuild left an NXDOMAIN window that
326
- // resolvers negative-cache for the zone's SOA neg-TTL (~30 min, not builder-editable)
327
- // — a client querying then kept getting ENOTFOUND long after the box was back.
328
- // REPOINTING to a non-routable sentinel keeps the name always-resolving (nothing to
329
- // negative-cache) while preserving 1180's guarantee that a torn-down box's name can
330
- // never dial a stranger's recycled IP; the next provision/wake PUTs the live IP
331
- // in-place at the 60s TTL, no gap. Best-effort like the prior delete. Reuses
332
- // runDnsHook's UPSERT path; `hook` injectable for tests. → { ok, error?, skipped? }
333
- function runDnsHookBlackhole(hostname, hook = CONFIG.dnsHook) {
334
- if (!hook || !hostname) return { ok: true, skipped: true };
335
- return runDnsHook(hostname, DNS_BLACKHOLE_IP, hook);
336
- }
337
-
338
- // Read the cloud-init template and substitute the repo URL from BOX_REPO_URL
339
- // (kept out of the repo file so no GitHub identity is hardcoded — V3.R12 rule).
340
- // An unset URL leaves the placeholder; the cloud-init then logs guidance rather
341
- // than cloning, and #600 wires the scoped credential.
342
- // Read + render the cloud-init. `authorizedKeysBlock` is the otb-managed block
343
- // for the builder being provisioned (#685) — base64'd here and baked in so the
344
- // FIRST SSH login works before the box has a GDS token. An empty/absent block
345
- // renders an empty managed block (a keyless box; the operator is warned upstream).
346
- function readCloudInit({ authorizedKeysBlock = '', boxEnv = '', gdsSession = '', infraDir = resolveBoxInfraDir() } = {}) {
347
- // Fail loud, never provision a BARE box (ADR 0150). No infra/ located → there is
348
- // no cloud-init to render, so THROW here rather than the old silent `return null`
349
- // that let cmdProvision ship empty user-data (a box that boots as bare Ubuntu).
350
- if (!infraDir) {
351
- throw new Error(
352
- `box cloud-init not found: no infra/${BOX_INFRA_TEMPLATE} in any candidate location `
353
- + `(set <PREFIX>_BOX_INFRA_DIR or <PREFIX>_INSTANCE_ROOT, or ship infra/). A box must `
354
- + `never be provisioned without user-data — it would boot bare (ADR 0150).`,
355
- );
356
- }
357
- const p = path.join(infraDir, BOX_INFRA_TEMPLATE);
358
- // gzip+base64 a repo file into the cloud-init write_files (`encoding: gz+b64`).
359
- // EVERY inlined infra file goes through this: cloud-init gunzips it back to the
360
- // exact bytes on the box, so it is byte-identical to plain b64 but ~2-3x smaller,
361
- // which keeps the rendered user-data well under DO's 64 KiB cap (#903; the source
362
- // -fetch growth in task 1314 had pushed the all-b64-but-source-fetch render to
363
- // 67 KB — gz+b64-ing the rest brings it back to ~51 KB, task 1321). Only the
364
- // dynamic runtime values (authorized_keys, box.env, gds-session) stay plain b64
365
- // below — they are minted per-provision, not read from infra/. A missing inlined
366
- // file is COLLECTED and fails loud after the render (ADR 0150) — a half-wired box
367
- // (that component silently never boots) is a bug, not something to warn past.
368
- const missingInfra = [];
369
- const fileGzB64 = (name) => {
370
- try {
371
- return zlib.gzipSync(fs.readFileSync(path.join(infraDir, name))).toString('base64');
372
- } catch {
373
- missingInfra.push(name);
374
- return '';
375
- }
376
- };
377
- const raw = fs.readFileSync(p, 'utf8');
378
- // Inject the rank-gated source-fetch (#600) + authorized-keys-fetch (#685)
379
- // scripts as gz+b64 (a single YAML-safe token — no indentation/escaping
380
- // pitfalls; cloud-init gunzips encoding: gz+b64 back to the exact file), plus
381
- // the baked authorized_keys block. ONE canonical copy of each lives in infra/.
382
- const rendered = raw
383
- .replace(/__REPO_URL__/g, process.env.BOX_REPO_URL || '')
384
- .replace(/__BOX_SOURCE_FETCH_B64__/g, fileGzB64('box-source-fetch.sh'))
385
- .replace(/__BOX_AUTHKEYS_FETCH_B64__/g, fileGzB64('box-authorized-keys-fetch.sh'))
386
- .replace(/__BUILDER_AUTHORIZED_KEYS_B64__/g, Buffer.from(authorizedKeysBlock || '', 'utf8').toString('base64'))
387
- // /etc/otb/box.env — empty by default (quick tunnel); the named-tunnel
388
- // connector token + stable hostname when BOX_NAMED_TUNNEL is on (#771).
389
- .replace(/__BOX_ENV_B64__/g, Buffer.from(boxEnv || '', 'utf8').toString('base64'))
390
- // /root/.config/otb/gds-session.json — the builder's GDS session, minted at
391
- // provision so box-source-fetch (#600) can authenticate on FIRST boot and
392
- // clone the rank-scoped repo. Without it the box never clones (the #818
393
- // chicken-and-egg: the session normally comes from /builder-setup, which
394
- // lives in the un-cloned repo).
395
- .replace(/__GDS_SESSION_B64__/g, Buffer.from(gdsSession || '', 'utf8').toString('base64'))
396
- // #774: the browser-terminal units, inlined so they start at FIRST BOOT
397
- // (independent of the repo clone). ONE canonical copy lives in infra/.
398
- .replace(/__BOX_TERMINAL_SERVICE_B64__/g, fileGzB64('box-terminal.service'))
399
- // #917: credential-drift watcher pair — restarts ttyd when the credential
400
- // file lands/changes after the service started (the wake/first-boot race).
401
- .replace(/__BOX_TERMINAL_CREDSYNC_SERVICE_B64__/g, fileGzB64('box-terminal-credsync.service'))
402
- .replace(/__BOX_TERMINAL_CREDENTIAL_PATH_B64__/g, fileGzB64('box-terminal-credential.path'))
403
- .replace(/__CLOUDFLARED_SERVICE_B64__/g, fileGzB64('cloudflared.service'))
404
- .replace(/__CLOUDFLARED_RUN_B64__/g, fileGzB64('cloudflared-run.sh'))
405
- // #893: the `close-box` teardown command, present at first boot so a bare
406
- // browser-only box has it before any repo clone (box-source-fetch refreshes
407
- // it on existing boxes). ONE canonical copy lives in infra/close-box.sh.
408
- .replace(/__CLOSE_BOX_B64__/g, fileGzB64('close-box.sh'))
409
- // task 986: the browser-terminal auto-launcher — runs `claude` on first
410
- // connect so the builder skips the "type claude" step. box-source-fetch
411
- // refreshes it on existing boxes. ONE canonical copy in infra/otb-box-launch.sh.
412
- .replace(/__OTB_BOX_LAUNCH_B64__/g, fileGzB64('otb-box-launch.sh'));
413
- if (missingInfra.length) {
414
- throw new Error(
415
- `box cloud-init is incomplete under ${infraDir}: missing ${missingInfra.join(', ')} `
416
- + `— a box would boot half-wired (ADR 0150). Fix the instance infra/ before provisioning.`,
417
- );
418
- }
419
- return rendered;
420
- }
421
-
422
178
  // ---- provision -------------------------------------------------------------
423
179
 
424
180
  async function cmdProvision(pool, boxes, boxOnboard, ref, { apply }) {