@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,690 @@
1
+ import { cancel, confirm, intro, isCancel, log, note, outro, spinner } from '@clack/prompts'
2
+ import {
3
+ type PromusNetwork,
4
+ type PromusPlugin,
5
+ type OperatorSigner,
6
+ SANDBOX_PROVIDER_URL_GALILEO,
7
+ SandboxProviderClient,
8
+ iNFTAgentId,
9
+ } from '@promus/core'
10
+ import {
11
+ type BootstrapMode,
12
+ UPGRADE_DONE_MARKER,
13
+ UPGRADE_FAIL_KEYWORDS,
14
+ UPGRADE_FAIL_MARKER,
15
+ UPGRADE_PROGRESS_LOG,
16
+ UPGRADE_SUCCESS_MARKER_PREFIX,
17
+ buildUpgradeScript,
18
+ } from '@promus/gateway'
19
+ import type { Address, Hex } from 'viem'
20
+ import { findAndLoadConfig } from '../config/load'
21
+ import { writeConfigTs } from '../config/render'
22
+ import { SandboxClient } from '../sandbox/client'
23
+ import { BootstrapProgressController } from '../util/bootstrap-progress-box'
24
+ import { resolveCliVersion } from '../util/cli-version'
25
+ import { checkTagExists } from '../util/github-releases'
26
+ import { loadProfileScopeKeyHex } from '../util/profile-key'
27
+ import {
28
+ PROMUS_REPO_URL,
29
+ LATEST_KEYWORD,
30
+ type ResolvedRef,
31
+ expectedVersionFromRef,
32
+ formatResolvedRef,
33
+ resolvePromusRef,
34
+ } from '../util/ref-resolver'
35
+ import { loadTelegramHandoffSecrets } from '../util/telegram-secrets'
36
+ import { loadOrPickOperatorSigner } from './init/operator-picker'
37
+ import {
38
+ ensureSandboxStarted,
39
+ extractExecOutput,
40
+ handoffAgentToGateway,
41
+ makeExecRead,
42
+ preflightProviderDeposit,
43
+ publishSandboxEndpoint,
44
+ runSandboxProvision,
45
+ unlockAgentKeystore,
46
+ } from './init/sandbox-provision'
47
+
48
+ export type UpgradeMode = 'in-place' | 'reprovision'
49
+
50
+ /**
51
+ * Parse the argv tail (everything AFTER the `upgrade` subcommand token) into
52
+ * {@link UpgradeOpts}. `--ref <val>` takes priority. Otherwise the first
53
+ * non-flag arg becomes the ref, so `promus upgrade latest` and
54
+ * `promus upgrade v0.17.8` work without `--ref`. Empty tail → undefined ref →
55
+ * command flow defaults to `latest` via GitHub API.
56
+ */
57
+ export function parseUpgradeArgs(tail: readonly string[]): UpgradeOpts {
58
+ const refIdx = tail.indexOf('--ref')
59
+ const flagRef = refIdx >= 0 ? tail[refIdx + 1] : undefined
60
+ const positionalRef = tail.find(a => !a.startsWith('-') && a !== flagRef)
61
+ return {
62
+ ref: flagRef ?? positionalRef,
63
+ yes: tail.includes('--yes') || tail.includes('-y'),
64
+ reprovision: tail.includes('--reprovision'),
65
+ }
66
+ }
67
+
68
+ interface UpgradeOpts {
69
+ ref?: string
70
+ yes?: boolean
71
+ /**
72
+ * Opt into the heavy container-swap path. Default (false) is in-place. We
73
+ * default to in-place because promus's harness layer is unsealed
74
+ * (`feedback-promus-is-unsealed-currently.md`), so a fresh container buys
75
+ * no real attestation freshness. Heavy mode is reserved for the future
76
+ * when sealed mode + image-hash attestation are wired up.
77
+ */
78
+ reprovision?: boolean
79
+ }
80
+
81
+ /**
82
+ * `promus upgrade`: roll the sandbox harness to a new git ref while preserving
83
+ * agent identity + memory.
84
+ *
85
+ * Default = in-place: `git fetch + checkout + bun install + harness restart`
86
+ * inside the existing Daytona container. ~30-60s downtime, $0 testnet cost,
87
+ * same sandbox UUID + endpoint.
88
+ *
89
+ * `--reprovision` (opt-in) = container swap: delete old sandbox + provision
90
+ * fresh + ECIES-handoff + publish new endpoint. ~2-5 min, ~0.9 0G testnet
91
+ * provider deposit. Reserved for sealed mode where attestation freshness is
92
+ * a load-bearing primitive.
93
+ *
94
+ * Both paths preserve: iNFT, agent EOA, encrypted keystore on 0G Storage,
95
+ * memory anchored on chain, 0G Compute ledger.
96
+ */
97
+ export async function runUpgrade(opts: UpgradeOpts = {}): Promise<void> {
98
+ intro('promus upgrade')
99
+
100
+ const loaded = await findAndLoadConfig()
101
+ if (!loaded) {
102
+ cancel('No promus.config.ts found.')
103
+ return
104
+ }
105
+ const { config } = loaded
106
+ if (!config.identity.iNFT || !config.identity.agent) {
107
+ cancel('Config has no iNFT or agent.')
108
+ return
109
+ }
110
+ if (config.deployTarget !== 'sandbox' || !config.sandbox?.id || !config.sandbox.endpoint) {
111
+ cancel(
112
+ `Agent is not deployed to a sandbox. (deployTarget=${config.deployTarget ?? 'local'}). Run \`promus deploy\` first.`,
113
+ )
114
+ return
115
+ }
116
+ if (!config.brain.provider) {
117
+ cancel('Brain provider not configured. Run `promus model` first.')
118
+ return
119
+ }
120
+
121
+ const mode: UpgradeMode = opts.reprovision ? 'reprovision' : 'in-place'
122
+
123
+ let resolved: ResolvedRef
124
+ try {
125
+ resolved = await resolvePromusRef(opts.ref)
126
+ } catch (e) {
127
+ cancel(
128
+ `could not resolve ref: ${(e as Error).message.slice(0, 200)}\nGitHub API may be unreachable. Pin a tag with \`--ref vX.Y.Z\` to skip the lookup.`,
129
+ )
130
+ return
131
+ }
132
+
133
+ // Pre-flight tag visibility — closes the silent-success bug from 2026-05-03
134
+ // (see upgrade-silent-success-bug.md). Skip when we just resolved from
135
+ // `latest` (the API IS the source of truth) or for branch/SHA refs.
136
+ if (resolved.isTag && !resolved.resolvedFromLatest) {
137
+ try {
138
+ const exists = await checkTagExists(PROMUS_REPO_URL, resolved.ref)
139
+ if (!exists) {
140
+ cancel(
141
+ `Tag ${resolved.ref} is not visible on the remote yet (CI may still be propagating).\nTry again in 30s, or run \`promus upgrade ${LATEST_KEYWORD}\` to pick the most recent published release.`,
142
+ )
143
+ return
144
+ }
145
+ } catch (e) {
146
+ cancel(
147
+ `tag visibility check failed: ${(e as Error).message.slice(0, 200)}\nGitHub API may be unreachable. Set \`PROMUS_BOOTSTRAP_REF=main\` to skip tag verification for dev builds.`,
148
+ )
149
+ return
150
+ }
151
+ }
152
+
153
+ const refDisplay = formatResolvedRef(resolved)
154
+
155
+ if (!opts.yes) {
156
+ const message =
157
+ mode === 'reprovision'
158
+ ? `Reprovision sandbox ${config.sandbox.id.slice(0, 8)} with a fresh container at ref=${refDisplay}? (~60-90s downtime, ~0.9 0G testnet)`
159
+ : `Upgrade sandbox ${config.sandbox.id.slice(0, 8)} in place to ref=${refDisplay}? (~30-60s downtime)`
160
+ const ok = await confirm({ message, initialValue: true })
161
+ if (isCancel(ok) || !ok) {
162
+ cancel('Aborted.')
163
+ return
164
+ }
165
+ }
166
+
167
+ const contractAddress = config.identity.iNFT.contract as Address
168
+ const tokenId = BigInt(config.identity.iNFT.tokenId)
169
+ const agentAddress = config.identity.agent as Address
170
+ const sandboxId = config.sandbox.id
171
+
172
+ const operator = await loadOrPickOperatorSigner({
173
+ network: config.network,
174
+ hint: config.operator,
175
+ })
176
+ if (!operator) {
177
+ cancel('No operator wallet available; cannot decrypt keystore.')
178
+ return
179
+ }
180
+
181
+ // Pre-flight: Galileo deposit balance. The May 2 INSUFFICIENT_BALANCE event
182
+ // archived enigma; refusing up-front with a clear suggestion is much better
183
+ // UX than letting the upgrade run + fail mid-bootstrap.
184
+ if (!(await preflightProviderDeposit(operator))) {
185
+ await operator.close?.()
186
+ return
187
+ }
188
+
189
+ const sUnlock = spinner()
190
+ sUnlock.start('Fetching keystore + decrypting via operator wallet')
191
+ let agentPrivkey: Hex
192
+ try {
193
+ agentPrivkey = await unlockAgentKeystore({
194
+ operator,
195
+ network: config.network,
196
+ contractAddress,
197
+ tokenId,
198
+ agentAddress,
199
+ })
200
+ sUnlock.stop('unlocked')
201
+ } catch (e) {
202
+ sUnlock.stop(`unlock failed: ${(e as Error).message.slice(0, 160)}`)
203
+ await operator.close?.()
204
+ return
205
+ }
206
+
207
+ if (mode === 'reprovision') {
208
+ await runReprovisionUpgrade({
209
+ operator,
210
+ agentPrivkey,
211
+ agentAddress,
212
+ contractAddress,
213
+ tokenId,
214
+ oldSandboxId: sandboxId,
215
+ config,
216
+ loadedPath: loaded.path,
217
+ resolved,
218
+ })
219
+ } else {
220
+ await runInPlaceUpgrade({
221
+ operator,
222
+ agentPrivkey,
223
+ agentAddress,
224
+ contractAddress,
225
+ tokenId,
226
+ sandboxId,
227
+ sandboxEndpoint: config.sandbox.endpoint,
228
+ iNFTNetwork: config.network,
229
+ brain: { provider: config.brain.provider as Address, model: config.brain.model ?? '' },
230
+ subname: config.subname,
231
+ plugins: config.plugins,
232
+ resolved,
233
+ })
234
+ }
235
+
236
+ await operator.close?.()
237
+ }
238
+
239
+ interface InPlaceUpgradeArgs {
240
+ operator: OperatorSigner
241
+ agentPrivkey: Hex
242
+ agentAddress: Address
243
+ contractAddress: Address
244
+ tokenId: bigint
245
+ sandboxId: string
246
+ sandboxEndpoint: string
247
+ iNFTNetwork: PromusNetwork
248
+ brain: { provider: Address; model: string }
249
+ /** Optional .0g subname forwarded into the harness handoff RuntimeConfig. */
250
+ subname?: string | null
251
+ /**
252
+ * Plugins enabled in the local config; threaded into the harness
253
+ * RuntimeConfig so the sandbox loads the same plugin set (telegram listener
254
+ * in particular). Without this the harness defaults to ['system','comms','onchain']
255
+ * and silently drops 'telegram' even when telegram-secrets are provisioned.
256
+ */
257
+ plugins?: PromusPlugin[]
258
+ resolved: ResolvedRef
259
+ }
260
+
261
+ async function runInPlaceUpgrade(args: InPlaceUpgradeArgs): Promise<void> {
262
+ const operatorAccount = await args.operator.account()
263
+ const provider = new SandboxProviderClient({
264
+ endpoint: SANDBOX_PROVIDER_URL_GALILEO,
265
+ operator: operatorAccount,
266
+ })
267
+
268
+ const sBox = spinner()
269
+ sBox.start('Ensuring sandbox is started')
270
+ try {
271
+ await ensureSandboxStarted(provider, args.sandboxId, {
272
+ onProgress: msg => sBox.message(msg),
273
+ })
274
+ } catch (e) {
275
+ sBox.stop(`ensure-started failed: ${(e as Error).message.slice(0, 200)}`)
276
+ note(
277
+ [
278
+ 'The sandbox could not be brought to started state.',
279
+ 'If state is `error` or restore failed, run `promus upgrade --reprovision` to spin a fresh container.',
280
+ ].join('\n'),
281
+ 'recoverable',
282
+ )
283
+ return
284
+ }
285
+
286
+ sBox.message('probing container bootstrap mode')
287
+ const probedMode = await probeContainerBootstrapMode(provider, args.sandboxId)
288
+ if (!probedMode) {
289
+ sBox.stop('cannot determine container bootstrap mode (no promus install detected)')
290
+ note(
291
+ [
292
+ 'Container has neither $HOME/promus/.git/ nor a global @promus/gateway binary.',
293
+ 'The container may have been wiped or never bootstrapped successfully.',
294
+ 'Try `promus upgrade --reprovision` to spin a fresh container.',
295
+ ].join('\n'),
296
+ 'recoverable',
297
+ )
298
+ return
299
+ }
300
+ const cliVersion = probedMode === 'npm' ? await resolveCliVersion() : undefined
301
+ sBox.message(`launching in-place upgrade to ref=${args.resolved.ref} (mode=${probedMode})`)
302
+ const { script } = buildUpgradeScript({
303
+ sandboxId: args.sandboxId,
304
+ operatorAddress: operatorAccount.address,
305
+ mode: probedMode,
306
+ ref: args.resolved.ref,
307
+ packageVersion: cliVersion,
308
+ })
309
+ let launchOut: string
310
+ try {
311
+ const launch = await provider.execInToolbox(args.sandboxId, { command: script, timeout: 60 })
312
+ launchOut = extractExecOutput(launch)
313
+ if (launch.exitCode !== 0) {
314
+ sBox.stop(`upgrade launch failed exitCode=${launch.exitCode}`)
315
+ note(launchOut.slice(0, 400), 'launch output')
316
+ return
317
+ }
318
+ } catch (e) {
319
+ sBox.stop(`execInToolbox failed: ${(e as Error).message.slice(0, 160)}`)
320
+ return
321
+ }
322
+
323
+ // Poll done/fail markers (mirror sandbox-provision Step 3 pattern).
324
+ sBox.message('upgrade running (git fetch + bun install + harness restart)')
325
+ const execRead = makeExecRead(provider, args.sandboxId)
326
+ // Lean poll: just FAIL + DONE markers (cheap `cat` of small files). The
327
+ // progress-log `tail` only attaches every 6th tick (~30s) since the
328
+ // consumer throttles its UX echo at 30s anyway. Saves ~5/6 of the
329
+ // signed-exec response payload through Daytona's HTTP channel.
330
+ const FAST_POLL = `echo --F--; cat ${UPGRADE_FAIL_MARKER} 2>/dev/null; echo --D--; cat ${UPGRADE_DONE_MARKER} 2>/dev/null`
331
+ const SLOW_POLL = `${FAST_POLL}; echo --P--; tail -n 1 ${UPGRADE_PROGRESS_LOG} 2>/dev/null`
332
+ const upgradeDeadline = Date.now() + 360_000 // 6 min ceiling for in-place
333
+ let tick = 0
334
+ let lastDone = ''
335
+ while (Date.now() < upgradeDeadline) {
336
+ const surfaceProgress = ++tick % 6 === 0
337
+ const out = await execRead(surfaceProgress ? SLOW_POLL : FAST_POLL)
338
+ const fail = sliceBetween(out, '--F--', '--D--')
339
+ const done = surfaceProgress ? sliceBetween(out, '--D--', '--P--') : sliceAfter(out, '--D--')
340
+ const failKeyword = UPGRADE_FAIL_KEYWORDS.find(k => fail.includes(k))
341
+ if (failKeyword) {
342
+ const log = await execRead(`tail -n 80 ${UPGRADE_PROGRESS_LOG} 2>/dev/null`)
343
+ sBox.stop(`upgrade-failed: ${failKeyword}`)
344
+ note(
345
+ [
346
+ `step failed: ${failKeyword}`,
347
+ 'log tail:',
348
+ log.slice(-400),
349
+ '',
350
+ 'You can retry with `promus upgrade` (the script is idempotent),',
351
+ 'or fall back to `promus upgrade --reprovision` for a fresh container.',
352
+ ].join('\n'),
353
+ 'recoverable',
354
+ )
355
+ return
356
+ }
357
+ if (done.includes(UPGRADE_SUCCESS_MARKER_PREFIX)) {
358
+ lastDone = done
359
+ const pidLine =
360
+ done
361
+ .split('\n')
362
+ .find(l => l.includes(UPGRADE_SUCCESS_MARKER_PREFIX))
363
+ ?.trim() ?? done.trim()
364
+ sBox.message(`upgrade complete (${pidLine})`)
365
+ break
366
+ }
367
+ if (surfaceProgress) {
368
+ const real = sliceAfter(out, '--P--').trim().split('\n').pop()
369
+ if (real) sBox.message(`upgrade: ${real}`)
370
+ }
371
+ await sleep(5000)
372
+ }
373
+ if (!lastDone.includes(UPGRADE_SUCCESS_MARKER_PREFIX)) {
374
+ const log = await execRead(`tail -n 80 ${UPGRADE_PROGRESS_LOG} 2>/dev/null`)
375
+ sBox.stop('upgrade timeout (6 min)')
376
+ note(`log tail:\n${log.slice(-400)}`, 'recoverable')
377
+ return
378
+ }
379
+
380
+ // Post-flight version verification — DONE marker only proves the inner
381
+ // script finished, not that git checkout moved HEAD. Read package.json
382
+ // from the container and compare. Skip for non-tag refs (no expectation).
383
+ const expected = expectedVersionFromRef(args.resolved)
384
+ if (expected !== null) {
385
+ const verifyPath =
386
+ probedMode === 'npm'
387
+ ? '$HOME/.bun/install/global/node_modules/@promus/gateway/package.json'
388
+ : '$HOME/promus/packages/gateway/package.json'
389
+ const verifyOut = await execRead(`grep '"version"' ${verifyPath} | head -1`)
390
+ const m = verifyOut.match(/"version"\s*:\s*"([^"]+)"/)
391
+ if (!m) {
392
+ sBox.stop('post-flight verification failed: cannot parse package.json version')
393
+ note(
394
+ [
395
+ 'The upgrade reported success but we could not read the deployed package.json.',
396
+ 'Re-running `promus upgrade` should land cleanly. If this persists, file an issue.',
397
+ ].join('\n'),
398
+ 'recoverable',
399
+ )
400
+ return
401
+ }
402
+ const actual = m[1] ?? ''
403
+ if (actual !== expected) {
404
+ // v0.24.4: when npm `latest` is newer than the github release `latest`
405
+ // tag (common during a ship window where the tag was published seconds
406
+ // before the github release was cut), `npm install promus-cli@latest`
407
+ // pulls a NEWER version than `expected`. Treat newer-than-requested as
408
+ // a soft pass: print a note, continue handoff, don't bail out.
409
+ const cmpNewer = isSemverNewer(actual, expected)
410
+ if (cmpNewer) {
411
+ sBox.message(`harness landed ${actual} (newer than requested ${expected}); continuing`)
412
+ } else {
413
+ sBox.stop(`silent-success regression: expected ${expected}, got ${actual}`)
414
+ note(
415
+ [
416
+ `The harness reported success but is running ${actual} instead of ${expected}.`,
417
+ 'This means git fetch may not have seen the tag yet. Re-running',
418
+ `\`promus upgrade --ref ${args.resolved.ref ?? 'latest'}\` should land it correctly,`,
419
+ 'or `promus upgrade latest` to pick the most recent published release.',
420
+ ].join('\n'),
421
+ 'recoverable',
422
+ )
423
+ return
424
+ }
425
+ } else {
426
+ sBox.message(`verified harness version=${actual}`)
427
+ }
428
+ }
429
+
430
+ // Re-handoff against the SAME endpoint (harness restarted with fresh keypair).
431
+ sBox.message('re-handing off agent privkey to restarted harness')
432
+ const sandboxClient = new SandboxClient({
433
+ endpoint: args.sandboxEndpoint,
434
+ sandboxId: args.sandboxId,
435
+ operator: operatorAccount,
436
+ })
437
+ const telegramSecretsPlain = await loadTelegramHandoffSecrets({
438
+ signer: args.operator,
439
+ agentAddress: args.agentAddress,
440
+ contractAddress: args.contractAddress,
441
+ tokenId: args.tokenId,
442
+ onNotice: msg => sBox.message(msg),
443
+ })
444
+ const inPlaceAgentId = iNFTAgentId({
445
+ contractAddress: args.contractAddress,
446
+ tokenId: args.tokenId,
447
+ })
448
+ const inPlaceProfileKeyHex = loadProfileScopeKeyHex(inPlaceAgentId)
449
+ if (!inPlaceProfileKeyHex) {
450
+ sBox.message('no cached PROFILE key; sandbox will boot without profile-slot anchoring')
451
+ }
452
+ try {
453
+ await handoffAgentToGateway({
454
+ sandboxClient,
455
+ agentPrivkey: args.agentPrivkey,
456
+ agentAddress: args.agentAddress,
457
+ iNFTRef: { contract: args.contractAddress, tokenId: args.tokenId },
458
+ iNFTNetwork: args.iNFTNetwork,
459
+ brain: args.brain,
460
+ subname: args.subname,
461
+ plugins: args.plugins,
462
+ telegramSecrets: telegramSecretsPlain,
463
+ profileScopeKeyHex: inPlaceProfileKeyHex,
464
+ onProgress: msg => sBox.message(msg),
465
+ })
466
+ sBox.stop(`sandbox ${args.sandboxId.slice(0, 8)} ready @ ${args.sandboxEndpoint}`)
467
+ } catch (e) {
468
+ sBox.stop(`handoff failed: ${(e as Error).message.slice(0, 200)}`)
469
+ note(
470
+ [
471
+ 'Container code rolled to the new ref but the agent privkey handoff did not complete.',
472
+ 'The harness is back in Bootstrapping state. Re-run `promus upgrade` to retry the handoff,',
473
+ 'or `promus upgrade --reprovision` to start fresh.',
474
+ ].join('\n'),
475
+ 'recoverable',
476
+ )
477
+ return
478
+ }
479
+
480
+ outro(
481
+ [
482
+ '',
483
+ ` sandbox ${args.sandboxId} (unchanged)`,
484
+ ` endpoint ${args.sandboxEndpoint} (unchanged)`,
485
+ ` ref ${formatResolvedRef(args.resolved)}`,
486
+ '',
487
+ 'Next: `promus` to chat (same harness endpoint, same agent EOA, new code)',
488
+ ].join('\n'),
489
+ )
490
+ }
491
+
492
+ interface ReprovisionUpgradeArgs {
493
+ operator: OperatorSigner
494
+ agentPrivkey: Hex
495
+ agentAddress: Address
496
+ contractAddress: Address
497
+ tokenId: bigint
498
+ oldSandboxId: string
499
+ config: NonNullable<Awaited<ReturnType<typeof findAndLoadConfig>>>['config']
500
+ loadedPath: string
501
+ resolved: ResolvedRef
502
+ }
503
+
504
+ async function runReprovisionUpgrade(args: ReprovisionUpgradeArgs): Promise<void> {
505
+ const operatorAccount = await args.operator.account()
506
+ const provider = new SandboxProviderClient({
507
+ endpoint: SANDBOX_PROVIDER_URL_GALILEO,
508
+ operator: operatorAccount,
509
+ })
510
+
511
+ const sDel = spinner()
512
+ sDel.start(`Deleting old sandbox ${args.oldSandboxId}`)
513
+ try {
514
+ await provider.deleteSandbox(args.oldSandboxId)
515
+ sDel.stop(`old sandbox ${args.oldSandboxId.slice(0, 8)} deleted`)
516
+ } catch (e) {
517
+ sDel.stop(`delete failed: ${(e as Error).message.slice(0, 160)}`)
518
+ note(
519
+ [
520
+ 'Old sandbox could not be deleted but provisioning a fresh one is still safe.',
521
+ 'You can manually delete the orphan via the provider dashboard later.',
522
+ ].join('\n'),
523
+ 'continuing',
524
+ )
525
+ }
526
+
527
+ const sBox = spinner()
528
+ sBox.start('Provisioning fresh sandbox container')
529
+ const telegramSecretsPlain = await loadTelegramHandoffSecrets({
530
+ signer: args.operator,
531
+ agentAddress: args.agentAddress,
532
+ contractAddress: args.contractAddress,
533
+ tokenId: args.tokenId,
534
+ onNotice: msg => sBox.message(msg),
535
+ })
536
+ const reprovisionAgentId = iNFTAgentId({
537
+ contractAddress: args.contractAddress,
538
+ tokenId: args.tokenId,
539
+ })
540
+ const reprovisionProfileKeyHex = loadProfileScopeKeyHex(reprovisionAgentId)
541
+ if (!reprovisionProfileKeyHex) {
542
+ sBox.message('no cached PROFILE key; fresh sandbox will boot without profile-slot anchoring')
543
+ }
544
+ let sandboxResult: Awaited<ReturnType<typeof runSandboxProvision>>
545
+ const boxCtl = new BootstrapProgressController({
546
+ spinner: sBox,
547
+ cliVersion: await resolveCliVersion(),
548
+ startedMsg: 'fresh sandbox started, running bootstrap',
549
+ })
550
+ try {
551
+ sandboxResult = await runSandboxProvision({
552
+ operator: args.operator,
553
+ agentPrivkey: args.agentPrivkey,
554
+ agentAddress: args.agentAddress,
555
+ iNFTRef: { contract: args.contractAddress, tokenId: args.tokenId },
556
+ brain: {
557
+ provider: args.config.brain.provider as Address,
558
+ model: args.config.brain.model ?? '',
559
+ },
560
+ iNFTNetwork: args.config.network,
561
+ name: args.config.subname || 'promus',
562
+ ref: args.resolved.ref,
563
+ subname: args.config.subname,
564
+ plugins: args.config.plugins,
565
+ telegramSecrets: telegramSecretsPlain,
566
+ profileScopeKeyHex: reprovisionProfileKeyHex,
567
+ onProgress: boxCtl.onProgress,
568
+ onStageEvent: boxCtl.onStageEvent,
569
+ onTick: boxCtl.onTick,
570
+ })
571
+ boxCtl.finalize(`sandbox ${sandboxResult.sandboxId} ready @ ${sandboxResult.endpoint}`, msg =>
572
+ log.step(msg),
573
+ )
574
+ } catch (e) {
575
+ boxCtl.fail(`re-provision failed: ${(e as Error).message.slice(0, 200)}`, msg => log.error(msg))
576
+ note(
577
+ [
578
+ 'Old sandbox was deleted but the new one did not provision.',
579
+ 'Identity + funds + memory all safe on chain / 0G Storage.',
580
+ 'Re-run `promus upgrade --reprovision` after fixing the issue, or `promus deploy` to start fresh.',
581
+ ].join('\n'),
582
+ 'recoverable (agent offline)',
583
+ )
584
+ return
585
+ }
586
+
587
+ if (args.config.subname) {
588
+ const sEp = spinner()
589
+ sEp.start(`Updating agent:endpoint on ${args.config.subname}.promus.0g`)
590
+ try {
591
+ await publishSandboxEndpoint({
592
+ subname: args.config.subname,
593
+ agentPrivkey: args.agentPrivkey,
594
+ endpoint: sandboxResult.endpoint,
595
+ })
596
+ sEp.stop('agent:endpoint updated')
597
+ } catch (e) {
598
+ sEp.stop(`agent:endpoint update failed: ${(e as Error).message.slice(0, 120)}`)
599
+ }
600
+ }
601
+
602
+ const updated = {
603
+ ...args.config,
604
+ sandbox: {
605
+ ...args.config.sandbox,
606
+ id: sandboxResult.sandboxId,
607
+ providerAddress: sandboxResult.providerAddress,
608
+ endpoint: sandboxResult.endpoint,
609
+ snapshotName: sandboxResult.snapshotName,
610
+ },
611
+ }
612
+ await writeConfigTs(args.loadedPath, updated, { subname: updated.subname ?? null })
613
+
614
+ outro(
615
+ [
616
+ '',
617
+ ` old sandbox ${args.oldSandboxId}`,
618
+ ` new sandbox ${sandboxResult.sandboxId}`,
619
+ ` endpoint ${sandboxResult.endpoint}`,
620
+ ` ref ${formatResolvedRef(args.resolved)}`,
621
+ '',
622
+ 'Next: `promus` to chat (now routes through the new harness)',
623
+ ].join('\n'),
624
+ )
625
+ }
626
+
627
+ /**
628
+ * v0.24.4: compare two semver-shaped strings as `a > b`. Strips a leading `v`
629
+ * if present. Returns true when `a` is strictly newer than `b`. Used by the
630
+ * post-flight verifier so a newer-than-requested install (npm latest > github
631
+ * release latest during a ship window) doesn't fire the "silent-success
632
+ * regression" warning. Lightweight — does not handle prerelease tags.
633
+ */
634
+ function isSemverNewer(a: string, b: string): boolean {
635
+ const parse = (s: string): number[] => {
636
+ const clean = s.replace(/^v/, '').split('-')[0] ?? ''
637
+ return clean.split('.').map(p => Number.parseInt(p, 10) || 0)
638
+ }
639
+ const pa = parse(a)
640
+ const pb = parse(b)
641
+ for (let i = 0; i < 3; i++) {
642
+ const av = pa[i] ?? 0
643
+ const bv = pb[i] ?? 0
644
+ if (av > bv) return true
645
+ if (av < bv) return false
646
+ }
647
+ return false
648
+ }
649
+
650
+ function sliceBetween(s: string, start: string, end: string): string {
651
+ const i = s.indexOf(start)
652
+ if (i < 0) return ''
653
+ const j = s.indexOf(end, i + start.length)
654
+ if (j < 0) return s.slice(i + start.length)
655
+ return s.slice(i + start.length, j)
656
+ }
657
+
658
+ function sliceAfter(s: string, marker: string): string {
659
+ const i = s.indexOf(marker)
660
+ return i < 0 ? '' : s.slice(i + marker.length)
661
+ }
662
+
663
+ function sleep(ms: number): Promise<void> {
664
+ return new Promise(r => setTimeout(r, ms))
665
+ }
666
+
667
+ /**
668
+ * Single execInToolbox round-trip that probes the container's bootstrap mode
669
+ * by checking filesystem state. Returns 'git' if `$HOME/promus/.git/` exists,
670
+ * 'npm' if global @promus/gateway binary exists, or null if neither.
671
+ *
672
+ * Used by `runInPlaceUpgrade` so the upgrade script ships only the path it
673
+ * actually needs (auto-detect inside the script blew the 5KB Daytona cap).
674
+ */
675
+ export async function probeContainerBootstrapMode(
676
+ provider: SandboxProviderClient,
677
+ sandboxId: string,
678
+ ): Promise<BootstrapMode | null> {
679
+ // Routed through makeExecRead so the `if [...]; then ...; fi` runs under
680
+ // a real bash. Daytona's exec is argv-only; without the wrap the probe
681
+ // tokenises `if` as argv[0] and returns empty. makeExecRead also swallows
682
+ // exec errors, returning '' on failure — matches the previous catch arm.
683
+ const execRead = makeExecRead(provider, sandboxId)
684
+ const out = await execRead(
685
+ `if [ -d "$HOME/promus/.git" ]; then echo MODE=git; elif [ -x "$HOME/.bun/install/global/node_modules/.bin/@promus/gateway" ]; then echo MODE=npm; else echo MODE=none; fi`,
686
+ )
687
+ if (out.includes('MODE=git')) return 'git'
688
+ if (out.includes('MODE=npm')) return 'npm'
689
+ return null
690
+ }