@promus/cli 0.24.17

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 (96) hide show
  1. package/README.md +18 -0
  2. package/bin/promus +33 -0
  3. package/package.json +51 -0
  4. package/src/commands/_agents.ts +14 -0
  5. package/src/commands/_inft-ref.ts +43 -0
  6. package/src/commands/_unlock.ts +74 -0
  7. package/src/commands/admin-autotopup-tick.ts +73 -0
  8. package/src/commands/admin.test.ts +34 -0
  9. package/src/commands/admin.ts +32 -0
  10. package/src/commands/balance.test.ts +10 -0
  11. package/src/commands/balance.ts +112 -0
  12. package/src/commands/chat-sandbox.tsx +520 -0
  13. package/src/commands/chat-telegram.ts +398 -0
  14. package/src/commands/chat.tsx +1916 -0
  15. package/src/commands/deploy.ts +204 -0
  16. package/src/commands/drain.ts +90 -0
  17. package/src/commands/gateway-logs.ts +47 -0
  18. package/src/commands/gateway-run.ts +54 -0
  19. package/src/commands/gateway-start.ts +218 -0
  20. package/src/commands/gateway-status.ts +88 -0
  21. package/src/commands/gateway-stop.ts +133 -0
  22. package/src/commands/gateway.ts +101 -0
  23. package/src/commands/init/cost.test.ts +169 -0
  24. package/src/commands/init/cost.ts +154 -0
  25. package/src/commands/init/funding-gate.ts +67 -0
  26. package/src/commands/init/model-picker.ts +81 -0
  27. package/src/commands/init/operator-picker.ts +263 -0
  28. package/src/commands/init/resume.ts +136 -0
  29. package/src/commands/init/sandbox-provision.test.ts +497 -0
  30. package/src/commands/init/sandbox-provision.ts +1177 -0
  31. package/src/commands/init/telegram-step.ts +229 -0
  32. package/src/commands/init/wizard-state.ts +95 -0
  33. package/src/commands/init.ts +612 -0
  34. package/src/commands/inspect.ts +529 -0
  35. package/src/commands/ledger.ts +176 -0
  36. package/src/commands/logs.ts +86 -0
  37. package/src/commands/migrate-keystore.ts +155 -0
  38. package/src/commands/model.ts +48 -0
  39. package/src/commands/pairing-approve.ts +114 -0
  40. package/src/commands/pairing-clear.ts +42 -0
  41. package/src/commands/pairing-list.ts +58 -0
  42. package/src/commands/pairing-revoke.ts +52 -0
  43. package/src/commands/pairing.test.ts +88 -0
  44. package/src/commands/pairing.ts +81 -0
  45. package/src/commands/pause.ts +99 -0
  46. package/src/commands/profile.ts +184 -0
  47. package/src/commands/restore.ts +221 -0
  48. package/src/commands/resume.ts +181 -0
  49. package/src/commands/status.ts +119 -0
  50. package/src/commands/sync.ts +147 -0
  51. package/src/commands/telegram-remove.ts +65 -0
  52. package/src/commands/telegram-setup.ts +74 -0
  53. package/src/commands/telegram-status.ts +89 -0
  54. package/src/commands/telegram.test.ts +50 -0
  55. package/src/commands/telegram.ts +44 -0
  56. package/src/commands/topup.ts +303 -0
  57. package/src/commands/transfer.test.ts +111 -0
  58. package/src/commands/transfer.ts +520 -0
  59. package/src/commands/upgrade.test.ts +137 -0
  60. package/src/commands/upgrade.ts +690 -0
  61. package/src/config/load.ts +35 -0
  62. package/src/config/render.test.ts +96 -0
  63. package/src/config/render.ts +110 -0
  64. package/src/index.ts +378 -0
  65. package/src/sandbox/client.test.ts +251 -0
  66. package/src/sandbox/client.ts +424 -0
  67. package/src/ui/app.tsx +677 -0
  68. package/src/ui/approval-summary.test.ts +154 -0
  69. package/src/ui/approval-summary.ts +34 -0
  70. package/src/ui/markdown-parse.ts +219 -0
  71. package/src/ui/markdown.test.ts +146 -0
  72. package/src/ui/markdown.tsx +37 -0
  73. package/src/ui/state.test.ts +74 -0
  74. package/src/ui/state.ts +198 -0
  75. package/src/util/bootstrap-mode.test.ts +40 -0
  76. package/src/util/bootstrap-mode.ts +25 -0
  77. package/src/util/bootstrap-progress-box.test.ts +190 -0
  78. package/src/util/bootstrap-progress-box.ts +378 -0
  79. package/src/util/brain-secrets.ts +96 -0
  80. package/src/util/cli-version.ts +28 -0
  81. package/src/util/format.test.ts +16 -0
  82. package/src/util/format.ts +11 -0
  83. package/src/util/gateway-spawn.test.ts +86 -0
  84. package/src/util/gateway-spawn.ts +128 -0
  85. package/src/util/gateway-version.test.ts +113 -0
  86. package/src/util/gateway-version.ts +154 -0
  87. package/src/util/github-releases.test.ts +116 -0
  88. package/src/util/github-releases.ts +79 -0
  89. package/src/util/profile-key.test.ts +60 -0
  90. package/src/util/profile-key.ts +25 -0
  91. package/src/util/ref-resolver.test.ts +77 -0
  92. package/src/util/ref-resolver.ts +55 -0
  93. package/src/util/silence-console.test.ts +53 -0
  94. package/src/util/silence-console.ts +40 -0
  95. package/src/util/telegram-secrets.test.ts +227 -0
  96. package/src/util/telegram-secrets.ts +223 -0
@@ -0,0 +1,497 @@
1
+ import { afterEach, describe, expect, test } from 'bun:test'
2
+ import type { SandboxRecord } from '@promus/core'
3
+ import {
4
+ type ResumeArchivedSandboxOpts,
5
+ type SandboxProvisionOpts,
6
+ createSandboxWithOrphanRetry,
7
+ ensureSandboxArchived,
8
+ ensureSandboxStarted,
9
+ extractBootstrapProgressLine,
10
+ pickPermissionMode,
11
+ resolveHandoffPlugins,
12
+ } from './sandbox-provision'
13
+
14
+ describe('pickPermissionMode', () => {
15
+ const original = process.env.PROMUS_PERMISSIONS
16
+
17
+ function unset(): void {
18
+ process.env.PROMUS_PERMISSIONS = undefined
19
+ }
20
+
21
+ afterEach(() => {
22
+ if (original === undefined) unset()
23
+ else process.env.PROMUS_PERMISSIONS = original
24
+ })
25
+
26
+ test('default is off when env unset', () => {
27
+ unset()
28
+ expect(pickPermissionMode()).toBe('off')
29
+ })
30
+
31
+ test('accepts prompt + strict + off, case-insensitive, trimmed', () => {
32
+ process.env.PROMUS_PERMISSIONS = 'prompt'
33
+ expect(pickPermissionMode()).toBe('prompt')
34
+ process.env.PROMUS_PERMISSIONS = ' STRICT '
35
+ expect(pickPermissionMode()).toBe('strict')
36
+ process.env.PROMUS_PERMISSIONS = 'Off'
37
+ expect(pickPermissionMode()).toBe('off')
38
+ })
39
+
40
+ test('falls back to off on unknown value (no crash)', () => {
41
+ process.env.PROMUS_PERMISSIONS = 'yolo'
42
+ expect(pickPermissionMode()).toBe('off')
43
+ process.env.PROMUS_PERMISSIONS = ''
44
+ expect(pickPermissionMode()).toBe('off')
45
+ })
46
+ })
47
+
48
+ describe('createSandboxWithOrphanRetry', () => {
49
+ function rec(id: string, name: string): SandboxRecord {
50
+ return { id, name, state: 'started' } as unknown as SandboxRecord
51
+ }
52
+
53
+ test('passes through on first-try success', async () => {
54
+ let createCalls = 0
55
+ let listCalls = 0
56
+ const provider = {
57
+ createSandbox: async () => {
58
+ createCalls++
59
+ return rec('sb-1', 'phantom')
60
+ },
61
+ listSandboxes: async () => {
62
+ listCalls++
63
+ return []
64
+ },
65
+ deleteSandbox: async () => {},
66
+ }
67
+ const r = await createSandboxWithOrphanRetry(provider, 'snap', 'phantom', () => {})
68
+ expect(r.id).toBe('sb-1')
69
+ expect(createCalls).toBe(1)
70
+ expect(listCalls).toBe(0)
71
+ })
72
+
73
+ test('on 409 with name collision: deletes orphan, retries, succeeds', async () => {
74
+ let createCalls = 0
75
+ const deleted: string[] = []
76
+ const provider = {
77
+ createSandbox: async () => {
78
+ createCalls++
79
+ if (createCalls === 1) {
80
+ throw new Error(
81
+ 'POST /api/sandbox: 409 {"message":"Sandbox with name phantom already exists"}',
82
+ )
83
+ }
84
+ return rec('sb-2', 'phantom')
85
+ },
86
+ listSandboxes: async () => [rec('sb-orphan', 'phantom'), rec('sb-other', 'enigma')],
87
+ deleteSandbox: async (id: string) => {
88
+ deleted.push(id)
89
+ },
90
+ }
91
+ const msgs: string[] = []
92
+ const r = await createSandboxWithOrphanRetry(provider, 'snap', 'phantom', m => msgs.push(m))
93
+ expect(r.id).toBe('sb-2')
94
+ expect(createCalls).toBe(2)
95
+ expect(deleted).toEqual(['sb-orphan'])
96
+ expect(msgs.some(m => m.includes('cleaning up'))).toBe(true)
97
+ })
98
+
99
+ test('non-409 errors propagate without cleanup', async () => {
100
+ let listCalls = 0
101
+ const provider = {
102
+ createSandbox: async () => {
103
+ throw new Error('POST /api/sandbox: 503 service unavailable')
104
+ },
105
+ listSandboxes: async () => {
106
+ listCalls++
107
+ return []
108
+ },
109
+ deleteSandbox: async () => {},
110
+ }
111
+ await expect(
112
+ createSandboxWithOrphanRetry(provider, 'snap', 'phantom', () => {}),
113
+ ).rejects.toThrow(/503/)
114
+ expect(listCalls).toBe(0)
115
+ })
116
+
117
+ test('409 without a name (anonymous create) propagates without cleanup', async () => {
118
+ let listCalls = 0
119
+ const provider = {
120
+ createSandbox: async () => {
121
+ throw new Error('POST /api/sandbox: 409 {"message":"already exists"}')
122
+ },
123
+ listSandboxes: async () => {
124
+ listCalls++
125
+ return []
126
+ },
127
+ deleteSandbox: async () => {},
128
+ }
129
+ await expect(
130
+ createSandboxWithOrphanRetry(provider, 'snap', undefined, () => {}),
131
+ ).rejects.toThrow(/409/)
132
+ expect(listCalls).toBe(0)
133
+ })
134
+
135
+ test('409 with empty list: re-throws original error (no orphan to clean)', async () => {
136
+ let createCalls = 0
137
+ const provider = {
138
+ createSandbox: async () => {
139
+ createCalls++
140
+ throw new Error('POST /api/sandbox: 409 already exists')
141
+ },
142
+ listSandboxes: async () => [rec('sb-other', 'enigma')], // no phantom
143
+ deleteSandbox: async () => {},
144
+ }
145
+ await expect(
146
+ createSandboxWithOrphanRetry(provider, 'snap', 'phantom', () => {}),
147
+ ).rejects.toThrow(/409/)
148
+ expect(createCalls).toBe(1)
149
+ })
150
+ })
151
+
152
+ describe('ensureSandboxStarted', () => {
153
+ function fakeProvider(stateSequence: Array<SandboxRecord['state']>) {
154
+ let i = 0
155
+ let starts = 0
156
+ const provider = {
157
+ getSandbox: async (id: string) =>
158
+ ({ id, state: stateSequence[Math.min(i++, stateSequence.length - 1)] }) as SandboxRecord,
159
+ startSandbox: async () => {
160
+ starts += 1
161
+ },
162
+ }
163
+ return { provider, startsCalled: () => starts }
164
+ }
165
+
166
+ test('no-op when sandbox already started', async () => {
167
+ const { provider, startsCalled } = fakeProvider(['started'])
168
+ const r = await ensureSandboxStarted(provider as never, 'sb-1')
169
+ expect(r.alreadyStarted).toBe(true)
170
+ expect(r.initialState).toBe('started')
171
+ expect(r.finalState).toBe('started')
172
+ expect(startsCalled()).toBe(0)
173
+ })
174
+
175
+ test('throws on error state without calling start', async () => {
176
+ const { provider, startsCalled } = fakeProvider(['error'])
177
+ await expect(ensureSandboxStarted(provider as never, 'sb-2')).rejects.toThrow(/error state/)
178
+ expect(startsCalled()).toBe(0)
179
+ })
180
+
181
+ test('stopped → started: calls /start, polls until state flips', async () => {
182
+ const { provider, startsCalled } = fakeProvider(['stopped', 'starting', 'started'])
183
+ const r = await ensureSandboxStarted(provider as never, 'sb-3', { intervalMs: 1 })
184
+ expect(r.alreadyStarted).toBe(false)
185
+ expect(r.initialState).toBe('stopped')
186
+ expect(r.finalState).toBe('started')
187
+ expect(startsCalled()).toBe(1)
188
+ })
189
+
190
+ test('archived → restoring → started: calls /start, accepts long path', async () => {
191
+ const { provider, startsCalled } = fakeProvider([
192
+ 'archived',
193
+ 'restoring',
194
+ 'restoring',
195
+ 'starting',
196
+ 'started',
197
+ ])
198
+ const msgs: string[] = []
199
+ const r = await ensureSandboxStarted(provider as never, 'sb-4', {
200
+ intervalMs: 1,
201
+ onProgress: m => msgs.push(m),
202
+ })
203
+ expect(r.alreadyStarted).toBe(false)
204
+ expect(r.initialState).toBe('archived')
205
+ expect(r.finalState).toBe('started')
206
+ expect(startsCalled()).toBe(1)
207
+ // Progress should mention the friendly "archived" wording at least once
208
+ expect(msgs.some(m => m.includes('archived'))).toBe(true)
209
+ })
210
+
211
+ test('transient state (restoring): does NOT re-issue /start', async () => {
212
+ const { provider, startsCalled } = fakeProvider(['restoring', 'restoring', 'started'])
213
+ const r = await ensureSandboxStarted(provider as never, 'sb-5', { intervalMs: 1 })
214
+ expect(r.finalState).toBe('started')
215
+ // initial state was already transient → don't double-fire /start
216
+ expect(startsCalled()).toBe(0)
217
+ })
218
+
219
+ test('throws if deadline expires without reaching started', async () => {
220
+ const { provider } = fakeProvider(['stopped', 'starting', 'starting', 'starting'])
221
+ await expect(
222
+ ensureSandboxStarted(provider as never, 'sb-6', {
223
+ intervalMs: 1,
224
+ stoppedDeadlineMs: 50,
225
+ }),
226
+ ).rejects.toThrow(/did not reach started/)
227
+ })
228
+
229
+ test('throws if state transitions to error mid-poll', async () => {
230
+ const { provider } = fakeProvider(['stopped', 'starting', 'error'])
231
+ await expect(
232
+ ensureSandboxStarted(provider as never, 'sb-7', { intervalMs: 1 }),
233
+ ).rejects.toThrow(/error state during resume/)
234
+ })
235
+ })
236
+
237
+ describe('ensureSandboxArchived', () => {
238
+ function fakeProvider(stateSequence: Array<SandboxRecord['state']>) {
239
+ let i = 0
240
+ let stops = 0
241
+ let archives = 0
242
+ const provider = {
243
+ getSandbox: async (id: string) =>
244
+ ({ id, state: stateSequence[Math.min(i++, stateSequence.length - 1)] }) as SandboxRecord,
245
+ stopSandbox: async () => {
246
+ stops += 1
247
+ },
248
+ archiveSandbox: async () => {
249
+ archives += 1
250
+ },
251
+ }
252
+ return { provider, archivesCalled: () => archives, stopsCalled: () => stops }
253
+ }
254
+
255
+ test('no-op when sandbox already archived', async () => {
256
+ const { provider, archivesCalled, stopsCalled } = fakeProvider(['archived'])
257
+ const r = await ensureSandboxArchived(provider as never, 'sb-a1')
258
+ expect(r.alreadyArchived).toBe(true)
259
+ expect(r.initialState).toBe('archived')
260
+ expect(r.finalState).toBe('archived')
261
+ expect(r.stoppedFirst).toBe(false)
262
+ expect(archivesCalled()).toBe(0)
263
+ expect(stopsCalled()).toBe(0)
264
+ })
265
+
266
+ test('throws on error state without calling stop or archive', async () => {
267
+ const { provider, archivesCalled, stopsCalled } = fakeProvider(['error'])
268
+ await expect(ensureSandboxArchived(provider as never, 'sb-a2')).rejects.toThrow(/error state/)
269
+ expect(archivesCalled()).toBe(0)
270
+ expect(stopsCalled()).toBe(0)
271
+ })
272
+
273
+ test('stopped → archiving → archived: skips stop, calls /archive', async () => {
274
+ // Phase 1 reads state once (stopped — no stop needed).
275
+ // Phase 2 reads state, sees stopped, calls archive.
276
+ // Then poll loop reads archiving → archived.
277
+ const { provider, archivesCalled, stopsCalled } = fakeProvider([
278
+ 'stopped',
279
+ 'stopped',
280
+ 'archiving',
281
+ 'archived',
282
+ ])
283
+ const r = await ensureSandboxArchived(provider as never, 'sb-a3', { intervalMs: 1 })
284
+ expect(r.alreadyArchived).toBe(false)
285
+ expect(r.initialState).toBe('stopped')
286
+ expect(r.finalState).toBe('archived')
287
+ expect(r.stoppedFirst).toBe(false)
288
+ expect(stopsCalled()).toBe(0)
289
+ expect(archivesCalled()).toBe(1)
290
+ })
291
+
292
+ test('started → stopping → stopped → archiving → archived: two-phase', async () => {
293
+ const { provider, archivesCalled, stopsCalled } = fakeProvider([
294
+ 'started',
295
+ 'stopping',
296
+ 'stopped',
297
+ 'stopped',
298
+ 'archiving',
299
+ 'archived',
300
+ ])
301
+ const r = await ensureSandboxArchived(provider as never, 'sb-a4', { intervalMs: 1 })
302
+ expect(r.initialState).toBe('started')
303
+ expect(r.finalState).toBe('archived')
304
+ expect(r.stoppedFirst).toBe(true)
305
+ expect(stopsCalled()).toBe(1)
306
+ expect(archivesCalled()).toBe(1)
307
+ })
308
+
309
+ test('transient state (archiving) on entry: does NOT re-issue /archive', async () => {
310
+ const { provider, archivesCalled, stopsCalled } = fakeProvider([
311
+ 'archiving',
312
+ 'archiving',
313
+ 'archived',
314
+ ])
315
+ const r = await ensureSandboxArchived(provider as never, 'sb-a5', { intervalMs: 1 })
316
+ expect(r.finalState).toBe('archived')
317
+ expect(stopsCalled()).toBe(0)
318
+ expect(archivesCalled()).toBe(0)
319
+ })
320
+
321
+ test('throws if archive deadline expires', async () => {
322
+ const { provider } = fakeProvider(['stopped', 'stopped', 'archiving', 'archiving', 'archiving'])
323
+ await expect(
324
+ ensureSandboxArchived(provider as never, 'sb-a6', { intervalMs: 1, deadlineMs: 30 }),
325
+ ).rejects.toThrow(/did not reach archived/)
326
+ })
327
+
328
+ test('throws if stop deadline expires', async () => {
329
+ const { provider } = fakeProvider(['started', 'stopping', 'stopping', 'stopping'])
330
+ await expect(
331
+ ensureSandboxArchived(provider as never, 'sb-a7', { intervalMs: 1, deadlineMs: 30 }),
332
+ ).rejects.toThrow(/did not reach stopped/)
333
+ })
334
+
335
+ test('throws if state transitions to error mid-archive', async () => {
336
+ const { provider } = fakeProvider(['stopped', 'stopped', 'archiving', 'error'])
337
+ await expect(
338
+ ensureSandboxArchived(provider as never, 'sb-a8', { intervalMs: 1 }),
339
+ ).rejects.toThrow(/error during archive/)
340
+ })
341
+
342
+ test('throws if state transitions to error mid-stop', async () => {
343
+ const { provider } = fakeProvider(['started', 'stopping', 'error'])
344
+ await expect(
345
+ ensureSandboxArchived(provider as never, 'sb-a9', { intervalMs: 1 }),
346
+ ).rejects.toThrow(/error during stop/)
347
+ })
348
+ })
349
+
350
+ describe('ResumeArchivedSandboxOpts shape', () => {
351
+ // Regression guard for the v0.19.18 fix: every pause→resume cycle on
352
+ // promus resume must be able to ship telegram secrets to the restored
353
+ // gateway, otherwise the TG listener silently drops on resume. This
354
+ // test fails to compile if anyone removes the telegramSecrets field
355
+ // from the interface.
356
+ test('telegramSecrets field is part of the public interface', () => {
357
+ type WithTg = Required<Pick<ResumeArchivedSandboxOpts, 'telegramSecrets'>>
358
+ const sample: WithTg['telegramSecrets'] = {
359
+ botToken: '123:abcdef',
360
+ allowedUserIds: [42],
361
+ }
362
+ expect(sample.botToken).toBe('123:abcdef')
363
+ expect(sample.allowedUserIds).toEqual([42])
364
+ })
365
+ })
366
+
367
+ describe('SandboxProvisionOpts shape (v0.21.19 telegramSecrets plumbing)', () => {
368
+ // Regression guard for Bug 1 in feedback-reprovision-skips-tg-and-probe-bug.
369
+ // Before v0.21.19, runReprovisionUpgrade + runDeploy both called
370
+ // runSandboxProvision without passing telegramSecrets, so fresh containers
371
+ // booted TG-less. The reprovision path is the recovery sledgehammer
372
+ // operators reach for after canary cycles or stale-UUID 403s; it MUST
373
+ // produce a fully working agent, not a half-configured one missing TG.
374
+ // This test compiles only if the field is still on the opts.
375
+ test('telegramSecrets field is part of the public interface', () => {
376
+ type WithTg = Required<Pick<SandboxProvisionOpts, 'telegramSecrets'>>
377
+ const sample: WithTg['telegramSecrets'] = {
378
+ botToken: '456:zyxwvu',
379
+ allowedUserIds: [99],
380
+ }
381
+ expect(sample.botToken).toBe('456:zyxwvu')
382
+ expect(sample.allowedUserIds).toEqual([99])
383
+ })
384
+ })
385
+
386
+ describe('extractBootstrapProgressLine (v0.24.4 STAGE-aware surfacing)', () => {
387
+ // Bug closed by v0.24.4 Bundle 5: operator stared at "launching bootstrap"
388
+ // spinner for 30s with no updates because (a) the poll loop only surfaced
389
+ // every 6th tick (~30s) and (b) the surface line was whatever raw `[$(date)
390
+ // ...]` log entry happened to land in `tail -n 1`. Now the bootstrap script
391
+ // emits explicit `STAGE: ...` markers and this helper prefers them.
392
+
393
+ test('prefers the last STAGE marker over raw tail lines (last-wins, prefix stripped)', () => {
394
+ const tail = [
395
+ '[2026-05-15T10:00:01Z] bootstrap-start (mode=npm)',
396
+ 'STAGE: updating package index',
397
+ '[apt update attempt 1/3]',
398
+ 'STAGE: installing system deps (build-essential, curl, git, xvfb)',
399
+ '[apt install attempt 1/3]',
400
+ 'STAGE: installing bun runtime',
401
+ 'curl: downloading bun...',
402
+ ].join('\n')
403
+ expect(extractBootstrapProgressLine(tail)).toBe('installing bun runtime')
404
+ })
405
+
406
+ test('returns the most recent STAGE even if non-STAGE lines follow it', () => {
407
+ const tail = [
408
+ 'STAGE: installing chrome for browser tools',
409
+ '[browser deps]',
410
+ 'Downloading Chromium 119...',
411
+ 'progress 42%',
412
+ ].join('\n')
413
+ // Should still pick the STAGE line — operator cares about the stage,
414
+ // not which sub-step within the stage is mid-stream.
415
+ expect(extractBootstrapProgressLine(tail)).toBe('installing chrome for browser tools')
416
+ })
417
+
418
+ test('falls back to filter/pop when no STAGE marker is present (older gateway)', () => {
419
+ const tail = [
420
+ '[2026-05-15T10:00:01Z] bootstrap-start (mode=npm)',
421
+ ' sandbox=sb-abc',
422
+ '[apt update attempt 1/3]',
423
+ 'Reading package lists... Done',
424
+ ].join('\n')
425
+ expect(extractBootstrapProgressLine(tail)).toBe('Reading package lists... Done')
426
+ })
427
+
428
+ test('filters bash setlocale warnings out of the fallback', () => {
429
+ const tail = [
430
+ 'STAGE-less old log',
431
+ 'bash: warning: setlocale: LC_ALL: cannot change locale',
432
+ 'real progress here',
433
+ 'bash: warning: setlocale: LC_ALL: cannot change locale',
434
+ ].join('\n')
435
+ expect(extractBootstrapProgressLine(tail)).toBe('real progress here')
436
+ })
437
+
438
+ test('strips exactly one `STAGE: ` prefix (does not double-strip)', () => {
439
+ const tail = 'STAGE: STAGE: nested'
440
+ expect(extractBootstrapProgressLine(tail)).toBe('STAGE: nested')
441
+ })
442
+
443
+ test('returns undefined for empty tail', () => {
444
+ expect(extractBootstrapProgressLine('')).toBeUndefined()
445
+ expect(extractBootstrapProgressLine(' \n \n')).toBeUndefined()
446
+ })
447
+
448
+ test('STAGE markers from v0.24.4 bootstrap script match the documented set', () => {
449
+ // Lock the canonical labels so a future bootstrap.ts rename surfaces
450
+ // here. The first 6 are the steps the operator sees in order; the last
451
+ // is the success terminator the poll loop detects via DONE_MARKER too.
452
+ const stages = [
453
+ 'updating package index',
454
+ 'installing system deps (build-essential, curl, git, xvfb)',
455
+ 'installing bun runtime',
456
+ 'installing promus (0.24.4)',
457
+ 'installing chrome for browser tools',
458
+ 'starting harness daemon',
459
+ 'harness ready',
460
+ ]
461
+ for (const stage of stages) {
462
+ const tail = ['some prior line', `STAGE: ${stage}`, 'noise after'].join('\n')
463
+ expect(extractBootstrapProgressLine(tail)).toBe(stage)
464
+ }
465
+ })
466
+ })
467
+
468
+ describe('resolveHandoffPlugins (v0.24.5 auto-include telegram)', () => {
469
+ test('no caller list + no TG secrets → safe default', () => {
470
+ expect(resolveHandoffPlugins(undefined, false)).toEqual(['system', 'comms', 'onchain'])
471
+ })
472
+
473
+ test('no caller list + TG secrets → default plus telegram', () => {
474
+ expect(resolveHandoffPlugins(undefined, true)).toEqual([
475
+ 'system',
476
+ 'comms',
477
+ 'onchain',
478
+ 'telegram',
479
+ ])
480
+ })
481
+
482
+ test('caller list already has telegram + TG secrets → unchanged', () => {
483
+ const caller = ['system', 'telegram'] as const
484
+ const out = resolveHandoffPlugins([...caller], true)
485
+ expect(out).toEqual(['system', 'telegram'])
486
+ })
487
+
488
+ test('caller list missing telegram + TG secrets → appends telegram', () => {
489
+ const out = resolveHandoffPlugins(['system', 'onchain'], true)
490
+ expect(out).toEqual(['system', 'onchain', 'telegram'])
491
+ })
492
+
493
+ test('caller list missing telegram + no TG secrets → unchanged (no implicit add)', () => {
494
+ const out = resolveHandoffPlugins(['system', 'onchain'], false)
495
+ expect(out).toEqual(['system', 'onchain'])
496
+ })
497
+ })