@bongos/core 1.19.902 → 1.19.903
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.bongos-core.json +32 -62
- package/bin/bongos.js +7 -13
- package/docs/module-api-changelog.md +2 -0
- package/docs/recipes/bongos-cli-release.md +3 -2
- package/docs/recipes/instance-first-run.md +1 -2
- package/docs/recipes/local-dev.md +0 -2
- package/modules/hall-ui/public/hall-kit.js +11 -5
- package/modules/hall-ui/public/profile-charts.js +1 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/api.js +1 -25
- package/scripts/gds/build-cli-package.js +17 -35
- package/scripts/gds/claude-materialize.js +1 -1
- package/scripts/gds/cli-lib.js +5 -127
- package/scripts/gds/go-live.js +1 -1
- package/scripts/gds/login.js +1 -1
- package/scripts/gds/plain-cards.js +0 -8
- package/scripts/gds/ship-land.js +2 -18
- package/src/module-api.js +1 -1
- package/tests/bongos_dispatch.mjs +4 -4
- package/tests/claude_materialize.mjs +1 -1
- package/tests/cli_package.mjs +1 -4
- package/tests/cli_surface.mjs +1 -88
- package/tests/hall_kit_charts.mjs +38 -0
- package/tests/plain_cards.mjs +2 -2
- package/tests/rank_tier_single_source.mjs +3 -5
- package/scripts/gds/box-connect-lib.js +0 -550
- package/scripts/gds/code.js +0 -105
- package/scripts/gds/shell.js +0 -102
- package/tests/bongos_box_connect.mjs +0 -166
- package/tests/box_scope_predicate.mjs +0 -117
- package/tests/cli_box_scope.mjs +0 -92
package/tests/plain_cards.mjs
CHANGED
|
@@ -107,7 +107,7 @@ test('every card is complete, and short enough to be read', () => {
|
|
|
107
107
|
|
|
108
108
|
test('the restrictions that must never be worked around are marked by_design', () => {
|
|
109
109
|
for (const code of ['REBASE_REQUIRED', 'INSUFFICIENT_RANK', 'rank_too_low_for_task', 'session_already_has_active_claim',
|
|
110
|
-
'main_checkout_claim', 'ship:protected_branch', 'ship:auth', '
|
|
110
|
+
'main_checkout_claim', 'ship:protected_branch', 'ship:auth', 'no_push_credential', 'grade_refused_authorization']) {
|
|
111
111
|
assert.equal(cardFor(code)?.by_design, true, code);
|
|
112
112
|
}
|
|
113
113
|
assert.match(cardFor('no_push_credential').next, /never ask/i);
|
|
@@ -164,7 +164,7 @@ test('a person at a terminal gets no card — it is written in the agent\'s voic
|
|
|
164
164
|
|
|
165
165
|
test('the sign-in printers, the release refusals and the claim guard each print their card', () => {
|
|
166
166
|
const wired = {
|
|
167
|
-
'scripts/gds/cli-lib.js': ['session_invalid', '
|
|
167
|
+
'scripts/gds/cli-lib.js': ['session_invalid', 'no_session'],
|
|
168
168
|
'scripts/gds/release.js': ['no_active_claim', 'CLAIM_SESSION_MISMATCH'],
|
|
169
169
|
'scripts/gds/claim.js': ['main_checkout_claim'],
|
|
170
170
|
'scripts/gds/ship-land.js': ['land_pending', 'reconcile_blocked'],
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
// modules/discord/discord-channels.js): dependency-free substrate
|
|
13
13
|
// (permission-path-check runs in a bare git hook), source-parsed literals
|
|
14
14
|
// (src/bongos/db.js — gate-review.js + diagram-facts.js regex its text),
|
|
15
|
-
// standalone CLIs (sequence
|
|
15
|
+
// standalone CLIs (sequence), the require-free dev-box
|
|
16
16
|
// module template (boxes.js), zero-dep catalog + its text-parsing auditor
|
|
17
17
|
// (route-rank-check), and the hall browser statics.
|
|
18
18
|
// 3. NO NEW parallel map can land: a repo scan flags any rank→number map or
|
|
@@ -38,7 +38,6 @@ const lifecycleDb = require(path.join(ROOT, 'modules', 'lifecycle', 'db.js'));
|
|
|
38
38
|
const ppc = require(path.join(ROOT, 'src', 'bongos', 'permission-path-check.js'));
|
|
39
39
|
const rrc = require(path.join(ROOT, 'src', 'bongos', 'route-rank-check.js'));
|
|
40
40
|
const sequence = require(path.join(ROOT, 'scripts', 'gds', 'sequence.js'));
|
|
41
|
-
const boxConnect = require(path.join(ROOT, 'scripts', 'gds', 'box-connect-lib.js'));
|
|
42
41
|
const boxes = require(path.join(ROOT, 'modules', 'dev-box', 'boxes.js'));
|
|
43
42
|
const catalog = require(path.join(ROOT, 'modules', 'government', 'catalog.js'));
|
|
44
43
|
|
|
@@ -82,8 +81,8 @@ test('1-based local copies match: permission-path-check, sequence CLI', () => {
|
|
|
82
81
|
assert.deepEqual(sequence.RANK_TIER, TIER, 'sequence.js CLI copy drifted');
|
|
83
82
|
});
|
|
84
83
|
|
|
85
|
-
test('0-based local copies match:
|
|
86
|
-
for (const [name, map] of [['
|
|
84
|
+
test('0-based local copies match: dev-box boxes.js', () => {
|
|
85
|
+
for (const [name, map] of [['boxes', boxes.RANK_ORDER]]) {
|
|
87
86
|
assert.deepEqual(Object.keys(map).sort(), [...RANK_KEYS].sort(), `${name} keys drifted`);
|
|
88
87
|
for (const r of RANK_KEYS) {
|
|
89
88
|
assert.equal(map[r], TIER[r] - 1, `${name}.RANK_ORDER.${r} must be tier-1 (0-based)`);
|
|
@@ -193,7 +192,6 @@ const MAP_ALLOWLIST = new Set([
|
|
|
193
192
|
'src/bongos/db.js',
|
|
194
193
|
'src/bongos/permission-path-check.js',
|
|
195
194
|
'scripts/gds/sequence.js',
|
|
196
|
-
'scripts/gds/box-connect-lib.js',
|
|
197
195
|
'modules/dev-box/boxes.js',
|
|
198
196
|
'modules/hall-ui/public/builders.js',
|
|
199
197
|
// task 1003206 carved the hall page scripts up. builders.js KEPT its map (the
|
|
@@ -1,550 +0,0 @@
|
|
|
1
|
-
// scripts/gds/box-connect-lib.js — shared machinery for `bongos shell` / `bongos code`
|
|
2
|
-
// (GDS-V4 · V4.R80 · task 1247; Phase 2 of the bongos toolkit, epic 1245).
|
|
3
|
-
//
|
|
4
|
-
// `bongos shell` opens a terminal on the builder's cloud dev box; `bongos code`
|
|
5
|
-
// attaches VS Code over Remote-SSH at /workspace. Both reduce to the same four
|
|
6
|
-
// steps, which live here so the two verbs stay thin and the string-building is
|
|
7
|
-
// unit-testable without a network or a box:
|
|
8
|
-
//
|
|
9
|
-
// 1. Ensure the local ed25519 key exists (~/.ssh/otb_builder) and is registered
|
|
10
|
-
// with the GDS — so a FRESH laptop with no prior setup still connects.
|
|
11
|
-
// 2. Wake/provision the box (POST /box/ensure) and poll (GET /box/me) until it
|
|
12
|
-
// is active and has a hostname (DNS is repointed on every wake/provision, so
|
|
13
|
-
// we always connect by hostname, never a cached IP).
|
|
14
|
-
// 3. Pin the box's reported host keys into ~/.ssh/known_hosts (authoritative,
|
|
15
|
-
// survives a re-provision) and write a marker-delimited Host alias into
|
|
16
|
-
// ~/.ssh/config (User root + the box key) so the system OpenSSH client — the
|
|
17
|
-
// one VS Code Remote-SSH drives — resolves the box the same way every time.
|
|
18
|
-
// 4. Hand off to `ssh` / `code`.
|
|
19
|
-
//
|
|
20
|
-
// No bash-isms: node is the one guaranteed runtime (#686). The pure helpers below
|
|
21
|
-
// (buildSshAliasBlock, upsertSshConfigText, buildKnownHostsUpsert, rankAtLeast,
|
|
22
|
-
// buildRemoteShellCommand, parseEnsure, remoteAuthority) take no I/O and are
|
|
23
|
-
// covered by tests/bongos_box_connect.mjs.
|
|
24
|
-
|
|
25
|
-
'use strict';
|
|
26
|
-
|
|
27
|
-
const fs = require('node:fs');
|
|
28
|
-
const os = require('node:os');
|
|
29
|
-
const path = require('node:path');
|
|
30
|
-
const crypto = require('node:crypto');
|
|
31
|
-
const { spawnSync } = require('node:child_process');
|
|
32
|
-
const ic = require('../../src/instance-config');
|
|
33
|
-
const { cliClient, surfaceAction, assertDevBoxAvailable } = require('./cli-lib');
|
|
34
|
-
|
|
35
|
-
// The box always runs as root and the repo is cloned to /workspace (the devcontainer
|
|
36
|
-
// standardizes the same path). NOT ~/workspace — root's ~ is /root, which has no clone.
|
|
37
|
-
const BOX_USER = 'root';
|
|
38
|
-
const WORKSPACE = '/workspace';
|
|
39
|
-
// VS Code extension id for Claude Code (mirrors .devcontainer/devcontainer.json).
|
|
40
|
-
const CLAUDE_VSCODE_EXTENSION = 'anthropic.claude-code';
|
|
41
|
-
|
|
42
|
-
// The rank ladder, entry→top. `--local` is a Metic+ convenience gate (a nudge to
|
|
43
|
-
// keep newcomers on the standard cloud box, NOT a security boundary — real
|
|
44
|
-
// authority is server-side and unaffected). thetes sits between xenos and metic
|
|
45
|
-
// even though requires_rank only enumerates xenos/metic/archon.
|
|
46
|
-
const RANK_ORDER = { xenos: 0, thetes: 1, metic: 2, archon: 3 };
|
|
47
|
-
|
|
48
|
-
function rankAtLeast(rank, min) {
|
|
49
|
-
const r = RANK_ORDER[rank];
|
|
50
|
-
const m = RANK_ORDER[min];
|
|
51
|
-
if (r == null || m == null) return false;
|
|
52
|
-
return r >= m;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
function escapeRegExp(s) {
|
|
56
|
-
return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
// -------------------------------------------------------------------------
|
|
60
|
-
// Pure string builders (no I/O — unit tested)
|
|
61
|
-
// -------------------------------------------------------------------------
|
|
62
|
-
|
|
63
|
-
// A marker-delimited, idempotent Host block for ONE box. Markers carry the
|
|
64
|
-
// hostname so re-running upserts THIS box's block without touching any other
|
|
65
|
-
// (a builder may connect to more than one instance's box over time).
|
|
66
|
-
function buildSshAliasBlock({ hostname, identityFile }) {
|
|
67
|
-
return [
|
|
68
|
-
`# >>> bongos-managed dev-box: ${hostname} >>>`,
|
|
69
|
-
`Host ${hostname}`,
|
|
70
|
-
` HostName ${hostname}`,
|
|
71
|
-
` User ${BOX_USER}`,
|
|
72
|
-
` IdentityFile ${identityFile}`,
|
|
73
|
-
` IdentitiesOnly yes`,
|
|
74
|
-
` StrictHostKeyChecking accept-new`,
|
|
75
|
-
`# <<< bongos-managed dev-box: ${hostname} <<<`,
|
|
76
|
-
].join('\n');
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
// Upsert the alias block into an existing ~/.ssh/config body. Replaces this
|
|
80
|
-
// host's block in place if present, else appends it with a blank-line gap.
|
|
81
|
-
function upsertSshConfigText(existing, { hostname, identityFile }) {
|
|
82
|
-
const block = buildSshAliasBlock({ hostname, identityFile });
|
|
83
|
-
const h = escapeRegExp(hostname);
|
|
84
|
-
const re = new RegExp(
|
|
85
|
-
`# >>> bongos-managed dev-box: ${h} >>>[\\s\\S]*?# <<< bongos-managed dev-box: ${h} <<<`,
|
|
86
|
-
'm'
|
|
87
|
-
);
|
|
88
|
-
const base = existing || '';
|
|
89
|
-
if (re.test(base)) return base.replace(re, block);
|
|
90
|
-
const gap = base ? (base.endsWith('\n') ? '\n' : '\n\n') : '';
|
|
91
|
-
return `${base}${gap}${block}\n`;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
// Does a known_hosts line name any of `names`? Field 1 is either a plain
|
|
95
|
-
// `host` / `host,ip` LIST, or — with HashKnownHosts on (the Debian/Ubuntu default)
|
|
96
|
-
// — the opaque `|1|<b64 salt>|<b64 hash>` form, hash = HMAC-SHA1(salt) of the name.
|
|
97
|
-
//
|
|
98
|
-
// (This duplicates ssh-setup.knownHostsLineMatchesHost on purpose: the Dev Box app
|
|
99
|
-
// under modules/dev-box/app/ is a separately-packaged subproject with ZERO requires
|
|
100
|
-
// into the repo — see modules/dev-box/CLAUDE.md — so the two cannot share a module.)
|
|
101
|
-
function knownHostsLineNamesAny(line, names) {
|
|
102
|
-
const t = String(line || '').trim();
|
|
103
|
-
if (!t || t.startsWith('#')) return false;
|
|
104
|
-
const field = t.split(/\s+/)[0] || '';
|
|
105
|
-
const wanted = names.filter(Boolean).map(String);
|
|
106
|
-
if (!wanted.length) return false;
|
|
107
|
-
if (!field.startsWith('|1|')) {
|
|
108
|
-
const listed = field.split(',');
|
|
109
|
-
return wanted.some((n) => listed.includes(n));
|
|
110
|
-
}
|
|
111
|
-
const [, , salt, hash] = field.split('|');
|
|
112
|
-
if (!salt || !hash) return false;
|
|
113
|
-
try {
|
|
114
|
-
const key = Buffer.from(salt, 'base64');
|
|
115
|
-
return wanted.some((n) => crypto.createHmac('sha1', key).update(n).digest('base64') === hash);
|
|
116
|
-
} catch (_) {
|
|
117
|
-
return false; // unparseable salt → leave the line alone rather than guess
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
// Authoritative known_hosts upsert: drop EVERY stale line for this box, keep every
|
|
122
|
-
// other host, then append the box's reported keys. hostKeysRaw is the newline-joined
|
|
123
|
-
// `<keytype> <base64>` string from /box/me; `ip` is optional and drops an IP-keyed
|
|
124
|
-
// line for the same box.
|
|
125
|
-
//
|
|
126
|
-
// It used to compare field 1 to the hostname with `!==`, which missed the two forms
|
|
127
|
-
// OpenSSH actually writes — `host,192.0.2.4` (what `StrictHostKeyChecking=accept-new`
|
|
128
|
-
// records) and the hashed `|1|salt|hash`. Those lines survived the "replace", so
|
|
129
|
-
// every re-pin ACCUMULATED another entry and the client kept verifying against the
|
|
130
|
-
// first, now-wrong one: "Host denied (verification failed)" on a box that had just
|
|
131
|
-
// re-reported valid keys. Comments and blank lines are preserved.
|
|
132
|
-
function buildKnownHostsUpsert(existing, hostname, hostKeysRaw, ip = null) {
|
|
133
|
-
const names = [hostname, ip];
|
|
134
|
-
const keep = (existing || '')
|
|
135
|
-
.split('\n')
|
|
136
|
-
.filter((line) => line.trim() && !knownHostsLineNamesAny(line, names));
|
|
137
|
-
const added = (hostKeysRaw || '')
|
|
138
|
-
.split('\n')
|
|
139
|
-
.map((k) => k.trim())
|
|
140
|
-
.filter(Boolean)
|
|
141
|
-
.map((k) => `${hostname} ${k}`);
|
|
142
|
-
const all = [...keep, ...added];
|
|
143
|
-
return all.length ? all.join('\n') + '\n' : '';
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
// The on-box re-scope STEP (BV1.R101 / goal 1000051) — no `cd`, the caller
|
|
147
|
-
// positions cwd. Re-runs the box source-fetch so /workspace holds exactly the
|
|
148
|
-
// builder's ACTIVE-claim scope: "claim → open → the scoped folder." box-sync.js
|
|
149
|
-
// re-runs infra/box-source-fetch.sh, which calls GET /box/source-access —
|
|
150
|
-
// claim-driven since R94 — so a fresh claim (or a switch) re-scopes the ONE
|
|
151
|
-
// working folder in place (the owner's one-folder decision). Best-effort (`|| true`):
|
|
152
|
-
// a sync hiccup must never block the connection.
|
|
153
|
-
const RESYNC_STEP = `echo '↪ Syncing your box to your active claim…'; node scripts/gds/box-sync.js || true`;
|
|
154
|
-
|
|
155
|
-
// The remote command `ssh -t root@host …` runs. Lands the builder in /workspace;
|
|
156
|
-
// with `sync` (BV1.R101) it re-scopes that folder to the active claim first, so a
|
|
157
|
-
// builder who just claimed/switched arrives in a folder holding exactly that task's
|
|
158
|
-
// code. On the first connection to this box it then runs an interactive `claude` so
|
|
159
|
-
// the Trusted-Devices /login enrollment (interactive-only by design — ADR 0038) is
|
|
160
|
-
// completed once, then drops to a login shell. Silent on every later connect.
|
|
161
|
-
function buildRemoteShellCommand({ firstRun = false, sync = false, workdir = WORKSPACE } = {}) {
|
|
162
|
-
const cd = `cd ${workdir} 2>/dev/null || true`;
|
|
163
|
-
const resync = sync ? `${RESYNC_STEP}; ` : '';
|
|
164
|
-
const login = firstRun
|
|
165
|
-
? `echo '↪ First time on this box — complete /login in claude once (browser device flow), then exit to a shell.'; claude || true; `
|
|
166
|
-
: '';
|
|
167
|
-
return `${cd}; ${resync}${login}exec bash -l`;
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
// The full one-shot remote command for `bongos code` (VS Code Remote-SSH opens the
|
|
171
|
-
// folder directly rather than running a login shell, so the re-scope rides a
|
|
172
|
-
// separate one-shot ssh before the window opens): cd + the re-scope step, no shell.
|
|
173
|
-
function buildRemoteResyncCommand({ workdir = WORKSPACE } = {}) {
|
|
174
|
-
return `cd ${workdir} 2>/dev/null || true; ${RESYNC_STEP}`;
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
// VS Code Remote-SSH authority for a host (VS Code resolves it via the ~/.ssh
|
|
178
|
-
// Host alias we wrote, so User/IdentityFile/host-key policy all apply).
|
|
179
|
-
function remoteAuthority(hostname) {
|
|
180
|
-
return `ssh-remote+${hostname}`;
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
// Normalize the POST /box/ensure body into { action, state, message }.
|
|
184
|
-
function parseEnsure(data) {
|
|
185
|
-
const d = data || {};
|
|
186
|
-
return { action: d.action || null, state: d.state || null, message: d.message || null };
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
// -------------------------------------------------------------------------
|
|
190
|
-
// Local toolchain + paths (thin I/O)
|
|
191
|
-
// -------------------------------------------------------------------------
|
|
192
|
-
|
|
193
|
-
function sshKeyPath() {
|
|
194
|
-
// OTB_SSH_KEY (via the instance env prefix) is the advanced/test override; the
|
|
195
|
-
// Dev Box app + connect.sh both pin the fixed ~/.ssh/otb_builder default.
|
|
196
|
-
return ic.resolveEnv('SSH_KEY') || path.join(os.homedir(), '.ssh', 'otb_builder');
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
function sshDir() {
|
|
200
|
-
return path.join(os.homedir(), '.ssh');
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
// Is a CLI launchable? (shell:true on win32 so a .cmd/.exe shim resolves — same
|
|
204
|
-
// posture as doctor.js's claude/gh checks.)
|
|
205
|
-
function binPresent(bin, versionArgs = ['--version']) {
|
|
206
|
-
try {
|
|
207
|
-
const r = spawnSync(bin, versionArgs, {
|
|
208
|
-
encoding: 'utf8',
|
|
209
|
-
shell: process.platform === 'win32',
|
|
210
|
-
windowsHide: true,
|
|
211
|
-
timeout: 15000,
|
|
212
|
-
});
|
|
213
|
-
return !!r && !r.error && (r.status === 0 || r.status === null);
|
|
214
|
-
} catch (_) {
|
|
215
|
-
return false;
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
// Local SHA256 fingerprint of a public key, or null if ssh-keygen can't read it.
|
|
220
|
-
function localKeyFingerprint(pubPath) {
|
|
221
|
-
try {
|
|
222
|
-
const r = spawnSync('ssh-keygen', ['-lf', pubPath], {
|
|
223
|
-
encoding: 'utf8',
|
|
224
|
-
shell: process.platform === 'win32',
|
|
225
|
-
windowsHide: true,
|
|
226
|
-
timeout: 15000,
|
|
227
|
-
});
|
|
228
|
-
if (!r || r.error || r.status !== 0) return null;
|
|
229
|
-
const m = (r.stdout || '').match(/SHA256:[A-Za-z0-9+/=]+/);
|
|
230
|
-
return m ? m[0] : null;
|
|
231
|
-
} catch (_) {
|
|
232
|
-
return null;
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
// -------------------------------------------------------------------------
|
|
237
|
-
// SSH key: ensure it exists locally + is registered with the GDS
|
|
238
|
-
// -------------------------------------------------------------------------
|
|
239
|
-
|
|
240
|
-
// Ensure ~/.ssh/otb_builder exists (generate an ed25519 pair if not) and return
|
|
241
|
-
// { keyPath, pubKey, created }. Throws with actionable guidance if ssh-keygen is
|
|
242
|
-
// missing or the key can't be read.
|
|
243
|
-
function ensureSshKey({ log = console.log } = {}) {
|
|
244
|
-
const keyPath = sshKeyPath();
|
|
245
|
-
const pubPath = `${keyPath}.pub`;
|
|
246
|
-
let created = false;
|
|
247
|
-
if (!fs.existsSync(keyPath)) {
|
|
248
|
-
fs.mkdirSync(sshDir(), { recursive: true });
|
|
249
|
-
try {
|
|
250
|
-
fs.chmodSync(sshDir(), 0o700);
|
|
251
|
-
} catch (_) {
|
|
252
|
-
/* best-effort on non-POSIX */
|
|
253
|
-
}
|
|
254
|
-
log(` Creating an SSH key at ${keyPath.replace(os.homedir(), '~')} (private half stays on this machine)…`);
|
|
255
|
-
const comment = `bongos ${os.hostname() || 'device'}`.slice(0, 80);
|
|
256
|
-
// No separate presence probe — ssh-keygen has no side-effect-free version
|
|
257
|
-
// flag, and a missing binary surfaces here as ENOENT with a clear message.
|
|
258
|
-
const r = spawnSync('ssh-keygen', ['-t', 'ed25519', '-N', '', '-C', comment, '-f', keyPath, '-q'], {
|
|
259
|
-
stdio: 'inherit',
|
|
260
|
-
shell: process.platform === 'win32',
|
|
261
|
-
windowsHide: true,
|
|
262
|
-
});
|
|
263
|
-
if (r && r.error && r.error.code === 'ENOENT') {
|
|
264
|
-
throw new Error(
|
|
265
|
-
'ssh-keygen was not found. Install OpenSSH (macOS/Linux ship it; on Windows enable the "OpenSSH Client" optional feature) and re-run.'
|
|
266
|
-
);
|
|
267
|
-
}
|
|
268
|
-
if (!r || r.error || r.status !== 0) {
|
|
269
|
-
throw new Error(`ssh-keygen failed to create ${keyPath}${r && r.error ? `: ${r.error.message}` : ''}`);
|
|
270
|
-
}
|
|
271
|
-
created = true;
|
|
272
|
-
}
|
|
273
|
-
let pubKey = null;
|
|
274
|
-
try {
|
|
275
|
-
// Trim to "<type> <base64>" — matches what connect.sh/the server keep.
|
|
276
|
-
const parts = fs.readFileSync(pubPath, 'utf8').trim().split(/\s+/);
|
|
277
|
-
if (parts.length >= 2) pubKey = `${parts[0]} ${parts[1]}`;
|
|
278
|
-
} catch (_) {
|
|
279
|
-
/* handled below */
|
|
280
|
-
}
|
|
281
|
-
if (!pubKey) throw new Error(`could not read a valid public key from ${pubPath}`);
|
|
282
|
-
return { keyPath, pubKey, created };
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
// PURE (unit tested): should we POST this key? The old inline version FAILED OPEN —
|
|
286
|
-
// when the fingerprint couldn't be computed (ssh-keygen missing from a leaner PATH)
|
|
287
|
-
// or the key list couldn't be fetched, it decided "don't register" and connected
|
|
288
|
-
// anyway, so a builder whose key was never on the server got a bare "Permission
|
|
289
|
-
// denied" with nothing to act on. Uncertainty now means REGISTER.
|
|
290
|
-
//
|
|
291
|
-
// A redundant POST costs nothing: POST /box/ssh-key upserts by fingerprint
|
|
292
|
-
// (modules/dev-box/routes/box.js — an existing fingerprint is explicitly exempt from
|
|
293
|
-
// MAX_SSH_KEYS_PER_BUILDER), so re-registering can neither add a row nor trip the
|
|
294
|
-
// cap. Do not "optimize" the skip back in.
|
|
295
|
-
function decideKeyRegistration({ created, fingerprint, listedKeys, listFailed } = {}) {
|
|
296
|
-
if (created) return { register: true, why: 'created' };
|
|
297
|
-
if (listFailed || !Array.isArray(listedKeys)) return { register: true, why: 'list_unavailable' };
|
|
298
|
-
if (!fingerprint) return { register: true, why: 'fingerprint_unknown' };
|
|
299
|
-
if (listedKeys.some((k) => k && k.fingerprint === fingerprint)) return { register: false, why: 'already_registered' };
|
|
300
|
-
return { register: true, why: 'not_listed' };
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
// Register the public key with the GDS unless the server already demonstrably has
|
|
304
|
-
// it. Best-effort: a failure here is surfaced as a warning, not fatal (SSH will
|
|
305
|
-
// report a genuine auth problem clearly).
|
|
306
|
-
async function ensureKeyRegistered({ created, keyPath, pubKey, log = console.log } = {}) {
|
|
307
|
-
const api = await cliClient();
|
|
308
|
-
let listedKeys = null;
|
|
309
|
-
let listFailed = false;
|
|
310
|
-
if (!created) {
|
|
311
|
-
try {
|
|
312
|
-
const res = await api.box.getBoxSshKey();
|
|
313
|
-
if (res.ok && res.data && Array.isArray(res.data.keys)) listedKeys = res.data.keys;
|
|
314
|
-
else listFailed = true;
|
|
315
|
-
} catch (_) {
|
|
316
|
-
listFailed = true;
|
|
317
|
-
}
|
|
318
|
-
}
|
|
319
|
-
const { register } = decideKeyRegistration({
|
|
320
|
-
created,
|
|
321
|
-
fingerprint: created ? null : localKeyFingerprint(`${keyPath}.pub`),
|
|
322
|
-
listedKeys,
|
|
323
|
-
listFailed,
|
|
324
|
-
});
|
|
325
|
-
if (!register) return { registered: false };
|
|
326
|
-
try {
|
|
327
|
-
const label = `bongos ${process.platform}`.slice(0, 80);
|
|
328
|
-
const res = await api.box.postBoxSshKey({ body: { public_key: pubKey, label } });
|
|
329
|
-
if (res.ok) {
|
|
330
|
-
log(' ✓ Registered your public key with the dev system.');
|
|
331
|
-
return { registered: true };
|
|
332
|
-
}
|
|
333
|
-
log(` ! Could not register your key (${res.data?.error || res.status}); if SSH is refused, add it via ${surfaceAction('box')}.`);
|
|
334
|
-
} catch (err) {
|
|
335
|
-
log(` ! Could not register your key (${err.message}); if SSH is refused, add it via ${surfaceAction('box')}.`);
|
|
336
|
-
}
|
|
337
|
-
return { registered: false };
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
// -------------------------------------------------------------------------
|
|
341
|
-
// Box lifecycle: ensure active + read connection details
|
|
342
|
-
// -------------------------------------------------------------------------
|
|
343
|
-
|
|
344
|
-
function sleep(ms) {
|
|
345
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
// Wake/provision the box and poll until it is active with a hostname. Returns the
|
|
349
|
-
// public box object from /box/me ({ state, hostname, ip, host_keys, … }).
|
|
350
|
-
// Throws on a provision denial (rank/blocked) or a timeout.
|
|
351
|
-
async function ensureBoxActive({ log = console.log, timeoutMs = 6 * 60 * 1000, intervalMs = 5000, sleepFn = sleep } = {}) {
|
|
352
|
-
await assertDevBoxAvailable();
|
|
353
|
-
const api = await cliClient();
|
|
354
|
-
const ensureRes = await api.box.postBoxEnsure();
|
|
355
|
-
if (ensureRes.status === 403) {
|
|
356
|
-
const msg = ensureRes.data?.message || ensureRes.data?.reason || 'dev-box access denied';
|
|
357
|
-
throw new Error(`Box could not be started: ${msg}`);
|
|
358
|
-
}
|
|
359
|
-
if (!ensureRes.ok) {
|
|
360
|
-
throw new Error(`POST /box/ensure failed (HTTP ${ensureRes.status}${ensureRes.data?.error ? `: ${ensureRes.data.error}` : ''})`);
|
|
361
|
-
}
|
|
362
|
-
const { action, message } = parseEnsure(ensureRes.data);
|
|
363
|
-
if (action === 'ready') {
|
|
364
|
-
log(' ✓ Box is already active.');
|
|
365
|
-
} else {
|
|
366
|
-
log(` Box is being ${action === 'wake' ? 'woken' : action === 'provision' ? 'created' : 'prepared'} — ${message || 'this can take a minute or two'}.`);
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
const deadline = Date.now() + timeoutMs;
|
|
370
|
-
let lastState = null;
|
|
371
|
-
let announced = 0;
|
|
372
|
-
for (;;) {
|
|
373
|
-
const me = await api.box.getBoxMe();
|
|
374
|
-
const box = me.ok && me.data ? me.data.box : null;
|
|
375
|
-
if (box && box.state === 'active' && box.hostname) {
|
|
376
|
-
log(` ✓ Box active: ${box.hostname}`);
|
|
377
|
-
return box;
|
|
378
|
-
}
|
|
379
|
-
if (box && box.state !== lastState) {
|
|
380
|
-
lastState = box.state;
|
|
381
|
-
}
|
|
382
|
-
if (Date.now() >= deadline) {
|
|
383
|
-
throw new Error(
|
|
384
|
-
`Timed out after ${Math.round(timeoutMs / 1000)}s waiting for the box to become active (last state: ${box ? box.state : 'unknown'}). ` +
|
|
385
|
-
'It may still be provisioning — re-run in a minute, or check `bongos box` / the hall.'
|
|
386
|
-
);
|
|
387
|
-
}
|
|
388
|
-
// A quiet progress line every ~15s so the wait doesn't look hung.
|
|
389
|
-
if (Date.now() - announced >= 15000) {
|
|
390
|
-
log(` …still ${box && box.state ? box.state : 'starting'} (waiting for the box to come up)`);
|
|
391
|
-
announced = Date.now();
|
|
392
|
-
}
|
|
393
|
-
await sleepFn(intervalMs);
|
|
394
|
-
}
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
// Fetch the caller's rank (GET /me). Returns the rank string, or null if it
|
|
398
|
-
// can't be determined.
|
|
399
|
-
async function fetchRank() {
|
|
400
|
-
try {
|
|
401
|
-
const api = await cliClient({ allowUnauthenticated: true });
|
|
402
|
-
const res = await api.me.getMe();
|
|
403
|
-
if (res.ok && res.data && res.data.builder) return res.data.builder.rank || null;
|
|
404
|
-
} catch (_) {
|
|
405
|
-
/* fall through */
|
|
406
|
-
}
|
|
407
|
-
return null;
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
// -------------------------------------------------------------------------
|
|
411
|
-
// Write ~/.ssh/config alias + pin known_hosts (idempotent, best-effort)
|
|
412
|
-
// -------------------------------------------------------------------------
|
|
413
|
-
|
|
414
|
-
function writeSshAlias({ hostname, keyPath, log = console.log }) {
|
|
415
|
-
const cfgPath = path.join(sshDir(), 'config');
|
|
416
|
-
// IdentityFile keeps the ~ literal so it reads cleanly and works cross-machine.
|
|
417
|
-
const identityFile = keyPath.startsWith(os.homedir())
|
|
418
|
-
? keyPath.replace(os.homedir(), '~')
|
|
419
|
-
: keyPath;
|
|
420
|
-
let existing = '';
|
|
421
|
-
try {
|
|
422
|
-
existing = fs.readFileSync(cfgPath, 'utf8');
|
|
423
|
-
} catch (_) {
|
|
424
|
-
/* no config yet */
|
|
425
|
-
}
|
|
426
|
-
const next = upsertSshConfigText(existing, { hostname, identityFile });
|
|
427
|
-
if (next !== existing) {
|
|
428
|
-
fs.mkdirSync(sshDir(), { recursive: true });
|
|
429
|
-
fs.writeFileSync(cfgPath, next, { mode: 0o600 });
|
|
430
|
-
try {
|
|
431
|
-
fs.chmodSync(cfgPath, 0o600);
|
|
432
|
-
} catch (_) {
|
|
433
|
-
/* best-effort */
|
|
434
|
-
}
|
|
435
|
-
log(` ✓ Wrote SSH host alias for ${hostname} into ~/.ssh/config`);
|
|
436
|
-
}
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
function pinKnownHosts({ hostname, hostKeys, ip = null, log = console.log }) {
|
|
440
|
-
if (!hostKeys) return; // box hasn't reported keys yet; accept-new covers first connect
|
|
441
|
-
const khPath = path.join(sshDir(), 'known_hosts');
|
|
442
|
-
let existing = '';
|
|
443
|
-
try {
|
|
444
|
-
existing = fs.readFileSync(khPath, 'utf8');
|
|
445
|
-
} catch (_) {
|
|
446
|
-
/* none yet */
|
|
447
|
-
}
|
|
448
|
-
// DNS is repointed on every wake, so an old line keyed by the PREVIOUS box's IP
|
|
449
|
-
// is stale too — pass it so the upsert drops it instead of leaving it to match.
|
|
450
|
-
const next = buildKnownHostsUpsert(existing, hostname, hostKeys, ip);
|
|
451
|
-
if (next !== existing) {
|
|
452
|
-
fs.mkdirSync(sshDir(), { recursive: true });
|
|
453
|
-
fs.writeFileSync(khPath, next, { mode: 0o600 });
|
|
454
|
-
try {
|
|
455
|
-
fs.chmodSync(khPath, 0o600);
|
|
456
|
-
} catch (_) {
|
|
457
|
-
/* best-effort */
|
|
458
|
-
}
|
|
459
|
-
log(` ✓ Pinned ${hostname}'s verified host key(s) into ~/.ssh/known_hosts`);
|
|
460
|
-
}
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
// The one-shot "get the box ready + local SSH set up" used by both verbs.
|
|
464
|
-
// Returns { box, keyPath }.
|
|
465
|
-
async function prepareConnection({ log = console.log } = {}) {
|
|
466
|
-
// Before touching keys: key registration hits the box API too, so without this the reader gets
|
|
467
|
-
// a puzzling "Could not register your key (not_found)" line before the real explanation.
|
|
468
|
-
await assertDevBoxAvailable();
|
|
469
|
-
const { keyPath, pubKey, created } = ensureSshKey({ log });
|
|
470
|
-
await ensureKeyRegistered({ created, keyPath, pubKey, log });
|
|
471
|
-
const box = await ensureBoxActive({ log });
|
|
472
|
-
pinKnownHosts({ hostname: box.hostname, hostKeys: box.host_keys, ip: box.ip, log });
|
|
473
|
-
writeSshAlias({ hostname: box.hostname, keyPath, log });
|
|
474
|
-
return { box, keyPath };
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
// Re-scope /workspace to the builder's active claim over a one-shot ssh, BEFORE
|
|
478
|
-
// `bongos code` opens the VS Code window (BV1.R101). Best-effort: a failure just
|
|
479
|
-
// means the folder holds its last-synced scope — the builder can /builder-sync
|
|
480
|
-
// inside the VS Code terminal — so it never blocks the open. Returns true on a
|
|
481
|
-
// clean run. Mirrors shell.js's ssh args (explicit key + host-key policy).
|
|
482
|
-
function resyncBoxOverSsh({ host, keyPath, log = console.log }) {
|
|
483
|
-
const args = [
|
|
484
|
-
'-i', keyPath, '-o', 'IdentitiesOnly=yes', '-o', 'StrictHostKeyChecking=accept-new',
|
|
485
|
-
`${BOX_USER}@${host}`, buildRemoteResyncCommand(),
|
|
486
|
-
];
|
|
487
|
-
log(` Scoping ${WORKSPACE} to your active claim…`);
|
|
488
|
-
const r = spawnSync('ssh', args, { stdio: 'inherit' });
|
|
489
|
-
return !!r && !r.error && (r.status === 0 || r.status == null);
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
// First-run marker (per box hostname). /login enrollment persists on the box, so
|
|
493
|
-
// we only force the interactive claude handoff the first time we connect to a
|
|
494
|
-
// given box from this laptop.
|
|
495
|
-
function loginMarkerPath(hostname) {
|
|
496
|
-
const safe = String(hostname).replace(/[^A-Za-z0-9._-]/g, '_');
|
|
497
|
-
return ic.configPath(`box-login-${safe}.done`);
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
function isFirstLogin(hostname) {
|
|
501
|
-
return !fs.existsSync(loginMarkerPath(hostname));
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
function markLoginDone(hostname) {
|
|
505
|
-
const p = loginMarkerPath(hostname);
|
|
506
|
-
try {
|
|
507
|
-
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
508
|
-
fs.writeFileSync(p, `${new Date().toISOString()}\n`);
|
|
509
|
-
} catch (_) {
|
|
510
|
-
/* best-effort — a missed marker just re-runs the harmless handoff */
|
|
511
|
-
}
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
module.exports = {
|
|
515
|
-
// constants
|
|
516
|
-
BOX_USER,
|
|
517
|
-
WORKSPACE,
|
|
518
|
-
CLAUDE_VSCODE_EXTENSION,
|
|
519
|
-
RANK_ORDER,
|
|
520
|
-
// pure helpers (tested)
|
|
521
|
-
rankAtLeast,
|
|
522
|
-
buildSshAliasBlock,
|
|
523
|
-
upsertSshConfigText,
|
|
524
|
-
buildKnownHostsUpsert,
|
|
525
|
-
knownHostsLineNamesAny,
|
|
526
|
-
decideKeyRegistration,
|
|
527
|
-
buildRemoteShellCommand,
|
|
528
|
-
buildRemoteResyncCommand, // BV1.R101 — one-shot re-scope for `bongos code`
|
|
529
|
-
RESYNC_STEP,
|
|
530
|
-
remoteAuthority,
|
|
531
|
-
parseEnsure,
|
|
532
|
-
// toolchain / paths
|
|
533
|
-
sshKeyPath,
|
|
534
|
-
binPresent,
|
|
535
|
-
localKeyFingerprint,
|
|
536
|
-
// key + box
|
|
537
|
-
ensureSshKey,
|
|
538
|
-
ensureKeyRegistered,
|
|
539
|
-
ensureBoxActive,
|
|
540
|
-
fetchRank,
|
|
541
|
-
// local ssh wiring
|
|
542
|
-
writeSshAlias,
|
|
543
|
-
pinKnownHosts,
|
|
544
|
-
prepareConnection,
|
|
545
|
-
resyncBoxOverSsh, // BV1.R101 — re-scope /workspace before `bongos code` opens
|
|
546
|
-
// first-run login handoff
|
|
547
|
-
loginMarkerPath,
|
|
548
|
-
isFirstLogin,
|
|
549
|
-
markLoginDone,
|
|
550
|
-
};
|