@bongos/core 1.19.884 → 1.19.886
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 +20 -285
- package/docs/architecture.md +0 -1
- package/docs/file-map.md +0 -1
- package/docs/module-api-changelog.md +4 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/gen-session-index.js +8 -1
- package/src/bongos/serve-internal.js +10 -56
- package/src/module-api.js +1 -1
- package/src/platform-server.js +0 -1
- package/tests/host_topology_skips.mjs +0 -1
- package/tests/instance_release_config.mjs +27 -26
- package/tests/session_rename_fallback.mjs +8 -90
- package/modules/dev-box/app/README.md +0 -74
- package/modules/dev-box/app/assets/tray/mac-alertTemplate.png +0 -0
- package/modules/dev-box/app/assets/tray/mac-alertTemplate@2x.png +0 -0
- package/modules/dev-box/app/assets/tray/mac-busyTemplate.png +0 -0
- package/modules/dev-box/app/assets/tray/mac-busyTemplate@2x.png +0 -0
- package/modules/dev-box/app/assets/tray/mac-offTemplate.png +0 -0
- package/modules/dev-box/app/assets/tray/mac-offTemplate@2x.png +0 -0
- package/modules/dev-box/app/assets/tray/mac-onTemplate.png +0 -0
- package/modules/dev-box/app/assets/tray/mac-onTemplate@2x.png +0 -0
- package/modules/dev-box/app/assets/tray/win-alert.png +0 -0
- package/modules/dev-box/app/assets/tray/win-busy.png +0 -0
- package/modules/dev-box/app/assets/tray/win-off.png +0 -0
- package/modules/dev-box/app/assets/tray/win-on.png +0 -0
- package/modules/dev-box/app/build-resources/dmg-background.png +0 -0
- package/modules/dev-box/app/build-resources/dmg-background@2x.png +0 -0
- package/modules/dev-box/app/build-resources/icon.png +0 -0
- package/modules/dev-box/app/electron-builder.tmpl.yml +0 -95
- package/modules/dev-box/app/electron-builder.yml +0 -95
- package/modules/dev-box/app/package-lock.json +0 -5291
- package/modules/dev-box/app/package.json +0 -30
- package/modules/dev-box/app/renderer/app.js +0 -319
- package/modules/dev-box/app/renderer/index.html +0 -45
- package/modules/dev-box/app/renderer/styles.css +0 -248
- package/modules/dev-box/app/renderer/theme.generated.css +0 -16
- package/modules/dev-box/app/scripts/apply-dmg-backdrop.sh +0 -82
- package/modules/dev-box/app/scripts/dmgbuild-settings.py +0 -39
- package/modules/dev-box/app/scripts/gen-brand.js +0 -138
- package/modules/dev-box/app/scripts/gen-dmg-backdrop.py +0 -323
- package/modules/dev-box/app/scripts/gen-icons.js +0 -286
- package/modules/dev-box/app/scripts/vendor-client.js +0 -23
- package/modules/dev-box/app/src/box-controller.js +0 -287
- package/modules/dev-box/app/src/brand.generated.json +0 -13
- package/modules/dev-box/app/src/brand.js +0 -16
- package/modules/dev-box/app/src/config.js +0 -31
- package/modules/dev-box/app/src/gds-client.js +0 -96
- package/modules/dev-box/app/src/main.js +0 -610
- package/modules/dev-box/app/src/pairing.js +0 -57
- package/modules/dev-box/app/src/preload.js +0 -29
- package/modules/dev-box/app/src/session-store.js +0 -82
- package/modules/dev-box/app/src/ssh-setup.js +0 -569
- package/modules/dev-box/app/src/task-picker.js +0 -70
- package/modules/dev-box/app/src/tray.js +0 -99
- package/modules/dev-box/app/src/updater.js +0 -55
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +0 -1014
- package/modules/dev-box/app/test/unit.mjs +0 -526
- package/src/devbox-downloads.js +0 -67
- package/tests/devbox_artifact_gate.mjs +0 -75
- package/tests/devbox_dmg_backdrop.mjs +0 -137
- package/tests/devbox_downloads.mjs +0 -140
- package/tests/devbox_icons.mjs +0 -304
- package/tests/devbox_installer_brand.mjs +0 -232
- package/tests/devbox_key_register.mjs +0 -234
- package/tests/devbox_update_path.mjs +0 -84
|
@@ -1,232 +0,0 @@
|
|
|
1
|
-
// tests/devbox_installer_brand.mjs — the Dev Box installer's BUILD INPUTS are
|
|
2
|
-
// branding-driven and match what the generator produces (task 1002512).
|
|
3
|
-
//
|
|
4
|
-
// The app is built separately from the server (ADR 0045), so its brand is baked
|
|
5
|
-
// in at BUILD time by modules/dev-box/app/scripts/gen-brand.js reading the
|
|
6
|
-
// instance branding pack (ADR 0145). Three artifacts are COMMITTED —
|
|
7
|
-
// src/brand.generated.json, renderer/theme.generated.css, electron-builder.yml —
|
|
8
|
-
// and until now nothing checked that they still equal the generator's output.
|
|
9
|
-
// A hand-edit to electron-builder.yml (the template's own "edit THIS file, never
|
|
10
|
-
// the generated one") survived silently until the next unrelated regeneration
|
|
11
|
-
// quietly reverted it, and a stale committed artifact means CI ships an installer
|
|
12
|
-
// whose appId / product name / artifact filenames are not what the pack says.
|
|
13
|
-
//
|
|
14
|
-
// PURE by construction: it calls the exported builders and NEVER main(), so a run
|
|
15
|
-
// writes nothing and cannot dirty the tree. That is also why gen-brand.js gained
|
|
16
|
-
// a `require.main === module` guard — importing it used to rewrite all three files.
|
|
17
|
-
//
|
|
18
|
-
// EOL: electron-builder.yml and theme.generated.css are CRLF on a default Windows
|
|
19
|
-
// checkout while the generator emits LF. Comparisons go through gen-freshness.js's
|
|
20
|
-
// sameContent() — a raw byte compare is red forever on Windows and green in Linux
|
|
21
|
-
// CI, the worst kind of gate.
|
|
22
|
-
//
|
|
23
|
-
// Regex/line based on the YAML (like tests/devbox_artifact_gate.mjs) — the repo
|
|
24
|
-
// declares no YAML parser. DB-free, no network. Run: node --test tests/devbox_installer_brand.mjs
|
|
25
|
-
|
|
26
|
-
import assert from 'node:assert/strict';
|
|
27
|
-
import { test } from 'node:test';
|
|
28
|
-
import fs from 'node:fs';
|
|
29
|
-
import path from 'node:path';
|
|
30
|
-
import { createRequire } from 'node:module';
|
|
31
|
-
import { fileURLToPath } from 'node:url';
|
|
32
|
-
|
|
33
|
-
const require = createRequire(import.meta.url);
|
|
34
|
-
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
35
|
-
const APP_DIR = path.join(ROOT, 'modules', 'dev-box', 'app');
|
|
36
|
-
|
|
37
|
-
const { sameContent } = require(path.join(ROOT, 'scripts', 'gds', 'gen-freshness.js'));
|
|
38
|
-
const genBrand = require(path.join(APP_DIR, 'scripts', 'gen-brand.js'));
|
|
39
|
-
const { buildBrand, buildThemeCss, renderElectronBuilder } = genBrand;
|
|
40
|
-
|
|
41
|
-
const neutral = JSON.parse(fs.readFileSync(path.join(ROOT, 'config', 'branding.neutral.json'), 'utf8'));
|
|
42
|
-
const read = (...p) => fs.readFileSync(path.join(APP_DIR, ...p), 'utf8');
|
|
43
|
-
|
|
44
|
-
const ORIGIN_ENV = ['DEVBOX_PUBLIC_ORIGIN', 'DEVBOX_BUILDERS_ORIGIN'];
|
|
45
|
-
|
|
46
|
-
// buildBrand reads the origin env vars at CALL time, so a developer's stray
|
|
47
|
-
// export would otherwise fake a drift failure. Run fn with a known env.
|
|
48
|
-
function withEnv(overrides, fn) {
|
|
49
|
-
const saved = Object.fromEntries(ORIGIN_ENV.map((k) => [k, process.env[k]]));
|
|
50
|
-
try {
|
|
51
|
-
for (const k of ORIGIN_ENV) {
|
|
52
|
-
if (overrides[k] === undefined) delete process.env[k];
|
|
53
|
-
else process.env[k] = overrides[k];
|
|
54
|
-
}
|
|
55
|
-
return fn();
|
|
56
|
-
} finally {
|
|
57
|
-
for (const k of ORIGIN_ENV) {
|
|
58
|
-
if (saved[k] === undefined) delete process.env[k];
|
|
59
|
-
else process.env[k] = saved[k];
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
// The committed artifacts are the NEUTRAL (env-free) build — that is what a
|
|
65
|
-
// release build re-renders over, so this is the expectation to compare against.
|
|
66
|
-
const neutralBrand = () => withEnv({}, () => buildBrand(neutral));
|
|
67
|
-
|
|
68
|
-
// A YAML value by key at any indent, first match wins (no parser in the repo deps).
|
|
69
|
-
function yamlValue(yml, key) {
|
|
70
|
-
const m = new RegExp(`^\\s*${key}:\\s*(.+?)\\s*$`, 'm').exec(yml);
|
|
71
|
-
return m ? m[1] : null;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
// Comment lines carry rationale prose that names the tokens on purpose (the
|
|
75
|
-
// template header says "@@TOKENS@@ are substituted") — scan the config only.
|
|
76
|
-
function settingsOnly(yml) {
|
|
77
|
-
return yml.split(/\r?\n/).filter((l) => !/^\s*#/.test(l)).join('\n');
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
// ── (a) drift gate: committed build inputs === generator output ────────────────
|
|
81
|
-
|
|
82
|
-
test('committed brand.generated.json is what the generator produces from the pack', () => {
|
|
83
|
-
assert.deepEqual(JSON.parse(read('src', 'brand.generated.json')), neutralBrand(),
|
|
84
|
-
'src/brand.generated.json is stale — run `npm run gen-brand` in modules/dev-box/app and commit');
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
test('committed theme.generated.css is what the generator produces from the pack', () => {
|
|
88
|
-
assert.ok(sameContent(read('renderer', 'theme.generated.css'), buildThemeCss(neutral)),
|
|
89
|
-
'renderer/theme.generated.css is stale — run `npm run gen-brand` and commit');
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
test('committed electron-builder.yml is what the template renders to', () => {
|
|
93
|
-
assert.ok(sameContent(read('electron-builder.yml'), renderElectronBuilder(neutralBrand())),
|
|
94
|
-
'electron-builder.yml was hand-edited or is stale — edit electron-builder.tmpl.yml, ' +
|
|
95
|
-
'run `npm run gen-brand`, and commit the rendered file');
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
// ── (b) installer identity, read out of the RENDERED yml ──────────────────────
|
|
99
|
-
|
|
100
|
-
test('the rendered installer declares the pack identity and no leftover token', () => {
|
|
101
|
-
const brand = neutralBrand();
|
|
102
|
-
const yml = renderElectronBuilder(brand);
|
|
103
|
-
assert.equal(yamlValue(yml, 'appId'), brand.APP_ID);
|
|
104
|
-
assert.equal(yamlValue(yml, 'appId'), 'com.cloudbongos.devbox');
|
|
105
|
-
assert.equal(yamlValue(yml, 'productName'), 'Cloud Bongos Dev Box');
|
|
106
|
-
assert.ok((yamlValue(yml, 'copyright') || '').length > 0, 'copyright must not render empty');
|
|
107
|
-
assert.doesNotMatch(settingsOnly(yml), /@@[A-Z_]+@@/,
|
|
108
|
-
'an unsubstituted @@TOKEN@@ reached the rendered config');
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
test('every installer filename carries the slug, the version and the arch', () => {
|
|
112
|
-
const { PRODUCT_SLUG } = neutralBrand();
|
|
113
|
-
const yml = renderElectronBuilder(neutralBrand());
|
|
114
|
-
const names = [...yml.matchAll(/^\s*artifactName:\s*(.+?)\s*$/gm)].map(([, v]) => v);
|
|
115
|
-
assert.ok(names.length >= 3, `expected mac + dmg + nsis artifactName, found ${names.length}`);
|
|
116
|
-
for (const name of names) {
|
|
117
|
-
assert.ok(name.startsWith(PRODUCT_SLUG), `artifactName "${name}" does not start with the brand slug`);
|
|
118
|
-
assert.match(name, /\$\{version\}/, `artifactName "${name}" has no \${version}`);
|
|
119
|
-
// #908: an arch-less name makes the x64 and arm64 builds collide on one file.
|
|
120
|
-
assert.match(name, /\$\{arch\}/, `artifactName "${name}" has no \${arch} — two arches would collide`);
|
|
121
|
-
}
|
|
122
|
-
assert.ok(names.some((n) => /-Setup-/.test(n)), 'the NSIS installer name must be distinguishable');
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
test('the Windows target still builds both arches', () => {
|
|
126
|
-
const yml = renderElectronBuilder(neutralBrand());
|
|
127
|
-
const winBlock = yml.slice(yml.indexOf('\nwin:'));
|
|
128
|
-
const arch = /arch:\s*\[([^\]]+)\]/.exec(winBlock);
|
|
129
|
-
assert.ok(arch, 'win target declares no arch list');
|
|
130
|
-
const arches = arch[1].split(',').map((s) => s.trim());
|
|
131
|
-
for (const a of ['x64', 'arm64']) assert.ok(arches.includes(a), `win arch list is missing ${a}`);
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
// ── (c) one source rebrands — the ADR 0145 promise, mechanized ────────────────
|
|
135
|
-
|
|
136
|
-
test('a different pack produces a different installer with no Cloud Bongos left in it', () => {
|
|
137
|
-
const pack = {
|
|
138
|
-
identity: { worldName: 'Kettle Drum', company: { name: 'Kettle Drum Co' } },
|
|
139
|
-
domains: { publicOrigin: 'https://kettle.example' },
|
|
140
|
-
configDir: 'kettle',
|
|
141
|
-
theme: { ui: { colors: { bg: '#101010' } } },
|
|
142
|
-
};
|
|
143
|
-
const brand = withEnv({}, () => buildBrand(pack));
|
|
144
|
-
assert.equal(brand.APP_ID, 'com.kettledrum.devbox');
|
|
145
|
-
assert.equal(brand.PRODUCT_NAME, 'Kettle Drum Dev Box');
|
|
146
|
-
assert.equal(brand.PRODUCT_SLUG, 'Kettle-Drum-Dev-Box');
|
|
147
|
-
assert.equal(brand.CONFIG_DIR, 'kettle');
|
|
148
|
-
assert.equal(brand.BG_COLOR, '#101010');
|
|
149
|
-
assert.equal(brand.COPYRIGHT, '© Kettle Drum Co');
|
|
150
|
-
const HOST_BRAND = /cloud ?bongos/i;
|
|
151
|
-
assert.doesNotMatch(JSON.stringify(brand), HOST_BRAND, 'a host pack still carries Cloud Bongos strings');
|
|
152
|
-
assert.doesNotMatch(settingsOnly(renderElectronBuilder(brand)), HOST_BRAND,
|
|
153
|
-
'the rendered installer config still names Cloud Bongos on a host pack');
|
|
154
|
-
});
|
|
155
|
-
|
|
156
|
-
// ── (d) origins are a build input ─────────────────────────────────────────────
|
|
157
|
-
|
|
158
|
-
test('the release origin env beats the pack', () => {
|
|
159
|
-
const brand = withEnv({
|
|
160
|
-
DEVBOX_PUBLIC_ORIGIN: 'https://cloudbongos.example',
|
|
161
|
-
DEVBOX_BUILDERS_ORIGIN: 'https://builders.cloudbongos.example',
|
|
162
|
-
}, () => buildBrand(neutral));
|
|
163
|
-
assert.equal(brand.PUBLIC_ORIGIN, 'https://cloudbongos.example');
|
|
164
|
-
assert.equal(brand.BUILDERS_ORIGIN, 'https://builders.cloudbongos.example');
|
|
165
|
-
});
|
|
166
|
-
|
|
167
|
-
test('with no env the pack origins are used, and buildersOrigin falls back to publicOrigin', () => {
|
|
168
|
-
const fromPack = withEnv({}, () => buildBrand(neutral));
|
|
169
|
-
assert.equal(fromPack.PUBLIC_ORIGIN, neutral.domains.publicOrigin);
|
|
170
|
-
assert.equal(fromPack.BUILDERS_ORIGIN, neutral.domains.buildersOrigin);
|
|
171
|
-
|
|
172
|
-
const noBuilders = withEnv({}, () => buildBrand({ domains: { publicOrigin: 'https://only.example' } }));
|
|
173
|
-
assert.equal(noBuilders.BUILDERS_ORIGIN, 'https://only.example',
|
|
174
|
-
'a pack without buildersOrigin must fall back to publicOrigin, not to localhost');
|
|
175
|
-
});
|
|
176
|
-
|
|
177
|
-
test('the COMMITTED artifact is the neutral localhost build — no host origin is checked in', () => {
|
|
178
|
-
const committed = JSON.parse(read('src', 'brand.generated.json'));
|
|
179
|
-
for (const key of ['PUBLIC_ORIGIN', 'BUILDERS_ORIGIN']) {
|
|
180
|
-
assert.match(committed[key], /^http:\/\/localhost(:\d+)?$/,
|
|
181
|
-
`${key} in the committed brand.generated.json is a host origin — a release build sets it via env`);
|
|
182
|
-
}
|
|
183
|
-
});
|
|
184
|
-
|
|
185
|
-
// ── (e) CI passes the origins through ─────────────────────────────────────────
|
|
186
|
-
|
|
187
|
-
test('both build jobs pass the origin repo variables into the build', () => {
|
|
188
|
-
const wf = fs.readFileSync(path.join(ROOT, '.github', 'workflows', 'build-devbox-app.yml'), 'utf8');
|
|
189
|
-
const lines = wf.split(/\r?\n/);
|
|
190
|
-
const jobStarts = lines.reduce((acc, l, i) => (/^ {2}[a-z0-9-]+:\s*$/.test(l) ? [...acc, i] : acc), []);
|
|
191
|
-
function jobBlock(name) {
|
|
192
|
-
const at = lines.findIndex((l) => l === ` ${name}:`);
|
|
193
|
-
assert.ok(at >= 0, `workflow has no ${name} job`);
|
|
194
|
-
const end = jobStarts.find((i) => i > at) ?? lines.length;
|
|
195
|
-
return lines.slice(at, end).join('\n');
|
|
196
|
-
}
|
|
197
|
-
for (const job of ['build-mac', 'build-win']) {
|
|
198
|
-
const block = jobBlock(job);
|
|
199
|
-
for (const v of ORIGIN_ENV) {
|
|
200
|
-
assert.match(block, new RegExp(`${v}:\\s*\\$\\{\\{\\s*vars\\.${v}\\s*\\}\\}`),
|
|
201
|
-
`${job} does not pass ${v} from repo variables — the release build would pair against localhost`);
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
});
|
|
205
|
-
|
|
206
|
-
// ── (f) published names are names the builder can emit ────────────────────────
|
|
207
|
-
|
|
208
|
-
test('the published asset filenames match what electron-builder will emit', () => {
|
|
209
|
-
// INSTANCE-ONLY config (publish-manifest.js INSTANCE_EXCLUDE) — absent in the
|
|
210
|
-
// core checkout, so this is a guarded cross-check, never a hard require.
|
|
211
|
-
const cfgPath = path.join(ROOT, 'config', 'devbox-app.json');
|
|
212
|
-
if (!fs.existsSync(cfgPath)) {
|
|
213
|
-
console.log(' (skipped: config/devbox-app.json is instance-only and absent in this checkout)');
|
|
214
|
-
return;
|
|
215
|
-
}
|
|
216
|
-
const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
|
|
217
|
-
const yml = renderElectronBuilder(neutralBrand());
|
|
218
|
-
const nsis = yamlValue(yml.slice(yml.indexOf('\nnsis:')), 'artifactName');
|
|
219
|
-
const mac = yamlValue(yml.slice(yml.indexOf('\ndmg:')), 'artifactName');
|
|
220
|
-
for (const [key, filename] of Object.entries(cfg.assets || {})) {
|
|
221
|
-
const m = /^(mac|win)-(\S+)$/.exec(key);
|
|
222
|
-
if (!m) continue;
|
|
223
|
-
const [, platform, arch] = m;
|
|
224
|
-
const pattern = platform === 'mac' ? mac : nsis;
|
|
225
|
-
const expected = pattern
|
|
226
|
-
.replace(/\$\{version\}/g, cfg.version)
|
|
227
|
-
.replace(/\$\{arch\}/g, arch)
|
|
228
|
-
.replace(/\$\{ext\}/g, platform === 'mac' ? 'dmg' : 'exe');
|
|
229
|
-
assert.equal(filename, expected,
|
|
230
|
-
`config/devbox-app.json assets["${key}"] names a file electron-builder will not produce`);
|
|
231
|
-
}
|
|
232
|
-
});
|
|
@@ -1,234 +0,0 @@
|
|
|
1
|
-
// tests/devbox_key_register.mjs — the Dev Box app's SSH-key registration and
|
|
2
|
-
// known_hosts replacement (task 1002509).
|
|
3
|
-
//
|
|
4
|
-
// Two user-visible defects are pinned here:
|
|
5
|
-
// 1. Key registration was ONE un-retried POST whose rejection was logged and
|
|
6
|
-
// dropped. A flaky network at sign-in left the builder with no key in the
|
|
7
|
-
// box's authorized_keys and nothing ever tried again — every connect refused.
|
|
8
|
-
// registerKeyWithVerify retries, then VERIFIES against the server's own list,
|
|
9
|
-
// because a 201 is not proof.
|
|
10
|
-
// 2. mergeKnownHosts only matched plain `host` / `host,ip` field-1 values, so a
|
|
11
|
-
// HASHED entry (`|1|salt|hash` — HashKnownHosts is on by default on Debian/
|
|
12
|
-
// Ubuntu) survived a "replace" and the stale line kept winning: "Host denied
|
|
13
|
-
// (verification failed)".
|
|
14
|
-
//
|
|
15
|
-
// It requires the app source directly (node builtins only — no Electron, no
|
|
16
|
-
// network, no fs): the app is a separately-packaged subproject whose own
|
|
17
|
-
// test/unit.mjs is NOT part of the root unit gate, so the gate lives here.
|
|
18
|
-
//
|
|
19
|
-
// Run: node tests/devbox_key_register.mjs
|
|
20
|
-
|
|
21
|
-
import { strict as assert } from 'node:assert';
|
|
22
|
-
import { test } from 'node:test';
|
|
23
|
-
import crypto from 'node:crypto';
|
|
24
|
-
import { createRequire } from 'node:module';
|
|
25
|
-
|
|
26
|
-
const require = createRequire(import.meta.url);
|
|
27
|
-
const {
|
|
28
|
-
registerKeyWithVerify,
|
|
29
|
-
sshFingerprintSha256,
|
|
30
|
-
publicKeyFingerprint,
|
|
31
|
-
mergeKnownHosts,
|
|
32
|
-
knownHostsLineMatchesHost,
|
|
33
|
-
reportedKeysAreStale,
|
|
34
|
-
} = require('../modules/dev-box/app/src/ssh-setup.js');
|
|
35
|
-
const boxOnboard = require('../modules/dev-box/box-onboard.js');
|
|
36
|
-
const boxes = require('../modules/dev-box/boxes.js');
|
|
37
|
-
|
|
38
|
-
// A REAL ed25519 public key (the same fixture tests/box_host_keys.mjs uses).
|
|
39
|
-
const ED = 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPCoIEgcBiGqM2Rd0rgfw0d7rHccvbpSe3vsSN7brif/';
|
|
40
|
-
const ED_BODY = ED.split(' ')[1];
|
|
41
|
-
const HOST = 'box-alice.dev.example.com';
|
|
42
|
-
|
|
43
|
-
// --- fingerprint parity with the server ------------------------------------
|
|
44
|
-
|
|
45
|
-
test('sshFingerprintSha256 matches the server-side implementation byte for byte', () => {
|
|
46
|
-
const mine = sshFingerprintSha256(ED_BODY);
|
|
47
|
-
assert.match(mine, /^SHA256:[A-Za-z0-9+/]+$/, 'SHA256: prefix, no base64 padding');
|
|
48
|
-
assert.equal(mine, boxOnboard.sshFingerprintSha256(ED_BODY),
|
|
49
|
-
'the app must compute the SAME fingerprint the GDS stores, or verification can never match');
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
test('publicKeyFingerprint ignores the comment and rejects a non-key line', () => {
|
|
53
|
-
assert.equal(publicKeyFingerprint(`${ED} someone@laptop`), sshFingerprintSha256(ED_BODY));
|
|
54
|
-
assert.equal(publicKeyFingerprint(ED), sshFingerprintSha256(ED_BODY));
|
|
55
|
-
assert.equal(publicKeyFingerprint('ssh-ed25519'), null);
|
|
56
|
-
assert.equal(publicKeyFingerprint(''), null);
|
|
57
|
-
assert.equal(publicKeyFingerprint(null), null);
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
// --- registerKeyWithVerify -------------------------------------------------
|
|
61
|
-
|
|
62
|
-
const FP = sshFingerprintSha256(ED_BODY);
|
|
63
|
-
|
|
64
|
-
// Fake clock: records every backoff so a test can prove the retry actually waited.
|
|
65
|
-
function fakeSleep() {
|
|
66
|
-
const waits = [];
|
|
67
|
-
const fn = async (ms) => { waits.push(ms); };
|
|
68
|
-
fn.waits = waits;
|
|
69
|
-
return fn;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
test('registration verified on the first attempt', async () => {
|
|
73
|
-
const calls = [];
|
|
74
|
-
const sleep = fakeSleep();
|
|
75
|
-
const r = await registerKeyWithVerify({
|
|
76
|
-
publicKey: ED,
|
|
77
|
-
label: 'devbox-app test',
|
|
78
|
-
registerKey: async (pk, label) => { calls.push([pk, label]); },
|
|
79
|
-
listKeys: async () => ({ keys: [{ fingerprint: FP }] }),
|
|
80
|
-
sleep,
|
|
81
|
-
});
|
|
82
|
-
assert.deepEqual(r, { ok: true, fingerprint: FP, verified: true, attempts: 1, reason: 'registered_verified' });
|
|
83
|
-
assert.equal(calls.length, 1, 'one POST');
|
|
84
|
-
assert.deepEqual(calls[0], [ED, 'devbox-app test']);
|
|
85
|
-
assert.deepEqual(sleep.waits, [], 'no backoff on a clean run');
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
test('a transient failure is RETRIED with backoff, not dropped', async () => {
|
|
89
|
-
let n = 0;
|
|
90
|
-
const sleep = fakeSleep();
|
|
91
|
-
const r = await registerKeyWithVerify({
|
|
92
|
-
publicKey: ED,
|
|
93
|
-
registerKey: async () => { n += 1; if (n < 3) throw new Error('socket hang up'); },
|
|
94
|
-
listKeys: async () => ({ keys: [{ fingerprint: FP }] }),
|
|
95
|
-
sleep,
|
|
96
|
-
});
|
|
97
|
-
assert.equal(r.ok, true);
|
|
98
|
-
assert.equal(r.attempts, 3, 'kept trying instead of giving up on the first error');
|
|
99
|
-
assert.deepEqual(sleep.waits, [1000, 2000], 'backed off 1s then 2s between attempts');
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
test('a 201 that did NOT land is reported as verify_failed, not success', async () => {
|
|
103
|
-
const r = await registerKeyWithVerify({
|
|
104
|
-
publicKey: ED,
|
|
105
|
-
registerKey: async () => ({ ok: true }),
|
|
106
|
-
listKeys: async () => ({ keys: [] }),
|
|
107
|
-
sleep: fakeSleep(),
|
|
108
|
-
});
|
|
109
|
-
assert.equal(r.ok, false);
|
|
110
|
-
assert.equal(r.verified, false);
|
|
111
|
-
assert.equal(r.reason, 'verify_failed');
|
|
112
|
-
assert.equal(r.fingerprint, FP);
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
test('every attempt failing returns register_failed and never throws', async () => {
|
|
116
|
-
const sleep = fakeSleep();
|
|
117
|
-
const r = await registerKeyWithVerify({
|
|
118
|
-
publicKey: ED,
|
|
119
|
-
registerKey: async () => { throw new Error('HTTP 500'); },
|
|
120
|
-
listKeys: async () => ({ keys: [] }),
|
|
121
|
-
sleep,
|
|
122
|
-
});
|
|
123
|
-
assert.equal(r.ok, false);
|
|
124
|
-
assert.equal(r.reason, 'register_failed');
|
|
125
|
-
assert.equal(r.attempts, 3);
|
|
126
|
-
assert.deepEqual(sleep.waits, [1000, 2000], 'exhausted the budget before conceding');
|
|
127
|
-
assert.match(r.detail, /HTTP 500/);
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
test('a key already on the account verifies even when the POST keeps failing', async () => {
|
|
131
|
-
const r = await registerKeyWithVerify({
|
|
132
|
-
publicKey: ED,
|
|
133
|
-
registerKey: async () => { throw new Error('offline'); },
|
|
134
|
-
listKeys: async () => ({ keys: [{ fingerprint: FP }] }),
|
|
135
|
-
sleep: fakeSleep(),
|
|
136
|
-
});
|
|
137
|
-
assert.equal(r.ok, true, 'presence on the server is the goal; the POST is only the means');
|
|
138
|
-
assert.equal(r.reason, 'registered_verified');
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
test('an unreadable key list is list_failed — never a false green', async () => {
|
|
142
|
-
const r = await registerKeyWithVerify({
|
|
143
|
-
publicKey: ED,
|
|
144
|
-
registerKey: async () => ({ ok: true }),
|
|
145
|
-
listKeys: async () => { throw new Error('HTTP 401'); },
|
|
146
|
-
sleep: fakeSleep(),
|
|
147
|
-
});
|
|
148
|
-
assert.equal(r.ok, false);
|
|
149
|
-
assert.equal(r.reason, 'list_failed');
|
|
150
|
-
assert.equal(r.verified, false);
|
|
151
|
-
});
|
|
152
|
-
|
|
153
|
-
test('a bare array from listKeys is accepted too', async () => {
|
|
154
|
-
const r = await registerKeyWithVerify({
|
|
155
|
-
publicKey: ED,
|
|
156
|
-
registerKey: async () => ({ ok: true }),
|
|
157
|
-
listKeys: async () => [{ fingerprint: FP }],
|
|
158
|
-
sleep: fakeSleep(),
|
|
159
|
-
});
|
|
160
|
-
assert.equal(r.ok, true);
|
|
161
|
-
});
|
|
162
|
-
|
|
163
|
-
// --- hashed known_hosts ----------------------------------------------------
|
|
164
|
-
|
|
165
|
-
// Build the hashed field-1 OpenSSH writes for `host`: |1|<b64 salt>|<b64 HMAC-SHA1>.
|
|
166
|
-
function hashedField(host, saltBytes = crypto.randomBytes(20)) {
|
|
167
|
-
const hash = crypto.createHmac('sha1', saltBytes).update(host).digest('base64');
|
|
168
|
-
return `|1|${saltBytes.toString('base64')}|${hash}`;
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
test('mergeKnownHosts drops a HASHED line for the host and keeps one for another host', () => {
|
|
172
|
-
const mine = `${hashedField(HOST)} ssh-ed25519 STALEKEY`;
|
|
173
|
-
const other = `${hashedField('unrelated.example.com')} ssh-ed25519 OTHERKEY`;
|
|
174
|
-
const out = mergeKnownHosts(`${mine}\n${other}\n`, HOST, [`${HOST} ${ED}`]);
|
|
175
|
-
assert.ok(!out.includes('STALEKEY'), 'the hashed entry for this host is REPLACED, not appended beside');
|
|
176
|
-
assert.ok(out.includes('OTHERKEY'), 'another host stays put');
|
|
177
|
-
assert.ok(out.includes(`${HOST} ${ED}`), 'the fresh key is written');
|
|
178
|
-
});
|
|
179
|
-
|
|
180
|
-
test('mergeKnownHosts still handles plain and host,ip entries, and keeps comments', () => {
|
|
181
|
-
const existing = [
|
|
182
|
-
'# my known hosts',
|
|
183
|
-
`${HOST} ssh-ed25519 STALE1`,
|
|
184
|
-
`${HOST},203.0.113.9 ssh-ed25519 STALE2`,
|
|
185
|
-
'other.example.com ssh-ed25519 KEEPME',
|
|
186
|
-
].join('\n');
|
|
187
|
-
const out = mergeKnownHosts(existing, HOST, [`${HOST} ${ED}`]);
|
|
188
|
-
assert.ok(out.startsWith('# my known hosts'), 'comments survive');
|
|
189
|
-
assert.ok(!out.includes('STALE1'));
|
|
190
|
-
assert.ok(!out.includes('STALE2'), 'a host,ip entry names this host too');
|
|
191
|
-
assert.ok(out.includes('KEEPME'));
|
|
192
|
-
});
|
|
193
|
-
|
|
194
|
-
test('mergeKnownHosts is idempotent — a re-pin does not accumulate', () => {
|
|
195
|
-
const first = mergeKnownHosts('', HOST, [`${HOST} ${ED}`]);
|
|
196
|
-
const second = mergeKnownHosts(first, HOST, [`${HOST} ${ED}`]);
|
|
197
|
-
assert.equal(second, first);
|
|
198
|
-
assert.equal(second.split('\n').filter((l) => l.trim()).length, 1);
|
|
199
|
-
});
|
|
200
|
-
|
|
201
|
-
test('knownHostsLineMatchesHost: hashed match, wrong host, junk salt, comments', () => {
|
|
202
|
-
const salt = crypto.randomBytes(20);
|
|
203
|
-
assert.equal(knownHostsLineMatchesHost(`${hashedField(HOST, salt)} ssh-ed25519 K`, HOST), true);
|
|
204
|
-
assert.equal(knownHostsLineMatchesHost(`${hashedField(HOST, salt)} ssh-ed25519 K`, 'nope.example.com'), false);
|
|
205
|
-
assert.equal(knownHostsLineMatchesHost('|1|| ssh-ed25519 K', HOST), false, 'malformed hashed field never matches');
|
|
206
|
-
assert.equal(knownHostsLineMatchesHost(`# ${HOST}`, HOST), false, 'comments are not host lines');
|
|
207
|
-
assert.equal(knownHostsLineMatchesHost('', HOST), false);
|
|
208
|
-
assert.equal(knownHostsLineMatchesHost(`${HOST} ssh-ed25519 K`, ''), false, 'no hostname → match nothing');
|
|
209
|
-
});
|
|
210
|
-
|
|
211
|
-
// --- host-key staleness ----------------------------------------------------
|
|
212
|
-
|
|
213
|
-
test('reportedKeysAreStale agrees with the server-side boxes.hostKeysAreStale', () => {
|
|
214
|
-
const cases = [
|
|
215
|
-
{ hostKeysAt: '2026-07-01T10:00:00Z', activeSince: '2026-07-01T09:00:00Z' }, // fresh
|
|
216
|
-
{ hostKeysAt: '2026-07-01T08:00:00Z', activeSince: '2026-07-01T09:00:00Z' }, // rebuilt since
|
|
217
|
-
{ hostKeysAt: null, activeSince: '2026-07-01T09:00:00Z' },
|
|
218
|
-
{ hostKeysAt: '2026-07-01T10:00:00Z', activeSince: null },
|
|
219
|
-
{ hostKeysAt: null, activeSince: null },
|
|
220
|
-
{ hostKeysAt: 'not-a-date', activeSince: '2026-07-01T09:00:00Z' },
|
|
221
|
-
];
|
|
222
|
-
const expected = [false, true, true, true, true, true];
|
|
223
|
-
for (const [i, c] of cases.entries()) {
|
|
224
|
-
assert.equal(reportedKeysAreStale(c), expected[i], `app helper, case ${i}: ${JSON.stringify(c)}`);
|
|
225
|
-
assert.equal(boxes.hostKeysAreStale(c), expected[i], `server helper, case ${i}: ${JSON.stringify(c)}`);
|
|
226
|
-
}
|
|
227
|
-
});
|
|
228
|
-
|
|
229
|
-
test('hostKeysAreStale accepts Date objects and an equal timestamp is fresh', () => {
|
|
230
|
-
const t = new Date('2026-07-01T09:00:00Z');
|
|
231
|
-
assert.equal(boxes.hostKeysAreStale({ hostKeysAt: t, activeSince: t }), false);
|
|
232
|
-
assert.equal(reportedKeysAreStale({ hostKeysAt: t, activeSince: t }), false);
|
|
233
|
-
assert.equal(boxes.hostKeysAreStale({ hostKeysAt: new Date('2026-07-01T08:59:59Z'), activeSince: t }), true);
|
|
234
|
-
});
|
|
@@ -1,84 +0,0 @@
|
|
|
1
|
-
// tests/devbox_update_path.mjs — the devbox-app update-check path pin (task 1002649).
|
|
2
|
-
//
|
|
3
|
-
// Deliberately SEPARATE from tests/devbox_downloads.mjs: that file topology-
|
|
4
|
-
// skips wherever config/devbox-app.json is absent (this neutral core checkout),
|
|
5
|
-
// but this pin reads only the app client's SOURCE, so it must run everywhere —
|
|
6
|
-
// including this repo's `unit` lane.
|
|
7
|
-
//
|
|
8
|
-
// Why the pin exists: a front proxy with a file-matcher can eat *.json paths
|
|
9
|
-
// before they reach Node — the live pre-ADR-0152 vhost 404'd
|
|
10
|
-
// /downloads/devbox/latest.json with an empty body while the extensionless
|
|
11
|
-
// /downloads/devbox/version twin worked, silently breaking the app's update
|
|
12
|
-
// check. The updater only needs { version, assets }, which /version carries.
|
|
13
|
-
//
|
|
14
|
-
// Run: node --test tests/devbox_update_path.mjs
|
|
15
|
-
|
|
16
|
-
import { strict as assert } from 'node:assert';
|
|
17
|
-
import { test } from 'node:test';
|
|
18
|
-
import { readFileSync } from 'node:fs';
|
|
19
|
-
import { createRequire } from 'node:module';
|
|
20
|
-
|
|
21
|
-
const require = createRequire(import.meta.url);
|
|
22
|
-
const src = readFileSync(new URL('../modules/dev-box/app/src/gds-client.js', import.meta.url), 'utf8');
|
|
23
|
-
|
|
24
|
-
test('latestRelease polls the extensionless /downloads/devbox/version, never a .json path', () => {
|
|
25
|
-
const line = src.split('\n').find((l) => l.includes('latestRelease:'));
|
|
26
|
-
assert.ok(line, 'gds-client exposes latestRelease');
|
|
27
|
-
assert.ok(line.includes("'/downloads/devbox/version'"), 'latestRelease polls /downloads/devbox/version');
|
|
28
|
-
assert.ok(!line.includes('latest.json'), 'latestRelease must not poll a .json-suffixed path');
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
// ---- The ACTUAL route handlers, over loopback (the docs_openapi_route ----
|
|
32
|
-
// pattern). This is the layer the repo owns: the Express routes must serve a
|
|
33
|
-
// NON-EMPTY manifest on all three paths and redirect targets. (The infra layer
|
|
34
|
-
// above Node — the host's front proxy — cannot be pinned from a unit test;
|
|
35
|
-
// that is blocker 1000105. These pins guarantee that when a request reaches
|
|
36
|
-
// Node, the manifest is never empty.)
|
|
37
|
-
const CFG = {
|
|
38
|
-
releases_repo: 'owner/devbox-releases',
|
|
39
|
-
version: '9.9.9',
|
|
40
|
-
assets: { 'win-x64': 'Setup-9.9.9-x64.exe', 'mac-arm64': 'App-9.9.9-arm64.dmg' },
|
|
41
|
-
aliases: { windows: 'win-x64', mac: 'mac-arm64' },
|
|
42
|
-
};
|
|
43
|
-
|
|
44
|
-
async function withDevboxServer(fn) {
|
|
45
|
-
const express = require('express');
|
|
46
|
-
const { mountDevboxDownloads } = require('../src/bongos/serve-internal.js');
|
|
47
|
-
const { buildDevboxDownloads } = require('../src/devbox-downloads.js');
|
|
48
|
-
const app = express();
|
|
49
|
-
mountDevboxDownloads(app, buildDevboxDownloads(CFG));
|
|
50
|
-
const server = await new Promise((resolve) => { const s = app.listen(0, () => resolve(s)); });
|
|
51
|
-
try {
|
|
52
|
-
await fn(`http://127.0.0.1:${server.address().port}`);
|
|
53
|
-
} finally {
|
|
54
|
-
await new Promise((r) => server.close(r));
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
test('latest.json and the extensionless /latest twin both serve the full non-empty manifest', async () => {
|
|
59
|
-
await withDevboxServer(async (base) => {
|
|
60
|
-
for (const p of ['/downloads/devbox/latest.json', '/downloads/devbox/latest']) {
|
|
61
|
-
const r = await fetch(base + p);
|
|
62
|
-
assert.equal(r.status, 200, `${p} must 200`);
|
|
63
|
-
assert.match(r.headers.get('content-type') || '', /application\/json/, `${p} content-type`);
|
|
64
|
-
const j = await r.json();
|
|
65
|
-
assert.equal(j.version, '9.9.9');
|
|
66
|
-
assert.ok(Object.keys(j.assets).length >= 1, `${p} manifest must carry assets — the task-1002649 empty-body class`);
|
|
67
|
-
assert.equal(j.assets['win-x64'].file, 'Setup-9.9.9-x64.exe');
|
|
68
|
-
assert.match(j.assets['win-x64'].url, /\/releases\/download\/v9\.9\.9\//);
|
|
69
|
-
}
|
|
70
|
-
});
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
test('/version serves the flat update-check payload; a target redirects 302', async () => {
|
|
74
|
-
await withDevboxServer(async (base) => {
|
|
75
|
-
const v = await fetch(base + '/downloads/devbox/version');
|
|
76
|
-
assert.equal(v.status, 200);
|
|
77
|
-
const j = await v.json();
|
|
78
|
-
assert.equal(j.version, '9.9.9');
|
|
79
|
-
assert.equal(typeof j.assets['win-x64'], 'string', 'flat {target: url} shape');
|
|
80
|
-
const r = await fetch(base + '/downloads/devbox/windows', { redirect: 'manual' });
|
|
81
|
-
assert.equal(r.status, 302);
|
|
82
|
-
assert.match(r.headers.get('location') || '', /Setup-9\.9\.9-x64\.exe$/);
|
|
83
|
-
});
|
|
84
|
-
});
|