@bongos/core 1.19.884 → 1.19.885

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 (65) hide show
  1. package/.bongos-core.json +19 -284
  2. package/docs/architecture.md +0 -1
  3. package/docs/file-map.md +0 -1
  4. package/docs/module-api-changelog.md +2 -0
  5. package/package-lock.json +2 -2
  6. package/package.json +1 -1
  7. package/src/bongos/serve-internal.js +10 -56
  8. package/src/module-api.js +1 -1
  9. package/src/platform-server.js +0 -1
  10. package/tests/host_topology_skips.mjs +0 -1
  11. package/tests/instance_release_config.mjs +27 -26
  12. package/tests/session_rename_fallback.mjs +8 -90
  13. package/modules/dev-box/app/README.md +0 -74
  14. package/modules/dev-box/app/assets/tray/mac-alertTemplate.png +0 -0
  15. package/modules/dev-box/app/assets/tray/mac-alertTemplate@2x.png +0 -0
  16. package/modules/dev-box/app/assets/tray/mac-busyTemplate.png +0 -0
  17. package/modules/dev-box/app/assets/tray/mac-busyTemplate@2x.png +0 -0
  18. package/modules/dev-box/app/assets/tray/mac-offTemplate.png +0 -0
  19. package/modules/dev-box/app/assets/tray/mac-offTemplate@2x.png +0 -0
  20. package/modules/dev-box/app/assets/tray/mac-onTemplate.png +0 -0
  21. package/modules/dev-box/app/assets/tray/mac-onTemplate@2x.png +0 -0
  22. package/modules/dev-box/app/assets/tray/win-alert.png +0 -0
  23. package/modules/dev-box/app/assets/tray/win-busy.png +0 -0
  24. package/modules/dev-box/app/assets/tray/win-off.png +0 -0
  25. package/modules/dev-box/app/assets/tray/win-on.png +0 -0
  26. package/modules/dev-box/app/build-resources/dmg-background.png +0 -0
  27. package/modules/dev-box/app/build-resources/dmg-background@2x.png +0 -0
  28. package/modules/dev-box/app/build-resources/icon.png +0 -0
  29. package/modules/dev-box/app/electron-builder.tmpl.yml +0 -95
  30. package/modules/dev-box/app/electron-builder.yml +0 -95
  31. package/modules/dev-box/app/package-lock.json +0 -5291
  32. package/modules/dev-box/app/package.json +0 -30
  33. package/modules/dev-box/app/renderer/app.js +0 -319
  34. package/modules/dev-box/app/renderer/index.html +0 -45
  35. package/modules/dev-box/app/renderer/styles.css +0 -248
  36. package/modules/dev-box/app/renderer/theme.generated.css +0 -16
  37. package/modules/dev-box/app/scripts/apply-dmg-backdrop.sh +0 -82
  38. package/modules/dev-box/app/scripts/dmgbuild-settings.py +0 -39
  39. package/modules/dev-box/app/scripts/gen-brand.js +0 -138
  40. package/modules/dev-box/app/scripts/gen-dmg-backdrop.py +0 -323
  41. package/modules/dev-box/app/scripts/gen-icons.js +0 -286
  42. package/modules/dev-box/app/scripts/vendor-client.js +0 -23
  43. package/modules/dev-box/app/src/box-controller.js +0 -287
  44. package/modules/dev-box/app/src/brand.generated.json +0 -13
  45. package/modules/dev-box/app/src/brand.js +0 -16
  46. package/modules/dev-box/app/src/config.js +0 -31
  47. package/modules/dev-box/app/src/gds-client.js +0 -96
  48. package/modules/dev-box/app/src/main.js +0 -610
  49. package/modules/dev-box/app/src/pairing.js +0 -57
  50. package/modules/dev-box/app/src/preload.js +0 -29
  51. package/modules/dev-box/app/src/session-store.js +0 -82
  52. package/modules/dev-box/app/src/ssh-setup.js +0 -569
  53. package/modules/dev-box/app/src/task-picker.js +0 -70
  54. package/modules/dev-box/app/src/tray.js +0 -99
  55. package/modules/dev-box/app/src/updater.js +0 -55
  56. package/modules/dev-box/app/src/vendor/bongos-client.cjs +0 -1014
  57. package/modules/dev-box/app/test/unit.mjs +0 -526
  58. package/src/devbox-downloads.js +0 -67
  59. package/tests/devbox_artifact_gate.mjs +0 -75
  60. package/tests/devbox_dmg_backdrop.mjs +0 -137
  61. package/tests/devbox_downloads.mjs +0 -140
  62. package/tests/devbox_icons.mjs +0 -304
  63. package/tests/devbox_installer_brand.mjs +0 -232
  64. package/tests/devbox_key_register.mjs +0 -234
  65. package/tests/devbox_update_path.mjs +0 -84
@@ -1,526 +0,0 @@
1
- // devbox-app/test/unit.mjs — hermetic unit tests for the app's pure logic
2
- // (no Electron, no network, no fs side effects beyond a temp dir).
3
- //
4
- // Run: npm test (inside devbox-app/), or node devbox-app/test/unit.mjs.
5
- // NOTE: lives under devbox-app/ (not the root tests/) on purpose — the root
6
- // run-unit-tests gate runs from the droplet-bound dependency set; this
7
- // subproject's tests ride its own CI job (.github/workflows/build-devbox-app.yml).
8
-
9
- import { strict as assert } from 'node:assert';
10
- import { createRequire } from 'node:module';
11
-
12
- const require = createRequire(import.meta.url);
13
- const { deriveStatus, pollDelayMs, transitionNotification, terminalAuthUrl, TERMINAL_USER, shouldHeartbeat } = require('../src/box-controller.js');
14
- const { mergeKnownHosts, settingsUpToDate, scanComplete, hostKeyLinesFor, knownHostsHasKey, verifyKnownHosts } = require('../src/ssh-setup.js');
15
- const { createPairing } = require('../src/pairing.js');
16
- const { apiBase } = require('../src/config.js');
17
- const brand = require('../src/brand.js');
18
- const { compareVersions, isUpdateAvailable, resolveTarget, updateBannerText } = require('../src/updater.js');
19
- const { createClient: createGdsClient, GdsError } = require('../src/gds-client.js');
20
- const { shapeClaimableTasks, boxScopeMessage, claimErrorMessage } = require('../src/task-picker.js');
21
-
22
- let passed = 0;
23
- let failed = 0;
24
- function t(name, fn) {
25
- try { fn(); passed++; console.log(` PASS ${name}`); }
26
- catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
27
- }
28
- async function ta(name, fn) {
29
- try { await fn(); passed++; console.log(` PASS ${name}`); }
30
- catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
31
- }
32
-
33
- console.log('deriveStatus:');
34
-
35
- t('no box → none; active box → active', () => {
36
- assert.equal(deriveStatus(null), 'none');
37
- assert.equal(deriveStatus({ box: { state: 'none' }, open_intent: null }), 'none');
38
- assert.equal(deriveStatus({ box: { state: 'active' }, open_intent: null }), 'active');
39
- });
40
-
41
- t('open intents win over raw state', () => {
42
- assert.equal(deriveStatus({ box: { state: 'none' }, open_intent: { action: 'provision', state: 'pending' } }), 'provisioning');
43
- assert.equal(deriveStatus({ box: { state: 'active' }, open_intent: { action: 'deprovision', state: 'pending' } }), 'closing');
44
- assert.equal(deriveStatus({ box: { state: 'parked' }, open_intent: { action: 'wake', state: 'running' } }), 'provisioning');
45
- });
46
-
47
- t('parked reads as off (the switch wakes it); error surfaces', () => {
48
- assert.equal(deriveStatus({ box: { state: 'parked' }, open_intent: null }), 'none');
49
- assert.equal(deriveStatus({ box: { state: 'error' }, open_intent: null }), 'error');
50
- assert.equal(deriveStatus({ box: { state: 'waking' }, open_intent: null }), 'provisioning');
51
- assert.equal(deriveStatus({ box: { state: 'parking' }, open_intent: null }), 'closing');
52
- });
53
-
54
- console.log('pollDelayMs:');
55
-
56
- t('transitions poll fast, settled states slow', () => {
57
- assert.ok(pollDelayMs('provisioning') <= 5000);
58
- assert.ok(pollDelayMs('closing') <= 5000);
59
- assert.ok(pollDelayMs('active') >= 30000);
60
- assert.ok(pollDelayMs('none') >= 60000);
61
- });
62
-
63
- console.log('shouldHeartbeat (leg 3 — connect-window keep-alive):');
64
-
65
- t('only an ACTIVE box ever heartbeats', () => {
66
- const now = 1_000_000;
67
- for (const status of ['none', 'provisioning', 'closing', 'error', 'signed_out']) {
68
- assert.equal(shouldHeartbeat({ status, panelVisible: true, lastConnectActionAt: now, now }), false, status);
69
- }
70
- });
71
-
72
- t('active + panel visible → heartbeat (the mid-connect lockout window)', () => {
73
- const now = 1_000_000;
74
- assert.equal(shouldHeartbeat({ status: 'active', panelVisible: true, lastConnectActionAt: 0, now }), true);
75
- });
76
-
77
- t('active + panel hidden but within the connect window → heartbeat', () => {
78
- const now = 1_000_000;
79
- assert.equal(shouldHeartbeat({ status: 'active', panelVisible: false, lastConnectActionAt: now - 60_000, now }), true);
80
- });
81
-
82
- t('active + panel hidden + connect window expired → NO heartbeat (idle box still reaps)', () => {
83
- const now = 1_000_000;
84
- // 11 min > the 10-min window
85
- assert.equal(shouldHeartbeat({ status: 'active', panelVisible: false, lastConnectActionAt: now - 11 * 60_000, now }), false);
86
- // never connected, panel hidden → also no beat
87
- assert.equal(shouldHeartbeat({ status: 'active', panelVisible: false, lastConnectActionAt: 0, now }), false);
88
- });
89
-
90
- console.log('terminalAuthUrl:');
91
-
92
- t('embeds otb:<credential> userinfo in the terminal URL', () => {
93
- assert.equal(
94
- terminalAuthUrl({ url: 'https://term-example-owner.example.com', credential: 'abc123' }),
95
- `https://${TERMINAL_USER}:abc123@term-example-owner.example.com/`
96
- );
97
- });
98
-
99
- t('no terminal / no url → null; no credential → bare url; bad url → bare url', () => {
100
- assert.equal(terminalAuthUrl(null), null);
101
- assert.equal(terminalAuthUrl({ credential: 'x' }), null);
102
- assert.equal(terminalAuthUrl({ url: 'https://t.example.com' }), 'https://t.example.com');
103
- assert.equal(terminalAuthUrl({ url: 'not a url', credential: 'x' }), 'not a url');
104
- });
105
-
106
- t('percent-encodes credentials that need it', () => {
107
- const out = terminalAuthUrl({ url: 'https://t.example.com', credential: 'p@ss/wor:d' });
108
- assert.ok(out.startsWith(`https://${TERMINAL_USER}:p%40ss%2Fwor%3Ad@t.example.com`));
109
- });
110
-
111
- console.log('transitionNotification:');
112
-
113
- t('ready / idle-close notify; no-change does not', () => {
114
- assert.equal(transitionNotification('active', 'active'), null);
115
- assert.ok(transitionNotification('provisioning', 'active', { envName: 'X' }).title.includes('ready'));
116
- assert.ok(transitionNotification('active', 'none').title.includes('closed'));
117
- assert.equal(transitionNotification('none', 'provisioning'), null);
118
- });
119
-
120
- t('ready notification names the Desktop environment when settings installed', () => {
121
- const n = transitionNotification('provisioning', 'active', { envName: 'Env Name', settingsInstalled: true });
122
- assert.ok(n.body.includes('Env Name'));
123
- });
124
-
125
- console.log('mergeKnownHosts:');
126
-
127
- t('replaces stale lines for the hostname, keeps everything else', () => {
128
- const existing = [
129
- 'github.com ssh-ed25519 AAAA1',
130
- 'box-lars.dev.example.com ssh-ed25519 OLDKEY',
131
- 'box-lars.dev.example.com,1.2.3.4 ecdsa-sha2 OLDKEY2',
132
- '# a comment',
133
- 'other.host ssh-rsa BBBB',
134
- ].join('\n');
135
- const out = mergeKnownHosts(existing, 'box-lars.dev.example.com', [
136
- 'box-lars.dev.example.com ssh-ed25519 NEWKEY',
137
- ]);
138
- assert.ok(!out.includes('OLDKEY'), 'old keys dropped');
139
- assert.ok(out.includes('NEWKEY'), 'new key added');
140
- assert.ok(out.includes('github.com ssh-ed25519 AAAA1'), 'unrelated host kept');
141
- assert.ok(out.includes('# a comment'), 'comments kept');
142
- assert.ok(out.endsWith('\n'), 'trailing newline');
143
- });
144
-
145
- t('works against an empty/missing known_hosts', () => {
146
- const out = mergeKnownHosts('', 'h.example.com', ['h.example.com ssh-ed25519 K']);
147
- assert.equal(out, 'h.example.com ssh-ed25519 K\n');
148
- });
149
-
150
- console.log('scanComplete:');
151
-
152
- t('a scan with an ed25519 line is complete', () => {
153
- assert.equal(scanComplete([
154
- 'h.example.com ssh-rsa AAAA',
155
- 'h.example.com ssh-ed25519 BBBB',
156
- ]), true);
157
- });
158
-
159
- t('an rsa-only scan is NOT complete (#925 — Desktop ssh2 negotiates ed25519)', () => {
160
- assert.equal(scanComplete(['h.example.com ssh-rsa AAAA']), false);
161
- assert.equal(scanComplete(['h.example.com ecdsa-sha2-nistp256 CCCC']), false);
162
- assert.equal(scanComplete([]), false);
163
- });
164
-
165
- console.log('hostKeyLinesFor (task 1187 / idea 235 — authoritative host keys):');
166
-
167
- t('builds <host> <type> <base64> lines from the reported keys, ed25519 present', () => {
168
- const r = hostKeyLinesFor('box-x.dev.example.com', 'ssh-ed25519 AAAA\necdsa-sha2-nistp256 BBBB');
169
- assert.equal(r.ok, true);
170
- assert.deepEqual(r.lines, [
171
- 'box-x.dev.example.com ssh-ed25519 AAAA',
172
- 'box-x.dev.example.com ecdsa-sha2-nistp256 BBBB',
173
- ]);
174
- });
175
-
176
- t('these lines feed mergeKnownHosts as a clean replace-by-host', () => {
177
- const r = hostKeyLinesFor('h.example.com', 'ssh-ed25519 NEW');
178
- const merged = mergeKnownHosts('h.example.com ssh-ed25519 OLD\nother ssh-rsa K', 'h.example.com', r.lines);
179
- assert.ok(merged.includes('other ssh-rsa K'), 'other host preserved');
180
- assert.ok(merged.includes('h.example.com ssh-ed25519 NEW'), 'fresh key written');
181
- assert.ok(!merged.includes('OLD'), 'stale key for our host removed');
182
- });
183
-
184
- t('an rsa-only report is rejected (no ed25519 — the #925 Desktop failure)', () => {
185
- const r = hostKeyLinesFor('h', 'ssh-rsa AAAA');
186
- assert.equal(r.ok, false);
187
- assert.equal(r.reason, 'no_ed25519');
188
- });
189
-
190
- t('empty report / missing hostname are rejected', () => {
191
- assert.equal(hostKeyLinesFor('h', '').ok, false);
192
- assert.equal(hostKeyLinesFor('h', ' \n ').reason, 'no_reported_keys');
193
- assert.equal(hostKeyLinesFor('', 'ssh-ed25519 AAAA').reason, 'no_hostname');
194
- });
195
-
196
- console.log('knownHostsHasKey + verifyKnownHosts (idea 264 — self-verify the on-disk pin):');
197
-
198
- const HKEY = 'AAAAC3NzaC1lZDI1NTE5AAAAIBh0r5JKa722SY0UXjqHv2'; // sample ed25519 blob
199
- const EXPECTED = hostKeyLinesFor('box-x.dev.example.com', `ssh-ed25519 ${HKEY}\necdsa-sha2-nistp256 BBBB`).lines;
200
-
201
- t('authoritative key present in file → true', () => {
202
- const file = `box-x.dev.example.com ssh-ed25519 ${HKEY}\nother.example.com ssh-rsa K`;
203
- assert.equal(knownHostsHasKey(file, 'box-x.dev.example.com', EXPECTED), true);
204
- });
205
-
206
- t('STALE ed25519 (rebuild minted a new key) → false, even though a host line exists', () => {
207
- const file = 'box-x.dev.example.com ssh-ed25519 OLDOLDOLD';
208
- assert.equal(knownHostsHasKey(file, 'box-x.dev.example.com', EXPECTED), false);
209
- });
210
-
211
- t('MISSING (ssh-keygen -R removed it / empty file) → false', () => {
212
- assert.equal(knownHostsHasKey('unrelated.example.com ssh-ed25519 ZZZZ', 'box-x.dev.example.com', EXPECTED), false);
213
- assert.equal(knownHostsHasKey('', 'box-x.dev.example.com', EXPECTED), false);
214
- });
215
-
216
- t('no authoritative key → any ed25519 line for the host counts (keyscan fallback)', () => {
217
- assert.equal(knownHostsHasKey('box-x.dev.example.com ssh-ed25519 WHATEVER', 'box-x.dev.example.com', []), true);
218
- // ...but an rsa-only pin (no ed25519, the algo Desktop verifies) is NOT enough.
219
- assert.equal(knownHostsHasKey('box-x.dev.example.com ssh-rsa RRRR', 'box-x.dev.example.com', []), false);
220
- });
221
-
222
- t('host,ip combined first field + comments/blanks are handled', () => {
223
- const file = `# a comment\n\nbox-x.dev.example.com,1.2.3.4 ssh-ed25519 ${HKEY}`;
224
- assert.equal(knownHostsHasKey(file, 'box-x.dev.example.com', EXPECTED), true);
225
- });
226
-
227
- t('no hostname → false (fail-closed)', () => {
228
- assert.equal(knownHostsHasKey(`box-x ssh-ed25519 ${HKEY}`, '', EXPECTED), false);
229
- });
230
-
231
- t('verifyKnownHosts: missing hostname is a clean not-ok with no file IO', () => {
232
- const r = verifyKnownHosts('', 'ssh-ed25519 AAAA');
233
- assert.equal(r.ok, false);
234
- assert.equal(r.reason, 'no_hostname');
235
- });
236
-
237
- console.log('settingsUpToDate:');
238
-
239
- t('content equality is structural, missing file is false', () => {
240
- assert.equal(settingsUpToDate('/nonexistent/path.json', '{"a":1}'), false);
241
- });
242
-
243
- console.log('pairing:');
244
-
245
- await ta('pairing run: opens browser, polls to approval, returns the grant', async () => {
246
- const calls = [];
247
- let polls = 0;
248
- const client = {
249
- pairStart: async () => ({ pair_code: 'ABCD-EFGH', poll_token: 'pt', verify_url: 'https://x/pair?code=ABCD-EFGH', expires_in: 600, interval: 0.01 }),
250
- pairPoll: async () => (++polls < 3 ? { status: 'pending' } : { status: 'approved', token: 'tok', builder: { github_login: 'l' }, expires_at: 'soon' }),
251
- };
252
- const pairing = createPairing({ client, openExternal: async (u) => calls.push(u), sleep: () => Promise.resolve() });
253
- const updates = [];
254
- const grant = await pairing.run((u) => updates.push(u));
255
- assert.deepEqual(calls, ['https://x/pair?code=ABCD-EFGH']);
256
- assert.equal(updates[0].phase, 'waiting');
257
- assert.equal(grant.token, 'tok');
258
- });
259
-
260
- await ta('pairing run: expired poll throws', async () => {
261
- const client = {
262
- pairStart: async () => ({ pair_code: 'A', poll_token: 'pt', verify_url: 'https://x', expires_in: 600, interval: 0.01 }),
263
- pairPoll: async () => ({ status: 'expired' }),
264
- };
265
- const pairing = createPairing({ client, openExternal: async () => {}, sleep: () => Promise.resolve() });
266
- await assert.rejects(() => pairing.run(), /expired/);
267
- });
268
-
269
- await ta('pairing run: cancel stops the loop', async () => {
270
- const client = {
271
- pairStart: async () => ({ pair_code: 'A', poll_token: 'pt', verify_url: 'https://x', expires_in: 600, interval: 0.01 }),
272
- pairPoll: async () => ({ status: 'pending' }),
273
- };
274
- const pairing = createPairing({ client, openExternal: async () => {}, sleep: () => Promise.resolve() });
275
- const p = pairing.run();
276
- pairing.cancel();
277
- await assert.rejects(() => p, /cancelled/);
278
- });
279
-
280
- console.log('mergeUserSshConfigs (#910 — the picker reads ~/.claude/settings.json):');
281
-
282
- t('merges our entry into empty/missing settings', () => {
283
- const { mergeUserSshConfigs } = require('../src/ssh-setup.js');
284
- const out = mergeUserSshConfigs('', [{ id: 'otb-x', name: 'Box', sshHost: 'root@h' }]);
285
- assert.deepEqual(out.sshConfigs, [{ id: 'otb-x', name: 'Box', sshHost: 'root@h' }]);
286
- });
287
-
288
- t('replaces our id, preserves other entries and unrelated settings', () => {
289
- const { mergeUserSshConfigs } = require('../src/ssh-setup.js');
290
- const existing = JSON.stringify({
291
- theme: 'dark',
292
- sshConfigs: [
293
- { id: 'personal', name: 'My server', sshHost: 'me@mine' },
294
- { id: 'otb-x', name: 'Old Box', sshHost: 'root@old' },
295
- ],
296
- });
297
- const out = mergeUserSshConfigs(existing, [{ id: 'otb-x', name: 'New Box', sshHost: 'root@new' }]);
298
- assert.equal(out.theme, 'dark', 'unrelated settings preserved');
299
- assert.deepEqual(out.sshConfigs.map((c) => c.id), ['personal', 'otb-x']);
300
- assert.equal(out.sshConfigs[1].sshHost, 'root@new');
301
- });
302
-
303
- t('corrupt settings file never throws — starts clean', () => {
304
- const { mergeUserSshConfigs } = require('../src/ssh-setup.js');
305
- const out = mergeUserSshConfigs('{not json', [{ id: 'a', name: 'A', sshHost: 'h' }]);
306
- assert.equal(out.sshConfigs.length, 1);
307
- });
308
-
309
- console.log('resolveSshTool (#909):');
310
-
311
- t('returns the bare name on POSIX (PATH resolution)', () => {
312
- const { resolveSshTool } = require('../src/ssh-setup.js');
313
- if (process.platform !== 'win32') {
314
- assert.equal(resolveSshTool('ssh-keyscan'), 'ssh-keyscan');
315
- } else {
316
- // On Windows it must come back absolute (or fall through to PATH).
317
- const out = resolveSshTool('ssh-keyscan');
318
- assert.ok(out === 'ssh-keyscan' || out.endsWith('ssh-keyscan.exe'));
319
- }
320
- });
321
-
322
- console.log('hostKeys snapshot wiring (#909):');
323
-
324
- t('setHostKeys lands in the snapshot and clears when the box leaves active', async () => {
325
- const { createBoxController } = require('../src/box-controller.js');
326
- let payload = { box: { state: 'active', hostname: 'h' }, open_intent: null };
327
- const client = {
328
- boxMe: async () => payload,
329
- boxTerminal: async () => ({ url: null }),
330
- };
331
- const c = createBoxController({ client, signedIn: () => true, onChange: () => {}, onNotify: () => {}, envName: 'E' });
332
- await c.refresh();
333
- c.setHostKeys({ ok: false, reason: 'no_keys', attempt: 2 });
334
- assert.deepEqual(c.snapshot().hostKeys, { ok: false, reason: 'no_keys', attempt: 2 });
335
- payload = { box: { state: 'none' }, open_intent: null };
336
- await c.refresh();
337
- assert.equal(c.snapshot().hostKeys, null, 'cleared when not active');
338
- c.stop(); // halt the (unref'd) poll timer
339
- });
340
-
341
- console.log('keyRegistered snapshot wiring (task 1002509):');
342
-
343
- await ta('setKeyRegistered surfaces registration state and SURVIVES the box going away', async () => {
344
- const { createBoxController } = require('../src/box-controller.js');
345
- let payload = {
346
- box: {
347
- state: 'active', hostname: 'h',
348
- host_keys: 'ssh-ed25519 K', host_keys_at: '2026-07-01T10:00:00Z', active_since: '2026-07-01T09:00:00Z',
349
- },
350
- open_intent: null,
351
- };
352
- const client = { boxMe: async () => payload, boxTerminal: async () => ({ url: null }) };
353
- const c = createBoxController({ client, signedIn: () => true, onChange: () => {}, onNotify: () => {}, envName: 'E' });
354
- await c.refresh();
355
- assert.equal(c.snapshot().keyRegistered, null, 'nothing reported yet');
356
- assert.equal(c.snapshot().box.hostKeysAt, '2026-07-01T10:00:00Z');
357
- assert.equal(c.snapshot().box.activeSince, '2026-07-01T09:00:00Z');
358
- c.setKeyRegistered({ ok: false, reason: 'verify_failed', fingerprint: 'SHA256:x', attempts: 3 });
359
- assert.deepEqual(c.snapshot().keyRegistered, { ok: false, reason: 'verify_failed', fingerprint: 'SHA256:x', attempts: 3 });
360
- payload = { box: { state: 'none' }, open_intent: null };
361
- await c.refresh();
362
- assert.deepEqual(
363
- c.snapshot().keyRegistered,
364
- { ok: false, reason: 'verify_failed', fingerprint: 'SHA256:x', attempts: 3 },
365
- 'the key lives on the ACCOUNT, not the box — a failure must stay visible after the box closes'
366
- );
367
- c.stop();
368
- });
369
-
370
- t('ready notification tells the user to RESTART Claude Desktop (#909 picker finding)', () => {
371
- const n = transitionNotification('provisioning', 'active', { envName: 'X', settingsInstalled: true });
372
- assert.ok(/restart claude desktop/i.test(n.body), n.body);
373
- });
374
-
375
- console.log('config:');
376
-
377
- t('apiBase: env override > session base > production default; slashes stripped', () => {
378
- delete process.env.OTB_DEVBOX_API_BASE;
379
- assert.equal(apiBase(null), brand.PUBLIC_ORIGIN);
380
- assert.equal(apiBase('https://staging.example.com/'), 'https://staging.example.com');
381
- process.env.OTB_DEVBOX_API_BASE = 'http://127.0.0.1:3000/';
382
- assert.equal(apiBase('https://staging.example.com'), 'http://127.0.0.1:3000');
383
- delete process.env.OTB_DEVBOX_API_BASE;
384
- });
385
-
386
- console.log('updater (#924 — in-app update check):');
387
-
388
- t('compareVersions orders X.Y.Z numerically (not lexically)', () => {
389
- assert.equal(compareVersions('1.0.5', '1.0.6'), -1);
390
- assert.equal(compareVersions('1.0.6', '1.0.5'), 1);
391
- assert.equal(compareVersions('1.0.10', '1.0.9'), 1, '10 > 9 numerically, not "10" < "9" lexically');
392
- assert.equal(compareVersions('1.2.0', '1.10.0'), -1);
393
- assert.equal(compareVersions('2.0.0', '1.9.9'), 1);
394
- assert.equal(compareVersions('1.0.0', '1.0.0'), 0);
395
- });
396
-
397
- t('isUpdateAvailable is true ONLY for a strictly-newer published release', () => {
398
- assert.equal(isUpdateAvailable('1.0.5', '1.0.6'), true);
399
- // The live-state bug this fixes: app AHEAD of the published config is NOT an update.
400
- assert.equal(isUpdateAvailable('1.0.6', '1.0.5'), false);
401
- assert.equal(isUpdateAvailable('1.0.6', '1.0.6'), false);
402
- });
403
-
404
- t('isUpdateAvailable never false-positives on a malformed version', () => {
405
- assert.equal(isUpdateAvailable('1.0.6', ''), false);
406
- assert.equal(isUpdateAvailable('1.0.6', null), false);
407
- assert.equal(isUpdateAvailable('1.0.6', 'v1.0.7'), false);
408
- assert.equal(isUpdateAvailable('1.0.6', 'latest'), false);
409
- assert.equal(isUpdateAvailable('garbage', '1.0.7'), false);
410
- });
411
-
412
- t('resolveTarget maps platform/arch to a config asset key (matching devbox-app.json)', () => {
413
- assert.equal(resolveTarget('darwin', 'arm64'), 'mac-arm64');
414
- assert.equal(resolveTarget('darwin', 'x64'), 'mac-x64');
415
- assert.equal(resolveTarget('win32', 'arm64'), 'win-arm64');
416
- assert.equal(resolveTarget('win32', 'x64'), 'win-x64');
417
- assert.equal(resolveTarget('linux', 'x64'), null, 'no installer target → no in-app offer');
418
- });
419
-
420
- t('updateBannerText is the exact copy the task specifies', () => {
421
- assert.equal(updateBannerText('1.0.6'), 'Update available — v1.0.6');
422
- });
423
-
424
- console.log('\ngds-client (backed by the vendored generated client, task 1996 / R09):');
425
-
426
- // A fake fetch that records the last request and returns a fixed response, so we
427
- // assert the generated named methods produce the exact GDS endpoints/verbs/bodies
428
- // the app has always used — and that the GdsError contract (status + raw body) holds.
429
- function recordingClient(response = { ok: true, status: 200, text: async () => '{"ok":true}' }) {
430
- const rec = {};
431
- const fetchImpl = async (url, init) => { rec.url = url; rec.init = init; return response; };
432
- const client = createGdsClient({ getToken: () => 'tok', getApiBase: () => 'https://x.test', fetchImpl });
433
- return { client, rec };
434
- }
435
-
436
- const ENDPOINTS = [
437
- ['logout', [], 'POST', '/api/bongos/auth/logout', undefined],
438
- ['pairStart', ['Lars-Mac', 'darwin'], 'POST', '/api/bongos/auth/app-pair/start', { device_name: 'Lars-Mac', platform: 'darwin' }],
439
- ['pairPoll', ['pt'], 'POST', '/api/bongos/auth/app-pair/poll', { poll_token: 'pt' }],
440
- ['boxMe', [], 'GET', '/api/bongos/box/me', undefined],
441
- ['boxEnsure', [], 'POST', '/api/bongos/box/ensure', undefined],
442
- ['boxClose', [], 'POST', '/api/bongos/box/close', undefined],
443
- ['boxHeartbeat', [], 'POST', '/api/bongos/box/heartbeat', undefined],
444
- ['boxTerminal', [], 'GET', '/api/bongos/box/terminal', undefined],
445
- ['registerSshKey', ['ssh-ed25519 AAA', 'lars'], 'POST', '/api/bongos/box/ssh-key', { public_key: 'ssh-ed25519 AAA', label: 'lars' }],
446
- ['managedSettings', [], 'GET', '/api/bongos/box/managed-settings', undefined],
447
- ['claimableTasks', [], 'GET', '/api/bongos/tasks/claimable', undefined],
448
- ['claimTask', ['1002388'], 'POST', '/api/bongos/claims', { task_id: '1002388' }],
449
- ['boxScope', [], 'GET', '/api/bongos/box/scope', undefined],
450
- ['latestRelease', [], 'GET', '/downloads/devbox/version', undefined],
451
- ];
452
-
453
- for (const [name, args, method, urlPath, body] of ENDPOINTS) {
454
- await ta(`${name} → ${method} ${urlPath} (Bearer + exact body)`, async () => {
455
- const { client, rec } = recordingClient();
456
- await client[name](...args);
457
- assert.equal(rec.init.method, method);
458
- assert.equal(rec.url, `https://x.test${urlPath}`);
459
- assert.equal(rec.init.headers.Authorization, 'Bearer tok');
460
- const sentBody = rec.init.body === undefined ? undefined : JSON.parse(rec.init.body);
461
- assert.deepEqual(sentBody, body);
462
- });
463
- }
464
-
465
- await ta('a non-2xx rejects with GdsError carrying status + the raw (flat) body', async () => {
466
- const { client } = recordingClient({ ok: false, status: 403, text: async () => '{"error":"BOX_PROVISION_DENIED","reason":"quota"}' });
467
- await assert.rejects(client.boxEnsure(), (e) => {
468
- assert.ok(e instanceof GdsError);
469
- assert.equal(e.status, 403);
470
- assert.equal(e.body.error, 'BOX_PROVISION_DENIED');
471
- assert.equal(e.body.reason, 'quota');
472
- return true;
473
- });
474
- });
475
-
476
- console.log('\ntask-picker (BV1.R100 — "What are you working on?" pick + confirm):');
477
-
478
- t('shapeClaimableTasks normalizes rows, caps to the limit, tolerates junk', () => {
479
- const payload = { claimable: [
480
- { id: 1002388, title: 'R100', kind: 'feature', est_minutes: 45 },
481
- { id: 7, kind: 'bug' },
482
- ...Array.from({ length: 10 }, (_v, i) => ({ id: 100 + i, title: `t${i}` })),
483
- ] };
484
- const rows = shapeClaimableTasks(payload, { limit: 6 });
485
- assert.equal(rows.length, 6, 'capped to limit');
486
- assert.deepEqual(rows[0], { id: '1002388', title: 'R100', kind: 'feature', estMinutes: 45 });
487
- assert.equal(rows[1].title, 'Task 7', 'missing title → fallback');
488
- assert.equal(rows[1].kind, 'bug');
489
- assert.equal(rows[1].estMinutes, null, 'missing est_minutes → null');
490
- assert.deepEqual(shapeClaimableTasks(null), [], 'no payload → empty');
491
- assert.deepEqual(shapeClaimableTasks({}), [], 'no claimable key → empty');
492
- });
493
-
494
- t('boxScopeMessage names EXACTLY the modules the box will hold (task scope) + directs to /builder-sync', () => {
495
- const m = boxScopeMessage({ task: { id: '1002388' }, scope: { scope: 'task', modules: ['dev-box', 'onboarding'], task_scope: true } });
496
- assert.ok(m.includes('task 1002388'), m);
497
- assert.ok(m.includes('dev-box, onboarding'), m);
498
- assert.ok(m.includes('nothing else'), 'the minimality is the point');
499
- assert.ok(/builder-sync/.test(m), 'directs to /builder-sync (R96)');
500
- });
501
-
502
- t('boxScopeMessage: base-only (empty modules) → nothing extra to pull', () => {
503
- const m = boxScopeMessage({ task: { id: '5' }, scope: { scope: 'base', modules: [], task_scope: true } });
504
- assert.ok(/base instance/.test(m) && /nothing extra to pull/.test(m), m);
505
- });
506
-
507
- t('boxScopeMessage: rank fallback (modules null) → generic scope + sync hint', () => {
508
- const m = boxScopeMessage({ task: { id: '9' }, scope: { scope: 'full', modules: null, task_scope: false } });
509
- assert.ok(/full scope/.test(m) && /builder-sync/.test(m), m);
510
- });
511
-
512
- t('boxScopeMessage: scope read failed (null) → claim still confirmed, still sync', () => {
513
- const m = boxScopeMessage({ task: { id: '9' }, scope: null });
514
- assert.ok(/Claimed task 9/.test(m) && /builder-sync/.test(m), m);
515
- });
516
-
517
- t('claimErrorMessage maps known refusals; falls back to server text/code', () => {
518
- assert.ok(/waiting on other work/.test(claimErrorMessage({ body: { error: 'DEPS_NOT_SHIPPED' } })));
519
- assert.ok(/in flight/.test(claimErrorMessage({ body: { error: 'session_already_has_active_claim' } })));
520
- assert.ok(/same files/.test(claimErrorMessage({ body: { error: 'touches_conflict' } })));
521
- assert.equal(claimErrorMessage({ body: { message: 'boom' } }), 'boom', 'server message wins the fallback');
522
- assert.ok(/mystery/.test(claimErrorMessage({ body: { error: 'mystery' } })), 'unknown code surfaces');
523
- });
524
-
525
- console.log(`\n${passed} passed, ${failed} failed`);
526
- process.exit(failed === 0 ? 0 : 1);
@@ -1,67 +0,0 @@
1
- // src/devbox-downloads.js — branded download redirects for the Dev Box
2
- // desktop app (#904, ADR 0045).
3
- //
4
- // The installers (~100MB Electron artifacts) cannot ride the git-based deploy
5
- // (private repo, no LFS, GitHub's 100MB hard cap), so CI publishes them as
6
- // release assets on a PUBLIC releases repo (config/devbox-app.json — same
7
- // off-repo pattern as the ADR 0029 status mirror). This module turns that
8
- // committed config into:
9
- //
10
- // GET /downloads/devbox/latest.json → { version, assets: { target: url } }
11
- // GET /downloads/devbox/<target> → 302 to the release asset
12
- //
13
- // so every URL a builder ever sees or shares stays on example.com, and a
14
- // release bump is a one-file config change that rides the normal ship flow.
15
- // Pure (config in, urls out) so tests need no server.
16
-
17
- function buildDevboxDownloads(cfg) {
18
- if (!cfg || typeof cfg !== 'object') throw new Error('devbox-downloads: missing config');
19
- const repo = String(cfg.releases_repo || '');
20
- const version = String(cfg.version || '');
21
- if (!/^[\w.-]+\/[\w.-]+$/.test(repo)) throw new Error(`devbox-downloads: bad releases_repo "${repo}"`);
22
- if (!/^\d+\.\d+\.\d+$/.test(version)) throw new Error(`devbox-downloads: bad version "${version}"`);
23
-
24
- const assets = cfg.assets || {};
25
- const aliases = cfg.aliases || {};
26
-
27
- function assetUrl(file) {
28
- // encodeURIComponent keeps a config typo from ever turning into a path
29
- // traversal on the redirect target.
30
- return `https://github.com/${repo}/releases/download/v${version}/${encodeURIComponent(file)}`;
31
- }
32
-
33
- // Resolve a request target ('mac-arm64', or an alias like 'mac') to a URL.
34
- // null = unknown target → caller 404s.
35
- function redirectFor(rawTarget) {
36
- if (typeof rawTarget !== 'string' || rawTarget.length > 32) return null;
37
- const target = rawTarget.toLowerCase().replace(/\.(dmg|exe)$/, '');
38
- const key = Object.prototype.hasOwnProperty.call(aliases, target) ? aliases[target] : target;
39
- const file = Object.prototype.hasOwnProperty.call(assets, key) ? assets[key] : null;
40
- return file ? assetUrl(file) : null;
41
- }
42
-
43
- function latestJson() {
44
- const out = { version, assets: {} };
45
- for (const [target, file] of Object.entries(assets)) {
46
- out.assets[target] = { file, url: assetUrl(file) };
47
- }
48
- return out;
49
- }
50
-
51
- // The lightweight update-check payload (#924): `{ version, assets: {target: url} }`
52
- // — the flat, download-ready form the Dev Box app polls to decide "is there a
53
- // newer release?" and the hall /settings courtesy line reads to show "latest
54
- // version: X". Distinct from latestJson()'s {file,url} shape (which the
55
- // downloads page renders). No auth — same trust level as the redirects.
56
- function versionJson() {
57
- const out = { version, assets: {} };
58
- for (const [target, file] of Object.entries(assets)) {
59
- out.assets[target] = assetUrl(file);
60
- }
61
- return out;
62
- }
63
-
64
- return { redirectFor, latestJson, versionJson, version };
65
- }
66
-
67
- module.exports = { buildDevboxDownloads };
@@ -1,75 +0,0 @@
1
- // tests/devbox_artifact_gate.mjs — task 1001463 (from idea 308).
2
- //
3
- // Guards the Actions-storage quota invariant in .github/workflows/build-devbox-app.yml:
4
- // an ungated upload of ~500MB of installers on every `claude/**` push filled the shared
5
- // storage cap and blocked ALL artifact uploads repo-wide. So every upload-artifact step
6
- // must be ref-gated to `main` AND retention-bounded — while the BUILD jobs stay ungated
7
- // (feature-branch CI feedback is the point of building there at all).
8
- //
9
- // Regex/line based like scripts/gds/lint-workflow-exec.js — the repo carries no YAML
10
- // parser in its declared deps, and a test in tests/ must stay publishable (no transitive
11
- // imports). DB-free, no network. Run: node tests/devbox_artifact_gate.mjs
12
-
13
- import { strict as assert } from 'node:assert';
14
- import fs from 'node:fs';
15
- import path from 'node:path';
16
- import { fileURLToPath } from 'node:url';
17
-
18
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
19
- const WF = path.resolve(__dirname, '..', '.github', 'workflows', 'build-devbox-app.yml');
20
- const MAX_RETENTION_DAYS = 7; // installers are republished to the releases repo; CI is not the archive
21
-
22
- let passed = 0, failed = 0;
23
- function test(name, fn) {
24
- try { fn(); console.log(` ok ${name}`); passed++; }
25
- catch (err) { console.error(` FAIL ${name}`); console.error(' ', err.message); failed++; }
26
- }
27
-
28
- const lines = fs.readFileSync(WF, 'utf8').split(/\r?\n/);
29
- // A step starts at a `- key:` list item; its block runs to the next list item at any indent.
30
- const stepStarts = lines.reduce((acc, l, i) => (/^\s+-\s+\S/.test(l) ? [...acc, i] : acc), []);
31
- function stepBlockAt(lineIdx) {
32
- const start = stepStarts.filter((i) => i <= lineIdx).pop();
33
- const end = stepStarts.find((i) => i > lineIdx) ?? lines.length;
34
- return lines.slice(start, end);
35
- }
36
-
37
- const uploadLines = lines.reduce(
38
- (acc, l, i) => (l.includes('uses: actions/upload-artifact@') ? [...acc, i] : acc), []);
39
-
40
- test('both installer uploads are still present (mac dmg + win exe)', () => {
41
- assert.equal(uploadLines.length, 2, `expected 2 upload-artifact steps, found ${uploadLines.length}`);
42
- });
43
-
44
- test('every upload is ref-gated to main (quota) — ref-based so workflow_dispatch on main uploads', () => {
45
- for (const i of uploadLines) {
46
- const block = stepBlockAt(i).join('\n');
47
- const m = /^\s+if:\s*(.+?)\s*$/m.exec(block);
48
- assert.ok(m, `upload step at line ${i + 1} has no if: gate — an ungated upload refills the quota`);
49
- assert.equal(m[1], "github.ref == 'refs/heads/main'",
50
- `upload step at line ${i + 1} gate must be the trusted-ref test, not an event_name test (got: ${m[1]})`);
51
- }
52
- });
53
-
54
- test('every upload is retention-bounded', () => {
55
- for (const i of uploadLines) {
56
- const block = stepBlockAt(i).join('\n');
57
- const m = /^\s+retention-days:\s*(\d+)\s*$/m.exec(block);
58
- assert.ok(m, `upload step at line ${i + 1} has no retention-days — GitHub defaults to 90`);
59
- const days = Number(m[1]);
60
- assert.ok(days >= 1 && days <= MAX_RETENTION_DAYS,
61
- `upload step at line ${i + 1} retention-days=${days}, want 1..${MAX_RETENTION_DAYS}`);
62
- }
63
- });
64
-
65
- test('the BUILD jobs stay ungated — feature branches must still compile (CI feedback)', () => {
66
- const jobLevelIf = lines.filter((l) => /^ {4}if:/.test(l));
67
- assert.deepEqual(jobLevelIf, [], `a job-level if: would stop feature branches from building: ${jobLevelIf}`);
68
- });
69
-
70
- test('claude/** push trigger intact — the gate is on upload, not on running the workflow', () => {
71
- assert.ok(lines.some((l) => /^\s+-\s+'claude\/\*\*'\s*$/.test(l)), "push trigger lost 'claude/**'");
72
- });
73
-
74
- console.log(`\ndevbox_artifact_gate: ${passed} passed, ${failed} failed`);
75
- process.exit(failed ? 1 : 0);